Compare commits

...

17 Commits

Author SHA1 Message Date
Rodger-Wang 7ce56f693d client: 通话翻译音频链路修复(iOS GATT 写入、后台保活、状态竞态) 1 month ago
Rodger-Wang 8baa082d9e client: 构建号递增;Xcode 自动改写的工程文件 1 month ago
Rodger-Wang 8b50e52572 client: 通话翻译字幕错位/总结残留/退不出去/切后台被冻结,及 B 路说话人门限 1 month ago
Rodger-Wang 7aef22905b client: 语音翻译的语言弹窗两侧给同一张表,用过的置顶 1 month ago
Rodger-Wang b61c4c91d7 client: 修助手闹钟的「同一条两张卡」与「日期落在昨天」 1 month ago
Rodger-Wang 90fe2d6c2a services: 阿里机器翻译的错误解析兼容数字 Code,未开通时给出可操作的提示 1 month ago
Rodger-Wang 76aa2032bf services: 梳理并修「账号 ↔ 实名记录」的绑定口径 1 month ago
Rodger-Wang f9b9c8c72d client: 翻译默认归档进语音纪要;列表改本地优先;登录按包收敛;EMAI 走 A2DP 1 month ago
Rodger-Wang 811a008545 services: 语音纪要→拾忆→MCP 链路审计落地;修两个「什么都没发生」的既有 bug 1 month ago
Rodger-Wang 5193b0e5e8 admin,console: 「会议模板」改成「会议配置」,一页管转写/总结/模板 1 month ago
Rodger-Wang 4d97c99528 services: 通话录音按左右声道分轨转写,说话人直接按音轨给 1 month ago
Rodger-Wang 41e3a023a3 client: 蓝牙名白名单(EaiRec/EaiCar);连接即按账号登记绑定;iOS 崩溃加固;弱网翻译超时 1 month ago
Rodger-Wang 3709aa6e3b services: 语音纪要 ASR 按优先级自动回退;翻译区分「语种不支持」与瞬时失败;OSS 配置在作用域归一后可兜底定位 1 month ago
Rodger-Wang c14580d7db client: 设备默认图去背;AI 页编排跟随设备;香薰 AI 回复走蓝牙;OTA 状态如实显示 1 month ago
Rodger-Wang 263c72ad31 services: 机器翻译只在服务端接得上的 provider 里挑,别随手挑中没接的那家 1 month ago
Rodger-Wang 461289b780 services,admin: 后台赠送只发 VIP 天数与算力;console 读系数走应用连接 1 month ago
Rodger-Wang e00b617025 services,admin,client: 服务配置按应用隔离、应用参数下线、客户端凭据走加密 v3 1 month ago
  1. 494
      CLAUDE.md
  2. 264
      apps/admin/app/components/AppEnvTab.vue
  3. 739
      apps/admin/app/components/MeetTemplatePanel.vue
  4. 7
      apps/admin/app/pages/appconfig.vue
  5. 7
      apps/admin/app/pages/globalconfig.vue
  6. 383
      apps/admin/app/pages/meetconfig.vue
  7. 726
      apps/admin/app/pages/meettemplates.vue
  8. 2
      apps/admin/app/pages/product.vue
  9. 282
      apps/admin/app/pages/serviceconfig.vue
  10. 29
      apps/admin/app/pages/users.vue
  11. 14
      apps/admin/app/utils/menus.ts
  12. 5
      apps/client/.env.example
  13. 5
      apps/client/.gitignore
  14. BIN
      apps/client/assets/audio/silence_1s.wav
  15. 0
      apps/client/assets/images/devices/EaiCar.png
  16. 0
      apps/client/assets/images/devices/EaiCar_icon.png
  17. BIN
      apps/client/assets/images/devices/Echomeet.png
  18. BIN
      apps/client/assets/images/devices/Echomeet_icon.png
  19. BIN
      apps/client/assets/images/devices/default.png
  20. BIN
      apps/client/assets/images/devices/default_icon.png
  21. 6
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  22. 2
      apps/client/ios/Runner/Info.plist
  23. 3
      apps/client/lib/core/bindings/initial_binding.dart
  24. 4
      apps/client/lib/core/translations/language/ar_sa.dart
  25. 4
      apps/client/lib/core/translations/language/bg_bg.dart
  26. 4
      apps/client/lib/core/translations/language/cs_cz.dart
  27. 4
      apps/client/lib/core/translations/language/da_dk.dart
  28. 4
      apps/client/lib/core/translations/language/de_de.dart
  29. 4
      apps/client/lib/core/translations/language/el_gr.dart
  30. 4
      apps/client/lib/core/translations/language/en_us.dart
  31. 4
      apps/client/lib/core/translations/language/es_es.dart
  32. 4
      apps/client/lib/core/translations/language/et_ee.dart
  33. 4
      apps/client/lib/core/translations/language/fa_ir.dart
  34. 4
      apps/client/lib/core/translations/language/fi_fi.dart
  35. 4
      apps/client/lib/core/translations/language/fr_fr.dart
  36. 4
      apps/client/lib/core/translations/language/hi_in.dart
  37. 4
      apps/client/lib/core/translations/language/hr_hr.dart
  38. 4
      apps/client/lib/core/translations/language/hu_hu.dart
  39. 4
      apps/client/lib/core/translations/language/id_id.dart
  40. 4
      apps/client/lib/core/translations/language/is_is.dart
  41. 4
      apps/client/lib/core/translations/language/it_it.dart
  42. 4
      apps/client/lib/core/translations/language/ja_jp.dart
  43. 4
      apps/client/lib/core/translations/language/ko_kr.dart
  44. 4
      apps/client/lib/core/translations/language/lt_lt.dart
  45. 4
      apps/client/lib/core/translations/language/lv_lv.dart
  46. 4
      apps/client/lib/core/translations/language/ms_my.dart
  47. 4
      apps/client/lib/core/translations/language/nb_no.dart
  48. 4
      apps/client/lib/core/translations/language/nl_nl.dart
  49. 4
      apps/client/lib/core/translations/language/pl_pl.dart
  50. 4
      apps/client/lib/core/translations/language/pt_pt.dart
  51. 4
      apps/client/lib/core/translations/language/ro_ro.dart
  52. 4
      apps/client/lib/core/translations/language/ru_ru.dart
  53. 4
      apps/client/lib/core/translations/language/sk_sk.dart
  54. 4
      apps/client/lib/core/translations/language/sl_si.dart
  55. 4
      apps/client/lib/core/translations/language/sr_rs.dart
  56. 4
      apps/client/lib/core/translations/language/sv_se.dart
  57. 4
      apps/client/lib/core/translations/language/th_th.dart
  58. 4
      apps/client/lib/core/translations/language/tl_ph.dart
  59. 4
      apps/client/lib/core/translations/language/tr_tr.dart
  60. 4
      apps/client/lib/core/translations/language/uk_ua.dart
  61. 4
      apps/client/lib/core/translations/language/vi_vn.dart
  62. 4
      apps/client/lib/core/translations/language/zh_cn.dart
  63. 4
      apps/client/lib/core/translations/language/zh_hk.dart
  64. 4
      apps/client/lib/core/translations/language/zh_tw.dart
  65. 4
      apps/client/lib/core/utils/ai_navigation.dart
  66. 58
      apps/client/lib/core/utils/recent_languages.dart
  67. 266
      apps/client/lib/core/utils/synchrodata.dart
  68. 45
      apps/client/lib/data/models/appconfig.dart
  69. 41
      apps/client/lib/data/models/appconfig_model.dart
  70. 24
      apps/client/lib/data/models/appconfig_model.g.dart
  71. 83
      apps/client/lib/data/models/assistant_directive.dart
  72. 8
      apps/client/lib/data/models/device_category.dart
  73. 7
      apps/client/lib/data/models/device_images.dart
  74. 5
      apps/client/lib/data/models/user_Info.dart
  75. 4
      apps/client/lib/data/services/account_service.dart
  76. 23
      apps/client/lib/data/services/agent_module_defaults.dart
  77. 79
      apps/client/lib/data/services/agent_module_service.dart
  78. 13
      apps/client/lib/data/services/alibaba_image_translation_service.dart
  79. 7
      apps/client/lib/data/services/assistant_directive_service.dart
  80. 9
      apps/client/lib/data/services/ast_service.dart
  81. 28
      apps/client/lib/data/services/bailian_multimodal_service.dart
  82. 324
      apps/client/lib/data/services/bailian_native_dialog.dart
  83. 56
      apps/client/lib/data/services/deapsound_ai_service.dart
  84. 330
      apps/client/lib/data/services/device_ai_session_service.dart
  85. 101
      apps/client/lib/data/services/device_call_recording_service.dart
  86. 97
      apps/client/lib/data/services/device_recording_service.dart
  87. 84
      apps/client/lib/data/services/device_stereo_recorder.dart
  88. 93
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  89. 29
      apps/client/lib/data/services/memory_service.dart
  90. 6
      apps/client/lib/data/services/microsoft_translation_service.dart
  91. 39
      apps/client/lib/data/services/network/api.dart
  92. 86
      apps/client/lib/data/services/network/api_crypto.dart
  93. 23
      apps/client/lib/data/services/network/auth_interceptor.dart
  94. 17
      apps/client/lib/data/services/network/dio_manager.dart
  95. 55
      apps/client/lib/data/services/recording_archive.dart
  96. 23
      apps/client/lib/data/services/server_translation_service.dart
  97. 10
      apps/client/lib/data/services/speech_impl/azure_asr_service.dart
  98. 64
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  99. 4
      apps/client/lib/data/services/speech_impl/azure_tts_service.dart
  100. 60
      apps/client/lib/data/services/wav_writer.dart

494
CLAUDE.md

@ -461,8 +461,9 @@ MAC 导入后已实测打通(2026-09-10):`user_binddevice` 返回
- `dotenv.env['X']` = `apps/client/.env`,本机/渠道级,编译进包; - `dotenv.env['X']` = `apps/client/.env`,本机/渠道级,编译进包;
- `AppConfig.env('X')`([appconfig.dart](apps/client/lib/data/models/appconfig.dart))= 后端 - `AppConfig.env('X')`([appconfig.dart](apps/client/lib/data/models/appconfig.dart))= 后端
`user_getappconfig` 下发的配置(env / agents / mcps / products),由 **console 后台**维护。 `user_getappconfig` 下发的配置(env / agents / mcps / products),由 **console 后台**维护。
⚠️ 2026-09-12 起 `env` 里的**凭据来自 `svc_config` 的 `env_key`**,不再来自 `global_config`; ⚠️ 2026-09-14 起 `env` **只来自 `svc_config` 字段的 `env_key`**:`global_config`(9-12)和业务库
业务库 `config` 表只剩应用自有的业务参数。详见下文「凭据归一」。 `config` 表(9-14)都不再参与下发。客户端读的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ)
也在 svc_config 里(服务 `llm_mobile_elf` / `app_params`)。详见下文「凭据归一」及「应用参数标签页下线」。
- 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端 - 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端
`pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的, `pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的,
**没有代码生成把两边绑在一起**。改 proto 字段后要手动同步 **没有代码生成把两边绑在一起**。改 proto 字段后要手动同步
@ -473,6 +474,31 @@ MAC 导入后已实测打通(2026-09-10):`user_binddevice` 返回
- `user` 模块的 v2/v3 api 把 `GetAppConfig`/`GetAgents` 列入 `EncryptMsgs`(加密协议), - `user` 模块的 v2/v3 api 把 `GetAppConfig`/`GetAgents` 列入 `EncryptMsgs`(加密协议),
抓包看到的是密文不是明文 JSON,别以为接口挂了。 抓包看到的是密文不是明文 JSON,别以为接口挂了。
### 客户端凭据走结构化通道:加密 v3 + `AppConfig.cred()`(2026-09-14)
EAIMAR 客户端不再从 `env` 兼容层拿第三方凭据,改走**自己的路**:
| 环节 | 位置 |
|---|---|
| 请求 | `Api.getappconfig()` → `.env` 配了 `GATEWAY_ENCRYPT_KEY` 就打 **`user_getappconfig_v3`**(整体 AES 加密),否则回退明文 v1 |
| 解密 | [api_crypto.dart](apps/client/lib/data/services/network/api_crypto.dart) 的 `ApiDecryptInterceptor`,**必须排在 `AuthInterceptor` 之前**(它一看到不是业务 JSON 就当失败) |
| 模型 | `UserGetAppConfigResp.thirdsvcs: List<ThirdSvc>`(id/name/provider/categories/fields),缺键 → 空列表 |
| 取值 | `AppConfig.cred(svcId, fieldKey, envKey)`:**thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null** |
- 服务 id / 字段名与服务端 `migrate_envtosvc.go` 的落点表一一对应(`stt_azure.subscription_key`、
`ast_alibaba.app_key`、`llm_doubao.api_key`…),**改哪边都要同改**;老键名只作兜底。
- 加密口径(`lego/utils/crypto/aes/cbc.go`):AES-CBC,IV 固定 16 个 `'0'`,PKCS5/7,密文 base64,
响应头 `X-Encrypted: 1`、`Content-Type: text/plain`。`test/api_crypto_test.dart` 的密文是 **openssl 按 Go 口径**
生成的金向量——用同一套 Dart 加密再解密只能证明自洽,证明不了与 Go 端一致。
- ⚠️ **`GATEWAY_ENCRYPT_KEY` 要进客户端 `.env`**(gitignore,值与服务器 `deploy/app` 的 `.env` 一致,测试/正式同一把);
没配就静默回退 v1,`thirdsvcs` 为空、`AppConfig.usingThirdSvcs == false`——排查「明明配了服务却读不到」先看这个。
密钥不一致的表现是解密后 PKCS7 去填充失败 → 拦截器 reject,报错文案指向密钥,别去查网络。
- ⚠️ **v3 必须在网关白名单**(`deploy/app/confs/gateway.yaml.example` 与各机 `confs/gateway.yaml`):splash 在**登录前**
就取配置,少这一行返回 `code:18` 然后整个启动流程走兜底。测试机已加;**正式机 ym-a11 的 gateway.yaml 尚未加**(要重启容器),
新客户端上正式前必须补。
- 未登录时 v3 的 `filterSvcsByVip` 拿不到 uid → 按有 VIP 放行;实名(11) 类照旧永不下发。
- `cred()` 返回 null 而不是空串:空串会把调用方的 `?? 默认值` 顶掉(`AZURE_TRANSLATION_ENDPOINT` 栽过)。
### 游客登录与功能闸门(2026-08-29 改) ### 游客登录与功能闸门(2026-08-29 改)
游客登录**走服务端** `api_sgin` 的 `Tourists` 分支(`stype=6` + `phonemac=设备id`),拿真实雪花 uid 与 JWT。 游客登录**走服务端** `api_sgin` 的 `Tourists` 分支(`stype=6` + `phonemac=设备id`),拿真实雪花 uid 与 JWT。
@ -712,6 +738,272 @@ Android/iOS 两端同改,改动都在各自的 `AliyunBailianE2EHelper` 的 `h
⚠️ 官方标称 2.8s 同传延迟,那是模型为语义完整做的等待,配不掉。文本接上流式之后 ⚠️ 官方标称 2.8s 同传延迟,那是模型为语义完整做的等待,配不掉。文本接上流式之后
提前的是「开始出字」的时间,音频总延迟不变。 提前的是「开始出字」的时间,音频总延迟不变。
### 通话状态:`BB 8E` 是权威,`BB 02/03/04` 是噪声(2026-09-21 实测,**尚未据此改代码**)
真机抓到的通话状态帧里,只有 `BB 8E` 是干净的,整份日志只有两种形态、末字节即状态:
```
bb 8e 07 00 02 01 02 → 通话中 (29 次)
bb 8e 07 00 02 01 03 → 已结束 (18 次)
```
它是 `queryCallState` 的应答,我们问一次它答一次,与真实状态始终一致。
而 `BB 02` / `BB 03` 会**成对、高频**地刷(间隔 68~260ms),带的是同一个电话号码
(`bb 02 16 0f 01 2b 38 36 31 33…` 与 `bb 03 13 0f 01 2b 38 36 31 33…`,`2b 38 36…` 是
ASCII `+8613…`)。更像是**通话列表的周期性上报**(类似 HFP 的 `+CLCC` 轮询),
而原生 `case 0x02 / 0x03` 把它们当成了通话起止事件。
实测后果:一次挂断之后 30 秒内 `DeviceHub.inCall` 翻转 10 多次,主界面那道
「通话中禁用同传/面对面/音视频」的闸门跟着反复开关,用户点的时候正好落在某个 true
的瞬间就被拦,表现是「电话挂了,同传要等好几秒、点好几次才进得去」。
⚠️ **对照时间线可以确认锅在 `BB 02/03` 而不在 `BB 8E`**:22:21:37~22:22:52 与
22:23:54~22:24:27 两段震荡期间,`BB 8E` 一直报「通话中」,直到 22:22:52 / 22:24:27
才报结束——也就是说电话确实还没挂断完,是 `BB 02/03` 在乱报。
⚠️ 还有一个**不带号码**的短帧 `bb 02 08 01 01 02 03 02`,同样被判成 `startMakeCall`,
总是紧跟在挂断序列(`bb 04 03` / `bb d2` 的 stopCall ACK)之后冒出来。
**想改的话**(2026-09-21 讨论后决定暂不动,先留记录):通话状态只认 `BB 8E`;
`BB 02` 可以立即置 true(宁可早开门,通话翻译本就只在通话中用),
`BB 03/04` **不直接置 false**、只触发一次 `queryCallState()` 由 `BB 8E` 定论,
再加 3 秒兜底防止哪天 `BB 8E` 也换了命令号(见上文「命令号是 (产品, 固件版本) 的函数」)。
⚠️ 这条链路是通话翻译的**准入判据**,判严的后果(通话中进不去)比现在的
「挂断后要多等几秒」严重得多,动之前先想清楚失败方向。
### 排查这条链路时日志怎么拿(2026-09-21)
⚠️ **`idevicesyslog` 会自己断**,而且断了之后进程还在、只是不再有输出(今晚断过两次),
`idevice_id -l` 返回空但 `devicectl` 仍能看到进程 —— 那是 libimobiledevice 与
macOS 26+ 的 usbmuxd 不对付,杀掉残留的 `iproxy` 也救不回来。
**更可靠的路子是直接拉 App 自己写的日志文件**,完全不依赖 usbmuxd:
```bash
# 先列出来看哪个文件在更新(Logger.initFileLog 写在 Documents/logs/)
xcrun devicectl device info files --device <UDID> \
--domain-type appDataContainer --domain-identifier com.yimai.eaimar | grep logs
xcrun devicectl device copy from --device <UDID> \
--domain-type appDataContainer --domain-identifier com.yimai.eaimar \
--source Documents/logs/app_2026-09-21_221314.log --destination /tmp/latest.log
```
⚠️ `copy from` **没有 `--username` 参数**(`info files` 有),带上会直接报 Unknown option。
文件日志的级别门槛与 syslog 不同,`Logger.i` 也会落盘,比 syslog 那条路看得更全。
### iOS 上恒玄走的是 **GATT over BR/EDR**,不是 BLE(2026-09-21 真机定性)
⚠️ **先记住这条,否则后面每一步推理都会跑偏。** iOS 侧 `bluetooth_manager` 用 CoreBluetooth
的 API(`CBCentralManager`/`CBPeripheral`),但底层链路是**经典蓝牙 BR/EDR 上的 GATT**:
连接靠 `registerForConnectionEvents` + `.peerConnected` 接管系统已配对的设备,
`retrieveConnectedPeripherals` 枚举的也是 BR/EDR GATT 设备。代码里 `enableBREDRConnectionEvents`
这个开关就是为它存在的。
**别把 BLE 那套模型套上来**,两处结论会直接错:
| BLE 的说法 | 在 BR/EDR GATT 上 |
|---|---|
| connection interval 15~30ms,一个 interval 只发一包 | 不适用。BR/EDR 是 625µs 时隙轮询 |
| 84B × 50 包/秒 ≈ 34 kbps 已接近链路上限 | 差得远。ACL 带宽几百 kbps ~ 2Mbps |
下行 84 字节 / 16~20ms 一包(50~62 包/秒)这个量级,**对这条链路完全不构成压力**——
2026-09-21 压测实测 10164 包、`dropped: 0`。排查「音频不流畅」时不要往带宽方向猜。
### GATT 写入:控制命令与实时音频的三条铁律(2026-09-21 真机定性)
通话翻译下行全部经 `BluetoothManager.write` 递给 CoreBluetooth,这条路上踩全了三个坑,
**每一个都不报错**:
1. **写类型必须优先 `.withoutResponse`。** 原实现是 `contains(.write) ? .withResponse : .withoutResponse`,
而特征通常两种写都支持(真机实测 `props: write=Y writeNoResp=Y`),于是恒选带应答。
**ATT 是请求-响应协议**(这一点与底层是 BLE 还是 BR/EDR 无关),带应答写每包都要等对端
ACK 才能发下一包,50+ 包/秒的实时流必然追不上,表现是音频越听越滞后、断断续续。
2. **`canSendWriteWithoutResponse` 只能作观测,绝不能作门槛。** 它是 CoreBluetooth
**为 BLE 设计**的流控提示,在 BR/EDR GATT 上不对应真实链路能力——压测实测它
**100% 为 false,同时 10164 包全部发出、零丢弃**。
⚠️ 拿它当闸门会**死锁**:为 false 时我们一个字节都不写,而
`peripheralIsReady(toSendWriteWithoutResponse:)` 的触发前提恰恰是「你写了、被限流了」——
完全不写,回调就永远不来,canSend 再也翻不回 true。真机表现:整场通话 `sent` 恒定停在 10、
`blocked` 停在 1(队列一次都没排空过),548 包只出去 11 个,**远端完全听不到译文**。
同一版里走无条件直发的控制命令(`AA 06`/`AA 09`/`queryCallState`)却全部成功——这组对比就是判据。
3. **实时音频不需要在这一层再做队列。** 下行本来就是 `CallTranslationDownlink` 用 20ms 定时器
**匀速**产出的,不存在突发。直接 `writeValue` 交给 CoreBluetooth 即可。
⚠️ **控制命令绝不能排队**。`AA 09`(查电量)/`AA 06`(查版本)/`queryCallState` 一旦排到积压的音频
后面就再也发不出去,表象是「耳机连着但电量、版本全查不到」「耳机在通话中但 App 里通话翻译
不让点」,**极易误判成固件换了命令号**(上文那条警告就是这么来的)。
现在两个入口都是无条件直发,`writeRealtimeAudio` 仅用于区分统计口径。
另外 `CallTranslationDownlink.stop()` 只清它自己那两条腿的队列,必要时还得调
`BluetoothManager.flushRealtimeQueue()` 清 GATT 层的遗留积压。
⚠️ **排查这条链路时 `NSLog` 一个字都看不到**:release / profile 包里 `idevicesyslog` 只收得到
`os_log`。`BesCallDownlink` / `BesCallPcmBridge` 原来全用 NSLog,于是下行是整条链路唯一的盲区,
只能靠猜。现在关键计数都走 `os_log`(`com.eaimar.bluetooth` 的 `write`/`downlink` 两个 category),
每 100 包一条队列长度 + 定时器真实间隔,会话结束一条收尾总账。
⚠️ **别去碰标准电池服务 `0x180F`**:Echo-one 上它是空壳(值恒为 `0%`,同一时刻私有 `BB 0A`
解出的是 L=90 R=86),而对它 `discoverCharacteristics` + `readValue` + `setNotifyValue` 之后,
**ATT 事务串行排队会把整条通道堵死**——实测下行 548 包只发出去 11 个,远端完全听不到译文。
这台耳机的完整服务清单(2026-09-21 一次性枚举,常态下只发现私有服务那一个):
`180A`(设备信息)、`180F`(空壳电池)、`00001100-D102-11E1-9B23-00025B00AFAE`(私有,在用)、
`66666666-…`、`86868686-…`。
### 通话翻译的 PCM 已下沉到原生:bluetooth_manager ⇄ azure_speech 直连(2026-09-20)
苹果测试反馈「WiFi 下断断续续、长句基本不行」正是此前记录的技术债触发条件(Dart 链路每秒约 200 次跨界,
event loop 一被拖住原生就报 `AudioSendSlow 1011`)。现在通话翻译的双路 PCM **不再经过 Dart**:
| 端 | 落点 | 传输 |
|---|---|---|
| 耳机插件 → 翻译插件(上行 A=本端麦 / B=对端) | `bluetooth_manager` 的 `CallPcmBridge.swift` / `BluetoothManager.callPcmTap`(kt) → `azure_speech` 的 `BesCallPcmBridge.swift/.kt` | iOS `NotificationCenter`(两插件一个是 CocoaPods 一个是 SwiftPM,**不能互相 import**);Android `compileOnly project(":bluetooth_manager")` 直接赋值静态槽 |
| 翻译插件 → 耳机插件(下行译文 PCM) | `pushAstTtsFrame`/`emitAstTtsFrame` 直接喂 `CallTranslationDownlink` | 同上 |
- **按会话开关**:`TranslationController._tryEnableNativeCallBridge` 先开翻译端(`AstService.setNativeCallBridge`)
再开设备端(`DeviceFeatures.callPcmNativeBridge`),任一端不支持就退回原来的 Dart 流路径(`openMic`/`pushExternalAudio`),
功能不受影响。关的顺序相反。桥开着时原生不再把 micData/spkData 推给 Dart 的 audio EventChannel,
TTS 也只回 `final` 标记;`[CALL-BRIDGE] 原生直连桥统计` 这行 warn 日志在每次会话结束时打,
`dropped>0` 表示上行拥塞(原生那边 2s 积压即丢帧,取代原来整个会话被 1011 掀掉)。
- **B 路说话人门限 `PeerSpeechGate` 已移植成 Swift/Kotlin**(`azure_speech` 内),阈值三处(Dart/Swift/Kotlin)相同,调要同调;
门限开启时原生发 `peerGateOpened` 事件,Dart 以 warn 打 `[CALL-BRIDGE] B 路门限开启(原生)`——真机调阈值仍看这行。
- **重连期间不丢音频**:AST helper 重建时帧留在桥的环形缓冲(A 路 3s,B 路因门限复位丢弃),`markReady` 后整段补推。
⚠️ iOS 通知名/键、Android 槽名两边各写一份,**改一处必须同改另一处**,改漏了不报错、只表现为「桥开着但一帧都到不了」。
- 顺带改的:AST 重连预算由「3 次」改成「首次掉线起 60s 窗口内不限次、退避封顶 8s」(`_astReconnectWindow`),
终止时按错误原文分类弹「网络连接失败」或「服务连接失败」(`_classifyAstFailure`),通话翻译页顶栏多了链路状态灯(`astLinkState`)。
### 音频链路继续下沉:录音落盘与 EMAI 会话(2026-09-21)
**判据不是「业务逻辑 vs 展示逻辑」,是「这条链路有没有实时截止期」。** Dart 侧的 CPU
开销基本不是瓶颈(一帧 640 字节算个 RMS 是微秒级);真正的代价有三条,只在特定链路上成立:
平台通道每条消息要序列化 + 跳一次主线程;Dart 只有一个 isolate,音频回调与页面布局、GC
排同一个 event loop(`AudioSendSlow 1011` 就是这么来的);以及后台存活要求。
没有实时截止期的状态机、接口编排、列表渲染搬原生只会把一份代码写成 Swift + Kotlin 两份。
⚠️ **「搬到原生就能扛住 iOS 锁屏」是错的**:iOS 挂起的是**整个进程**,原生和 Dart 一起停。
锁屏后能不能活着只取决于 `UIBackgroundModes=audio` + 确实有音频在播
(`BackgroundKeepalive` 干的事),与代码在哪一层无关。排查「锁屏断连」别往下沉的方向查。
#### 设备录音直接在原生落盘(`PcmWavRecorder`)
| | 之前 | 现在 |
|---|---|---|
| 路径 | 原生解码 → EventChannel → Dart → `WavWriter`(RandomAccessFile 排队) | 原生解码完**就地写 WAV** |
| 跨界 | 单路 50 帧/s、通话双路 100 帧/s | 只剩 100ms 一条 `{bytes, level}` |
| 入口 | `session.openMic(...)` + 自己写盘 | `session.openFileRecorder(path:, routes:)` |
- 契约在 `device_plugin_interface`:`DeviceFileRecorder` / `DeviceRecordStats`,
`DeviceSession.openFileRecorder` **默认返回 null**——其它厂商零改动,老原生自动退回
Dart 落盘那条路(三个服务都保留了完整的退路分支)。
- 实现是 `bluetooth_manager` 的 `PcmWavRecorder.swift` / `.kt`:**实例化,不是单例**。
azure_speech 里那个 `RecordFile` 是单例且已被通话翻译的立体声录音占着,复用会互相踩。
- **录音是额外的 sink,不拦截原有回调**(与 `CallPcmBridge` 不同):通话翻译开着桥时
micData/spkData 不再上抛 Dart,但用户可能同时在录这通电话,拦了就录出空文件。
- `StereoPairer` 的配对逻辑三处(Dart / Swift / Kotlin)各一份,金测试
`test/stereo_pairer_test.dart` 定的是同一个行为。**写成「只留最后一帧对端」会把右声道
断续抽掉,转写出来只剩自己这一方**。
- 顺带修掉现场录音的**相位问题**:Dart 原来用 `_alternate(audioStream)` 隔帧取主麦,
靠的是「订阅时恰好对上相位」,中途订阅就永久取到前馈麦。原生早把两路标成了
`mainMicData` / `ffMicData`,按类型取即可。映射由 `test/devices/bes_recorder_sources_test.dart`
钉死——那几个串是跨语言契约,写错不报任何错,只表现为「录音起来了但一个字节都没写进去」。
- 断连兜底:设备断开时原生自己收尾回填 WAV 头。不做这一步、进程随后被杀,就留下一个
`data` 长度为 0 的文件——有几十 MB,播放器却认为它是空的。
#### EMAI 耳机唤醒整条会话下沉(`BailianDialogClient`)
耳机按键唤醒那条链路的百炼 WebSocket 与音频收发都搬到了原生,**Dart 只收文本与状态**:
```
bluetooth_manager(AiPcmBridge) → azure_speech(BesAiPcmBridge 攒 100ms 包)
→ BailianDialogClient(WS) → 合成 PCM 原路回 AiAudioDownlink
```
- 按会话开关,**两端都要开**:`BailianNativeDialog.start()`(azure_speech 端)+
`DeviceFeatures.aiPcmNativeBridge`(bluetooth_manager 端)。任一端不支持就整条退回
Dart 的 `BailianMultimodalService`。**不能只开一半**:只开翻译端一帧都收不到,
只开设备端 PCM 被摘走没人接,两种都表现为「唤醒了但 AI 不说话」。
- **手机麦那条链路不动**(EMAI 页按住说话),音频本来就在 Dart 侧采集,没有下沉的意义。
- **指令解析刻意留在 Dart**:`extra_info.tool_calls` 那些坑有一批真实 payload 的金测试守着
(`test/assistant_directive_test.dart`),解析结果又要落进 GetStorage 和「代办」页。
原生把 `extra_info` 原样转成字符串带上来,走同一个解析器。
- **门控(AI 播报时把上行换成等长静音)只有逐帧替换在原生**,什么时候开关门仍在
`DeviceAiSessionService`:那套有兜底计时、非致命错误要关门、纯指令帧收尾也要关门的分支
是来之不易的(门卡开着 = 会话活着但是聋的)。
- Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数估播完时刻,
所以原生按 100ms 报一次累计 `bytes`;设备上行有声按 500ms 报一次 `micActivity` 续空闲计时。
⚠️ `micActivity` **不能当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列
(「真人插话」的语义),混用会让 AI 每说一句就被自己的回声打断一次。
- ⚠️ **`AiAudioDownlink.flush()` 的语义是「清空队列」不是「把尾包发出去」**
(`outQueue.clear()`)。名字容易读反,在「本轮播报结束」处调它等于把每句回复的尾音切掉。
真正要丢弃待播的只有真人插话,走 `_spk?.discardPending()`。
- 握手参数、事件名映射、心跳间隔在 Dart / Swift / Kotlin 三份实现里逐条对齐;
配置解析(apiKey / appId / workspaceId)与 MCP 工具名**只在 Dart 维护一份**,
由 `start` 的参数传给原生(`test/bailian_native_parity_test.dart` 守着)。
对不上不报任何错,只表现为「用耳机问和在 App 里问,助手的能力不一样」。
#### EMAI 下行播放两端都走原生(`PcmSpeakerPlayer`)
Dart 的 `PcmStreamPlayer` 现在**默认走原生播放器**(`azure_speech/pcm_player`,
iOS `AVAudioEngine` / Android `AudioTrack`,方法名 start/feed/end/stop/dispose 逐字一致),
只有老包没有这条通道时才退回 just_audio。
- iOS 早就是原生的,那边是**被迫**:AVPlayer 放不了 just_audio 代理服务给的
「长度未知的 chunked WAV」,只发一次 `Range: bytes=0-1` 的嗅探就判定不可播、
回 `-11850 Operation Stopped`,一次都没真正起播。
- Android 是**为了少绕一圈**:ExoPlayer 不发那个嗅探请求,所以老路一直能用,但一条
实时语音流要先经过 just_audio 内部起的**本地代理 HTTP 服务**(chunked 编码 →
localhost TCP → ExoPlayer)。直接喂 AudioTrack 更短、起播更快。
- ⚠️ **播完的判定不能是「队列排空」**:Android 侧按 `playbackHeadPosition` 对比已写入
帧数(iOS 用 `.dataPlayedBack` 而不是 `.dataRendered`,同一个道理)。按排空报完成,
最后一两百毫秒还在缓冲里没出声,调用方据此回 `LocalRespondingEnded`,尾音被切掉。
- ⚠️ AudioTrack 的 `AudioAttributes` 与 `emai_audio_session.dart` 里声明的
`AndroidAudioAttributes` 对齐(`USAGE_MEDIA` + `CONTENT_TYPE_SPEECH`)。
改这两行会改变输出路由(比如 A2DP 还走不走)。
- 原生路径**只支持单声道**(两端都是 mono 链路),`channels != 1` 时自动退回 just_audio,
而不是把交织 PCM 当单声道播成两倍语速。当前两个调用点(EMAI 24k、车载香薰 16k)都是单声道。
#### 顺带清掉的每帧 Rx
电平原来是**每帧** `level.value = rms(pcm)`(50~100 次/s),而读它的只有 100ms 一次的
采样定时器——每 5 次里有 4 次是唤醒了 Obx 又被下一帧覆盖。现在一律:每帧只更新**纯字段的
窗口峰值**,由采样定时器发布。涉及 `device_recording_service` / `device_call_recording_service` /
`pcm_stream_player` / `mic_pcm_streamer` / `meeting_record_controller`。
`meeting_record_controller.rms` 在 meeting 模块里**一个 widget 消费方都没有**。
⚠️ `WavWriter.write` 的 `onWritten` 回调**已删除**:它把 UI 挂在文件写入队列的尾巴上,
队列一堵界面跟着停、写失败波形整段消失。电平在调用方直接从那块 PCM 上算,与落盘无关。
⚠️ **CocoaPods 的 `source_files` 是在 `pod install` 时展开通配的**:给
`bluetooth_manager`(CocoaPods 包)新增 .swift 文件后必须 `cd ios && pod install`,
否则报 `Cannot find 'XXX' in scope`,看着像 import 问题。azure_speech 是 SwiftPM 包,不受影响。
### 苹果测试那一轮一并修掉的四处(2026-09-20)
- **iOS 字幕小窗被别的 App 的画中画顶掉后再也不回来**:iOS 全系统只允许一个画中画。`SubtitlePipController` 现在区分
「我们主动停」和「被系统停」,后者退避重试(3/6/12s,5 次)并在 App 下次 `willResignActive` 时再拉一次;
状态经 `native_plugin` 通道的 `subtitlePipStateChanged` 回传(`started`/`stopped`/`stoppedBySystem`/`failed`),
`isFloatingWindowEnabled` 以此为准(原来只在进页面查一次,被顶掉后点开关是「关」,要点两下)。
⚠️ **两条真机实测出来的前置条件,都会让画中画"静默失败"**(2026-09-21,`SubtitlePip.diagnostics()` 是为此加的,
起不来时先问它,别靠猜):
1. **音频会话类别必须支持后台音频**(`.playback` / `.playAndRecord` / `.multiRoute`)。类别是 `.ambient` / `.soloAmbient`
时 `startPictureInPicture()` **连 `failedToStartPictureInPicture` 都不回调**,表现就是"点了什么都没发生"。
所以 `subtitlePipStart` 仍要配会话,但**只在当前类别撑不起画中画时才动**(`prepareAudioSessionForPip`)——
无条件改成 `.playback` 会把通话/多媒体翻译正在跑的 `.playAndRecord` 录音链路踩掉,而 `.playAndRecord` 本身就满足要求。
2. **App 在前台时不会真正进小窗**:`possible=true` 但 `active` 恒为 false,并回调一次 `failed`。
要等 App 切到后台才由 `canStartPictureInPictureAutomaticallyFromInline` / 我们的 `willResignActive` 重试拉起来。
这是 sample-buffer 画中画的正常行为,**不是 bug**;真机实测:前台 `failed` → 切后台 `started` → 小窗浮在别的 App 上。
- **语音纪要「列表显示总结完成、第一次点进去是空的」**:`OverviewTab` 原来在 `initState` 快照一次,`SummaryTab` 只听 `personnelNumberModifications`;
现在两个 tab 都跟着 `meetingDetails` / `summaryNumberModifications` 重画。`MeetingDetailsController` 的 `ever(tasktype)` 只取
service 里**非空**的值(原来会用空值把刚拉回来的总结抹掉),`_everType` 门槛删除;进行中状态每 6s 自己拉一次
`refreshFromServer()`、切后台回来补拉一次、占位图上有「刷新」按钮——不再依赖那个会被首页退出清空的全局轮询。
`MeetingTaskService.addTask` 改成返回是否受理,没受理不再把页面切成「等待转写」。
- **Ask AI 抽屉的 X 点不到**:`SafeArea` 挪到受约束的 Container 里面、顶部留 `viewPadding.top`、不再重复减 `viewInsets.bottom`
(GetX 已经减过一次)。原来抽屉比屏幕只矮 18pt,标题行整个埋在状态栏里。
- **EMAI 耳机对话锁屏易断、蜂窝易断连**:`DeviceAiSessionService` 的空闲超时 5s→15s 且设备上行有声(RMS≥600)也算活动;
百炼 WS 掉线先重连 2 次(1s/3s)再拆会话;设备断连给 3s 观察期;iOS 会话期间用 `BackgroundKeepalive`
(just_audio 循环 `assets/audio/silence_1s.wav`,`playback+mixWithOthers`)撑住 `audio` 后台模式——Android 有前台服务,iOS 原来什么都没有。
### 面对面 / 同声翻译:延迟构成与已做的优化(2026-09-03) ### 面对面 / 同声翻译:延迟构成与已做的优化(2026-09-03)
这两个模式**不走端到端**(端到端只用在 call 模式),是 ASR → HTTP 机器翻译 → TTS 这两个模式**不走端到端**(端到端只用在 call 模式),是 ASR → HTTP 机器翻译 → TTS
@ -832,18 +1124,33 @@ Android/iOS 两端同改,改动都在各自的 `AliyunBailianE2EHelper` 的 `h
`test/version_compare_test.dart` **在当前工作区里并不存在**(version_update_service.dart `test/version_compare_test.dart` **在当前工作区里并不存在**(version_update_service.dart
有大量未提交改动),所以 OTA 这条没有复用它,版本比较是本地实现的。 有大量未提交改动),所以 OTA 这条没有复用它,版本比较是本地实现的。
### ⚠️ 私有协议命令号会随固件版本变(2026-09-02 实测) ### ⚠️ 私有协议命令号是 (产品, 固件版本) 的函数,不是全局常量
**下面那张命令表是在 Echo-one 固件 0.0.2 上扫出来的,固件 0.0.3 上 `AA 09` 已经不回 **每个产品有自己的固件线,指令集也各是各的**,下面那张表只属于 **Echo-one**。
`BB 0A` 了**——连 `BB 89` ack 都没有,静默无回包,表现就是「电量那块不显示」。 换产品要重新拿厂商文档 + 实测,换固件版本要重扫;任何情况下都别把这张表
同一次连接里 `AA 06`(固件版本)仍然正常回 `BB 86`,所以不是链路问题,是命令号变了。 当成「恒玄耳机通用协议」跨产品套用。
结论:**换固件就要重扫命令**,别把这张表当稳定契约。重扫用 同一个 `AA 09`(查电量)在 Echo-one 自己的固件线上就反复变过:
`BesBluetoothService.to.probeCommands(from: 0x01, to: 0x20)`(当初 `AA 09` 就是在这段扫出来的;
`0x21~0x7F` 已确认整段无回包,2026-09-01 扫过 69 个命令,不用再浪费 28 秒)。 | Echo-one 固件 | `AA 09` → `BB 0A` | 实测日期 |
|---|---|---|
| 0.0.2 | ✅ 有效(这张表就是在它上面扫出来的) | 2026-09-01 |
| 0.0.3 | ❌ 静默无回包,连 `BB 89` ack 都没有 | 2026-09-02 |
| 0.0.8 | ✅ 又有效了,解出 L=90 R=86 | 2026-09-21 |
重扫用 `BesBluetoothService.to.probeCommands(from: 0x01, to: 0x20)`(当初 `AA 09` 就是在这段
扫出来的;`0x21~0x7F` 已确认整段无回包,2026-09-01 扫过 69 个命令,不用再浪费 28 秒)。
`0x5x`/`0x6x` 会切工作模式(进通话/录音/AI),任何情况下都别扫。 `0x5x`/`0x6x` 会切工作模式(进通话/录音/AI),任何情况下都别扫。
### 恒玄耳机电量:`AA 09` 请求 / `BB 0A` 上报(2026-09-01 真机实测定稿,**仅适用固件 0.0.2**) ⚠️ **「`AA 06` 成功、`AA 09` 无回包」不足以判定命令号失效**——2026-09-21 就这么误判过一次。
握手里这两条是**紧挨着连发**的(中间没有延时),而当时 iOS 的 BLE 写入刚加了无应答写的
流控队列:第一条赶上 `canSendWriteWithoutResponse` 为真、直发成功;第二条紧随其后,
CoreBluetooth 刚吃下一个写请求、`canSend` 转假,于是进了队列等 `peripheralIsReady` 回调——
而那个回调在队列满住后不再触发(见下文「控制命令不能和实时音频共用发送队列」),
`AA 09` 就永远卡在队列里。**表象与「固件不认这个命令号」一模一样。**
判定命令号之前,先确认同一条链路上**后发**的命令也能出去。
### 恒玄耳机电量:`AA 09` 请求 / `BB 0A` 上报(**仅适用 Echo-one**,2026-09-01 定稿于固件 0.0.2,2026-09-21 在 0.0.8 复测仍成立)
厂商协议 3.1.10「耳机响应 APP 获取状态(响应命令 20)或者耳机状态变化主动上报」。 厂商协议 3.1.10「耳机响应 APP 获取状态(响应命令 20)或者耳机状态变化主动上报」。
响应帧 `BB 0A` 与文档一致,但**请求码不是文档写的那个**——实测是 **`AA 09`**。 响应帧 `BB 0A` 与文档一致,但**请求码不是文档写的那个**——实测是 **`AA 09`**。
@ -1486,10 +1793,10 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
| 原菜单项 | 处置 | 依据 | | 原菜单项 | 处置 | 依据 |
|---|---|---| |---|---|---|
| 全局环境配置 | **下线**(页面只留重定向,表保留待人工 DROP) | `global_config` 473 行按区域整份复制凭据,且**悄悄覆盖**业务库 `config` 表的同名键 | | 全局环境配置 | **下线**(页面只留重定向,表保留待人工 DROP) | `global_config` 473 行按区域整份复制凭据,且**悄悄覆盖**业务库 `config` 表的同名键 |
| 应用环境配置 | 并入「服务与环境配置 → 应用参数」标签页 | 凭据搬走后只剩应用自己的业务参数 | | 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处 | — | | 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — |
| Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` | | Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` |
| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 | | 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留;2026-09-17 以「会议配置」的形式回来(见下文) | 见下方警告 |
⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个 ⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个
`AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*` `AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*`
@ -1499,7 +1806,7 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
**现在的下发链路**([svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `appEnvForUser`): **现在的下发链路**([svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `appEnvForUser`):
``` ```
env = 业务库 config 表(应用自有业务参数) ← svc_config 按字段的 env_key 覆盖 env = svc_config 各服务字段按 env_key 平铺(2026-09-14 起;9-12 ~ 9-14 之间还有业务库 config 表打底)
``` ```
- `comm.SvcField` 多了一个 **`EnvKey`**(后台字段表里的「下发键名」列):非空时该字段的明文值 - `comm.SvcField` 多了一个 **`EnvKey`**(后台字段表里的「下发键名」列):非空时该字段的明文值
@ -1526,6 +1833,83 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
`migrateLicenseToDeviceMac` 的取舍)。业务库 `config` 里**已确认迁走**的键则会删除。 `migrateLicenseToDeviceMac` 的取舍)。业务库 `config` 里**已确认迁走**的键则会删除。
确认新链路跑稳后由人手工 `DROP TABLE global_config;`。 确认新链路跑稳后由人手工 `DROP TABLE global_config;`。
### 「应用参数」标签页下线:业务库 config 表不再下发(2026-09-14)
后台只剩「第三方服务配置」一个页面。原第二个标签页是业务库 `config` 表的裸 key/value 编辑器,
而 `user_getappconfig` 把这张表**整张**塞进 `env`——COS 密钥、灵犀产品密钥、连「会员与算力」页写进去的
`COMPUTE_RATE_*` / `COMPUTE_GATE` 都明文发到每个客户端。逐项 grep 过服务端/客户端/原生插件/后台后,
两台机上完全相同的 22 行处置如下(迁移在 [migrate_appparams.go](apps/services/modules/console/migrate_appparams.go),
console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
| config 表的键 | 去向 |
|---|---|
| `AGENT_TYPE` / `MOBILE_ELF_AGENT_ID` / `MOBILE_ELF_PRODUCT_ID` / `MOBILE_ELF_PRODUCT_KEY`(加密) / `YIDONG_PID` | 服务 **`llm_mobile_elf`**(中国移动 灵犀 / 移动精灵,类别「文本大模型」只作分组),字段带同名 `env_key` |
| `iapCustomerServiceQQ` | 服务 **`app_params`**(内置类别 12「应用参数」),字段 `iap_customer_service_qq`;要加新的应用参数就往这个模板加字段 |
| `COMPUTE_*` / `NEWUSER_GIFT_*` / `VIP_WARN_DAYS` / `COMPUTE_WARN` / `COMPUTE_LEGACY_MIGRATED` | 留在 `config` 表,由「会员与算力」页维护,**不下发** |
| 音乐 ×5 / 公码 ×2 / 导航 / `COS_*` ×5 / `MeetServers` | 无人读,删 |
| 其它不认识的键 | 保留 + 启动日志告警(已无后台入口也不下发,要么加进落点表要么手工删) |
- 客户端四处读取改成 `AppConfig.cred('llm_mobile_elf','agent_type','AGENT_TYPE')` 这种形状,老键名作兜底。
`AppConfig.env(` 在 lib 里已是 **0 处**调用;新代码别再用它,凭据/参数都走 `cred()`。
- 图片翻译的 `ALIBABA_VL_MODEL` / `ALIBABA_VL_ENDPOINT` 原先哪张表都没有(客户端一直用写死的默认值),现在是
`ast_alibaba` 上的 `vl_model` / `vl_endpoint` 两个明文字段(留空=客户端默认)。**没挂到 `llmv_qwen`**:
那条是服务端会议总结用的 `qwen-plus`,`base_url` 是不带路径的根地址,与客户端要的视觉模型/完整地址对不上。
- `llm_mobile_elf` **停用 = 不下发 `AGENT_TYPE`** = 客户端 AI 球一律进 EMAI,这是预期行为。
- console 的 `appcfg/*` 四个接口、api 模块的 `api_getconfig/addconfig/updateconfig/delconfig` 已删(后台与客户端都没有调用方)。
- 顺手修了 [model_config.go](apps/services/modules/user/model_config.go) 的 `getmcpservers`:作用域归一后它还在查
`app_name=''` 的全局层,MCP 类服务永远读不到、且不报错。现按 `comm.AppName()` 读本应用。
### 服务配置按应用隔离 + 类别可增删改(2026-09-14)
**没有「全局默认」层了。** 原模型是 `app_name=''` 全局默认 + 应用覆盖(应用没配就回退全局行),
现在每个应用一套、**互不干涉**:应用 A 删掉某个服务,不会悄悄回退到别人那份。涉及五张表,
作用域键都是 `app_name`:`svc_config` / `svc_region_override` / `echomeet_orch` / `echomeet_orch_setting` /
`echomeet_template`(公共模板也按应用分了,后台「会议模板」页多了应用选择)。
- 迁移在 [migrate_scope.go](apps/services/modules/console/migrate_scope.go),console 启动跑、幂等:
**首个应用(`app_registry.id` 最小)拿原行改名**(id/row_id 保住,`echomeet_record` 里存的模板 id 不断),
其余应用各拷一份。跑完全局层为空;运行时那 16 处 `OR app_name=''` 回退分支再也匹配不到行,代码没删。
- ⚠️ **作用域键是 `app_registry.app_name`(应用中心的应用名,如 `EAIMAR` / `deepGlass`),不是
`app_registry.name`(部署名 `deepglass`)**。运行时 `ANALYZE_APP_NAME` 实测等于 `app_name`;
后台下拉用的也是它。用错列会让 deepGlass 一个服务都读不到,且不报错。
- ⚠️ `migrateEnvToSvcConfig` 在作用域分配之后**必须跳过**(已加守卫:`svc_config` 有非空 `app_name` 行即返回)。
否则它会从 `global_config` 把服务重新建回全局层,再被分配时与首个应用撞 `(app_name,id)` 唯一键。
新应用要凭据走后台「从其它应用复制」(`api_copysvcconfig`,密文原样搬不经前端),不再从 `global_config` 长出来。
- 后台所有写接口 `app_name` 必填(`svcScopeOK` / `meetTplScopeOK`),空值直接拒绝——前端漏传就会在谁也看不见的作用域里建出服务。
- 后台老账号的 `requireAppNameScope` 现在真的被调了(删 Agent 页之后它一度没有调用方)。
**类别落库**(`svc_category`,[model_svccategory.go](apps/services/modules/console/model_svccategory.go)),后台「类别管理」可增删改(内置 12 个,第 12 个「应用参数」是 9-14 加的)。
两种东西要分清:**展示属性**(名称/角标/颜色/排序/启用)任何类别都能改;**运行时语义**只挂在
`comm.SvcCat*`(MT=3 `user_translate`、AST=4 VIP 过期停发、MCP=10、实名=11 永不下发)和模板/会议编排引用的
内置 id(1/2/5/8/9)上——**内置 11 个 id 不可改不可删**,只能停用。自定义类别(id ≥ 100)只作分组:
叫「实名」也不会获得「不下发」的保护,`server_only` 是从 comm 读出来的只读属性,刻意不落库。
删自定义类别前查 `svc_config.categories` 与 `third_svc_template.category` 的引用,有就拒绝。
前端 `SCAT` 现在是从 `api_getsvccategories` 加载的 computed,形状与原写死数组一致;`VOICE_CATS`/`SERVER_ONLY_SCATS`
改为读类别的 `voice`/`server_only` 标记。
⚠️ **未升级到本版的应用服务会看到两份会议模板**:老代码查 `source='public'` 不带 `app_name`,
迁移后两个应用各 287 条 → 老 deepglass-a11 会拿到 574 条(重复)。升级到本版即消失。
### 服务商模板 ≠ 已接入:科大讯飞 AIUI(2026-09-14 加)
「服务商模板」只是**后台建服务时的字段清单**——加一条模板,运营就能在「第三方服务」里建出这个服务、
填凭据、按区域分叉,凭据也会随 `user_getthirdsvcs_v2` / `user_getappconfig_v3` 的 `thirdsvcs` 下发。
但**端侧有没有代码去用它是另一回事**。
`sts_iflytek_aiui`(科大讯飞 AIUI,类别 STS=7)当前就是这个状态:**模板有了,全项目没有任何一处调用**
(搜 `aiui` 零命中)。所以配完不会有任何效果,也不会报错——要真正用起来,客户端得按
`AppConfig.cred('sts_iflytek_aiui', '<字段>', '')` 取值并实现那条 WebSocket 链路。
- 归 STS 不是 STT:AIUI 是「唤醒→识别→语义→内容服务→合成」一整条,吐的是**答复**不是转写文本。
归错类别会被会议记录/通话翻译那些按类别选路的地方选中,然后拿不到期望的结果。
- 鉴权自成一套,别照别家套 key/secret:请求头 `X-Appid` / `X-CurTime` / `X-Param`(业务参数 base64)
/ `X-CheckSum = MD5(api_key + X-CurTime + X-Param)`。必填凭据只有 `app_id` 与 `api_key` 两个。
- ⚠️ `auth_id` 是**每用户/每设备一个**的标识(32 位小写字母+数字),AIUI 拿它存个性化数据与多轮上下文。
模板里默认留空(由端侧按设备生成),**填死等于全部用户共用一份上下文**,A 说的话会进 B 的对话历史。
- `languages` 预填为空是正常的:内置音色表只覆盖 azure / alibaba(`comm.TTSProviders()`),
讯飞不在表里,和 `sts_google` 同一情况,由运营自己填;巡检会提示「没配语言」。
- 契约由 `TestIflytekAIUITemplate` 守着(类别、必填字段、api_key 必须加密、默认 ws 地址)。
### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错) ### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错)
这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移: 这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移:
@ -1553,12 +1937,49 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在** ⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在**
(两台机各 4 行:`asrfile_alibaba`/`asrfile_azure` 识别、`mt_alibaba` 翻译、`llmv_qwen` 总结), (两台机各 4 行:`asrfile_alibaba`/`asrfile_azure` 识别、`mt_alibaba` 翻译、`llmv_qwen` 总结),
它是语音纪要整条流水线的服务选型来源,见 它是语音纪要整条流水线的服务选型来源,见
[orch_resolver.go](apps/services/modules/echomeet/orch_resolver.go)。**要换服务商现在只能直接改库。** [orch_resolver.go](apps/services/modules/echomeet/orch_resolver.go)。
**转写段与总结段的后台入口已于 2026-09-17 在「会议配置」页恢复(见下节);翻译段仍只能改库。**
⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate` ⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate`
不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由 不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。 [auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。
## 「会议模板」改成「会议配置」:一页管转写/总结/模板(2026-09-17)
后台菜单项 `meettemplate` 的**标签与路由**变了(`/meettemplates` → `/meetconfig`,旧地址留重定向),
**菜单 id 没变**——存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口。
**一页全展开,不分标签页**:上半区两栏(左「会议转写」、右「会议总结」,1100px 以下叠成一列),
下半区「会议模板」占满剩余空间、内容多了整页往下滚(**刻意不给模板区单独开滚动条**——
模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空)。
顶部**一个**应用选择器三块共用(都按应用隔离,各拉各的会出现「转写配的是 A、模板改的是 B」
这种对不上的状态):
| 区块 | 配什么 | 存哪 |
|---|---|---|
| 会议转写 | 有序选若干个「录音文件识别」(类别 8) 服务 | `echomeet_orch` kind=1,顺序 = `priority` 升序 |
| 会议总结 | 单选一个「文本/多媒体大模型」(类别 5/9) 服务 | `echomeet_orch` kind=3,选中的那条 `is_default=true` |
| 会议模板 | 原「会议模板」页原样搬来 | `echomeet_template`,`source='public'` |
模板页本体已拆成组件 [MeetTemplatePanel.vue](apps/admin/app/components/MeetTemplatePanel.vue)(应用名由父页面以 prop 下发),
页面壳是 [meetconfig.vue](apps/admin/app/pages/meetconfig.vue),后端是重新写的
[api_echomeet_orch.go](apps/services/modules/console/api_echomeet_orch.go)(`api_getechoorch` / `api_saveechoorch`)。
- **「选一个就用一个、选两个第二个兜底」是运行时本来就有的行为**,这一页只是把它的入口补回来:
`PickASR` 按 priority 升序取第一个「已启用 + 支持源语言」的,提交失败由 `NextASR` 顺延下一家
(2026-09-15 线上 `asrfile_alibaba` 的 key 失效、排后面的 `asrfile_azure` 完好却一次没被碰,
就是因为当时还没有 `NextASR`)。**总结段没有兜底**:`PickLLM` 只认 `is_default`,选中的不可用就直接失败。
- ⚠️ **保存是声明式的,两段各自独立**:请求里 `asr` / `llm` 都是**指针**,缺哪个就不动哪段。
写成值类型的话「保存会议总结」会带一个空的 `asr` 数组过来,把转写编排整段清空。
- ⚠️ **翻译段 (kind=2) 本页不编辑,保存时必须原样保留**:它同样是语音纪要流水线的一环,
跟着一起 reconcile 会让纪要能转写、**译文整体消失**。
- ⚠️ **服务池查询是 `app_name=?`,不带 `OR app_name=''`**:2026-09-14 作用域归一后没有全局默认层了,
照旧版写回退查询会让一个应用读到另一个应用(或迁移前残留的全局行)的服务选型,且不报任何错。
- 本页只编辑 `region=0`(全区域)那一层。区域分叉(`region>0`)优先级更高、仍由运行时消费,
接口把它们的行数回报给前端并在页面上明说——后台显示的若与线上跑的不是一回事,排查会被带偏。
- 段 → 服务类别的映射在 `echoKindSvcCats`(转写 8、翻译 3、总结 5+9),
`TestEchoKindSvcCats` 钉着它:类别编号是三处同写的(comm / console / admin 前端 `SCAT`),
漏改一处的表现是**某一段的下拉空空如也且不报任何错**,很容易去怀疑数据。
## 第三方服务配置:服务端专用类别(2026-08-28 新增) ## 第三方服务配置:服务端专用类别(2026-08-28 新增)
`svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go) `svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go)
@ -1598,6 +2019,27 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
- 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。
- 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 - 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。
#### 账号 ↔ 实名记录的绑定口径(2026-09-19 梳理并修)
纯逻辑集中在 [idverify_binding.go](apps/services/modules/user/idverify_binding.go),落库在 `model_idverify.go`。
- **`useridverify` 明细是权威,`user.idverified/idverifiedtime` 只是派生缓存**(后台列表筛选用)。
客户端闸门 `user_getidverify` 只读明细。两边是两次独立写,所以 home 启动时 `reconcileUserFlags`
双向校正(明细通过而标识没打 → 补;标识打了而明细没通过 → 清),幂等。
- ⚠️ **已实名账号「修改认证」失败(不一致 / 服务商报错)时,明细上的身份字段一律不动**
(`applyIdVerifyAttempt`)。原实现先无条件覆盖姓名/掩码/指纹再看结果,一次填错就把账号上的
实名身份换成了没核验通过的人,`verifytime` 却还是通过状态;再提交回原来那个人又被当换人再计费。
- **注销必须同时清 `idverified / idverifiedtime / gender`**(`scrubCancelledUserIdVerify`)。
原先只删明细行,账号在后台仍显示「已实名」、详情却没有姓名证件;复活分支不碰这三个字段,只能在注销时清。
- **换人 / 注销留痕到 `useridverify_log`**(本地 gorm 结构,不进 pb):只存掩码 + 指纹,**不存姓名**
(注销要清 PII)。留痕失败只告警不阻断注销。用户投诉「身份证被别的账号用过」时按 `oldhash/newhash` 查。
- ⚠️ **`user` 表是 `utf8mb4_general_ci`(老库导入),gorm 新建的表是 `utf8mb4_0900_ai_ci`**,
两表联查直接报 `1267 Illegal mix of collations`。`alignCollation` 启动时把实名两张表 CONVERT 到
与 `user` 表一致;手写联表 SQL 时记得加 `COLLATE`。其它 gorm 新建表与 `user` 联查同样会踩这个。
- **未修、属产品决策**:① 实名跟 uid 走,游客号实名后换手机号登录是另一个 uid,要再实名一次(再计费),
没有账号合并/转正接口——这是「同证多号」最自然的来源,后台「同证件账号数」分不清是一人多号还是多人共证;
② 实名按应用库隔离,同一个人在 ym / deepGlass 要各实名一次。
## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改) ## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改)
**客户端不再持有对象存储的长期密钥。** **客户端不再持有对象存储的长期密钥。**
@ -1654,6 +2096,28 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
- ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。 - ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。
- 早期那套 `deploy.sh <env> [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。 - 早期那套 `deploy.sh <env> [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。
### 启动日志里的「未配置」是设计,ERROR 才要查(2026-09-14 清过一轮)
home/api 的各 `sys` 子系统都是「配不上就降级」:缺凭据只留一条
`init sys.xxx err: …(xx未配置,该功能不可用)` 的 **WARN**,包级函数返回 `ErrNotInited` 而不是 panic
(会 panic 的只有 `wordfilter` 与 `ip2region`,见 deploy/app/README)。阿龙测试机现在常态缺 4 项:
Google 语音、Google 登录、微信支付、支付宝、微软翻译——都是没放对应 json/pem 或没填 key,**不是故障**。
这轮修掉的两条真 ERROR:
- `firebase/auth.go:36 [sys.tavily] cannot read credentials file` —— `sys/auth/firebase` **整个包已删**。
它是 `google_auth` 的重复实现,`Auth()` 一处调用方都没有(登录类型里没有 Firebase;客户端拿的
Firebase ID Token 走 `stype=Google`)。它还在 `newSys` 里 `Log.Errorln` 一次、又把同一个 error 返回给
调用方再打一条 WARN,于是同一件事刷两行、其中一行是红的。
- `Error 1062 Duplicate entry 'admin' for key 'adnim.PRIMARY'` —— `modules/api/model.go` 每次启动无条件
`Insert` 一次默认超管、返回值还没接。改成**只在账号不存在时**插入。
⚠️ 别图省事改用 `Save`/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。
顺带两处:`sys/auth/*` 五个包的 logger 名全被复制成了 `sys.tavily`(所以 firebase 的错误显示成
`[sys.tavily]`,查的时候会被带偏),已各改各名;`google_auth` 原先 `newSys` 无条件返回成功、日志打
`init sys.google_auth success!`,而配置指的 json 根本不存在,直到有人点 Google 登录才失败——
现在启动时就判「配得上没有」(空配置 / 写的是路径但文件不在),`ApiKeyFile` 以 `{` 开头则视为内嵌 JSON 放行。
## 迁移上下文 ## 迁移上下文
源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。 源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。

264
apps/admin/app/components/AppEnvTab.vue

@ -1,264 +0,0 @@
<template>
<div>
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#f8fafc;border:1px solid #e2e8f0;color:#475569">
这里只放<b>应用自己的业务参数</b>(导航方式、客服 QQ、智能体标识…),直接读写所选部署的业务库 config 表。
第三方服务的<b>凭据请到「第三方服务」标签页配</b>——2026-09-12 起凭据统一收在那里(带加密、区域分叉与巡检),
下发给客户端时按字段上的「下发键名」自动回填一份 env,老客户端无需升级。
</div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-3">
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm text-slate-500">应用</span>
<select v-model="curAppName" class="select select-bordered select-sm min-w-40" @change="onAppNameChange">
<option value="" disabled>请选择应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
<span class="text-sm text-slate-500">部署环境</span>
<select
v-model.number="curApp"
class="select select-bordered select-sm min-w-48"
:disabled="!curAppName"
@change="loadData"
>
<option :value="0" disabled>请选择环境</option>
<option v-for="d in appDeployments" :key="d.id" :value="d.id">{{ deployLabel(d) }}</option>
</select>
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" />
</div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="openForm()">+ 新增参数</button>
</div>
<div v-if="!curApp" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🗂️</div>
<p class="text-sm">{{ curAppName ? '该应用还没有部署环境,请先在「应用中心」为它部署一个环境' : '请先在上方选择应用和部署环境' }}</p>
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr><th>键</th><th>值</th><th>描述</th><th class="text-right">操作</th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="4" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!filteredRows.length"><td colspan="4" class="text-center text-slate-400 py-8">暂无参数</td></tr>
<template v-for="grp in groupedRows" :key="grp.key">
<tr class="bg-slate-100/80">
<td colspan="4" class="py-2">
<span class="font-semibold text-slate-600 text-sm">▸ {{ grp.label }}</span>
<span class="text-xs text-slate-400 ml-2">{{ grp.items.length }} 项</span>
</td>
</tr>
<tr v-for="c in grp.items" :key="c.id" class="hover">
<td class="font-mono text-sm font-medium">{{ c.key || '' }}</td>
<td class="text-sm text-slate-500 max-w-xs truncate" :title="c.value || ''">{{ c.value || '' }}</td>
<td class="text-sm text-slate-400">{{ c.description || '' }}</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button class="btn btn-ghost btn-xs" @click="openForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delCfg(c)">删除</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- 编辑弹窗:留在同一个标签页里改,不跳走第二屏 -->
<div v-if="formOpen" class="modal modal-open">
<div class="modal-box max-w-xl">
<h3 class="font-bold text-base mb-1">{{ form.id ? '编辑参数' : '新增参数' }}</h3>
<p class="text-xs text-slate-400 mb-4">配置目标:{{ curAppLabel }}</p>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-500">分组 (group)</label>
<input v-model="form.group" class="input input-bordered input-sm w-full" placeholder="如 APP / AI / other" />
</div>
<div>
<label class="text-xs text-slate-500">键 (key) *</label>
<input v-model="form.key" class="input input-bordered input-sm w-full font-mono" placeholder="如 APP_NAVIGATION_MODE" />
</div>
<div>
<label class="text-xs text-slate-500">值 (value)</label>
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
</div>
<div>
<label class="text-xs text-slate-500">描述</label>
<input v-model="form.description" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="formOpen = false">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveCfg">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { regionLabel } from '~/utils/regions'
// 「应用环境配置」原来是独立页面(pages/appconfig.vue),2026-09-12 并进「服务与环境配置」的第二个标签页。
// 合并的理由:凭据已统一收进 svc_config,这张表剩下的只是应用自己的业务参数,
// 单独占一个一级菜单项与「第三方服务配置」并列,只会让人分不清凭据该填哪儿。
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)。
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——参数按「应用 × 环境」独立。
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number }
interface EnvOption { id: number; name: string; code: string; enabled: boolean }
interface AppConfig { id: number; group: string; key: string; value: string; description: string }
const { consoleApi } = useApi()
const { success, error } = useToast()
const apps = ref<Deployment[]>([])
const envs = ref<EnvOption[]>([])
const curAppName = ref('')
const curApp = ref(0)
const items = ref<AppConfig[]>([])
const loading = ref(false)
const submitting = ref(false)
const keyword = ref('')
const formOpen = ref(false)
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' })
const appNames = computed(() =>
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort()
)
const appDeployments = computed(() =>
apps.value.filter((a) => (a.app_name || a.name) === curAppName.value)
)
// 部署对应的环境名;区域不同也一并标出,避免同环境多区域时分不清
function deployLabel(d: Deployment) {
const e = envs.value.find((x) => x.id === d.env_id)
const envName = e ? (e.name || e.code) : d.env_id ? `环境#${d.env_id}` : '未关联环境'
const suffix = d.status === 'pending' ? '(待部署)' : ''
return d.region ? `${envName} · ${regionLabel(d.region)}${suffix}` : `${envName}${suffix}`
}
const curAppLabel = computed(() => {
const a = apps.value.find((x) => x.id === curApp.value)
return a ? `${a.app_name || a.name} · ${deployLabel(a)}` : ''
})
const filteredRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((c) => ((c.group || '') + (c.key || '')).toLowerCase().includes(kw))
})
// 按 group 分组:相同组归在一起(未分组排最前)
const groupedRows = computed(() => {
const groups: Record<string, AppConfig[]> = {}
filteredRows.value.forEach((c) => {
const g = c.group || ''
;(groups[g] = groups[g] || []).push(c)
})
const names = Object.keys(groups).sort((a, b) => {
if (a === '') return -1
if (b === '') return 1
return a < b ? -1 : 1
})
return names.map((g) => ({ key: g, label: g || '未分组', items: groups[g] }))
})
async function loadApps() {
try {
const [list, es] = await Promise.all([
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
apps.value = list ?? []
envs.value = es ?? []
if (!curAppName.value && appNames.value.length) {
curAppName.value = appNames.value[0]!
pickDefaultDeployment()
await loadData()
}
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
apps.value = []
envs.value = []
}
}
// 在当前应用下挑一个默认部署环境;优先已接入的(待部署的业务库通常还连不上)
function pickDefaultDeployment() {
const list = appDeployments.value
const first = list.find((d) => d.status !== 'pending') ?? list[0]
curApp.value = first?.id ?? 0
}
function onAppNameChange() {
pickDefaultDeployment()
loadData()
}
async function loadData() {
if (!curApp.value) {
items.value = []
return
}
loading.value = true
try {
const data = await consoleApi<{ items: AppConfig[] }>('appcfg/list', { app_id: curApp.value })
items.value = data?.items ?? []
} catch (e: any) {
error(e?.message ?? '获取参数失败')
items.value = []
} finally {
loading.value = false
}
}
function openForm(cfg?: AppConfig) {
form.id = cfg?.id ?? 0
form.group = cfg?.group ?? ''
form.key = cfg?.key ?? ''
form.value = cfg?.value ?? ''
form.description = cfg?.description ?? ''
formOpen.value = true
}
async function saveCfg() {
if (!curApp.value) { error('请先选择应用和部署环境'); return }
if (!form.key.trim()) { error('键(key) 必填'); return }
submitting.value = true
try {
const data: Record<string, unknown> = {
app_id: curApp.value,
group: form.group.trim(),
key: form.key.trim(),
value: form.value,
description: form.description.trim(),
}
if (form.id) {
data.id = Number(form.id)
await consoleApi('appcfg/update', data)
} else {
await consoleApi('appcfg/add', data)
}
success('保存成功')
formOpen.value = false
loadData()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delCfg(c: AppConfig) {
if (!window.confirm(`确定删除参数 ${c.key} 吗?`)) return
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadApps)
</script>

739
apps/admin/app/components/MeetTemplatePanel.vue

@ -0,0 +1,739 @@
<template>
<div>
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ -->
<div v-if="view === 'list'">
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
<p class="text-xs text-slate-400 max-w-3xl">
会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
</p>
<button class="btn btn-primary btn-sm text-white shrink-0" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
</div>
<!-- 筛选条 -->
<div class="panel p-3 mb-5">
<div class="flex flex-wrap items-center gap-2">
<select v-model="catFilter" class="select select-bordered select-sm w-40">
<option value="">全部分类</option>
<option v-for="c in catOptions" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="typeFilter" class="select select-bordered select-sm w-40">
<option value="">全部类型</option>
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="kwFilter" class="input input-bordered input-sm w-56" placeholder="搜索标题 / tid / 描述" />
<span class="ml-auto text-xs text-slate-400">
{{ cards.length }} 个模板 · {{ rows.length }} 个语言版本 · 命中 {{ filteredCards.length }}
</span>
</div>
</div>
<div v-if="loading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else-if="!filteredCards.length" class="panel py-16 text-center text-slate-400 text-sm">
{{ rows.length ? '当前筛选下没有匹配的模板' : '暂无模板,点击右上角「新增模板」创建' }}
</div>
<div v-else class="tpl-grid">
<div v-for="c in filteredCards" :key="c.tid" class="tpl-card" @click="openDetail(c)">
<div class="tc-top">
<div class="tc-ico">
<img v-if="isImg(c.primary.icon)" :src="c.primary.icon" alt="" />
<span v-else>{{ initials(c) }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="tc-title" :title="c.primary.title">{{ c.primary.title || '(未命名)' }}</div>
<div class="tc-tid" :title="c.tid">{{ c.tid || '无 tid' }}</div>
</div>
</div>
<div class="tc-desc">{{ c.primary.description || '(无描述)' }}</div>
<div class="tc-tags">
<span v-if="c.primary.ttype" class="chip chip-type">{{ c.primary.ttype }}</span>
<span v-for="(tag, i) in tagList(c.primary.tags)" :key="i" class="chip">{{ tag }}</span>
</div>
<div class="tc-foot">
<span class="chip chip-lang">{{ c.langs.length }} 种语言</span>
<span v-if="!c.hasPrimary" class="chip chip-warn">缺 {{ PRIMARY_LANG }},按 {{ c.primary.language }} 展示</span>
<span class="ml-auto tc-go">查看 →</span>
</div>
</div>
</div>
</div>
<!-- ══════════════ 详情视图:一个 tid 的全部语言版本 ══════════════ -->
<div v-else-if="view === 'detail' && current">
<div class="flex flex-wrap items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm" @click="backToList()">&larr; 返回列表</button>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-slate-800 truncate">{{ form.title || current.tid }}</h2>
<div class="text-xs text-slate-400 font-mono">{{ current.tid }} · {{ current.langs.length }} 种语言</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn btn-sm" @click="openSync()">同步到其他语言</button>
<button class="btn btn-sm" @click="openAddLang()">+ 添加语言</button>
<button class="btn btn-ghost btn-sm text-red-500" @click="delWholeTid()">删除整组</button>
</div>
</div>
<!-- 语言切换条 -->
<div class="panel p-3 mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-slate-400 shrink-0">语言版本</span>
<button
v-for="l in current.langs"
:key="l.language"
class="lang-pill"
:class="{ on: l.language === curLang, primary: isPrimary(l.language) }"
:title="langName(l.language)"
@click="switchLang(l.language)"
>
{{ l.language }}
<span class="lp-name">{{ langName(l.language) }}</span>
</button>
</div>
</div>
<!-- 当前语言内容 -->
<div class="panel p-6">
<div v-if="detailLoading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<form v-else @submit.prevent="saveCurrent()">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<span class="badge badge-outline">{{ curLang }}</span>
<span class="text-sm text-slate-500">{{ langName(curLang) }}</span>
<span v-if="isPrimary(curLang)" class="chip chip-primary">主语言</span>
<span class="text-xs text-slate-300 font-mono ml-2">id {{ form.id }}</span>
<div class="ml-auto flex gap-2">
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="delCurrentLang()">
删除此语言版本
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="form.title" type="text" class="input input-bordered w-full" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">类型 (ttype)</span></label>
<input v-model="form.ttype" type="text" class="input input-bordered w-full" list="ttypelist" />
<datalist id="ttypelist">
<option v-for="t in typeOptions" :key="t" :value="t" />
</datalist>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">标签 (tags)</span></label>
<input v-model="form.tags" type="text" class="input input-bordered w-full" placeholder="逗号分隔" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">图标 (icon)</span></label>
<input v-model="form.icon" type="text" class="input input-bordered w-full" placeholder="图标 URL 或名称" />
</div>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">大纲 (outline)</span></label>
<textarea v-model="form.outline" class="textarea textarea-bordered w-full font-mono text-sm" rows="5"></textarea>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">模板内容 (template)</span></label>
<textarea v-model="form.template" class="textarea textarea-bordered w-full font-mono text-sm" rows="16"></textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存当前语言</button>
<button type="button" class="btn btn-ghost" :disabled="submitting" @click="reloadCurrent()">放弃修改</button>
</div>
</form>
</div>
</div>
<!-- ══════════════ 同步弹窗 ══════════════ -->
<div v-if="syncOpen" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-base mb-1">同步到其他语言</h3>
<p class="text-xs text-slate-400 mb-4">
以 <span class="font-mono">{{ curLang }}</span> 版本为源,把勾选字段覆盖写入所选语言版本。
标题/描述/大纲/模板属译文,覆盖后目标语言的译文会丢失,请谨慎勾选。
</p>
<div class="mb-4">
<div class="text-sm font-medium text-slate-600 mb-2">同步哪些字段</div>
<div class="flex flex-wrap gap-x-5 gap-y-2">
<label v-for="f in SYNC_FIELDS" :key="f.key" class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="syncFields" type="checkbox" :value="f.key" class="checkbox checkbox-sm checkbox-primary" />
<span>{{ f.label }}</span>
<span v-if="f.translated" class="chip chip-warn">译文</span>
</label>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-medium text-slate-600">同步到哪些语言</span>
<span class="text-xs text-slate-400">已选 {{ syncLangs.length }} / {{ otherLangs.length }}</span>
<button type="button" class="btn btn-ghost btn-xs ml-auto" @click="syncLangs = otherLangs.slice()">全选</button>
<button type="button" class="btn btn-ghost btn-xs" @click="syncLangs = []">清空</button>
</div>
<div class="lang-picker">
<label v-for="l in otherLangs" :key="l" class="lang-opt">
<input v-model="syncLangs" type="checkbox" :value="l" class="checkbox checkbox-xs checkbox-primary" />
<span class="lo-code">{{ l }}</span>
<span class="lo-name">{{ langName(l) }}</span>
</label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="syncOpen = false">取消</button>
<button
class="btn btn-primary text-white"
:disabled="submitting || !syncFields.length || !syncLangs.length"
@click="doSync()"
>
同步到 {{ syncLangs.length }} 个语言
</button>
</div>
</div>
</div>
<!-- ══════════════ 添加语言弹窗 ══════════════ -->
<div v-if="addLangOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">为「{{ current?.tid }}」添加语言版本</h3>
<p class="text-xs text-slate-400 mb-4">
新版本以当前 <span class="font-mono">{{ curLang }}</span> 版本的内容为初稿,创建后再逐项译成目标语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">语言码 *</span></label>
<input
v-model="newLang"
class="input input-bordered w-full"
list="addlanglist"
placeholder="BCP-47,如 en-US"
/>
<datalist id="addlanglist">
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.name }}</option>
</datalist>
<span v-if="newLangErr" class="text-xs text-rose-600 mt-1">{{ newLangErr }}</span>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="addLangOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doAddLang()">创建</button>
</div>
</div>
</div>
<!-- ══════════════ 新增模板弹窗 ══════════════ -->
<div v-if="createOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">新增模板</h3>
<p class="text-xs text-slate-400 mb-4">
先建出主语言版本,创建后在详情页补齐内容,再用「添加语言」扩展其他语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">模板标识 tid *</span></label>
<input v-model="createTid" class="input input-bordered w-full" placeholder="如 Meeting-WeeklyMeeting" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="createTitle" class="input input-bordered w-full" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">语言</span></label>
<input v-model="createLang" class="input input-bordered w-full" list="addlanglist" />
</div>
<span v-if="createErr" class="text-xs text-rose-600 mt-2 block">{{ createErr }}</span>
<div class="modal-action">
<button class="btn btn-ghost" @click="createOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doCreate()">创建</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions'
interface MeetTemplate {
id: number
title: string
tid: string
language: string
ttype: string
tags: string
icon: string
description: string
outline: string
template: string
}
// 一个 tid 的聚合视图:主语言版本用于卡面展示,langs 是该 tid 下的全部语言版本。
interface TplCard {
tid: string
primary: MeetTemplate // 主语言(中文)版本;没有则回退到第一个可用语言
hasPrimary: boolean
langs: MeetTemplate[]
}
const PRIMARY_LANG = 'zh-CN'
// 可同步字段。translated=true 的属各语言译文,同步等于用源语言覆盖,默认不勾。
const SYNC_FIELDS = [
{ key: 'ttype', label: '类型 ttype' },
{ key: 'tags', label: '标签 tags' },
{ key: 'icon', label: '图标 icon' },
{ key: 'title', label: '标题 title', translated: true },
{ key: 'description', label: '描述 description', translated: true },
{ key: 'outline', label: '大纲 outline', translated: true },
{ key: 'template', label: '模板内容 template', translated: true },
] as const
const { webApi } = useApi()
const { success, error } = useToast()
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名由「会议配置」页统一下发,
// 这里不再自己拉一份应用列表——同一页上三块配置共用一个应用选择器,各拉各的会出现
// 「转写配的是 A、模板改的是 B」这种对不上的状态。
const props = defineProps<{ appName: string }>()
const scopeApp = computed(() => props.appName)
const view = ref<'list' | 'detail'>('list')
const rows = ref<MeetTemplate[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const submitting = ref(false)
const catFilter = ref('')
const typeFilter = ref('')
const kwFilter = ref('')
const current = ref<TplCard | null>(null)
const curLang = ref('')
// 详情大字段(outline/template)列表接口不返回,按 id 单独拉;缓存避免来回切语言重复请求。
const detailCache = new Map<number, MeetTemplate>()
const form = reactive<MeetTemplate>({
id: 0, title: '', tid: '', language: '', ttype: '', tags: '',
icon: '', description: '', outline: '', template: '',
})
const syncOpen = ref(false)
const syncFields = ref<string[]>([])
const syncLangs = ref<string[]>([])
const addLangOpen = ref(false)
const newLang = ref('')
const newLangErr = ref('')
const createOpen = ref(false)
const createTid = ref('')
const createTitle = ref('')
const createLang = ref(PRIMARY_LANG)
const createErr = ref('')
function tagList(s?: string) {
return (s || '').split(',').map((x) => x.trim()).filter(Boolean)
}
function isImg(s?: string) {
return /^https?:\/\//i.test(s || '')
}
function isPrimary(lang: string) {
return lang === PRIMARY_LANG
}
// tid 惯例是 `分类-名称`(General-Meeting / Speech-Lecture),取前缀当分类。
function catOf(tid: string) {
const i = (tid || '').indexOf('-')
return i > 0 ? tid.slice(0, i) : '其他'
}
function initials(c: TplCard) {
const t = (c.primary.title || c.tid || '?').trim()
return t.slice(0, 1).toUpperCase()
}
// ── 按 tid 聚合成卡片 ──
const cards = computed<TplCard[]>(() => {
const byTid = new Map<string, MeetTemplate[]>()
rows.value.forEach((t) => {
const k = t.tid || ''
if (!byTid.has(k)) byTid.set(k, [])
byTid.get(k)!.push(t)
})
const out: TplCard[] = []
byTid.forEach((items, tid) => {
const langs = items.slice().sort((a, b) => {
// 主语言永远排最前,其余按语言码字典序
if (isPrimary(a.language) !== isPrimary(b.language)) return isPrimary(a.language) ? -1 : 1
return (a.language || '') < (b.language || '') ? -1 : 1
})
const zh = langs.find((t) => isPrimary(t.language))
out.push({ tid, primary: zh || langs[0]!, hasPrimary: !!zh, langs })
})
return out.sort((a, b) => (a.tid < b.tid ? -1 : 1))
})
const catOptions = computed(() =>
Array.from(new Set(cards.value.map((c) => catOf(c.tid)))).sort(),
)
const typeOptions = computed(() =>
Array.from(new Set(rows.value.map((t) => t.ttype).filter(Boolean))).sort(),
)
const filteredCards = computed(() => {
const kw = kwFilter.value.trim().toLowerCase()
return cards.value.filter((c) => {
if (catFilter.value && catOf(c.tid) !== catFilter.value) return false
if (typeFilter.value && c.primary.ttype !== typeFilter.value) return false
if (!kw) return true
return `${c.tid} ${c.primary.title} ${c.primary.description}`.toLowerCase().includes(kw)
})
})
// 当前 tid 下除当前语言外的其它语言码(同步/添加语言用)
const otherLangs = computed(() =>
(current.value?.langs ?? []).map((t) => t.language).filter((l) => l && l !== curLang.value),
)
const addableLangs = computed(() => {
const has = new Set((current.value?.langs ?? []).map((t) => t.language))
return ALL_LANG_OPTIONS.filter((o) => !has.has(o.code))
})
function fillForm(t: Partial<MeetTemplate>) {
form.id = t.id || 0
form.title = t.title || ''
form.tid = t.tid || ''
form.language = t.language || ''
form.ttype = t.ttype || ''
form.tags = t.tags || ''
form.icon = t.icon || ''
form.description = t.description || ''
form.outline = t.outline || ''
form.template = t.template || ''
}
async function loadData() {
loading.value = true
try {
if (!scopeApp.value) { rows.value = []; loading.value = false; return }
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', { app_name: scopeApp.value })
rows.value = d?.templates ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
rows.value = []
} finally {
loading.value = false
}
}
function backToList() {
view.value = 'list'
current.value = null
curLang.value = ''
loadData()
}
function openDetail(c: TplCard) {
current.value = c
view.value = 'detail'
switchLang(c.primary.language)
}
// 切到某个语言版本:先用列表里的浅字段占位,再拉详情补 outline/template。
async function switchLang(lang: string) {
const t = current.value?.langs.find((x) => x.language === lang)
if (!t) return
curLang.value = lang
fillForm(t)
const cached = detailCache.get(t.id)
if (cached) {
fillForm(cached)
return
}
detailLoading.value = true
try {
const detail = await webApi<MeetTemplate>('getmeettemplate', { id: t.id })
if (detail) {
detailCache.set(t.id, detail)
// 只有仍停在这个语言上才回填,避免慢请求覆盖用户已切走的内容
if (curLang.value === lang) fillForm(detail)
}
} catch (e: any) {
error(e?.message ?? '加载详情失败')
} finally {
detailLoading.value = false
}
}
function reloadCurrent() {
detailCache.delete(form.id)
switchLang(curLang.value)
}
async function saveCurrent() {
submitting.value = true
try {
await webApi('updatemeettemplate', {
app_name: scopeApp.value,
id: Number(form.id),
title: form.title.trim(),
tid: form.tid.trim(),
language: form.language.trim(),
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
detailCache.delete(form.id)
success('已保存')
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
// 列表数据变动后,重新拉列表并把 current 指向同一 tid 的新聚合结果。
async function refreshCurrentCard(preferLang?: string) {
const tid = current.value?.tid
const lang = preferLang || curLang.value
await loadData()
if (!tid) return
const c = cards.value.find((x) => x.tid === tid)
if (!c) {
backToList()
return
}
current.value = c
const target = c.langs.find((x) => x.language === lang) ? lang : c.primary.language
await switchLang(target)
}
// ── 同步 ──
function openSync() {
syncFields.value = SYNC_FIELDS.filter((f) => !('translated' in f && f.translated)).map((f) => f.key)
syncLangs.value = otherLangs.value.slice()
syncOpen.value = true
}
async function doSync() {
submitting.value = true
try {
const res = await webApi<{ updated: number }>('syncmeettemplate', {
app_name: scopeApp.value,
src_id: Number(form.id),
fields: syncFields.value,
languages: syncLangs.value,
})
syncOpen.value = false
detailCache.clear()
success(`已同步 ${res?.updated ?? 0} 个语言版本`)
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '同步失败')
} finally {
submitting.value = false
}
}
// ── 添加语言 ──
function openAddLang() {
newLang.value = ''
newLangErr.value = ''
addLangOpen.value = true
}
async function doAddLang() {
const lang = newLang.value.trim()
newLangErr.value = ''
if (!lang) {
newLangErr.value = '请填写语言码'
return
}
if (current.value?.langs.some((t) => t.language === lang)) {
newLangErr.value = `该 tid 下已存在 ${lang} 版本`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', {
app_name: scopeApp.value,
title: form.title.trim(),
tid: form.tid.trim(),
language: lang,
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
addLangOpen.value = false
success(`已创建 ${lang} 版本`)
await refreshCurrentCard(lang)
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// ── 新增模板 ──
function openCreate() {
createTid.value = ''
createTitle.value = ''
createLang.value = PRIMARY_LANG
createErr.value = ''
createOpen.value = true
}
async function doCreate() {
const tid = createTid.value.trim()
const title = createTitle.value.trim()
const lang = createLang.value.trim()
createErr.value = ''
if (!tid || !title) {
createErr.value = 'tid 与标题均必填'
return
}
if (cards.value.some((c) => c.tid === tid)) {
createErr.value = `tid「${tid}」已存在,请到列表中打开它再「添加语言」`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', { app_name: scopeApp.value, tid, title, language: lang })
createOpen.value = false
success('已创建')
await loadData()
const c = cards.value.find((x) => x.tid === tid)
if (c) openDetail(c)
} catch (e: any) {
createErr.value = e?.message ?? '创建失败'
} finally {
submitting.value = false
}
}
// ── 删除 ──
async function delCurrentLang() {
if (!current.value) return
if (current.value.langs.length <= 1) {
error('这是最后一个语言版本,请用「删除整组」')
return
}
if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: [Number(form.id)] })
detailCache.delete(form.id)
success('已删除')
await refreshCurrentCard(PRIMARY_LANG)
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
async function delWholeTid() {
const c = current.value
if (!c) return
if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { app_name: scopeApp.value, ids: c.langs.map((t) => Number(t.id)) })
detailCache.clear()
success('已删除整组')
backToList()
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
// 切换应用要退回列表:详情停在上一个应用的模板上,保存时会带着新应用名写过去。
watch(scopeApp, () => {
view.value = 'list'
current.value = null
curLang.value = ''
detailCache.clear()
loadData()
})
onMounted(() => { loadData() })
</script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
.tpl-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); }
.tpl-card {
background:#fff; border:1px solid #eef2f7; border-radius:16px; padding:16px;
cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:10px;
box-shadow:0 1px 2px rgba(0,0,0,0.04);
}
.tpl-card:hover { border-color:#c7d2fe; box-shadow:0 6px 18px rgba(15,23,42,0.08); transform:translateY(-1px); }
.tc-top { display:flex; align-items:flex-start; gap:11px; }
.tc-ico {
width:40px; height:40px; border-radius:11px; flex-shrink:0; overflow:hidden;
background:linear-gradient(135deg,#eef2ff,#e0e7ff); color:#4338ca;
display:flex; align-items:center; justify-content:center; font-weight:700; font-size:17px;
}
.tc-ico img { width:100%; height:100%; object-fit:cover; }
.tc-title { font-weight:600; font-size:14.5px; color:#1e293b; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-tid { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-desc {
font-size:12.5px; color:#64748b; line-height:1.55; min-height:39px;
display:-webkit-box; -webkit-line-clamp:2; line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.tc-tags { display:flex; flex-wrap:wrap; gap:4px; min-height:20px; }
.tc-foot { display:flex; align-items:center; gap:6px; flex-wrap:wrap; padding-top:9px; border-top:1px solid #f8fafc; }
.tc-go { font-size:11.5px; color:#6366f1; font-weight:600; }
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.chip-type { background:#e0e7ff; color:#4338ca; }
.chip-lang { background:#cffafe; color:#0e7490; }
.chip-warn { background:#fef3c7; color:#b45309; }
.chip-primary { background:#d1fae5; color:#047857; }
.lang-pill {
display:inline-flex; align-items:center; gap:5px; font-size:11.5px; padding:3px 9px;
border-radius:999px; border:1px solid #e2e8f0; background:#f8fafc; color:#64748b;
cursor:pointer; transition:all .12s; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
}
.lang-pill:hover { border-color:#c7d2fe; color:#4338ca; }
.lang-pill.on { background:#4338ca; border-color:#4338ca; color:#fff; }
.lang-pill.primary:not(.on) { background:#ecfdf5; border-color:#a7f3d0; color:#047857; }
.lp-name { font-family:inherit; opacity:.75; font-size:11px; }
.lang-pill.on .lp-name { opacity:.9; }
.lang-picker {
display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:3px;
max-height:260px; overflow-y:auto; border:1px solid #eef2f7; border-radius:10px; padding:8px;
}
.lang-opt { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:6px; cursor:pointer; font-size:12px; }
.lang-opt:hover { background:#f8fafc; }
.lo-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#475569; }
.lo-name { color:#94a3b8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>

7
apps/admin/app/pages/appconfig.vue

@ -1,15 +1,16 @@
<template> <template>
<div class="py-16 text-center text-slate-400"> <div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> <p class="text-sm mt-3">本页已并入「第三方服务配置」,正在跳转…</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), // 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 // 「应用环境配置」先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),这里只剩重定向。
// 保留本路由做重定向,兼容旧书签/直链。 // 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
onMounted(() => { onMounted(() => {
navigateTo('/serviceconfig') navigateTo('/serviceconfig')
}) })

7
apps/admin/app/pages/globalconfig.vue

@ -1,15 +1,16 @@
<template> <template>
<div class="py-16 text-center text-slate-400"> <div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> <p class="text-sm mt-3">本页已并入「第三方服务配置」,正在跳转…</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), // 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 // 「应用环境配置」先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),这里只剩重定向。
// 保留本路由做重定向,兼容旧书签/直链。 // 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
onMounted(() => { onMounted(() => {
navigateTo('/serviceconfig') navigateTo('/serviceconfig')
}) })

383
apps/admin/app/pages/meetconfig.vue

@ -0,0 +1,383 @@
<template>
<div>
<!-- 页头 + 应用选择:三块配置共用一个作用域 -->
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议配置</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
语音纪要这条流水线的应用级参数:<b>会议转写</b>用哪家录音文件识别、<b>会议总结</b>用哪个大模型、
以及<b>会议模板</b>。都<b>按应用各一套</b>,互不干涉。
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40">
<option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
</div>
</div>
<div v-if="!scopeApp" class="panel py-16 text-center text-slate-400 text-sm">
请先在右上角选择应用。
</div>
<template v-else>
<!-- 上半区:左「会议转写」右「会议总结」,窄屏自动叠成一列 -->
<div class="topgrid">
<!-- ══════════════ 会议转写 ══════════════ -->
<section class="panel p-5">
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
<div>
<div class="text-sm font-semibold text-slate-700">会议转写(录音文件识别)</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
从<b>第三方服务配置</b>里已配好的「录音文件识别」类服务中选。
<b>选一个就只用这一个;选两个及以上,排在后面的是兜底</b>——
第一家提交失败(凭据失效、欠费、报错)时自动换下一家。
另外,服务若填了支持语言,遇到它不支持的源语言也会直接顺延。
</div>
</div>
<button class="btn btn-sm shrink-0" :disabled="!addableAsr.length || saving" @click="openAdd()">
+ 添加转写服务
</button>
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<template v-else>
<div v-if="!asrPick.length" class="empty mt-4">
还没有选转写服务,语音纪要会直接转写失败。
<span v-if="!svcOf('asr').length">
当前应用下也没有可选的「录音文件识别」服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。
</span>
</div>
<ol v-else class="picklist mt-4">
<li v-for="(id, i) in asrPick" :key="id" class="pick">
<span class="rank" :class="{ main: i === 0 }">{{ i === 0 ? '主用' : '兜底 ' + i }}</span>
<div class="min-w-0 flex-1">
<div class="pick-name">
{{ svcName(id) }}
<span class="pick-id">{{ id }}</span>
<span v-if="!svcMap.get(id)" class="chip chip-warn">该服务已停用或不存在</span>
</div>
<div class="pick-sub">
{{ svcProvider(id) }} ·
<template v-if="svcLangs(id)">支持 {{ svcLangs(id).length }} 种语言</template>
<template v-else>未限定支持语言(由引擎判断)</template>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button class="btn btn-ghost btn-xs" :disabled="i === 0" title="上移" @click="moveAsr(i, -1)">↑</button>
<button class="btn btn-ghost btn-xs" :disabled="i === asrPick.length - 1" title="下移" @click="moveAsr(i, 1)">↓</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="asrPick.splice(i, 1)">移除</button>
</div>
</li>
</ol>
<div class="savebar">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !asrDirty" @click="saveAsr()">保存会议转写</button>
<button class="btn btn-ghost btn-sm" :disabled="saving || !asrDirty" @click="resetAsr()">放弃修改</button>
<span v-if="asrDirty" class="text-xs text-amber-600">有未保存的改动</span>
</div>
</template>
</section>
<!-- ══════════════ 会议总结 ══════════════ -->
<section class="panel p-5">
<div class="text-sm font-semibold text-slate-700">会议总结(大模型)</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。
这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<template v-else>
<div class="form-control max-w-xl mt-4">
<label class="label"><span class="label-text font-medium">总结模型</span></label>
<select v-model="llmPick" class="select select-bordered">
<option value="">不配置(不生成总结)</option>
<option v-for="s in svcOf('llm')" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option>
<!-- 已选服务在服务池里找不到(被停用/删除)时仍要列出来,否则下拉会静默跳到「不配置」 -->
<option v-if="llmPick && !svcMap.get(llmPick)" :value="llmPick">{{ llmPick }}(已停用或不存在)</option>
</select>
</div>
<div v-if="!svcOf('llm').length" class="empty mt-4">
当前应用下没有可选的大模型服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。
</div>
<div v-if="extraLlm.length" class="alert alert-warning py-2 text-xs mt-4">
<span>
该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。
保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。
<template v-if="clientChoice">
⚠️ 该应用开着「客户端可指定总结模型」,这些服务现在可能正被 App 端选用,移除后客户端只剩这一个可选。
</template>
</span>
</div>
<div class="savebar">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !llmSavable" @click="saveLlm()">保存会议总结</button>
<button class="btn btn-ghost btn-sm" :disabled="saving || !llmDirty" @click="resetLlm()">放弃修改</button>
<span v-if="llmDirty" class="text-xs text-amber-600">有未保存的改动</span>
</div>
</template>
</section>
</div>
<!-- 区域分叉:本页只编辑「全区域」那一层,有分叉就说清楚,别让后台显示的和线上跑的不是一回事 -->
<div v-if="regionRows" class="alert alert-warning py-2 text-xs mt-4">
<span>
该应用另有 {{ regionRows }} 条<b>按区域</b>的编排,本页只编辑「全区域」这一层。
区域分叉优先于全区域,对应区域的部署实际跑的是那几条,如需调整请直接改库
(表 <span class="font-mono">echomeet_orch</span>)。
</span>
</div>
<!-- ══════════════ 会议模板:占满下方,内容多了整页往下滚 ══════════════ -->
<section class="tplsec">
<h3 class="tplsec-title">会议模板</h3>
<MeetTemplatePanel :app-name="scopeApp" />
</section>
</template>
<!-- 添加转写服务 -->
<div v-if="addOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">添加转写服务</h3>
<p class="text-xs text-slate-400 mb-4">加进列表末尾作为兜底,可再用上下箭头调整顺序。</p>
<div class="form-control">
<select v-model="addSvc" class="select select-bordered">
<option value="" disabled>选择一个服务</option>
<option v-for="s in addableAsr" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option>
</select>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="addOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="!addSvc" @click="doAdd()">添加</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '会议配置' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
// 编排类别(与后端 comm.EchoKindASR/MT/LLM 同一套数字)。翻译段(2)本页不编辑,
// 后端 saveechoorch 也不会碰它——它同样是语音纪要流水线的一环,跟着一起重置会让译文整体消失。
const KIND_ASR = 1
const KIND_LLM = 3
interface Orch {
row_id: number
app_name: string
region: number
kind: number
svc_id: string
priority: number
is_default: boolean
enabled: boolean
}
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string }
const loading = ref(false)
const saving = ref(false)
// 应用名来源同 appversion.vue:products/list ∪ apps/list,无权读时走 getmyapps。
// 用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 空列表,页面提示先选应用 */ }
}
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
}
const available = ref<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '3': [5, 9] })
const regionRows = ref(0)
// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库),
// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。
const clientChoice = ref(false)
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。
const asrSaved = ref<string[]>([])
const llmSaved = ref('')
const extraLlm = ref<string[]>([])
const asrPick = ref<string[]>([])
const llmPick = ref('')
const addOpen = ref(false)
const addSvc = ref('')
const svcMap = computed(() => {
const m = new Map<string, SvcView>()
for (const s of available.value) m.set(s.id, s)
return m
})
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id }
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '未知服务商' }
function svcLangs(id: string) {
return (svcMap.value.get(id)?.languages ?? '').split(',').map((x) => x.trim()).filter(Boolean)
}
function catsOf(s: SvcView) {
return (s.categories || '').split(',').map((x) => Number(x.trim())).filter((n) => !Number.isNaN(n))
}
// 某段可选的服务 = 服务池里 categories 命中该段类别的
function svcOf(seg: 'asr' | 'llm') {
const want = kindCats.value[String(seg === 'asr' ? KIND_ASR : KIND_LLM)] ?? []
return available.value.filter((s) => catsOf(s).some((c) => want.includes(c)))
}
const addableAsr = computed(() => svcOf('asr').filter((s) => !asrPick.value.includes(s.id)))
const asrDirty = computed(() => asrPick.value.join(',') !== asrSaved.value.join(','))
const llmDirty = computed(() => llmPick.value !== llmSaved.value)
// 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。
// 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。
const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0)
function resetAsr() { asrPick.value = asrSaved.value.slice() }
function resetLlm() { llmPick.value = llmSaved.value }
function openAdd() {
addSvc.value = ''
addOpen.value = true
}
function doAdd() {
if (addSvc.value && !asrPick.value.includes(addSvc.value)) asrPick.value.push(addSvc.value)
addOpen.value = false
}
function moveAsr(i: number, dir: number) {
const list = asrPick.value
const j = i + dir
if (j < 0 || j >= list.length) return
const tmp = list[i]!
list[i] = list[j]!
list[j] = tmp
}
async function loadOrch() {
if (!scopeApp.value) { available.value = []; asrSaved.value = []; llmSaved.value = ''; return }
loading.value = true
try {
const d = await webApi<{
items: Orch[]
available: SvcView[]
kind_cats: Record<string, number[]>
other_regions: Record<string, number>
setting?: { llm_client_choice?: boolean }
}>('getechoorch', { app_name: scopeApp.value })
available.value = d?.available ?? []
clientChoice.value = !!d?.setting?.llm_client_choice
if (d?.kind_cats) kindCats.value = d.kind_cats
regionRows.value = Object.values(d?.other_regions ?? {}).reduce((a, b) => a + b, 0)
const items = (d?.items ?? []).filter((o) => o.enabled)
asrSaved.value = items.filter((o) => o.kind === KIND_ASR).sort((a, b) => a.priority - b.priority).map((o) => o.svc_id)
// 总结取默认模型;没有标默认就按优先级第一个(与运行时 PickLLM 的口径一致)。
const llms = items.filter((o) => o.kind === KIND_LLM).sort((a, b) => a.priority - b.priority)
const def = llms.find((o) => o.is_default) ?? llms[0]
llmSaved.value = def?.svc_id ?? ''
extraLlm.value = llms.filter((o) => o.svc_id !== llmSaved.value).map((o) => o.svc_id)
resetAsr()
resetLlm()
} catch (e: any) {
error(e?.message ?? '加载失败')
available.value = []
} finally {
loading.value = false
}
}
async function saveAsr() {
saving.value = true
try {
await webApi('saveechoorch', { app_name: scopeApp.value, asr: asrPick.value })
success('会议转写已保存')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function saveLlm() {
saving.value = true
try {
await webApi('saveechoorch', { app_name: scopeApp.value, llm: llmPick.value })
success('会议总结已保存')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
watch(scopeApp, () => { loadOrch() })
onMounted(async () => {
await loadScopeApps()
await loadOrch()
})
</script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
/* 上半区两栏:左转写、右总结。两块内容长短不一,用 stretch 让卡片等高,视觉上是一排。
1100px 以下叠成一列——两栏挤在窄屏上会让服务名和按钮换行成一团。 */
.topgrid { display:grid; grid-template-columns:1fr 1fr; gap:16px; align-items:stretch; }
@media (max-width: 1100px) { .topgrid { grid-template-columns:1fr; } }
/* 两块内容长短不一,保存条用 margin-top:auto 压到卡片底部,左右才对得齐 */
.topgrid > .panel { display:flex; flex-direction:column; }
.topgrid .savebar { margin-top:auto; }
/* 会议模板占满下方剩余空间,超出就整页往下滚(不给它自己开滚动条:
模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空) */
.tplsec { margin-top:20px; }
.tplsec-title { font-size:15px; font-weight:600; color:#1e293b; margin-bottom:12px; }
.picklist { display:flex; flex-direction:column; gap:8px; }
.pick {
display:flex; align-items:center; gap:12px; padding:11px 13px;
border:1px solid #eef2f7; border-radius:12px; background:#fff;
}
.rank {
flex-shrink:0; font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px;
background:#f1f5f9; color:#64748b; white-space:nowrap;
}
.rank.main { background:#e0e7ff; color:#4338ca; }
.pick-name { font-size:13.5px; font-weight:600; color:#1e293b; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.pick-id { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-weight:400; }
.pick-sub { font-size:11.5px; color:#94a3b8; margin-top:2px; }
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.chip-warn { background:#fef3c7; color:#b45309; }
.empty { font-size:12.5px; color:#94a3b8; background:#f8fafc; border:1px dashed #e2e8f0; border-radius:12px; padding:18px; }
.link { color:#4338ca; text-decoration:underline; }
.savebar { display:flex; align-items:center; gap:10px; margin-top:20px; padding-top:16px; border-top:1px solid #f1f5f9; }
</style>

726
apps/admin/app/pages/meettemplates.vue

@ -1,725 +1,11 @@
<template> <template>
<div> <div class="py-16 text-center text-slate-400 text-sm">正在跳转到「会议配置」…</div>
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ -->
<div v-if="view === 'list'">
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议模板</h2>
<p class="text-xs text-slate-400 mt-1">
会议纪要公共模板(source=public),所有应用共享。同一 tid 下每种语言一份,卡面展示
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="openCreate()">+ 新增模板</button>
</div>
<!-- 筛选条 -->
<div class="panel p-3 mb-5">
<div class="flex flex-wrap items-center gap-2">
<select v-model="catFilter" class="select select-bordered select-sm w-40">
<option value="">全部分类</option>
<option v-for="c in catOptions" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="typeFilter" class="select select-bordered select-sm w-40">
<option value="">全部类型</option>
<option v-for="t in typeOptions" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="kwFilter" class="input input-bordered input-sm w-56" placeholder="搜索标题 / tid / 描述" />
<span class="ml-auto text-xs text-slate-400">
{{ cards.length }} 个模板 · {{ rows.length }} 个语言版本 · 命中 {{ filteredCards.length }}
</span>
</div>
</div>
<div v-if="loading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else-if="!filteredCards.length" class="panel py-16 text-center text-slate-400 text-sm">
{{ rows.length ? '当前筛选下没有匹配的模板' : '暂无模板,点击右上角「新增模板」创建' }}
</div>
<div v-else class="tpl-grid">
<div v-for="c in filteredCards" :key="c.tid" class="tpl-card" @click="openDetail(c)">
<div class="tc-top">
<div class="tc-ico">
<img v-if="isImg(c.primary.icon)" :src="c.primary.icon" alt="" />
<span v-else>{{ initials(c) }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="tc-title" :title="c.primary.title">{{ c.primary.title || '(未命名)' }}</div>
<div class="tc-tid" :title="c.tid">{{ c.tid || '无 tid' }}</div>
</div>
</div>
<div class="tc-desc">{{ c.primary.description || '(无描述)' }}</div>
<div class="tc-tags">
<span v-if="c.primary.ttype" class="chip chip-type">{{ c.primary.ttype }}</span>
<span v-for="(tag, i) in tagList(c.primary.tags)" :key="i" class="chip">{{ tag }}</span>
</div>
<div class="tc-foot">
<span class="chip chip-lang">{{ c.langs.length }} 种语言</span>
<span v-if="!c.hasPrimary" class="chip chip-warn">缺 {{ PRIMARY_LANG }},按 {{ c.primary.language }} 展示</span>
<span class="ml-auto tc-go">查看 →</span>
</div>
</div>
</div>
</div>
<!-- ══════════════ 详情视图:一个 tid 的全部语言版本 ══════════════ -->
<div v-else-if="view === 'detail' && current">
<div class="flex flex-wrap items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm" @click="backToList()">&larr; 返回列表</button>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-slate-800 truncate">{{ form.title || current.tid }}</h2>
<div class="text-xs text-slate-400 font-mono">{{ current.tid }} · {{ current.langs.length }} 种语言</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn btn-sm" @click="openSync()">同步到其他语言</button>
<button class="btn btn-sm" @click="openAddLang()">+ 添加语言</button>
<button class="btn btn-ghost btn-sm text-red-500" @click="delWholeTid()">删除整组</button>
</div>
</div>
<!-- 语言切换条 -->
<div class="panel p-3 mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-slate-400 shrink-0">语言版本</span>
<button
v-for="l in current.langs"
:key="l.language"
class="lang-pill"
:class="{ on: l.language === curLang, primary: isPrimary(l.language) }"
:title="langName(l.language)"
@click="switchLang(l.language)"
>
{{ l.language }}
<span class="lp-name">{{ langName(l.language) }}</span>
</button>
</div>
</div>
<!-- 当前语言内容 -->
<div class="panel p-6">
<div v-if="detailLoading" class="py-16 text-center">
<span class="loading loading-spinner loading-md"></span>
</div>
<form v-else @submit.prevent="saveCurrent()">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<span class="badge badge-outline">{{ curLang }}</span>
<span class="text-sm text-slate-500">{{ langName(curLang) }}</span>
<span v-if="isPrimary(curLang)" class="chip chip-primary">主语言</span>
<span class="text-xs text-slate-300 font-mono ml-2">id {{ form.id }}</span>
<div class="ml-auto flex gap-2">
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="delCurrentLang()">
删除此语言版本
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="form.title" type="text" class="input input-bordered w-full" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">类型 (ttype)</span></label>
<input v-model="form.ttype" type="text" class="input input-bordered w-full" list="ttypelist" />
<datalist id="ttypelist">
<option v-for="t in typeOptions" :key="t" :value="t" />
</datalist>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">标签 (tags)</span></label>
<input v-model="form.tags" type="text" class="input input-bordered w-full" placeholder="逗号分隔" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">图标 (icon)</span></label>
<input v-model="form.icon" type="text" class="input input-bordered w-full" placeholder="图标 URL 或名称" />
</div>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">大纲 (outline)</span></label>
<textarea v-model="form.outline" class="textarea textarea-bordered w-full font-mono text-sm" rows="5"></textarea>
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text font-medium">模板内容 (template)</span></label>
<textarea v-model="form.template" class="textarea textarea-bordered w-full font-mono text-sm" rows="16"></textarea>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存当前语言</button>
<button type="button" class="btn btn-ghost" :disabled="submitting" @click="reloadCurrent()">放弃修改</button>
</div>
</form>
</div>
</div>
<!-- ══════════════ 同步弹窗 ══════════════ -->
<div v-if="syncOpen" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-base mb-1">同步到其他语言</h3>
<p class="text-xs text-slate-400 mb-4">
以 <span class="font-mono">{{ curLang }}</span> 版本为源,把勾选字段覆盖写入所选语言版本。
标题/描述/大纲/模板属译文,覆盖后目标语言的译文会丢失,请谨慎勾选。
</p>
<div class="mb-4">
<div class="text-sm font-medium text-slate-600 mb-2">同步哪些字段</div>
<div class="flex flex-wrap gap-x-5 gap-y-2">
<label v-for="f in SYNC_FIELDS" :key="f.key" class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="syncFields" type="checkbox" :value="f.key" class="checkbox checkbox-sm checkbox-primary" />
<span>{{ f.label }}</span>
<span v-if="f.translated" class="chip chip-warn">译文</span>
</label>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-medium text-slate-600">同步到哪些语言</span>
<span class="text-xs text-slate-400">已选 {{ syncLangs.length }} / {{ otherLangs.length }}</span>
<button type="button" class="btn btn-ghost btn-xs ml-auto" @click="syncLangs = otherLangs.slice()">全选</button>
<button type="button" class="btn btn-ghost btn-xs" @click="syncLangs = []">清空</button>
</div>
<div class="lang-picker">
<label v-for="l in otherLangs" :key="l" class="lang-opt">
<input v-model="syncLangs" type="checkbox" :value="l" class="checkbox checkbox-xs checkbox-primary" />
<span class="lo-code">{{ l }}</span>
<span class="lo-name">{{ langName(l) }}</span>
</label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="syncOpen = false">取消</button>
<button
class="btn btn-primary text-white"
:disabled="submitting || !syncFields.length || !syncLangs.length"
@click="doSync()"
>
同步到 {{ syncLangs.length }} 个语言
</button>
</div>
</div>
</div>
<!-- ══════════════ 添加语言弹窗 ══════════════ -->
<div v-if="addLangOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">为「{{ current?.tid }}」添加语言版本</h3>
<p class="text-xs text-slate-400 mb-4">
新版本以当前 <span class="font-mono">{{ curLang }}</span> 版本的内容为初稿,创建后再逐项译成目标语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">语言码 *</span></label>
<input
v-model="newLang"
class="input input-bordered w-full"
list="addlanglist"
placeholder="BCP-47,如 en-US"
/>
<datalist id="addlanglist">
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.name }}</option>
</datalist>
<span v-if="newLangErr" class="text-xs text-rose-600 mt-1">{{ newLangErr }}</span>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="addLangOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doAddLang()">创建</button>
</div>
</div>
</div>
<!-- ══════════════ 新增模板弹窗 ══════════════ -->
<div v-if="createOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">新增模板</h3>
<p class="text-xs text-slate-400 mb-4">
先建出主语言版本,创建后在详情页补齐内容,再用「添加语言」扩展其他语言。
</p>
<div class="form-control">
<label class="label"><span class="label-text font-medium">模板标识 tid *</span></label>
<input v-model="createTid" class="input input-bordered w-full" placeholder="如 Meeting-WeeklyMeeting" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">标题 *</span></label>
<input v-model="createTitle" class="input input-bordered w-full" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text font-medium">语言</span></label>
<input v-model="createLang" class="input input-bordered w-full" list="addlanglist" />
</div>
<span v-if="createErr" class="text-xs text-rose-600 mt-2 block">{{ createErr }}</span>
<div class="modal-action">
<button class="btn btn-ghost" @click="createOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="submitting" @click="doCreate()">创建</button>
</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' // 2026-09-17:「会议模板」并入「会议配置」(/meetconfig)的一个标签页,页面本体已拆成
import { useApi } from '~/composables/useApi' // components/MeetTemplateTab.vue。这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,
import { useToast } from '~/composables/useToast' // 直接删文件会变成 404(同「全局环境配置」下线时的处理)。
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions' definePageMeta({ title: '会议配置' })
onMounted(() => { navigateTo('/meetconfig', { replace: true }) })
definePageMeta({ title: '会议模板' })
interface MeetTemplate {
id: number
title: string
tid: string
language: string
ttype: string
tags: string
icon: string
description: string
outline: string
template: string
}
// 一个 tid 的聚合视图:主语言版本用于卡面展示,langs 是该 tid 下的全部语言版本。
interface TplCard {
tid: string
primary: MeetTemplate // 主语言(中文)版本;没有则回退到第一个可用语言
hasPrimary: boolean
langs: MeetTemplate[]
}
const PRIMARY_LANG = 'zh-CN'
// 可同步字段。translated=true 的属各语言译文,同步等于用源语言覆盖,默认不勾。
const SYNC_FIELDS = [
{ key: 'ttype', label: '类型 ttype' },
{ key: 'tags', label: '标签 tags' },
{ key: 'icon', label: '图标 icon' },
{ key: 'title', label: '标题 title', translated: true },
{ key: 'description', label: '描述 description', translated: true },
{ key: 'outline', label: '大纲 outline', translated: true },
{ key: 'template', label: '模板内容 template', translated: true },
] as const
const { webApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'detail'>('list')
const rows = ref<MeetTemplate[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const submitting = ref(false)
const catFilter = ref('')
const typeFilter = ref('')
const kwFilter = ref('')
const current = ref<TplCard | null>(null)
const curLang = ref('')
// 详情大字段(outline/template)列表接口不返回,按 id 单独拉;缓存避免来回切语言重复请求。
const detailCache = new Map<number, MeetTemplate>()
const form = reactive<MeetTemplate>({
id: 0, title: '', tid: '', language: '', ttype: '', tags: '',
icon: '', description: '', outline: '', template: '',
})
const syncOpen = ref(false)
const syncFields = ref<string[]>([])
const syncLangs = ref<string[]>([])
const addLangOpen = ref(false)
const newLang = ref('')
const newLangErr = ref('')
const createOpen = ref(false)
const createTid = ref('')
const createTitle = ref('')
const createLang = ref(PRIMARY_LANG)
const createErr = ref('')
function tagList(s?: string) {
return (s || '').split(',').map((x) => x.trim()).filter(Boolean)
}
function isImg(s?: string) {
return /^https?:\/\//i.test(s || '')
}
function isPrimary(lang: string) {
return lang === PRIMARY_LANG
}
// tid 惯例是 `分类-名称`(General-Meeting / Speech-Lecture),取前缀当分类。
function catOf(tid: string) {
const i = (tid || '').indexOf('-')
return i > 0 ? tid.slice(0, i) : '其他'
}
function initials(c: TplCard) {
const t = (c.primary.title || c.tid || '?').trim()
return t.slice(0, 1).toUpperCase()
}
// ── 按 tid 聚合成卡片 ──
const cards = computed<TplCard[]>(() => {
const byTid = new Map<string, MeetTemplate[]>()
rows.value.forEach((t) => {
const k = t.tid || ''
if (!byTid.has(k)) byTid.set(k, [])
byTid.get(k)!.push(t)
})
const out: TplCard[] = []
byTid.forEach((items, tid) => {
const langs = items.slice().sort((a, b) => {
// 主语言永远排最前,其余按语言码字典序
if (isPrimary(a.language) !== isPrimary(b.language)) return isPrimary(a.language) ? -1 : 1
return (a.language || '') < (b.language || '') ? -1 : 1
})
const zh = langs.find((t) => isPrimary(t.language))
out.push({ tid, primary: zh || langs[0]!, hasPrimary: !!zh, langs })
})
return out.sort((a, b) => (a.tid < b.tid ? -1 : 1))
})
const catOptions = computed(() =>
Array.from(new Set(cards.value.map((c) => catOf(c.tid)))).sort(),
)
const typeOptions = computed(() =>
Array.from(new Set(rows.value.map((t) => t.ttype).filter(Boolean))).sort(),
)
const filteredCards = computed(() => {
const kw = kwFilter.value.trim().toLowerCase()
return cards.value.filter((c) => {
if (catFilter.value && catOf(c.tid) !== catFilter.value) return false
if (typeFilter.value && c.primary.ttype !== typeFilter.value) return false
if (!kw) return true
return `${c.tid} ${c.primary.title} ${c.primary.description}`.toLowerCase().includes(kw)
})
})
// 当前 tid 下除当前语言外的其它语言码(同步/添加语言用)
const otherLangs = computed(() =>
(current.value?.langs ?? []).map((t) => t.language).filter((l) => l && l !== curLang.value),
)
const addableLangs = computed(() => {
const has = new Set((current.value?.langs ?? []).map((t) => t.language))
return ALL_LANG_OPTIONS.filter((o) => !has.has(o.code))
})
function fillForm(t: Partial<MeetTemplate>) {
form.id = t.id || 0
form.title = t.title || ''
form.tid = t.tid || ''
form.language = t.language || ''
form.ttype = t.ttype || ''
form.tags = t.tags || ''
form.icon = t.icon || ''
form.description = t.description || ''
form.outline = t.outline || ''
form.template = t.template || ''
}
async function loadData() {
loading.value = true
try {
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', {})
rows.value = d?.templates ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
rows.value = []
} finally {
loading.value = false
}
}
function backToList() {
view.value = 'list'
current.value = null
curLang.value = ''
loadData()
}
function openDetail(c: TplCard) {
current.value = c
view.value = 'detail'
switchLang(c.primary.language)
}
// 切到某个语言版本:先用列表里的浅字段占位,再拉详情补 outline/template。
async function switchLang(lang: string) {
const t = current.value?.langs.find((x) => x.language === lang)
if (!t) return
curLang.value = lang
fillForm(t)
const cached = detailCache.get(t.id)
if (cached) {
fillForm(cached)
return
}
detailLoading.value = true
try {
const detail = await webApi<MeetTemplate>('getmeettemplate', { id: t.id })
if (detail) {
detailCache.set(t.id, detail)
// 只有仍停在这个语言上才回填,避免慢请求覆盖用户已切走的内容
if (curLang.value === lang) fillForm(detail)
}
} catch (e: any) {
error(e?.message ?? '加载详情失败')
} finally {
detailLoading.value = false
}
}
function reloadCurrent() {
detailCache.delete(form.id)
switchLang(curLang.value)
}
async function saveCurrent() {
submitting.value = true
try {
await webApi('updatemeettemplate', {
id: Number(form.id),
title: form.title.trim(),
tid: form.tid.trim(),
language: form.language.trim(),
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
detailCache.delete(form.id)
success('已保存')
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
// 列表数据变动后,重新拉列表并把 current 指向同一 tid 的新聚合结果。
async function refreshCurrentCard(preferLang?: string) {
const tid = current.value?.tid
const lang = preferLang || curLang.value
await loadData()
if (!tid) return
const c = cards.value.find((x) => x.tid === tid)
if (!c) {
backToList()
return
}
current.value = c
const target = c.langs.find((x) => x.language === lang) ? lang : c.primary.language
await switchLang(target)
}
// ── 同步 ──
function openSync() {
syncFields.value = SYNC_FIELDS.filter((f) => !('translated' in f && f.translated)).map((f) => f.key)
syncLangs.value = otherLangs.value.slice()
syncOpen.value = true
}
async function doSync() {
submitting.value = true
try {
const res = await webApi<{ updated: number }>('syncmeettemplate', {
src_id: Number(form.id),
fields: syncFields.value,
languages: syncLangs.value,
})
syncOpen.value = false
detailCache.clear()
success(`已同步 ${res?.updated ?? 0} 个语言版本`)
await refreshCurrentCard()
} catch (e: any) {
error(e?.message ?? '同步失败')
} finally {
submitting.value = false
}
}
// ── 添加语言 ──
function openAddLang() {
newLang.value = ''
newLangErr.value = ''
addLangOpen.value = true
}
async function doAddLang() {
const lang = newLang.value.trim()
newLangErr.value = ''
if (!lang) {
newLangErr.value = '请填写语言码'
return
}
if (current.value?.langs.some((t) => t.language === lang)) {
newLangErr.value = `该 tid 下已存在 ${lang} 版本`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', {
title: form.title.trim(),
tid: form.tid.trim(),
language: lang,
ttype: form.ttype.trim(),
tags: form.tags.trim(),
icon: form.icon.trim(),
description: form.description.trim(),
outline: form.outline,
template: form.template,
})
addLangOpen.value = false
success(`已创建 ${lang} 版本`)
await refreshCurrentCard(lang)
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// ── 新增模板 ──
function openCreate() {
createTid.value = ''
createTitle.value = ''
createLang.value = PRIMARY_LANG
createErr.value = ''
createOpen.value = true
}
async function doCreate() {
const tid = createTid.value.trim()
const title = createTitle.value.trim()
const lang = createLang.value.trim()
createErr.value = ''
if (!tid || !title) {
createErr.value = 'tid 与标题均必填'
return
}
if (cards.value.some((c) => c.tid === tid)) {
createErr.value = `tid「${tid}」已存在,请到列表中打开它再「添加语言」`
return
}
submitting.value = true
try {
await webApi('addmeettemplate', { tid, title, language: lang })
createOpen.value = false
success('已创建')
await loadData()
const c = cards.value.find((x) => x.tid === tid)
if (c) openDetail(c)
} catch (e: any) {
createErr.value = e?.message ?? '创建失败'
} finally {
submitting.value = false
}
}
// ── 删除 ──
async function delCurrentLang() {
if (!current.value) return
if (current.value.langs.length <= 1) {
error('这是最后一个语言版本,请用「删除整组」')
return
}
if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { ids: [Number(form.id)] })
detailCache.delete(form.id)
success('已删除')
await refreshCurrentCard(PRIMARY_LANG)
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
async function delWholeTid() {
const c = current.value
if (!c) return
if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return
submitting.value = true
try {
await webApi('delmeettemplate', { ids: c.langs.map((t) => Number(t.id)) })
detailCache.clear()
success('已删除整组')
backToList()
} catch (e: any) {
error(e?.message ?? '删除失败')
} finally {
submitting.value = false
}
}
onMounted(loadData)
</script> </script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
.tpl-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); }
.tpl-card {
background:#fff; border:1px solid #eef2f7; border-radius:16px; padding:16px;
cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:10px;
box-shadow:0 1px 2px rgba(0,0,0,0.04);
}
.tpl-card:hover { border-color:#c7d2fe; box-shadow:0 6px 18px rgba(15,23,42,0.08); transform:translateY(-1px); }
.tc-top { display:flex; align-items:flex-start; gap:11px; }
.tc-ico {
width:40px; height:40px; border-radius:11px; flex-shrink:0; overflow:hidden;
background:linear-gradient(135deg,#eef2ff,#e0e7ff); color:#4338ca;
display:flex; align-items:center; justify-content:center; font-weight:700; font-size:17px;
}
.tc-ico img { width:100%; height:100%; object-fit:cover; }
.tc-title { font-weight:600; font-size:14.5px; color:#1e293b; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-tid { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tc-desc {
font-size:12.5px; color:#64748b; line-height:1.55; min-height:39px;
display:-webkit-box; -webkit-line-clamp:2; line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.tc-tags { display:flex; flex-wrap:wrap; gap:4px; min-height:20px; }
.tc-foot { display:flex; align-items:center; gap:6px; flex-wrap:wrap; padding-top:9px; border-top:1px solid #f8fafc; }
.tc-go { font-size:11.5px; color:#6366f1; font-weight:600; }
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.chip-type { background:#e0e7ff; color:#4338ca; }
.chip-lang { background:#cffafe; color:#0e7490; }
.chip-warn { background:#fef3c7; color:#b45309; }
.chip-primary { background:#d1fae5; color:#047857; }
.lang-pill {
display:inline-flex; align-items:center; gap:5px; font-size:11.5px; padding:3px 9px;
border-radius:999px; border:1px solid #e2e8f0; background:#f8fafc; color:#64748b;
cursor:pointer; transition:all .12s; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
}
.lang-pill:hover { border-color:#c7d2fe; color:#4338ca; }
.lang-pill.on { background:#4338ca; border-color:#4338ca; color:#fff; }
.lang-pill.primary:not(.on) { background:#ecfdf5; border-color:#a7f3d0; color:#047857; }
.lp-name { font-family:inherit; opacity:.75; font-size:11px; }
.lang-pill.on .lp-name { opacity:.9; }
.lang-picker {
display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:3px;
max-height:260px; overflow-y:auto; border:1px solid #eef2f7; border-radius:10px; padding:8px;
}
.lang-opt { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:6px; cursor:pointer; font-size:12px; }
.lang-opt:hover { background:#f8fafc; }
.lo-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#475569; }
.lo-name { color:#94a3b8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>

2
apps/admin/app/pages/product.vue

@ -368,7 +368,7 @@
</div> </div>
<div class="form-control mb-3"> <div class="form-control mb-3">
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label> <label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot" @change="onOtaPick" /> <input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot,.upd" @change="onOtaPick" />
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label> <label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>

282
apps/admin/app/pages/serviceconfig.vue

@ -1,32 +1,22 @@
<template> <template>
<div> <div>
<!-- 标签页:第三方服务(凭据,结构化)/ 应用参数(业务库 key-value)。 <!-- 2026-09-14 起只剩「第三方服务」这一个页面:原第二个标签页「应用参数」(业务库 config 表的裸 key/value)
2026-09-12 由两个一级菜单项合并而来,凭据只有「第三方服务」一个出处。 --> 已下线——客户端真正读的那几个键(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ)由 console 启动迁移
<div role="tablist" class="tabs tabs-boxed bg-slate-100 mb-5 w-fit"> 搬进了 svc_config(服务 llm_mobile_elf / app_params,字段带下发键名),config 表只剩服务端自用的
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'svc' }" @click="tab = 'svc'">第三方服务</a> 算力/运营参数(在「会员与算力」页配)。凭据与应用参数都只有这一处出处。 -->
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'env' }" @click="tab = 'env'">应用参数</a>
</div>
<AppEnvTab v-if="tab === 'env'" />
<div v-show="tab === 'svc'">
<div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e"> <div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e">
⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。 ⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。
</div> </div>
<!-- 顶部 --> <!-- 顶部操作条。标题与大段说明已去掉(2026-09-14):上面的标签页已经写着「第三方服务」,
<div class="flex items-center justify-between mb-5"> 说明文字每次进页面都要跨过去才能点到控件。控件整体左对齐,窄屏自动换行。 -->
<div> <div class="mb-5">
<h2 class="text-lg font-semibold text-slate-800">第三方服务</h2> <div class="flex flex-wrap items-center gap-3">
<p class="text-xs text-slate-400 mt-1">
所有第三方凭据的<b>唯一出处</b>,按服务类型分组(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;
区域默认<b>中国</b>,切换区域后点击卡片可为该区域覆盖字段值。字段上的「下发键名」用于兼容只读 env 的老客户端。
</p>
</div>
<div class="flex items-center gap-3">
<select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange"> <select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange">
<option value="">全局默认</option> <option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option> <option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option>
</select> </select>
<button class="btn btn-ghost btn-sm" :disabled="!scopeApp || scopeApps.length < 2" title="把另一个应用的服务(含区域分叉、密文)复制到当前应用" @click="openCopyModal">从其它应用复制</button>
<button class="btn btn-ghost btn-sm" @click="openCatModal">类别管理</button>
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange"> <select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange">
<option :value="0">默认(全区域)</option> <option :value="0">默认(全区域)</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> <option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
@ -42,8 +32,13 @@
✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。 ✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。
</div> </div>
<!-- 未选应用:作用域是必填的(2026-09-14 起没有全局层) -->
<div v-if="!scopeApp" class="bg-white rounded-2xl border border-slate-100 shadow-sm text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🗂️</div>
<p class="text-sm">{{ scopeApps.length ? '请先在右上角选择应用' : '还没有应用,请先到「应用中心」创建' }}</p>
</div>
<!-- 服务卡片:按服务类型分组 --> <!-- 服务卡片:按服务类型分组 -->
<div v-if="groupedSvcs.length" class="flex flex-col gap-6"> <div v-else-if="groupedSvcs.length" class="flex flex-col gap-6">
<section v-for="g in groupedSvcs" :key="g.v"> <section v-for="g in groupedSvcs" :key="g.v">
<div class="group-head" @click="toggleGroup(g.v)"> <div class="group-head" @click="toggleGroup(g.v)">
<span class="fold-arrow" :class="{ folded: folded[g.v] }">▾</span> <span class="fold-arrow" :class="{ folded: folded[g.v] }">▾</span>
@ -251,7 +246,7 @@
<template v-if="wizardStep === 1"> <template v-if="wizardStep === 1">
<p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p> <p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p>
<div class="type-grid"> <div class="type-grid">
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)"> <div v-for="sc in SCAT.filter((c) => c.enabled || c.v === editType)" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span> <span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span> <span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
<span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</span> <span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</span>
@ -558,6 +553,84 @@
</div> </div>
<!-- 服务商模板管理弹窗 --> <!-- 服务商模板管理弹窗 -->
<!-- ===== 从其它应用复制 ===== -->
<div v-if="copyModalVisible" class="modal modal-open" style="z-index:135">
<div class="modal-box max-w-md">
<h3 class="font-bold text-base mb-1">从其它应用复制服务</h3>
<p class="text-xs text-slate-400 mb-4">把源应用的全部服务(含区域分叉与密文)复制到 <b>{{ scopeApp }}</b>。各应用互不干涉,复制后各改各的。</p>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-500">源应用</label>
<select v-model="copySrcApp" class="select select-bordered select-sm w-full">
<option v-for="a in scopeApps.filter((x) => x !== scopeApp)" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="copyOverwrite" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
覆盖当前应用已有的同名服务(默认跳过)
</label>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="copyModalVisible = false">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="!copySrcApp || copySubmitting" @click="doCopyFromApp">复制</button>
</div>
</div>
</div>
<!-- ===== 类别管理 ===== -->
<div v-if="catModalVisible" class="modal modal-open" style="z-index:135">
<div class="modal-box max-w-2xl">
<template v-if="!catEditing">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-bold text-base">服务类别</h3>
<p class="text-xs text-slate-400 mt-1">内置类别的 id 被运行时与内置模板引用,不能删、不能改 id,可改名称/颜色/排序或停用;自定义类别只作分组。</p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm text-white" @click="catShowForm()">+ 新增类别</button>
<button class="btn btn-ghost btn-sm" @click="catModalVisible = false">关闭</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead><tr><th>ID</th><th>角标</th><th>名称</th><th class="text-center">音色预填</th><th class="text-center">仅服务端</th><th class="text-center">启用</th><th></th></tr></thead>
<tbody>
<tr v-for="c in categories" :key="c.id" :class="{ 'opacity-50': !c.enabled }">
<td class="font-mono text-xs">{{ c.id }}<span v-if="c.builtin" class="badge badge-xs badge-ghost ml-1">内置</span></td>
<td><span class="cat-chip" :style="{ color: c.color, background: c.bg }">{{ c.short }}</span></td>
<td class="text-sm">{{ c.label }}</td>
<td class="text-center text-xs">{{ c.voice ? '✓' : '' }}</td>
<td class="text-center text-xs text-rose-600">{{ c.server_only ? '✓' : '' }}</td>
<td class="text-center text-xs">{{ c.enabled ? '✓' : '停用' }}</td>
<td class="text-right whitespace-nowrap">
<button class="btn btn-ghost btn-xs" @click="catShowForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" :disabled="c.builtin" :title="c.builtin ? '内置类别不能删除' : ''" @click="delCat(c)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else>
<h3 class="font-bold text-base mb-3">{{ catForm.id ? '编辑类别' : '新增类别' }}<span v-if="catEditing.builtin" class="badge badge-xs badge-ghost ml-2">内置</span></h3>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2"><label class="text-xs text-slate-500">名称 *</label><input v-model="catForm.label" class="input input-bordered input-sm w-full" placeholder="如 OCR 文字识别" /></div>
<div><label class="text-xs text-slate-500">角标 *(≤8 字)</label><input v-model="catForm.short" class="input input-bordered input-sm w-full" placeholder="如 OCR" /></div>
<div><label class="text-xs text-slate-500">排序</label><input v-model.number="catForm.sort" type="number" class="input input-bordered input-sm w-full" /></div>
<div><label class="text-xs text-slate-500">前景色</label><div class="flex gap-2"><input v-model="catForm.color" type="color" class="w-10 h-8 p-0 border rounded" /><input v-model="catForm.color" class="input input-bordered input-sm flex-1 font-mono" /></div></div>
<div><label class="text-xs text-slate-500">背景色</label><div class="flex gap-2"><input v-model="catForm.bg" type="color" class="w-10 h-8 p-0 border rounded" /><input v-model="catForm.bg" class="input input-bordered input-sm flex-1 font-mono" /></div></div>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input v-model="catForm.voice" type="checkbox" class="checkbox checkbox-sm checkbox-primary" :disabled="catEditing.builtin" />音频输出类(新建时按音色表预填 languages)</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input v-model="catForm.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />启用(停用后新增向导里不再出现)</label>
</div>
<p class="text-xs text-slate-400 mt-3">预览:<span class="cat-chip" :style="{ color: catForm.color, background: catForm.bg }">{{ catForm.short || '角标' }}</span></p>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="catEditing = null">返回</button>
<button class="btn btn-primary btn-sm text-white" :disabled="catSubmitting" @click="saveCat">保存</button>
</div>
</template>
</div>
</div>
<div v-show="tplModalVisible" class="modal-overlay" style="z-index:130"> <div v-show="tplModalVisible" class="modal-overlay" style="z-index:130">
<div class="tpl-box"> <div class="tpl-box">
<!-- 模板列表 --> <!-- 模板列表 -->
@ -653,7 +726,6 @@
</template> </template>
</div> </div>
</div> </div>
</div>
</div> </div>
</template> </template>
@ -662,11 +734,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
// 两个标签页:第三方服务(凭据,带加密/区域分叉/巡检)与应用参数(业务库 config 表的 key-value)。
// 合并前它们是并列的两个一级菜单项,运营常把凭据填错地方——凭据现在只认「第三方服务」这一处。
const tab = ref<'svc' | 'env'>('svc')
// 区域枚举(与 pb.Region 一一对应) // 区域枚举(与 pb.Region 一一对应)
const REGIONS = [ const REGIONS = [
@ -677,30 +745,43 @@ const REGIONS = [
{ value: 13, label: '巴西' }, { value: 14, label: '印度' }, { value: 13, label: '巴西' }, { value: 14, label: '印度' },
] ]
// 服务类别常量(与后端 svcCatXxx 一一对应)。categories 支持逗号分隔多值,一个服务可同属多类。 // 服务类别:2026-09-14 起落库(svc_category),后台「类别管理」可增删改。
const SCAT = [ // 这里的 SCAT 形状与原来写死的数组一致(v/label/short/color/bg),下面的分组/角标代码不用改。
{ v: 1, label: 'STT 语音识别', short: 'STT', color: '#0369a1', bg: '#dbeafe' }, // 内置 11 个的 id 与后端 comm.SvcCat* / console svcCat* 同一套数字,后端不允许改 id 或删除;
{ v: 8, label: '录音文件识别', short: '录音识别', color: '#0e7490', bg: '#cffafe' }, // server_only / voice 两个语义标记也由后端给,前端只负责展示与预填行为。
{ v: 2, label: 'TTS 语音合成', short: 'TTS', color: '#7c3aed', bg: '#ede9fe' }, interface SvcCategory {
{ v: 3, label: 'MT 机器翻译', short: 'MT', color: '#059669', bg: '#dcfce7' }, id: number
{ v: 4, label: 'AST 端到端翻译', short: 'AST', color: '#c2410c', bg: '#fff7ed' }, label: string
{ v: 5, label: 'LLM 文本大模型', short: '文本LLM', color: '#b45309', bg: '#fef9c3' }, short: string
{ v: 9, label: '多媒体大模型', short: '多媒体LLM', color: '#9333ea', bg: '#f3e8ff' }, color: string
{ v: 7, label: 'STS 端到端对话', short: 'STS', color: '#be185d', bg: '#fce7f3' }, bg: string
{ v: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' }, sort: number
{ v: 10, label: 'MCP 服务', short: 'MCP', color: '#0d9488', bg: '#ccfbf1' }, builtin: boolean
{ v: 11, label: '身份证校验', short: '实名', color: '#9f1239', bg: '#ffe4e6' }, voice: boolean
] enabled: boolean
server_only: boolean
}
const categories = ref<SvcCategory[]>([])
const SCAT = computed(() =>
categories.value.map((c) => ({ v: c.id, label: c.label, short: c.short, color: c.color, bg: c.bg,
enabled: c.enabled, builtin: c.builtin, voice: c.voice, server_only: c.server_only })),
)
async function loadCategories() {
try {
const d = await webApi<{ items: SvcCategory[] }>('getsvccategories', {})
categories.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载服务类别失败')
}
}
// MCP 服务类别(与后端 svcCatMCP 一致):字段固定为 url/type/tools,type 用 HTTP/SSE 下拉。
const SCAT_MCP = 10 const SCAT_MCP = 10
// 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。 // 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。
// 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉, // 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉,
// 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚, // 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚,
// 真正的拦截在后端 —— 改这里不会改变下发行为。 // 真正的拦截在后端 —— 改这里不会改变下发行为。
const SERVER_ONLY_SCATS = [11] const isServerOnlyCat = (v: number) => (SCAT.value.find((s) => s.v === v)?.server_only ?? (v === 11))
const isServerOnlyCat = (v: number) => SERVER_ONLY_SCATS.includes(v)
interface SvcField { interface SvcField {
key: string key: string
@ -765,23 +846,104 @@ const { success, error, warn } = useToast()
// ===== 作用域:全局默认('') 或 某应用(app_name) ===== // ===== 作用域:全局默认('') 或 某应用(app_name) =====
// 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。 // 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。
// 2026-09-14 起没有「全局默认」层:每个应用一套配置互不干涉,作用域必选。
// 应用名来源与 appversion.vue 同一套:应用中心 products/list ∪ 部署注册 apps/list,无权读时走 getmyapps。
// ⚠️ 用的是应用名(app_name,如 EAIMAR / deepGlass),运行时 ANALYZE_APP_NAME 就是它;别用部署名。
const scopeApp = ref('') const scopeApp = ref('')
const scopeApps = ref<string[]>([]) const scopeApps = ref<string[]>([])
async function loadScopeApps() { async function loadScopeApps() {
try { const names = new Set<string>()
const list = await consoleApi<Array<{ name: string }>>('products/list', {}) const [products, deploys] = await Promise.all([
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean) consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
} catch { consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
scopeApps.value = [] ])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 拿不到就空列表,页面给出提示 */ }
} }
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
} }
function onScopeChange() { function onScopeChange() {
closeSvcModal() closeSvcModal()
loadSvcs() loadSvcs()
} }
// ───── 从其它应用复制 ─────
const copyModalVisible = ref(false)
const copySrcApp = ref('')
const copyOverwrite = ref(false)
const copySubmitting = ref(false)
function openCopyModal() {
copySrcApp.value = scopeApps.value.find((a) => a !== scopeApp.value) || ''
copyOverwrite.value = false
copyModalVisible.value = true
}
async function doCopyFromApp() {
if (!copySrcApp.value || !scopeApp.value) return
copySubmitting.value = true
try {
const r = await webApi<{ copied: number; skipped: number }>('copysvcconfig', {
src_app: copySrcApp.value, dst_app: scopeApp.value, overwrite: copyOverwrite.value,
})
success(`已复制 ${r?.copied ?? 0} 个服务${r?.skipped ? `,跳过已存在的 ${r.skipped} 个` : ''}`)
copyModalVisible.value = false
loadSvcs()
} catch (e: any) {
error(e?.message ?? '复制失败')
} finally {
copySubmitting.value = false
}
}
// ───── 类别管理 ─────
const catModalVisible = ref(false)
const catEditing = ref<SvcCategory | null>(null)
const catForm = reactive({ id: 0, label: '', short: '', color: '#475569', bg: '#f1f5f9', sort: 0, voice: false, enabled: true })
const catSubmitting = ref(false)
function openCatModal() { catEditing.value = null; catModalVisible.value = true; loadCategories() }
function catShowForm(c?: SvcCategory) {
catEditing.value = c ?? ({ id: 0 } as SvcCategory)
catForm.id = c?.id ?? 0
catForm.label = c?.label ?? ''
catForm.short = c?.short ?? ''
catForm.color = c?.color ?? '#475569'
catForm.bg = c?.bg ?? '#f1f5f9'
catForm.sort = c?.sort ?? (categories.value.length ? Math.max(...categories.value.map((x) => x.sort)) + 1 : 0)
catForm.voice = c?.voice ?? false
catForm.enabled = c?.enabled ?? true
}
async function saveCat() {
if (!catForm.label.trim() || !catForm.short.trim()) { error('名称与角标必填'); return }
catSubmitting.value = true
try {
await webApi('savesvccategory', { ...catForm, label: catForm.label.trim(), short: catForm.short.trim() })
success('已保存')
catEditing.value = null
await loadCategories()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
catSubmitting.value = false
}
}
async function delCat(c: SvcCategory) {
if (!window.confirm(`确定删除类别「${c.label}」?被服务或模板引用时会被拒绝。`)) return
try {
await webApi('delsvccategory', { id: c.id })
success('已删除')
await loadCategories()
} catch (e: any) {
error(e?.message ?? '删除失败')
}
}
function scatInfo(v: number) { function scatInfo(v: number) {
return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' } return SCAT.value.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' }
} }
// 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。 // 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。
// ── 按模板补齐字段 ── // ── 按模板补齐字段 ──
@ -893,10 +1055,11 @@ interface TTSVoice {
name: string name: string
} }
// 会出声的类别:TTS / AST 端到端翻译 / STS 端到端对话——它们的音色都按服务商区分。 // 会出声的类别:TTS / AST 端到端翻译 / STS 端到端对话——它们的音色都按服务商区分。
const VOICE_CATS = [2, 4, 7] // 音频输出类(原写死 [2,4,7]):现在由类别表的 voice 标记决定,新建时按音色表预填 languages。
const isVoiceCatId = (v: number) => !!SCAT.value.find((s) => s.v === v)?.voice
const voiceRows = ref<TTSVoice[]>([]) const voiceRows = ref<TTSVoice[]>([])
const voicesLoading = ref(false) const voicesLoading = ref(false)
const isVoiceCat = computed(() => VOICE_CATS.includes(editType.value)) const isVoiceCat = computed(() => isVoiceCatId(editType.value))
// 按语言分组,组内保持后端顺序(第一个即默认音色,与运行时 comm.PickVoice 口径一致) // 按语言分组,组内保持后端顺序(第一个即默认音色,与运行时 comm.PickVoice 口径一致)
const voiceLangGroups = computed(() => { const voiceLangGroups = computed(() => {
const map = new Map<string, TTSVoice[]>() const map = new Map<string, TTSVoice[]>()
@ -986,7 +1149,7 @@ const curRegionLabel = computed(() => {
// 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组 // 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组
const groupedSvcs = computed(() => { const groupedSvcs = computed(() => {
const groups = SCAT.map((sc) => ({ const groups = SCAT.value.map((sc) => ({
...sc, ...sc,
items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)), items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)),
})).filter((g) => g.items.length) })).filter((g) => g.items.length)
@ -1012,7 +1175,7 @@ function toggleAllGroups() {
// 模板按类别分组(供下拉与模板管理列表) // 模板按类别分组(供下拉与模板管理列表)
const templatesByCat = computed(() => { const templatesByCat = computed(() => {
return SCAT.map((sc) => ({ return SCAT.value.map((sc) => ({
cat: sc.v, cat: sc.v,
label: sc.label, label: sc.label,
items: templates.value.filter((t) => t.category === sc.v), items: templates.value.filter((t) => t.category === sc.v),
@ -1021,6 +1184,7 @@ const templatesByCat = computed(() => {
// ===== 加载服务列表 ===== // ===== 加载服务列表 =====
async function loadSvcs() { async function loadSvcs() {
if (!scopeApp.value) { allSvcs.value = []; return }
try { try {
const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value }) const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value })
allSvcs.value = data?.items ?? [] allSvcs.value = data?.items ?? []
@ -1132,7 +1296,7 @@ function chooseProvider(t: SvcTemplate | null) {
wizardStep.value = 3 wizardStep.value = 3
// 新建语音输出类服务时,languages 用音色表自动填好——有音色才说得出那门语言, // 新建语音输出类服务时,languages 用音色表自动填好——有音色才说得出那门语言,
// 让运营对着文档手抄一遍既费事又容易漏。填完仍可增删。 // 让运营对着文档手抄一遍既费事又容易漏。填完仍可增删。
if (editForm.provider && VOICE_CATS.includes(editType.value)) { if (editForm.provider && isVoiceCatId(editType.value)) {
loadVoicesForProvider(editForm.provider).then(() => fillLanguagesFromVoices(true)) loadVoicesForProvider(editForm.provider).then(() => fillLanguagesFromVoices(true))
} }
} }
@ -1434,8 +1598,8 @@ watch([() => editForm.provider, isVoiceCat, svcModalVisible], ([provider, isVoic
loadVoicesForProvider(provider as string) loadVoicesForProvider(provider as string)
}, { immediate: false }) }, { immediate: false })
onMounted(() => { onMounted(async () => {
loadScopeApps() await Promise.all([loadCategories(), loadScopeApps()])
loadSvcs() loadSvcs()
loadTemplates() loadTemplates()
}) })

29
apps/admin/app/pages/users.vue

@ -290,20 +290,20 @@
目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。 目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。
这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。 这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。
</p> </p>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">VIP天数</span></label> <label class="label py-1"><span class="label-text text-sm">VIP天数</span></label>
<input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">翻译分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label> <label class="label py-1"><span class="label-text text-sm">算力</span></label>
<input v-model.number="gift.trademin" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.compute" type="number" class="input input-bordered" min="0" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">会议分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
<input v-model.number="gift.meetmin" type="number" class="input input-bordered" min="0" />
</div> </div>
</div> </div>
<p class="text-xs text-slate-400 mt-3">
只发算力,不按功能分桶。翻译 / 会议纪要 / AI 对话消耗时,服务端按「会员与算力」页配的系数
把上报的分钟数、字数折成算力再扣,后台无需换算。
</p>
<div class="modal-action"> <div class="modal-action">
<button class="btn btn-sm" @click="closeGift">取消</button> <button class="btn btn-sm" @click="closeGift">取消</button>
<button class="btn btn-success btn-sm text-white" @click="submitGift">确认赠送</button> <button class="btn btn-success btn-sm text-white" @click="submitGift">确认赠送</button>
@ -387,7 +387,7 @@ const logs = ref<Record<string, any>[]>([])
const logsTotal = ref(0) const logsTotal = ref(0)
const giftModal = ref<HTMLDialogElement | null>(null) const giftModal = ref<HTMLDialogElement | null>(null)
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 }) const gift = reactive({ vipday: 0, compute: 0 })
// 一级:去重后的应用名(同一应用的国内/海外部署合成一项) // 一级:去重后的应用名(同一应用的国内/海外部署合成一项)
const appNames = computed(() => { const appNames = computed(() => {
@ -704,22 +704,21 @@ async function refreshCurrent() {
function openGift() { function openGift() {
if (!lastUid.value) { warn('请先选择用户'); return } if (!lastUid.value) { warn('请先选择用户'); return }
gift.vipday = 0 gift.vipday = 0
gift.trademin = 0 gift.compute = 0
gift.meetmin = 0
giftModal.value?.showModal() giftModal.value?.showModal()
} }
function closeGift() { giftModal.value?.close() } function closeGift() { giftModal.value?.close() }
async function submitGift() { async function submitGift() {
const vipday = Number(gift.vipday) || 0 const vipday = Number(gift.vipday) || 0
const trademin = Number(gift.trademin) || 0 const compute = Number(gift.compute) || 0
const meetmin = Number(gift.meetmin) || 0 if (vipday < 0 || compute < 0) { warn('数量不能为负'); return }
if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return } if (!vipday && !compute) { warn('请至少填写一项'); return }
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return } if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return }
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
try { try {
await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin }) // 只送 VIP 天数与算力。后端的 trademin/meetmin 只是老前端的兼容入参,新前端不再传。
await webApi('giftuserresource', { uid: lastUid.value, vipday, compute })
success('赠送成功') success('赠送成功')
giftModal.value?.close() giftModal.value?.close()
await refreshCurrent() await refreshCurrent()

14
apps/admin/app/utils/menus.ts

@ -65,17 +65,19 @@ export const MENU_GROUPS: MenuGroup[] = [
// · 全局环境配置(globalconfig) —— 473 行按区域复制的凭据,且**悄悄覆盖**应用环境配置里的同名键, // · 全局环境配置(globalconfig) —— 473 行按区域复制的凭据,且**悄悄覆盖**应用环境配置里的同名键,
// 同一个 AZURE_SPEECH_REGION/OPENAI_API_KEY 两边值不一样、运营改哪边都像没生效。 // 同一个 AZURE_SPEECH_REGION/OPENAI_API_KEY 两边值不一样、运营改哪边都像没生效。
// 凭据已归一到 svc_config(见 services 的 migrate_envtosvc.go),页面下线只留重定向。 // 凭据已归一到 svc_config(见 services 的 migrate_envtosvc.go),页面下线只留重定向。
// · 应用环境配置(appconfig) —— 并入「服务与环境配置」的「应用参数」标签页,只剩非凭据的业务参数。 // · 应用环境配置(appconfig) —— 先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),config 表不再有后台裸编辑入口。
// · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule // · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule
// 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧 // 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧
// (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。 // (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。
// · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑 // · 会议记录服务(meetingsvc) —— 2026-09-12 下架,2026-09-17 以「会议配置」的形式回来:
// (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口; // 转写/总结两段的服务选型并进了会议模板那一页(翻译段仍只能改库)。
// 要换服务商得直接改库(见 services/modules/echomeet/orch_resolver.go)。 { id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'svcconfig', label: '服务与环境配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' }, { id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, // 2026-09-17「会议模板」→「会议配置」:同一页里管会议转写(录音文件识别)、会议总结(大模型)、会议模板三块。
// ⚠️ 菜单 id 仍是 meettemplate:存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口。
{ id: 'meettemplate', label: '会议配置', to: '/meetconfig', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。 // 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改, // 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。 // 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。

5
apps/client/.env.example

@ -74,3 +74,8 @@ APP_IOS_IAP_SUFFIX=
APP_IOS_IAP_ID_MAP= APP_IOS_IAP_ID_MAP=
# 请求签名密钥(可选,不配用内置默认值;须与服务端一致) # 请求签名密钥(可选,不配用内置默认值;须与服务端一致)
API_SIGN_KEY= API_SIGN_KEY=
# 网关加密接口(user_getappconfig_v3 等)的 AES-CBC 密钥,16/24/32 字节,
# 须与服务器 deploy/app 的 .env 里 GATEWAY_ENCRYPT_KEY 一致(IV 固定 16 个 '0',PKCS7,密文 base64,响应头 X-Encrypted:1)。
# 留空 → 客户端回退到明文 v1 user_getappconfig(凭据只走 env 兼容层,thirdsvcs 拿不到)。
GATEWAY_ENCRYPT_KEY=

5
apps/client/.gitignore

@ -72,3 +72,8 @@ app.*.map.json
# 多渠道打包产物(build_channels.sh 的默认输出目录) # 多渠道打包产物(build_channels.sh 的默认输出目录)
build_outputs/ build_outputs/
# 本地插件(库)的 pubspec.lock 不入库——仓库里其它插件都没有,
# 主工程 apps/client/pubspec.lock 才该锁版本。
# 不写这条的话,一次 `git add apps/client` 就会把它们顺手带进提交。
local_plugins/**/pubspec.lock

BIN
apps/client/assets/audio/silence_1s.wav

Binary file not shown.

0
apps/client/assets/images/devices/Smartcar.png → apps/client/assets/images/devices/EaiCar.png

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 370 KiB

0
apps/client/assets/images/devices/Smartcar_icon.png → apps/client/assets/images/devices/EaiCar_icon.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
apps/client/assets/images/devices/Echomeet.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

BIN
apps/client/assets/images/devices/Echomeet_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
apps/client/assets/images/devices/default.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 572 KiB

BIN
apps/client/assets/images/devices/default_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 32 KiB

6
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -641,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -835,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -869,7 +869,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

2
apps/client/ios/Runner/Info.plist

@ -172,6 +172,8 @@
<string>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.</string> <string>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.</string>
<key>NSMotionUsageDescription</key> <key>NSMotionUsageDescription</key>
<string>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.</string> <string>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.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Photo library permission is required to save exported images (mind maps, statistics) to your album</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Photo library permission is required to save recording files and import audio files</string> <string>Photo library permission is required to save recording files and import audio files</string>
<key>NSRemindersUsageDescription</key> <key>NSRemindersUsageDescription</key>

3
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_file_import_service.dart';
import '../../data/services/device_recording_service.dart'; import '../../data/services/device_recording_service.dart';
import '../../devices/device_bootstrap.dart'; import '../../devices/device_bootstrap.dart';
import '../../devices/device_auth.dart';
import '../../devices/device_hub.dart'; import '../../devices/device_hub.dart';
import '../../modules/module_registry.dart'; import '../../modules/module_registry.dart';
import '../services/ai_orb_controller.dart'; import '../services/ai_orb_controller.dart';
@ -97,6 +98,8 @@ class InitialBinding extends Bindings {
installDeviceHub(); installDeviceHub();
// 设备品类首次连上时把默认功能铺到 AI 页 // 设备品类首次连上时把默认功能铺到 AI 页
AgentDefaults.bind(DeviceHub.to); AgentDefaults.bind(DeviceHub.to);
// 登录完成后把「连接时因未登录跳过」的绑定登记补上,见 DeviceAuth.bindLoginRetry
DeviceAuth.bindLoginRetry();
// 设备主麦录音(现场录音)的全局采集/归档:设备端按键在任意页面都生效 // 设备主麦录音(现场录音)的全局采集/归档:设备端按键在任意页面都生效
Get.put<DeviceRecordingService>(DeviceRecordingService(), permanent: true); Get.put<DeviceRecordingService>(DeviceRecordingService(), permanent: true);
// 通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停 // 通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停

4
apps/client/lib/core/translations/language/ar_sa.dart

@ -1814,6 +1814,7 @@ const Map<String, String> arSA = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'لديك أحدث إصدار', 'otaAlreadyLatest': 'لديك أحدث إصدار',
'otaNoServerVersion': 'لا توجد نسخة برنامج ثابت لهذا الجهاز على الخادم',
'otaCurrentVersion': 'الإصدار الحالي', 'otaCurrentVersion': 'الإصدار الحالي',
'otaDone': 'تم', 'otaDone': 'تم',
'otaDownloading': 'جارٍ تنزيل البرنامج الثابت...', 'otaDownloading': 'جارٍ تنزيل البرنامج الثابت...',
@ -2072,4 +2073,7 @@ const Map<String, String> arSA = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'الترجمة قيد التشغيل. أوقفها قبل تغيير اللغة.',
'meetingRefreshNow': 'تحديث',
'pipTakenOverByOtherApp': 'تم استبدال نافذة الترجمة بصورة داخل صورة من تطبيق آخر. ارجع إلى هذا التطبيق ثم غادره مرة أخرى لاستعادتها تلقائيًا',
}; };

4
apps/client/lib/core/translations/language/bg_bg.dart

@ -1877,6 +1877,7 @@ const Map<String, String> bgBG = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Вече е актуален', 'otaAlreadyLatest': 'Вече е актуален',
'otaNoServerVersion': 'Няма версия на фърмуера за това устройство на сървъра',
'otaCurrentVersion': 'Текуща версия', 'otaCurrentVersion': 'Текуща версия',
'otaDone': 'Готово', 'otaDone': 'Готово',
'otaDownloading': 'Изтегляне на фърмуера...', 'otaDownloading': 'Изтегляне на фърмуера...',
@ -2135,4 +2136,7 @@ const Map<String, String> bgBG = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Преводът е в ход. Спрете го, преди да смените езика.',
'meetingRefreshNow': 'Опресни',
'pipTakenOverByOtherApp': 'Прозорецът със субтитри беше изместен от картина в картина на друго приложение. Върнете се в това приложение и излезте отново, за да се възстанови автоматично',
}; };

4
apps/client/lib/core/translations/language/cs_cz.dart

@ -1845,6 +1845,7 @@ const Map<String, String> csCZ = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Již aktuální', 'otaAlreadyLatest': 'Již aktuální',
'otaNoServerVersion': 'Na serveru není k dispozici verze firmwaru pro toto zařízení',
'otaCurrentVersion': 'Aktuální verze', 'otaCurrentVersion': 'Aktuální verze',
'otaDone': 'Hotovo', 'otaDone': 'Hotovo',
'otaDownloading': 'Stahování firmwaru...', 'otaDownloading': 'Stahování firmwaru...',
@ -2103,4 +2104,7 @@ const Map<String, String> csCZ = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Překlad běží. Před změnou jazyka jej zastavte.',
'meetingRefreshNow': 'Obnovit',
'pipTakenOverByOtherApp': 'Okno titulků bylo nahrazeno obrazem v obraze jiné aplikace. Vraťte se do této aplikace a znovu ji opusťte, obnoví se automaticky',
}; };

4
apps/client/lib/core/translations/language/da_dk.dart

@ -1847,6 +1847,7 @@ const Map<String, String> daDK = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede opdateret', 'otaAlreadyLatest': 'Allerede opdateret',
'otaNoServerVersion': 'Ingen firmwareversion til denne enhed på serveren',
'otaCurrentVersion': 'Nuværende version', 'otaCurrentVersion': 'Nuværende version',
'otaDone': 'Færdig', 'otaDone': 'Færdig',
'otaDownloading': 'Downloader firmware...', 'otaDownloading': 'Downloader firmware...',
@ -2105,4 +2106,7 @@ const Map<String, String> daDK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Oversættelse kører. Stop den, før du skifter sprog.',
'meetingRefreshNow': 'Opdater',
'pipTakenOverByOtherApp': 'Undertekstvinduet blev overtaget af en anden apps billede-i-billede. Gå tilbage til denne app og forlad den igen, så gendannes det automatisk',
}; };

4
apps/client/lib/core/translations/language/de_de.dart

@ -2091,6 +2091,7 @@ const Map<String, String> deDE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Bereits aktuell', 'otaAlreadyLatest': 'Bereits aktuell',
'otaNoServerVersion': 'Keine Firmware-Version für dieses Gerät auf dem Server',
'otaCurrentVersion': 'Aktuelle Version', 'otaCurrentVersion': 'Aktuelle Version',
'otaDone': 'Fertig', 'otaDone': 'Fertig',
'otaDownloading': 'Firmware wird heruntergeladen...', 'otaDownloading': 'Firmware wird heruntergeladen...',
@ -2172,4 +2173,7 @@ const Map<String, String> deDE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Übersetzung läuft. Stoppen Sie sie, bevor Sie die Sprache wechseln.',
'meetingRefreshNow': 'Aktualisieren',
'pipTakenOverByOtherApp': 'Das Untertitelfenster wurde vom Bild-im-Bild einer anderen App verdrängt. Kehren Sie zu dieser App zurück und verlassen Sie sie erneut, dann wird es automatisch wiederhergestellt',
}; };

4
apps/client/lib/core/translations/language/el_gr.dart

@ -1881,6 +1881,7 @@ const Map<String, String> elGR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ήδη ενημερωμένο', 'otaAlreadyLatest': 'Ήδη ενημερωμένο',
'otaNoServerVersion': 'Δεν υπάρχει έκδοση υλικολογισμικού για αυτή τη συσκευή στον διακομιστή',
'otaCurrentVersion': 'Τρέχουσα έκδοση', 'otaCurrentVersion': 'Τρέχουσα έκδοση',
'otaDone': 'Τέλος', 'otaDone': 'Τέλος',
'otaDownloading': 'Λήψη υλικολογισμικού...', 'otaDownloading': 'Λήψη υλικολογισμικού...',
@ -2139,4 +2140,7 @@ const Map<String, String> elGR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Η μετάφραση εκτελείται. Σταματήστε την πριν αλλάξετε γλώσσα.',
'meetingRefreshNow': 'Ανανέωση',
'pipTakenOverByOtherApp': 'Το παράθυρο υποτίτλων αντικαταστάθηκε από το εικόνα-σε-εικόνα άλλης εφαρμογής. Επιστρέψτε σε αυτήν την εφαρμογή και βγείτε ξανά για αυτόματη επαναφορά',
}; };

4
apps/client/lib/core/translations/language/en_us.dart

@ -2059,6 +2059,7 @@ const Map<String, String> enUS = {
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Already up to date', 'otaAlreadyLatest': 'Already up to date',
'otaNoServerVersion': 'No firmware version for this device on the server',
'otaCurrentVersion': 'Current version', 'otaCurrentVersion': 'Current version',
'otaDone': 'Done', 'otaDone': 'Done',
'otaDownloading': 'Downloading firmware...', 'otaDownloading': 'Downloading firmware...',
@ -2128,4 +2129,7 @@ const Map<String, String> enUS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Translation is running. Stop it before changing languages.',
'meetingRefreshNow': 'Refresh',
'pipTakenOverByOtherApp': 'The subtitle window was taken over by another app\'s Picture in Picture. Return to this app and leave it again to restore it automatically',
}; };

4
apps/client/lib/core/translations/language/es_es.dart

@ -2085,6 +2085,7 @@ const Map<String, String> esES = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ya está actualizado', 'otaAlreadyLatest': 'Ya está actualizado',
'otaNoServerVersion': 'No hay versión de firmware para este dispositivo en el servidor',
'otaCurrentVersion': 'Versión actual', 'otaCurrentVersion': 'Versión actual',
'otaDone': 'Listo', 'otaDone': 'Listo',
'otaDownloading': 'Descargando firmware...', 'otaDownloading': 'Descargando firmware...',
@ -2166,4 +2167,7 @@ const Map<String, String> esES = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'La traducción está en curso. Deténla antes de cambiar de idioma.',
'meetingRefreshNow': 'Actualizar',
'pipTakenOverByOtherApp': 'La ventana de subtítulos fue reemplazada por la imagen en imagen de otra app. Vuelve a esta app y sal de nuevo para restaurarla automáticamente',
}; };

4
apps/client/lib/core/translations/language/et_ee.dart

@ -1841,6 +1841,7 @@ const Map<String, String> etEE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Juba ajakohane', 'otaAlreadyLatest': 'Juba ajakohane',
'otaNoServerVersion': 'Serveris pole selle seadme püsivara versiooni',
'otaCurrentVersion': 'Praegune versioon', 'otaCurrentVersion': 'Praegune versioon',
'otaDone': 'Valmis', 'otaDone': 'Valmis',
'otaDownloading': 'Püsivara allalaadimine...', 'otaDownloading': 'Püsivara allalaadimine...',
@ -2099,4 +2100,7 @@ const Map<String, String> etEE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tõlkimine käib. Peata see enne keele vahetamist.',
'meetingRefreshNow': 'Värskenda',
'pipTakenOverByOtherApp': 'Subtiitriakna võttis üle teise rakenduse pilt-pildis. Naase sellesse rakendusse ja lahku uuesti, see taastatakse automaatselt',
}; };

4
apps/client/lib/core/translations/language/fa_ir.dart

@ -1832,6 +1832,7 @@ const Map<String, String> faIR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'هم‌اکنون به‌روز است', 'otaAlreadyLatest': 'هم‌اکنون به‌روز است',
'otaNoServerVersion': 'هیچ نسخه میان‌افزاری برای این دستگاه روی سرور موجود نیست',
'otaCurrentVersion': 'نسخهٔ فعلی', 'otaCurrentVersion': 'نسخهٔ فعلی',
'otaDone': 'انجام شد', 'otaDone': 'انجام شد',
'otaDownloading': 'در حال دانلود میان‌افزار...', 'otaDownloading': 'در حال دانلود میان‌افزار...',
@ -2090,4 +2091,7 @@ const Map<String, String> faIR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'ترجمه در حال اجراست. قبل از تغییر زبان آن را متوقف کنید.',
'meetingRefreshNow': 'تازه‌سازی',
'pipTakenOverByOtherApp': 'پنجره زیرنویس توسط تصویر در تصویر برنامه دیگری جایگزین شد. به این برنامه برگردید و دوباره خارج شوید تا خودکار بازیابی شود',
}; };

4
apps/client/lib/core/translations/language/fi_fi.dart

@ -1851,6 +1851,7 @@ const Map<String, String> fiFI = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jo ajan tasalla', 'otaAlreadyLatest': 'Jo ajan tasalla',
'otaNoServerVersion': 'Palvelimella ei ole laiteohjelmistoversiota tälle laitteelle',
'otaCurrentVersion': 'Nykyinen versio', 'otaCurrentVersion': 'Nykyinen versio',
'otaDone': 'Valmis', 'otaDone': 'Valmis',
'otaDownloading': 'Ladataan laiteohjelmistoa...', 'otaDownloading': 'Ladataan laiteohjelmistoa...',
@ -2109,4 +2110,7 @@ const Map<String, String> fiFI = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Käännös on käynnissä. Pysäytä se ennen kielen vaihtamista.',
'meetingRefreshNow': 'Päivitä',
'pipTakenOverByOtherApp': 'Toinen sovellus otti tekstitysikkunan kuva kuvassa -tilan haltuunsa. Palaa tähän sovellukseen ja poistu uudelleen, niin se palautuu automaattisesti',
}; };

4
apps/client/lib/core/translations/language/fr_fr.dart

@ -2104,6 +2104,7 @@ const Map<String, String> frFR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Déjà à jour', 'otaAlreadyLatest': 'Déjà à jour',
'otaNoServerVersion': 'Aucune version de micrologiciel pour cet appareil sur le serveur',
'otaCurrentVersion': 'Version actuelle', 'otaCurrentVersion': 'Version actuelle',
'otaDone': 'Terminé', 'otaDone': 'Terminé',
'otaDownloading': 'Téléchargement du firmware...', 'otaDownloading': 'Téléchargement du firmware...',
@ -2185,4 +2186,7 @@ const Map<String, String> frFR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traduction en cours. Arrêtez-la avant de changer de langue.',
'meetingRefreshNow': 'Actualiser',
'pipTakenOverByOtherApp': 'La fenêtre de sous-titres a été remplacée par l\'image dans l\'image d\'une autre app. Revenez dans cette app puis quittez-la à nouveau pour la restaurer automatiquement',
}; };

4
apps/client/lib/core/translations/language/hi_in.dart

@ -1850,6 +1850,7 @@ const Map<String, String> hiIN = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'पहले से नवीनतम है', 'otaAlreadyLatest': 'पहले से नवीनतम है',
'otaNoServerVersion': 'सर्वर पर इस डिवाइस के लिए कोई फ़र्मवेयर संस्करण नहीं है',
'otaCurrentVersion': 'वर्तमान संस्करण', 'otaCurrentVersion': 'वर्तमान संस्करण',
'otaDone': 'पूर्ण', 'otaDone': 'पूर्ण',
'otaDownloading': 'फ़र्मवेयर डाउनलोड हो रहा है...', 'otaDownloading': 'फ़र्मवेयर डाउनलोड हो रहा है...',
@ -2108,4 +2109,7 @@ const Map<String, String> hiIN = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'अनुवाद चल रहा है। भाषा बदलने से पहले इसे रोकें।',
'meetingRefreshNow': 'रीफ़्रेश',
'pipTakenOverByOtherApp': 'सबटाइटल विंडो को किसी अन्य ऐप के पिक्चर-इन-पिक्चर ने हटा दिया। इस ऐप में वापस आएँ और फिर बाहर जाएँ, यह अपने आप वापस आ जाएगी',
}; };

4
apps/client/lib/core/translations/language/hr_hr.dart

@ -1866,6 +1866,7 @@ const Map<String, String> hrHR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno', 'otaAlreadyLatest': 'Već je ažurno',
'otaNoServerVersion': 'Na poslužitelju nema verzije firmvera za ovaj uređaj',
'otaCurrentVersion': 'Trenutačna verzija', 'otaCurrentVersion': 'Trenutačna verzija',
'otaDone': 'Gotovo', 'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...', 'otaDownloading': 'Preuzimanje firmvera...',
@ -2124,4 +2125,7 @@ const Map<String, String> hrHR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prijevod je u tijeku. Zaustavite ga prije promjene jezika.',
'meetingRefreshNow': 'Osvježi',
'pipTakenOverByOtherApp': 'Prozor s titlovima preuzela je slika u slici druge aplikacije. Vratite se u ovu aplikaciju i ponovno je napustite kako bi se automatski vratio',
}; };

4
apps/client/lib/core/translations/language/hu_hu.dart

@ -1869,6 +1869,7 @@ const Map<String, String> huHU = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Már naprakész', 'otaAlreadyLatest': 'Már naprakész',
'otaNoServerVersion': 'A kiszolgálón nincs firmware-verzió ehhez az eszközhöz',
'otaCurrentVersion': 'Jelenlegi verzió', 'otaCurrentVersion': 'Jelenlegi verzió',
'otaDone': 'Kész', 'otaDone': 'Kész',
'otaDownloading': 'Firmware letöltése...', 'otaDownloading': 'Firmware letöltése...',
@ -2127,4 +2128,7 @@ const Map<String, String> huHU = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'A fordítás fut. Nyelvváltás előtt állítsa le.',
'meetingRefreshNow': 'Frissítés',
'pipTakenOverByOtherApp': 'A feliratablakot egy másik alkalmazás kép a képben funkciója kiszorította. Térjen vissza ebbe az alkalmazásba, majd lépjen ki újra, és automatikusan helyreáll',
}; };

4
apps/client/lib/core/translations/language/id_id.dart

@ -1850,6 +1850,7 @@ const Map<String, String> idID = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terbaru', 'otaAlreadyLatest': 'Sudah versi terbaru',
'otaNoServerVersion': 'Tidak ada versi firmware untuk perangkat ini di server',
'otaCurrentVersion': 'Versi saat ini', 'otaCurrentVersion': 'Versi saat ini',
'otaDone': 'Selesai', 'otaDone': 'Selesai',
'otaDownloading': 'Mengunduh firmware...', 'otaDownloading': 'Mengunduh firmware...',
@ -2108,4 +2109,7 @@ const Map<String, String> idID = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dulu sebelum mengganti bahasa.',
'meetingRefreshNow': 'Segarkan',
'pipTakenOverByOtherApp': 'Jendela subtitle diambil alih oleh gambar-dalam-gambar aplikasi lain. Kembali ke aplikasi ini lalu keluar lagi agar pulih otomatis',
}; };

4
apps/client/lib/core/translations/language/is_is.dart

@ -1847,6 +1847,7 @@ const Map<String, String> isIS = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Þegar uppfært', 'otaAlreadyLatest': 'Þegar uppfært',
'otaNoServerVersion': 'Engin fastbúnaðarútgáfa fyrir þetta tæki á þjóninum',
'otaCurrentVersion': 'Núverandi útgáfa', 'otaCurrentVersion': 'Núverandi útgáfa',
'otaDone': 'Lokið', 'otaDone': 'Lokið',
'otaDownloading': 'Sæki fastbúnað...', 'otaDownloading': 'Sæki fastbúnað...',
@ -2105,4 +2106,7 @@ const Map<String, String> isIS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Þýðing er í gangi. Stöðvaðu hana áður en þú skiptir um tungumál.',
'meetingRefreshNow': 'Endurnýja',
'pipTakenOverByOtherApp': 'Textagluggi var tekinn yfir af mynd í mynd í öðru forriti. Farðu aftur í þetta forrit og út aftur til að endurheimta hann sjálfkrafa',
}; };

4
apps/client/lib/core/translations/language/it_it.dart

@ -1888,6 +1888,7 @@ const Map<String, String> itIT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Già aggiornato', 'otaAlreadyLatest': 'Già aggiornato',
'otaNoServerVersion': 'Nessuna versione del firmware per questo dispositivo sul server',
'otaCurrentVersion': 'Versione attuale', 'otaCurrentVersion': 'Versione attuale',
'otaDone': 'Fatto', 'otaDone': 'Fatto',
'otaDownloading': 'Download del firmware...', 'otaDownloading': 'Download del firmware...',
@ -2146,4 +2147,7 @@ const Map<String, String> itIT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traduzione in corso. Interrompila prima di cambiare lingua.',
'meetingRefreshNow': 'Aggiorna',
'pipTakenOverByOtherApp': 'La finestra dei sottotitoli è stata sostituita dal Picture in Picture di un\'altra app. Torna in questa app ed esci di nuovo per ripristinarla automaticamente',
}; };

4
apps/client/lib/core/translations/language/ja_jp.dart

@ -1886,6 +1886,7 @@ const Map<String, String> jaJP = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '最新の状態です', 'otaAlreadyLatest': '最新の状態です',
'otaNoServerVersion': 'サーバーにこのデバイスのファームウェアがありません',
'otaCurrentVersion': '現在のバージョン', 'otaCurrentVersion': '現在のバージョン',
'otaDone': '完了', 'otaDone': '完了',
'otaDownloading': 'ファームウェアをダウンロード中...', 'otaDownloading': 'ファームウェアをダウンロード中...',
@ -1967,4 +1968,7 @@ const Map<String, String> jaJP = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻訳中です。言語を切り替える前に翻訳を停止してください。',
'meetingRefreshNow': '更新',
'pipTakenOverByOtherApp': '字幕ウィンドウが他のアプリのピクチャ・イン・ピクチャに置き換えられました。このアプリに戻ってから再度切り替えると自動的に復元されます',
}; };

4
apps/client/lib/core/translations/language/ko_kr.dart

@ -1879,6 +1879,7 @@ const Map<String, String> koKR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '이미 최신 버전입니다', 'otaAlreadyLatest': '이미 최신 버전입니다',
'otaNoServerVersion': '서버에 이 기기의 펌웨어 버전이 없습니다',
'otaCurrentVersion': '현재 버전', 'otaCurrentVersion': '현재 버전',
'otaDone': '완료', 'otaDone': '완료',
'otaDownloading': '펌웨어 다운로드 중...', 'otaDownloading': '펌웨어 다운로드 중...',
@ -1960,4 +1961,7 @@ const Map<String, String> koKR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '번역이 진행 중입니다. 언어를 바꾸려면 먼저 번역을 중지하세요.',
'meetingRefreshNow': '새로고침',
'pipTakenOverByOtherApp': '자막 창이 다른 앱의 PIP에 의해 대체되었습니다. 이 앱으로 돌아왔다가 다시 나가면 자동으로 복원됩니다',
}; };

4
apps/client/lib/core/translations/language/lt_lt.dart

@ -1865,6 +1865,7 @@ const Map<String, String> ltLT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau naujausia versija', 'otaAlreadyLatest': 'Jau naujausia versija',
'otaNoServerVersion': 'Serveryje nėra šio įrenginio programinės įrangos versijos',
'otaCurrentVersion': 'Dabartinė versija', 'otaCurrentVersion': 'Dabartinė versija',
'otaDone': 'Atlikta', 'otaDone': 'Atlikta',
'otaDownloading': 'Atsisiunčiama programinė aparatinė įranga...', 'otaDownloading': 'Atsisiunčiama programinė aparatinė įranga...',
@ -2123,4 +2124,7 @@ const Map<String, String> ltLT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Vyksta vertimas. Sustabdykite jį prieš keisdami kalbą.',
'meetingRefreshNow': 'Atnaujinti',
'pipTakenOverByOtherApp': 'Subtitrų langą perėmė kitos programos vaizdas vaizde. Grįžkite į šią programą ir vėl išeikite – jis bus atkurtas automatiškai',
}; };

4
apps/client/lib/core/translations/language/lv_lv.dart

@ -1870,6 +1870,7 @@ const Map<String, String> lvLV = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau ir jaunākā versija', 'otaAlreadyLatest': 'Jau ir jaunākā versija',
'otaNoServerVersion': 'Serverī nav šīs ierīces aparātprogrammatūras versijas',
'otaCurrentVersion': 'Pašreizējā versija', 'otaCurrentVersion': 'Pašreizējā versija',
'otaDone': 'Gatavs', 'otaDone': 'Gatavs',
'otaDownloading': 'Notiek programmaparatūras lejupielāde...', 'otaDownloading': 'Notiek programmaparatūras lejupielāde...',
@ -2128,4 +2129,7 @@ const Map<String, String> lvLV = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Notiek tulkošana. Apturiet to pirms valodas maiņas.',
'meetingRefreshNow': 'Atsvaidzināt',
'pipTakenOverByOtherApp': 'Subtitru logu pārņēma citas lietotnes attēls attēlā. Atgriezieties šajā lietotnē un izejiet vēlreiz, lai to atjaunotu automātiski',
}; };

4
apps/client/lib/core/translations/language/ms_my.dart

@ -1854,6 +1854,7 @@ const Map<String, String> msMY = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terkini', 'otaAlreadyLatest': 'Sudah versi terkini',
'otaNoServerVersion': 'Tiada versi perisian tegar untuk peranti ini pada pelayan',
'otaCurrentVersion': 'Versi semasa', 'otaCurrentVersion': 'Versi semasa',
'otaDone': 'Selesai', 'otaDone': 'Selesai',
'otaDownloading': 'Memuat turun perisian tegar...', 'otaDownloading': 'Memuat turun perisian tegar...',
@ -2112,4 +2113,7 @@ const Map<String, String> msMY = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dahulu sebelum menukar bahasa.',
'meetingRefreshNow': 'Segar semula',
'pipTakenOverByOtherApp': 'Tetingkap sari kata diambil alih oleh gambar-dalam-gambar aplikasi lain. Kembali ke aplikasi ini dan keluar semula untuk memulihkannya secara automatik',
}; };

4
apps/client/lib/core/translations/language/nb_no.dart

@ -1848,6 +1848,7 @@ const Map<String, String> nbNO = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede oppdatert', 'otaAlreadyLatest': 'Allerede oppdatert',
'otaNoServerVersion': 'Ingen fastvareversjon for denne enheten på serveren',
'otaCurrentVersion': 'Gjeldende versjon', 'otaCurrentVersion': 'Gjeldende versjon',
'otaDone': 'Ferdig', 'otaDone': 'Ferdig',
'otaDownloading': 'Laster ned fastvare...', 'otaDownloading': 'Laster ned fastvare...',
@ -2106,4 +2107,7 @@ const Map<String, String> nbNO = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Oversettelse pågår. Stopp den før du bytter språk.',
'meetingRefreshNow': 'Oppdater',
'pipTakenOverByOtherApp': 'Undertekstvinduet ble overtatt av en annen apps bilde-i-bilde. Gå tilbake til denne appen og forlat den igjen, så gjenopprettes det automatisk',
}; };

4
apps/client/lib/core/translations/language/nl_nl.dart

@ -1747,6 +1747,7 @@ const Map<String, String> nlNL = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Al up-to-date', 'otaAlreadyLatest': 'Al up-to-date',
'otaNoServerVersion': 'Geen firmwareversie voor dit apparaat op de server',
'otaCurrentVersion': 'Huidige versie', 'otaCurrentVersion': 'Huidige versie',
'otaDone': 'Klaar', 'otaDone': 'Klaar',
'otaDownloading': 'Firmware downloaden...', 'otaDownloading': 'Firmware downloaden...',
@ -2160,4 +2161,7 @@ const Map<String, String> nlNL = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Vertaling loopt. Stop deze voordat u van taal wisselt.',
'meetingRefreshNow': 'Vernieuwen',
'pipTakenOverByOtherApp': 'Het ondertitelvenster is overgenomen door beeld-in-beeld van een andere app. Ga terug naar deze app en verlaat hem opnieuw om het automatisch te herstellen',
}; };

4
apps/client/lib/core/translations/language/pl_pl.dart

@ -1876,6 +1876,7 @@ const Map<String, String> plPL = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Masz najnowszą wersję', 'otaAlreadyLatest': 'Masz najnowszą wersję',
'otaNoServerVersion': 'Brak wersji oprogramowania układowego dla tego urządzenia na serwerze',
'otaCurrentVersion': 'Bieżąca wersja', 'otaCurrentVersion': 'Bieżąca wersja',
'otaDone': 'Gotowe', 'otaDone': 'Gotowe',
'otaDownloading': 'Pobieranie oprogramowania...', 'otaDownloading': 'Pobieranie oprogramowania...',
@ -2134,4 +2135,7 @@ const Map<String, String> plPL = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tłumaczenie trwa. Zatrzymaj je przed zmianą języka.',
'meetingRefreshNow': 'Odśwież',
'pipTakenOverByOtherApp': 'Okno napisów zostało zastąpione przez obraz w obrazie innej aplikacji. Wróć do tej aplikacji i opuść ją ponownie, aby przywrócić je automatycznie',
}; };

4
apps/client/lib/core/translations/language/pt_pt.dart

@ -2091,6 +2091,7 @@ const Map<String, String> ptPT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Já está atualizado', 'otaAlreadyLatest': 'Já está atualizado',
'otaNoServerVersion': 'Nenhuma versão de firmware para este dispositivo no servidor',
'otaCurrentVersion': 'Versão atual', 'otaCurrentVersion': 'Versão atual',
'otaDone': 'Concluído', 'otaDone': 'Concluído',
'otaDownloading': 'A transferir firmware...', 'otaDownloading': 'A transferir firmware...',
@ -2172,4 +2173,7 @@ const Map<String, String> ptPT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'A tradução está em curso. Pare-a antes de mudar de idioma.',
'meetingRefreshNow': 'Atualizar',
'pipTakenOverByOtherApp': 'A janela de legendas foi substituída pelo picture-in-picture de outra app. Volte a esta app e saia novamente para a restaurar automaticamente',
}; };

4
apps/client/lib/core/translations/language/ro_ro.dart

@ -1878,6 +1878,7 @@ const Map<String, String> roRO = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Deja la zi', 'otaAlreadyLatest': 'Deja la zi',
'otaNoServerVersion': 'Nu există o versiune de firmware pentru acest dispozitiv pe server',
'otaCurrentVersion': 'Versiunea curentă', 'otaCurrentVersion': 'Versiunea curentă',
'otaDone': 'Gata', 'otaDone': 'Gata',
'otaDownloading': 'Se descarcă firmware-ul...', 'otaDownloading': 'Se descarcă firmware-ul...',
@ -2136,4 +2137,7 @@ const Map<String, String> roRO = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traducerea este în curs. Opriți-o înainte de a schimba limba.',
'meetingRefreshNow': 'Reîmprospătează',
'pipTakenOverByOtherApp': 'Fereastra de subtitrări a fost preluată de imaginea în imagine a altei aplicații. Reveniți în această aplicație și ieșiți din nou pentru a o restaura automat',
}; };

4
apps/client/lib/core/translations/language/ru_ru.dart

@ -2066,6 +2066,7 @@ const Map<String, String> ruRU = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлена последняя версия', 'otaAlreadyLatest': 'Установлена последняя версия',
'otaNoServerVersion': 'На сервере нет версии прошивки для этого устройства',
'otaCurrentVersion': 'Текущая версия', 'otaCurrentVersion': 'Текущая версия',
'otaDone': 'Готово', 'otaDone': 'Готово',
'otaDownloading': 'Загрузка прошивки...', 'otaDownloading': 'Загрузка прошивки...',
@ -2147,4 +2148,7 @@ const Map<String, String> ruRU = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Идёт перевод. Остановите его, прежде чем менять язык.',
'meetingRefreshNow': 'Обновить',
'pipTakenOverByOtherApp': 'Окно субтитров было вытеснено режимом «картинка в картинке» другого приложения. Вернитесь в это приложение и снова выйдите — оно восстановится автоматически',
}; };

4
apps/client/lib/core/translations/language/sk_sk.dart

@ -1859,6 +1859,7 @@ const Map<String, String> skSK = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Už je aktuálny', 'otaAlreadyLatest': 'Už je aktuálny',
'otaNoServerVersion': 'Na serveri nie je verzia firmvéru pre toto zariadenie',
'otaCurrentVersion': 'Aktuálna verzia', 'otaCurrentVersion': 'Aktuálna verzia',
'otaDone': 'Hotovo', 'otaDone': 'Hotovo',
'otaDownloading': 'Sťahovanie firmvéru...', 'otaDownloading': 'Sťahovanie firmvéru...',
@ -2117,4 +2118,7 @@ const Map<String, String> skSK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Preklad beží. Pred zmenou jazyka ho zastavte.',
'meetingRefreshNow': 'Obnoviť',
'pipTakenOverByOtherApp': 'Okno titulkov nahradil obraz v obraze inej aplikácie. Vráťte sa do tejto aplikácie a znova ju opustite, obnoví sa automaticky',
}; };

4
apps/client/lib/core/translations/language/sl_si.dart

@ -1862,6 +1862,7 @@ const Map<String, String> slSI = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Že posodobljeno', 'otaAlreadyLatest': 'Že posodobljeno',
'otaNoServerVersion': 'Na strežniku ni različice vdelane programske opreme za to napravo',
'otaCurrentVersion': 'Trenutna različica', 'otaCurrentVersion': 'Trenutna različica',
'otaDone': 'Končano', 'otaDone': 'Končano',
'otaDownloading': 'Prenašanje vdelane programske opreme...', 'otaDownloading': 'Prenašanje vdelane programske opreme...',
@ -2120,4 +2121,7 @@ const Map<String, String> slSI = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prevajanje poteka. Ustavite ga, preden zamenjate jezik.',
'meetingRefreshNow': 'Osveži',
'pipTakenOverByOtherApp': 'Okno s podnapisi je prevzela slika v sliki druge aplikacije. Vrnite se v to aplikacijo in jo znova zapustite, da se samodejno obnovi',
}; };

4
apps/client/lib/core/translations/language/sr_rs.dart

@ -1858,6 +1858,7 @@ const Map<String, String> srRS = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno', 'otaAlreadyLatest': 'Već je ažurno',
'otaNoServerVersion': 'Na serveru nema verzije firmvera za ovaj uređaj',
'otaCurrentVersion': 'Trenutna verzija', 'otaCurrentVersion': 'Trenutna verzija',
'otaDone': 'Gotovo', 'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...', 'otaDownloading': 'Preuzimanje firmvera...',
@ -2116,4 +2117,7 @@ const Map<String, String> srRS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prevod je u toku. Zaustavite ga pre promene jezika.',
'meetingRefreshNow': 'Освежи',
'pipTakenOverByOtherApp': 'Прозор са титловима је преузела слика у слици друге апликације. Вратите се у ову апликацију и поново је напустите да би се аутоматски вратио',
}; };

4
apps/client/lib/core/translations/language/sv_se.dart

@ -1854,6 +1854,7 @@ const Map<String, String> svSE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Redan uppdaterad', 'otaAlreadyLatest': 'Redan uppdaterad',
'otaNoServerVersion': 'Ingen fast programvaruversion för den här enheten på servern',
'otaCurrentVersion': 'Nuvarande version', 'otaCurrentVersion': 'Nuvarande version',
'otaDone': 'Klar', 'otaDone': 'Klar',
'otaDownloading': 'Laddar ner fast programvara...', 'otaDownloading': 'Laddar ner fast programvara...',
@ -2112,4 +2113,7 @@ const Map<String, String> svSE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Översättning pågår. Stoppa den innan du byter språk.',
'meetingRefreshNow': 'Uppdatera',
'pipTakenOverByOtherApp': 'Undertextfönstret togs över av en annan apps bild-i-bild. Gå tillbaka till den här appen och lämna den igen så återställs det automatiskt',
}; };

4
apps/client/lib/core/translations/language/th_th.dart

@ -1807,6 +1807,7 @@ const Map<String, String> thTH = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'เป็นเวอร์ชันล่าสุดแล้ว', 'otaAlreadyLatest': 'เป็นเวอร์ชันล่าสุดแล้ว',
'otaNoServerVersion': 'ไม่มีเวอร์ชันเฟิร์มแวร์สำหรับอุปกรณ์นี้บนเซิร์ฟเวอร์',
'otaCurrentVersion': 'เวอร์ชันปัจจุบัน', 'otaCurrentVersion': 'เวอร์ชันปัจจุบัน',
'otaDone': 'เสร็จสิ้น', 'otaDone': 'เสร็จสิ้น',
'otaDownloading': 'กำลังดาวน์โหลดเฟิร์มแวร์...', 'otaDownloading': 'กำลังดาวน์โหลดเฟิร์มแวร์...',
@ -2065,4 +2066,7 @@ const Map<String, String> thTH = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'กำลังแปลอยู่ โปรดหยุดการแปลก่อนเปลี่ยนภาษา',
'meetingRefreshNow': 'รีเฟรช',
'pipTakenOverByOtherApp': 'หน้าต่างคำบรรยายถูกแทนที่ด้วยภาพซ้อนภาพของแอปอื่น กลับมาที่แอปนี้แล้วออกอีกครั้งเพื่อกู้คืนโดยอัตโนมัติ',
}; };

4
apps/client/lib/core/translations/language/tl_ph.dart

@ -1892,6 +1892,7 @@ const Map<String, String> tlPH = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Pinakabago na', 'otaAlreadyLatest': 'Pinakabago na',
'otaNoServerVersion': 'Walang bersyon ng firmware para sa device na ito sa server',
'otaCurrentVersion': 'Kasalukuyang bersyon', 'otaCurrentVersion': 'Kasalukuyang bersyon',
'otaDone': 'Tapos na', 'otaDone': 'Tapos na',
'otaDownloading': 'Dina-download ang firmware...', 'otaDownloading': 'Dina-download ang firmware...',
@ -2150,4 +2151,7 @@ const Map<String, String> tlPH = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tumatakbo ang pagsasalin. Ihinto muna ito bago magpalit ng wika.',
'meetingRefreshNow': 'I-refresh',
'pipTakenOverByOtherApp': 'Napalitan ang subtitle window ng picture-in-picture ng ibang app. Bumalik sa app na ito at lumabas muli para awtomatiko itong maibalik',
}; };

4
apps/client/lib/core/translations/language/tr_tr.dart

@ -1846,6 +1846,7 @@ const Map<String, String> trTR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Zaten güncel', 'otaAlreadyLatest': 'Zaten güncel',
'otaNoServerVersion': 'Sunucuda bu cihaz için bellenim sürümü yok',
'otaCurrentVersion': 'Geçerli sürüm', 'otaCurrentVersion': 'Geçerli sürüm',
'otaDone': 'Tamam', 'otaDone': 'Tamam',
'otaDownloading': 'Ürün yazılımı indiriliyor...', 'otaDownloading': 'Ürün yazılımı indiriliyor...',
@ -2106,4 +2107,7 @@ const Map<String, String> trTR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Çeviri devam ediyor. Dili değiştirmeden önce çeviriyi durdurun.',
'meetingRefreshNow': 'Yenile',
'pipTakenOverByOtherApp': 'Altyazı penceresi başka bir uygulamanın resim içinde resim özelliği tarafından devralındı. Bu uygulamaya dönüp tekrar çıkarsanız otomatik olarak geri gelir',
}; };

4
apps/client/lib/core/translations/language/uk_ua.dart

@ -1866,6 +1866,7 @@ const Map<String, String> ukUA = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлено найновішу версію', 'otaAlreadyLatest': 'Установлено найновішу версію',
'otaNoServerVersion': 'На сервері немає версії прошивки для цього пристрою',
'otaCurrentVersion': 'Поточна версія', 'otaCurrentVersion': 'Поточна версія',
'otaDone': 'Готово', 'otaDone': 'Готово',
'otaDownloading': 'Завантаження прошивки...', 'otaDownloading': 'Завантаження прошивки...',
@ -2124,4 +2125,7 @@ const Map<String, String> ukUA = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Триває переклад. Зупиніть його, перш ніж змінювати мову.',
'meetingRefreshNow': 'Оновити',
'pipTakenOverByOtherApp': 'Вікно субтитрів витіснив режим «картинка в картинці» іншого застосунку. Поверніться в цей застосунок і знову вийдіть — воно відновиться автоматично',
}; };

4
apps/client/lib/core/translations/language/vi_vn.dart

@ -1826,6 +1826,7 @@ const Map<String, String> viVN = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Đã là phiên bản mới nhất', 'otaAlreadyLatest': 'Đã là phiên bản mới nhất',
'otaNoServerVersion': 'Máy chủ chưa có phiên bản phần mềm cho thiết bị này',
'otaCurrentVersion': 'Phiên bản hiện tại', 'otaCurrentVersion': 'Phiên bản hiện tại',
'otaDone': 'Xong', 'otaDone': 'Xong',
'otaDownloading': 'Đang tải phần mềm...', 'otaDownloading': 'Đang tải phần mềm...',
@ -2084,4 +2085,7 @@ const Map<String, String> viVN = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Đang dịch. Hãy dừng dịch trước khi đổi ngôn ngữ.',
'meetingRefreshNow': 'Làm mới',
'pipTakenOverByOtherApp': 'Cửa sổ phụ đề đã bị ảnh-trong-ảnh của ứng dụng khác chiếm chỗ. Quay lại ứng dụng này rồi thoát ra lần nữa để tự động khôi phục',
}; };

4
apps/client/lib/core/translations/language/zh_cn.dart

@ -1873,6 +1873,7 @@ const Map<String, String> zhCN = {
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本', 'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '服务端暂无该设备的固件版本',
'otaCurrentVersion': '当前版本', 'otaCurrentVersion': '当前版本',
'otaDone': '完成', 'otaDone': '完成',
'otaDownloading': '正在下载固件...', 'otaDownloading': '正在下载固件...',
@ -1940,4 +1941,7 @@ const Map<String, String> zhCN = {
'computePack': '算力包', 'computePack': '算力包',
'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要', 'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要',
'computeLow': '算力不多了,用完后将无法继续使用', 'computeLow': '算力不多了,用完后将无法继续使用',
'stopTranslationBeforeLanguageChange': '翻译进行中,请先停止翻译再切换语言',
'meetingRefreshNow': '刷新',
'pipTakenOverByOtherApp': '字幕小窗被其它应用的画中画占用了,回到本应用再切出去会自动恢复',
}; };

4
apps/client/lib/core/translations/language/zh_hk.dart

@ -1856,6 +1856,7 @@ const Map<String, String> zhHK = {
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改', 'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本', 'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '伺服器暫無此裝置的韌體版本',
'otaCurrentVersion': '目前版本', 'otaCurrentVersion': '目前版本',
'otaDone': '完成', 'otaDone': '完成',
'otaDownloading': '正在下載韌體...', 'otaDownloading': '正在下載韌體...',
@ -1937,4 +1938,7 @@ const Map<String, String> zhHK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言',
'meetingRefreshNow': '重新整理',
'pipTakenOverByOtherApp': '字幕小窗被其他應用程式的畫中畫佔用了,返回本應用程式再切出去會自動恢復',
}; };

4
apps/client/lib/core/translations/language/zh_tw.dart

@ -1856,6 +1856,7 @@ const Map<String, String> zhTW = {
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改', 'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ---- // ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本', 'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '伺服器暫無此裝置的韌體版本',
'otaCurrentVersion': '目前版本', 'otaCurrentVersion': '目前版本',
'otaDone': '完成', 'otaDone': '完成',
'otaDownloading': '正在下載韌體...', 'otaDownloading': '正在下載韌體...',
@ -1937,4 +1938,7 @@ const Map<String, String> zhTW = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言',
'meetingRefreshNow': '重新整理',
'pipTakenOverByOtherApp': '字幕小窗被其他應用程式的子母畫面佔用了,返回本應用程式再切出去會自動恢復',
}; };

4
apps/client/lib/core/utils/ai_navigation.dart

@ -13,8 +13,10 @@ import 'id_verify_guard.dart';
/// 「Azure STT → 火山 LLM/TTS」的老链路。 /// 「Azure STT → 火山 LLM/TTS」的老链路。
void navigateToAIAssistant() { void navigateToAIAssistant() {
if (IdVerifyGuard.blocked()) return; if (IdVerifyGuard.blocked()) return;
// 服务端落点:svc_config 的 llm_mobile_elf.agent_type(老键名 AGENT_TYPE 作兜底)。
// 后台停用那条服务 = 两边都没有 = 一律进 EMAI。
final agentType = GetStorage().read<String>('device_agent_type') ?? final agentType = GetStorage().read<String>('device_agent_type') ??
AppConfig.env('AGENT_TYPE') ?? AppConfig.cred('llm_mobile_elf', 'agent_type', 'AGENT_TYPE') ??
'stt_llm_tts'; 'stt_llm_tts';
if (agentType == 'yidong') { if (agentType == 'yidong') {
Get.toNamed(Routes.mobileElf); Get.toNamed(Routes.mobileElf);

58
apps/client/lib/core/utils/recent_languages.dart

@ -0,0 +1,58 @@
import 'package:get_storage/get_storage.dart';
/// 语言选择弹窗的「最近使用」排序。
///
/// 语言表本身有几十项(面对面/同传/音视频共用 LanguageManager 那份),
/// 每次都从头翻很慢,所以用过的往上提,最顶的是最近一次选的。
///
/// ⚠️ 源/目标**分开记**:同一个人的源语言常年是母语、目标语言才是变的那个,
/// 合成一份会让刚选的目标语言跑到源语言列表顶上,越用越乱。
class RecentLanguages {
RecentLanguages._();
/// 每侧最多记几个。再多就没有「置顶」的意义了,整张表都成了最近使用。
static const int maxKeep = 8;
static const String _sourceKey = 'recent_languages_source';
static const String _targetKey = 'recent_languages_target';
static String _keyOf(bool isSource) => isSource ? _sourceKey : _targetKey;
static final GetStorage _storage = GetStorage();
/// 读最近使用列表(最新的在前)。存储里可能是任意类型,逐项转字符串兜底。
static List<String> read(bool isSource) {
final raw = _storage.read(_keyOf(isSource));
if (raw is! List) return <String>[];
return raw
.map((e) => e?.toString() ?? '')
.where((e) => e.isNotEmpty)
.toList();
}
/// 记一次使用:已存在就提到最前,不重复占位。
static void touch(String language, {required bool isSource}) {
if (language.isEmpty) return;
final list = read(isSource)..remove(language);
list.insert(0, language);
if (list.length > maxKeep) list.removeRange(maxKeep, list.length);
_storage.write(_keyOf(isSource), list);
}
/// 按最近使用重排:用过的按使用顺序排在前面,其余保持原有顺序。
///
/// 只提「本次列表里确实有」的语言 —— 各模块传进来的语言表不一样
/// (语音翻译走 LanguageManager、图片翻译另有一份),
/// 直接把记录拼上去会冒出当前模式根本不支持的语言。
static List<String> sort(List<String> languages, {required bool isSource}) {
final recent = read(isSource);
if (recent.isEmpty) return languages;
final remaining = List<String>.from(languages);
final ordered = <String>[];
for (final name in recent) {
if (remaining.remove(name)) ordered.add(name);
}
return [...ordered, ...remaining];
}
}

266
apps/client/lib/core/utils/synchrodata.dart

@ -1,193 +1,133 @@
import 'dart:convert'; import 'dart:io';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../data/services/db/database_helper.dart'; import '../../data/services/db/database_helper.dart';
import '../../data/services/db/sqflite_api.dart';
import '../../data/services/meeting/meeting_task_service.dart'; import '../../data/services/meeting/meeting_task_service.dart';
import '../../data/services/meeting/meeting_upload_service.dart';
import '../../data/services/network/api.dart'; import '../../data/services/network/api.dart';
import 'logger.dart';
/// 语音纪要列表与服务端对齐。
///
/// 模型:**服务端是唯一真源,本地 sqlite 只是缓存。**
/// - `echomeet_getallrecords` 只回骨架(title/rtype/state/seconds/audiourl/creationtime),
/// 每条 ~250 字节,上千条也是一次拉完,所以没有分页、没有增量、没有软删除;
/// - 转写(`meetingspeaker`) 与总结(`summary/overview/personnel`) **不在这里写**,
/// 详情页进页面时 `echomeet_getrecord` 拉整条再落库(见 MeetingDetailsController);
/// - 本地有、服务端没有的记录 = 在别的设备上删了,这里跟着删。
/// 本地行必然带服务端 id(归档是先 `addrecord` 拿到 id 才 `insertMeeting`),不会误删归档中的。
///
/// 原先这个类还带一套「离线操作队列 → echomeet_putrecords」的上行逻辑,服务端那个接口整个
/// 注释掉了、客户端上传段也注释掉了,是死代码,2026-09-18 一并删除。
class Synchrodata { class Synchrodata {
static final GetStorage _storage = GetStorage(); static const String _tag = 'Synchrodata';
static bool _running = false;
static List<Map<String, dynamic>> _records = /// 拉一次骨架并对齐本地库。并发调用直接返回;失败只记日志(列表照用本地缓存)。
(_storage.read("operation_records") as List?) static Future<void> sync() async {
?.map((e) => Map<String, dynamic>.from(e as Map)) if (_running) return;
.toList() ?? _running = true;
[]; try {
static List _delids = _storage.read("operation_delids") ?? []; await _sync();
static bool _isSynchro = false; } catch (e, st) {
Logger.w(_tag, '列表同步失败(沿用本地缓存): $e\n$st');
// 保存操作记录 } finally {
static Future saveOperationRecord( _running = false;
String method,
dynamic params,
) async {
String userId = '';
Map? userInfo = _storage.read("user_info");
if (userInfo != null) {
userId = userInfo['user']['uid'];
}
String id = '${userId}_${params['id']}';
if (params['type'] != null) {
params['rtype'] = params['type'];
}
switch (method) {
case 'insert':
_records.add({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
'uid': userId,
});
_storage.write("operation_records", _records);
break;
case 'update':
Map<String, dynamic>? foundElement = _records.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (foundElement.isNotEmpty) {
foundElement.addAll({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
});
} else {
_records.add({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
'uid': userId,
});
}
_storage.write("operation_records", _records);
break;
case 'delete':
_delids.add(id);
_storage.write("operation_delids", _delids);
break;
default:
break;
} }
} }
// 上传操作记录 static Future<void> _sync() async {
static Future uploadOperationRecords({bool isSynchro = false}) async { final result = await Api.getOperationAllRecord();
if (_isSynchro) return; // ⚠️ 业务错误时 AuthInterceptor 把 data 置 null 后 resolve(不抛),必须判空——
_isSynchro = true; // 把 null 当「服务端没有记录」会把本地整个清空。
// if (_records.isNotEmpty || _delids.isNotEmpty) { if (result == null) {
// // 获取需要修改的全部数据 Logger.w(_tag, '列表接口返回业务错误,本轮不对齐');
// List putRecords = []; return;
// for (var element in _records) {
// Map result = {'details': {}};
// List speakerList = [];
// if (!_delids.contains(element['id'])) {
// result = await SqfliteApi.getMeetingDetails(element['number']);
// speakerList = await SqfliteApi.getMeetingSpeaker(element['number']);
// }
// putRecords.add({
// ...Map<String, dynamic>.from(result),
// ...Map<String, dynamic>.from(result['details']),
// 'rtype': result['type'],
// 'original': jsonEncode(speakerList),
// ...Map<String, dynamic>.from(element),
// });
// }
// var result = await Api.putOperationRecord({
// 'delids': _delids,
// 'records': putRecords,
// });
// if (result != null) {
// _records = [];
// _delids = [];
// _storage.write("operation_records", _records);
// _storage.write("operation_delids", _delids);
// }
// }
if (isSynchro) {
await _synchrodata();
} }
_isSynchro = false; final List records = result['records'] ?? [];
} final serverIds = <int>{};
final taskList = <Map<String, Object?>>[];
// 同步数据
static Future _synchrodata() async {
var result = await Api.getOperationAllRecord();
List records = result['records'] ?? [];
if (records.isEmpty) return;
final meetingTaskService = Get.find<MeetingTaskService>();
List taskList = [];
final db = await DatabaseHelper().database; final db = await DatabaseHelper().database;
await db.transaction((txn) async { await db.transaction((txn) async {
for (var element in records) { for (final element in records) {
if (element['state'] > 0 && element['state'] < 5) { final int id = element['id'];
taskList.add({ final int state = element['state'] ?? 0;
'id': element['id'], serverIds.add(id);
'tasktype': element['state'], if (state > 0 && state < 5) {
}); taskList.add({'id': id, 'tasktype': state});
} }
final List existList = await txn.query( final meeting = <String, Object?>{
'meeting', 'id': id,
where: 'id = ?',
whereArgs: [element['id']],
);
Map<String, Object?> meetingData = {
'id': element['id'],
'title': element['title'] ?? '', 'title': element['title'] ?? '',
'type': element['rtype'] ?? '', 'type': element['rtype'] ?? '',
'seconds': element['seconds'] ?? 0, 'seconds': element['seconds'] ?? 0,
'audiourl': element['audiourl'] ?? '', 'audiourl': element['audiourl'] ?? '',
'tasktype': element['state'] ?? 0, 'tasktype': state,
// 服务端秒,本地毫秒
'creationtime': (element['creationtime'] ?? 0) * 1000, 'creationtime': (element['creationtime'] ?? 0) * 1000,
}; };
Map<String, Object?> meetingDetailsData = { final exists = await txn.query('meeting',
'meetingid': element['id'], columns: ['id'], where: 'id = ?', whereArgs: [id], limit: 1);
'address': element['address'] ?? '', if (exists.isNotEmpty) {
'personnel': element['personnel'] ?? '', // 不碰 filepath(本机录音的路径只有本机知道)
'taskid': element['taskid'] ?? '', await txn.update('meeting', meeting, where: 'id = ?', whereArgs: [id]);
'tasktype': element['state'] ?? 0, // 状态在两张表各存一份,以 meeting 表为准,details 跟着走(详情页读的是这份)
'overview': element['overview'] ?? '', await txn.update('meetingdetails', {'tasktype': state},
'summary': element['summary'] ?? '', where: 'meetingid = ?', whereArgs: [id]);
'mindmap': '',
};
if (existList.isNotEmpty) {
await txn.update(
'meeting',
meetingData,
where: 'id = ?',
whereArgs: [element['id']],
);
await txn.update(
'meetingdetails',
meetingDetailsData,
where: 'meetingid = ?',
whereArgs: [element['id']],
);
} else { } else {
await txn.insert('meeting', { await txn.insert('meeting', {...meeting, 'filepath': ''});
...meetingData, await txn.insert('meetingdetails', {
'filepath': '', 'meetingid': id,
'address': element['address'] ?? '',
'personnel': element['personnel'] ?? '',
'taskid': element['taskid'] ?? '',
'tasktype': state,
'overview': '',
'summary': '',
'mindmap': '',
}); });
await txn.insert('meetingdetails', meetingDetailsData);
}
if (element['translate'].isNotEmpty) {
List speakerList = jsonDecode(element['translate']);
await txn.delete(
'meetingspeaker',
where: 'meetingid = ?',
whereArgs: [element['id']],
);
final batch = txn.batch();
for (var item in speakerList) {
batch.insert('meetingspeaker', item);
}
await batch.commit();
} }
} }
}); });
meetingTaskService.startTask(taskList);
await _removeLocalOrphans(serverIds);
if (taskList.isNotEmpty && Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().startTask(taskList);
}
Logger.i(_tag, '列表已对齐:服务端 ${records.length} 条,进行中 ${taskList.length} 条');
}
/// 删掉「本地有、服务端没有」的记录:连本地音频、上传队列、轮询任务一起清。
static Future<void> _removeLocalOrphans(Set<int> serverIds) async {
final db = await DatabaseHelper().database;
final local = await db.query('meeting', columns: ['id', 'filepath']);
final orphans = local.where((r) => !serverIds.contains(r['id'] as int));
for (final row in orphans) {
final int id = row['id'] as int;
final String path = (row['filepath'] as String?) ?? '';
await db.transaction((txn) async {
await txn.delete('meeting', where: 'id = ?', whereArgs: [id]);
await txn.delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]);
await txn.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]);
});
if (path.isNotEmpty) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.w(_tag, '删除孤儿录音文件失败 $path: $e');
}
}
if (Get.isRegistered<MeetingUploadService>()) {
Get.find<MeetingUploadService>().removeUpload(id, isDeleteTask: true);
}
if (Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().removeLocalTask(id);
}
Logger.i(_tag, '服务端已不存在,删除本地记录 id=$id');
}
} }
} }

45
apps/client/lib/data/models/appconfig.dart

@ -1,4 +1,3 @@
import 'dart:convert';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
@ -20,11 +19,53 @@ class AppConfig {
_isInitialized = true; // <-- 新增此行 _isInitialized = true; // <-- 新增此行
} }
//环境变量 //环境变量(应用参数 + 老客户端兼容层)
static String? env(String key) { static String? env(String key) {
return config.env[key]; return config.env[key];
} }
// ───── 第三方服务(结构化,2026-09-14 起凭据的首选来源)─────
//
// 后台「服务与环境配置 → 第三方服务」每个应用一套;服务端按用户区域合并区域分叉、解密后经加密的
// user_getappconfig_v3 下发为 thirdsvcs。各业务服务应改用 [cred] 取凭据:先查 thirdsvcs 的
// 「服务 id + 字段名」,取不到再退回 env[老键名]——这样对没配 GATEWAY_ENCRYPT_KEY、或还在跑旧版
// 服务端的环境也能继续工作,而不是一刀切断。
/// 按服务 id 取一条(如 'stt_azure')。未初始化或不存在返回 null。
static ThirdSvc? svc(String id) {
if (!_isInitialized) return null;
for (final s in config.thirdsvcs) {
if (s.id == id) return s;
}
return null;
}
/// 某类别下的全部服务(类别 id 见 ThirdSvc 注释)。
static List<ThirdSvc> svcsByCategory(int cat) {
if (!_isInitialized) return const [];
return config.thirdsvcs.where((s) => s.hasCategory(cat)).toList();
}
/// 取某服务的某个字段;服务不存在、字段不存在或为空串都返回 null(空串等同没配)。
static String? field(String svcId, String fieldKey) {
final v = svc(svcId)?.fields[fieldKey]?.trim();
return (v == null || v.isEmpty) ? null : v;
}
/// 凭据读取入口:thirdsvcs 的 (svcId, fieldKey) 优先,退回 env[envKey]。
///
/// 返回 null 表示两边都没配。调用方自己决定 `?? ''` 还是 `?? 默认值`——
/// 别在这里给空串,那会把调用方的 `?? 默认值` 顶掉(AZURE_TRANSLATION_ENDPOINT 栽过)。
static String? cred(String svcId, String fieldKey, String envKey) {
final v = field(svcId, fieldKey);
if (v != null) return v;
final e = _isInitialized ? config.env[envKey]?.trim() : null;
return (e == null || e.isEmpty) ? null : e;
}
/// 当前是否已从结构化通道拿到凭据(用于日志/诊断:为 false 说明还在走 env 兼容层)。
static bool get usingThirdSvcs => _isInitialized && config.thirdsvcs.isNotEmpty;
//mcp配置 //mcp配置
static dynamic mcpConfig() { static dynamic mcpConfig() {
return {"mcpServers": config.mcps}; return {"mcpServers": config.mcps};

41
apps/client/lib/data/models/appconfig_model.dart

@ -18,17 +18,56 @@ class UserGetAppConfigResp {
@JsonKey(defaultValue: <DBProduct>[]) @JsonKey(defaultValue: <DBProduct>[])
final List<DBProduct> products; final List<DBProduct> products;
/// 第三方服务(结构化,凭据已按用户区域合并并解密为明文)——2026-09-14 起客户端凭据的**首选来源**。
/// 只有加密的 `user_getappconfig_v3` 下发;走明文 v1 时为空,各服务退回 [env] 兼容层。
/// 形状镜像服务端 pb.ThirdSvcItem(apps/proto/user/user_msg_v2.proto)。
@JsonKey(defaultValue: <ThirdSvc>[])
final List<ThirdSvc> thirdsvcs;
UserGetAppConfigResp( UserGetAppConfigResp(
{required this.env, {required this.env,
required this.agents, required this.agents,
required this.mcps, required this.mcps,
required this.products}); required this.products,
this.thirdsvcs = const <ThirdSvc>[]});
factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) => factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) =>
_$UserGetAppConfigRespFromJson(json); _$UserGetAppConfigRespFromJson(json);
Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this); Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this);
} }
/// 第三方服务条目(镜像服务端 pb.ThirdSvcItem)。
/// - [categories] 逗号分隔的类别 id(1 STT / 2 TTS / 3 MT / 4 AST / 5 LLM / 6 存储 / 7 STS / 8 录音识别 / 9 多媒体 LLM / 10 MCP)
/// - [fields] 字段名 → 明文值,字段名以后台「第三方服务」里配置的为准(如 stt_azure 的 subscription_key / region)
@JsonSerializable()
class ThirdSvc {
@JsonKey(defaultValue: '')
final String id;
@JsonKey(defaultValue: '')
final String name;
@JsonKey(defaultValue: '')
final String provider;
@JsonKey(defaultValue: '')
final String categories;
@JsonKey(defaultValue: <String, String>{})
final Map<String, String> fields;
ThirdSvc({
required this.id,
required this.name,
required this.provider,
required this.categories,
required this.fields,
});
/// 是否属于某个类别(categories 是逗号分隔多值,别用 contains('1') —— 会命中 10/11)。
bool hasCategory(int cat) =>
categories.split(',').map((e) => e.trim()).contains(cat.toString());
factory ThirdSvc.fromJson(Map<String, dynamic> json) => _$ThirdSvcFromJson(json);
Map<String, dynamic> toJson() => _$ThirdSvcToJson(this);
}
@JsonSerializable() @JsonSerializable()
class DBAgent { class DBAgent {
@JsonKey(defaultValue: '') @JsonKey(defaultValue: '')

24
apps/client/lib/data/models/appconfig_model.g.dart

@ -26,6 +26,10 @@ UserGetAppConfigResp _$UserGetAppConfigRespFromJson(
?.map((e) => DBProduct.fromJson(e as Map<String, dynamic>)) ?.map((e) => DBProduct.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[], [],
thirdsvcs: (json['thirdsvcs'] as List<dynamic>?)
?.map((e) => ThirdSvc.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$UserGetAppConfigRespToJson( Map<String, dynamic> _$UserGetAppConfigRespToJson(
@ -35,6 +39,26 @@ Map<String, dynamic> _$UserGetAppConfigRespToJson(
'agents': instance.agents, 'agents': instance.agents,
'mcps': instance.mcps, 'mcps': instance.mcps,
'products': instance.products, 'products': instance.products,
'thirdsvcs': instance.thirdsvcs,
};
ThirdSvc _$ThirdSvcFromJson(Map<String, dynamic> json) => ThirdSvc(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
provider: json['provider'] as String? ?? '',
categories: json['categories'] as String? ?? '',
fields: (json['fields'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
{},
);
Map<String, dynamic> _$ThirdSvcToJson(ThirdSvc instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'provider': instance.provider,
'categories': instance.categories,
'fields': instance.fields,
}; };
DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent( DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent(

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

@ -354,7 +354,12 @@ class AssistantReminder {
id: d.id, id: d.id,
content: (d.args['content'] ?? '').toString().trim(), content: (d.args['content'] ?? '').toString().trim(),
time: time, time: time,
date: _normalizeDate((d.args['date'] ?? '').toString()), date: rollForwardPastDate(
_normalizeDate((d.args['date'] ?? '').toString()),
time,
d.createdAt,
parsed.$1,
),
repeat: parsed.$1, repeat: parsed.$1,
repeatRaw: rawRepeat, repeatRaw: rawRepeat,
weekday: parsed.$2, weekday: parsed.$2,
@ -364,6 +369,52 @@ class AssistantReminder {
); );
} }
/// 模型给的 `date` 落在过去时,挪到就近的未来。
///
/// 起因是真机实测(2026-09-19 09:25 定「下午2点去恒玄公司」,服务端 id=35):
/// 模型下发的 `date` 是 **2026-09-18**,比当天早一天。客户端原样信了,于是
/// 闹钟落在昨天那一页 —— 助手说"设好了",拾忆页翻到今天却空空如也,
/// 而且**永远不会响**(服务端 remind 只排未来的,那条的 next_remind_at=0)。
///
/// 根因在模型侧:会话 Start 里没有告诉百炼"今天是几号"(见
/// BailianMultimodalService 的 parameters),模型只能自己猜,偶尔差一天
/// (同一张表里 9-14 那条也差了一天,9-15 的两条又是对的)。
/// 正解是在百炼应用里注入当前时间,但那是控制台配置、不在本仓库;
/// 在那之前这道防线兜住最难受的表现。
///
/// 口径:**过去的一次性闹钟没有意义**,所以
/// - 今天这个点还没到 → 挪到今天;
/// - 已经过了 → 挪到明天。
///
/// ⚠️ 只管一次性的。重复闹钟的 `date` 是锚点,落在过去是正常的
/// ("每周一"从上周一起算),挪了反而会把第一次触发推迟一周。
///
/// ⚠️ 只在**确实早于今天**时才动。模型把"明天"算成今天这类偏差
/// 客户端分辨不了(时间仍在未来),不猜。
static String? rollForwardPastDate(
String? date,
String time,
DateTime now,
RepeatRule repeat,
) {
if (date == null) return null;
if (repeat != RepeatRule.once && repeat != RepeatRule.unknown) return date;
final parsed = DateTime.tryParse(date);
if (parsed == null) return date;
final today = DateTime(now.year, now.month, now.day);
if (!parsed.isBefore(today)) return date;
final parts = time.split(':');
final at = DateTime(now.year, now.month, now.day,
int.parse(parts[0]), int.parse(parts[1]));
final target = at.isAfter(now) ? today : today.add(const Duration(days: 1));
return '${target.year.toString().padLeft(4, '0')}-'
'${target.month.toString().padLeft(2, '0')}-'
'${target.day.toString().padLeft(2, '0')}';
}
/// 把 12 小时制的歧义时间按回复文案校正到 24 小时制。 /// 把 12 小时制的歧义时间按回复文案校正到 24 小时制。
/// ///
/// 起因是实测拿到的这一帧:用户说 "3:30pm",模型回 "已经帮你设定了明天下午 /// 起因是实测拿到的这一帧:用户说 "3:30pm",模型回 "已经帮你设定了明天下午
@ -480,6 +531,36 @@ class AssistantReminder {
} }
/// 本地提醒里**还需要投影到拾忆页**的那些:服务端已经有的排除掉。
///
/// 双写迁移期,同一条闹钟可能同时存在于两处——本地 reminders 与服务端
/// memory_item。页面两边都投影就会出现「同一个闹钟两张卡,一张能编辑、
/// 点另一张说没同步到服务器」(2026-09-19 真机实测,服务端 id=36/37 各只有
/// 一行,重复纯在客户端)。
///
/// ⚠️ **不能只信本地的 `synced` 标志**。它是服务端事实的一份影子拷贝,
/// 丢了就会凭空多一条:上传成功后进程被杀(标志还没落盘)、重装、清缓存
/// 都会把一条服务端已有的闹钟打回 unsynced。服务端那份数据自己就带着答案——
/// `client_key` / `source_id` 存的正是指令 id(见 [AssistantReminderToMemory]),
/// 对上了就说明已经在服务端了。两个键都认:历史数据里有只写了其中一个的。
List<AssistantReminder> pendingLocalReminders(
Iterable<AssistantReminder> reminders,
Iterable<MemoryItem> serverItems,
) {
final uploaded = <String>{};
for (final it in serverItems) {
if (it.clientKey.isNotEmpty) uploaded.add(it.clientKey);
if (it.sourceId.isNotEmpty) uploaded.add(it.sourceId);
}
return [
for (final r in reminders)
if (!r.synced &&
!(r.directiveId.isNotEmpty && uploaded.contains(r.directiveId)) &&
!(r.id.isNotEmpty && uploaded.contains(r.id)))
r,
];
}
/// 提醒 → 记忆项的换算。 /// 提醒 → 记忆项的换算。
/// ///
/// 收敛在这里而不是各写一份:上传服务端(AssistantDirectiveService._uploadReminder) /// 收敛在这里而不是各写一份:上传服务端(AssistantDirectiveService._uploadReminder)

8
apps/client/lib/data/models/device_category.dart

@ -59,7 +59,7 @@ class DeviceCategories {
static const String studyMachine = 'studyMachine'; static const String studyMachine = 'studyMachine';
static const String noteCard = 'noteCard'; static const String noteCard = 'noteCard';
/// AI车载香薰(Smartcar)。2026-09-08 新增。 /// AI车载香薰(EaiCar,旧名 Smartcar)。2026-09-08 新增。
static const String carAroma = 'carAroma'; static const String carAroma = 'carAroma';
// ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage / // ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage /
@ -72,7 +72,7 @@ class DeviceCategories {
// 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动 // 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动
// 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。 // 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。
// //
// 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(Smartcar)。 // 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(EaiCar)。
// 下架某条 = 把它挪回 [hidden]。 // 下架某条 = 把它挪回 [hidden]。
static const List<DeviceCategory> all = [ static const List<DeviceCategory> all = [
DeviceCategory( DeviceCategory(
@ -98,14 +98,14 @@ class DeviceCategories {
imageAsset: '${DeviceImages.dir}EaiRec.png', imageAsset: '${DeviceImages.dir}EaiRec.png',
nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'], nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'],
), ),
// AI车载香薰 = Smartcar。2026-09-08 新增,蓝牙链路同样未接。 // AI车载香薰 = EaiCar(广播名,旧名 Smartcar 已于 2026-09-15 停止接纳)。
DeviceCategory( DeviceCategory(
id: carAroma, id: carAroma,
title: 'catCarAroma', title: 'catCarAroma',
subtitle: 'catCarAromaDesc', subtitle: 'catCarAromaDesc',
icon: Icons.air, icon: Icons.air,
gradient: [Color(0xFFFFB74D), Color(0xFFE65100)], gradient: [Color(0xFFFFB74D), Color(0xFFE65100)],
imageAsset: '${DeviceImages.dir}Smartcar.png', imageAsset: '${DeviceImages.dir}EaiCar.png',
nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'], nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'],
), ),
]; ];

7
apps/client/lib/data/models/device_images.dart

@ -16,6 +16,13 @@ class DeviceImages {
DeviceImages._(); DeviceImages._();
static const String _tag = 'DeviceImages'; static const String _tag = 'DeviceImages';
/// ⚠️ 目录里的图**必须是去背的透明 PNG**,`default.png` / `default_icon.png`
/// 也不例外。设备页的展示区是横宽的(约 1.14:1),而出图习惯是竖版海报
/// (892×1152,0.77:1)——带不透明底色的竖图用 `BoxFit.contain` 塞进去,
/// 会变成中间一条窄的色块卡片、左右大片留白,且底色与页面背景(尤其深色模式)
/// 硬碰硬。2026-09-14 之前的默认图正是这样,表现为「图片和显示框对不上」。
/// 去背之后主体自己铺满框、周围就是页面背景,不需要任何代码适配。
static const String dir = 'assets/images/devices/'; static const String dir = 'assets/images/devices/';
static const String defaultImage = '${dir}default.png'; static const String defaultImage = '${dir}default.png';
static const String defaultIcon = '${dir}default_icon.png'; static const String defaultIcon = '${dir}default_icon.png';

5
apps/client/lib/data/models/user_Info.dart

@ -143,6 +143,10 @@ class User {
[], [],
); );
_instance = u; _instance = u;
// 登录完成也是「用户信息变了」,要发信号:设备的绑定登记挂在这上面补报
// (见 DeviceAuth.bindLoginRetry)。原来只有 updateUserInfo 会 +1,
// 于是「先连设备后登录」这条路上没有任何人知道登录已经完成。
refreshTick.value++;
Logger.i("User", 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}"); "初始化用户: 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; return u;
@ -180,6 +184,7 @@ class User {
: [], : [],
); );
_instance = u; _instance = u;
refreshTick.value++; // 同上:token 登录(冷启动恢复会话)也要发信号
Logger.i("User", 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}"); "初始化用户(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; return u;

4
apps/client/lib/data/services/account_service.dart

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import '../../core/utils/synchrodata.dart';
import '../../modules/login/controllers/googleLogin.dart'; import '../../modules/login/controllers/googleLogin.dart';
import '../services/network/api.dart'; import '../services/network/api.dart';
import 'db/database_helper.dart'; import 'db/database_helper.dart';
@ -119,9 +118,6 @@ class AccountService extends GetxService {
final uploadService = Get.find<MeetingUploadService>(); final uploadService = Get.find<MeetingUploadService>();
uploadService.cancelUpload(); uploadService.cancelUpload();
// 上传会议记录操作记录
await Synchrodata.uploadOperationRecords();
// 关闭数据库连接 // 关闭数据库连接
await DatabaseHelper().close(); await DatabaseHelper().close();

23
apps/client/lib/data/services/agent_module_defaults.dart

@ -37,12 +37,31 @@ class AgentDefaults {
static void bind(DeviceHub hub) { static void bind(DeviceHub hub) {
_worker?.dispose(); _worker?.dispose();
_worker = ever<List<DeviceSession>>(hub.sessions, (list) { _worker = ever<List<DeviceSession>>(hub.sessions, (_) => _apply(hub));
// ⚠️ 还要对**当前状态**跑一次:GetX 的 ever 只在之后的赋值上触发,
// 拿不到绑定那一刻已经在的值。设备是被动回连上来的(原生扫到广播就接),
// 完全可能赶在 bind 之前就绪 —— 那样 AI 页这一整轮都不会按设备铺编排,
// 而且不报任何错,表现就是「设备明明连着,功能却是全量的」。
_apply(hub);
}
/// 按「当前连着的设备,没有就按最近连过的那台」铺默认编排。
///
/// ⚠️ 断开后必须回退到 [DeviceHub.lastKnownVendor],不能什么都不做:
/// 设备页的产品图和「设备管理」在断连后仍然显示最近那台设备,AI 页却停在
/// 上一台设备铺下的编排里 —— 于是出现「图是 Echo-one 耳机、功能却是车载香薰
/// 那三个」。三处必须同一个口径,见 DeviceHub._lastKnown。
static void _apply(DeviceHub hub) {
final list = hub.sessions;
if (list.isNotEmpty) {
for (final s in list) { for (final s in list) {
final cat = _vendorCategory[s.vendor]; final cat = _vendorCategory[s.vendor];
if (cat != null) applyAgentDefaultsFor(cat); if (cat != null) applyAgentDefaultsFor(cat);
} }
}); return;
}
final cat = _vendorCategory[hub.lastKnownVendor];
if (cat != null) applyAgentDefaultsFor(cat);
} }
static void applyAgentDefaultsFor(String categoryId) { static void applyAgentDefaultsFor(String categoryId) {

79
apps/client/lib/data/services/agent_module_service.dart

@ -40,14 +40,93 @@ class AgentModuleService extends GetxService {
.where((id) => AgentModules.byId(id) != null) .where((id) => AgentModules.byId(id) != null)
.toList(); .toList();
enabledIds.assignAll(list); enabledIds.assignAll(list);
// ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移
// 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该
// 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部
// 追加回来,等于把「连上香薰只显示 Smartcar/现场录音/语音纪要」直接抹掉。
//
// 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成
// 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults
// 一比较发现「对不上」,判成用户动过编排,**从此再也不铺**。表现就是
// 连着香薰却显示全量功能,且怎么重连都恢复不了。
if (_matchesLastApplied()) return;
_migrateEmaiAssistant(); _migrateEmaiAssistant();
_migrateDefaultLayout(); _migrateDefaultLayout();
_migrateDefaultOrder(); _migrateDefaultOrder();
_rebaseDeviceDefaultsBaseline();
} else { } else {
enabledIds.assignAll(AgentModules.defaultEnabled); enabledIds.assignAll(AgentModules.defaultEnabled);
} }
} }
/// 当前编排是否与「上一次自动铺下去的那份」逐项相同 —— 即用户没动过。
bool _matchesLastApplied() {
if (!_storage.hasData(_lastAppliedKey)) return false;
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
.map((e) => e.toString())
.toList();
return last.isNotEmpty && _sameOrder(enabledIds, last);
}
/// 一次性纠正:把已经被 [_migrateDefaultLayout] 追加过的设备编排重新认作基线。
///
/// 修上面那条时序 bug 之前装过的机器,storage 里已经是「enabledIds=11 项、
/// 基线=3 项」的坏状态,而迁移带 migratedKey 不会再跑一次,光修 _load 救不回来。
///
/// 判据收得很紧,必须**同时**满足:基线存在、当前编排以基线为前缀、多出来的项
/// 全部来自 defaultEnabled —— 这正是 _migrateDefaultLayout「保持原序、把缺的
/// append 到尾部」留下的形态。
///
/// 纠正的是**基线**不是编排:把基线抬到当前值,界面此刻不变,等下次连上设备时
/// 由 applyDeviceDefaults 正常铺成该设备的编排 —— 那本来就是用户预期的时机。
/// (代价:用户如果**恰好**手动加的也全是 defaultEnabled 里的项、且加在尾部,
/// 会被误判成迁移污染,下次连设备时编排被重铺一次。两种误判范围相同,
/// 选这个是因为它不会在启动瞬间凭空改变用户看到的界面。)
void _rebaseDeviceDefaultsBaseline() {
const rebaseKey = 'ai_tab_device_defaults_rebase_v1';
if (_storage.read<bool>(rebaseKey) == true) return;
_storage.write(rebaseKey, true);
if (!_storage.hasData(_lastAppliedKey)) return;
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
.map((e) => e.toString())
.toList();
if (!isLayoutMigrationArtifact(
enabled: enabledIds,
lastApplied: last,
defaults: AgentModules.defaultEnabled,
)) {
return;
}
_storage.write(_lastAppliedKey, enabledIds.toList());
}
/// 当前编排看起来是不是「设备默认编排 + 被一次性迁移追加的全量项」。
///
/// 形态要求([_migrateDefaultLayout] 的行为特征):
/// 1. [lastApplied] 非空,且原样是 [enabled] 的**前缀**(顺序都没动);
/// 2. [enabled] 确实更长;
/// 3. 多出来的项**全部**出自 [defaults] —— 迁移只会补这些。
///
/// 抽成纯函数是为了能脱离 GetStorage 测试,见 test/agent_module_defaults_test.dart。
static bool isLayoutMigrationArtifact({
required List<String> enabled,
required List<String> lastApplied,
required List<String> defaults,
}) {
if (lastApplied.isEmpty || enabled.length <= lastApplied.length) {
return false;
}
for (var i = 0; i < lastApplied.length; i++) {
if (enabled[i] != lastApplied[i]) return false;
}
for (final id in enabled.sublist(lastApplied.length)) {
if (!defaults.contains(id)) return false;
}
return true;
}
/// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。 /// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。
/// ///
/// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致, /// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致,

13
apps/client/lib/data/services/alibaba_image_translation_service.dart

@ -49,14 +49,11 @@ class AlibabaImageTranslationService extends GetxService {
Future<bool> initialize() async { Future<bool> initialize() async {
if (_isInitialized) return true; if (_isInitialized) return true;
try { try {
_apiKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; _apiKey = AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
_endpoint = // 模型/接口地址挂在 ast_alibaba 服务上(vl_model / vl_endpoint),和上面的 API Key 同一条;
(AppConfig.env('ALIBABA_VL_ENDPOINT') ?? '').trim().isNotEmpty // cred() 对空串返回 null,所以留空自然落到默认值。
? AppConfig.env('ALIBABA_VL_ENDPOINT')! _endpoint = AppConfig.cred('ast_alibaba', 'vl_endpoint', 'ALIBABA_VL_ENDPOINT') ?? _defaultEndpoint;
: _defaultEndpoint; _model = AppConfig.cred('ast_alibaba', 'vl_model', 'ALIBABA_VL_MODEL') ?? _defaultModel;
_model = (AppConfig.env('ALIBABA_VL_MODEL') ?? '').trim().isNotEmpty
? AppConfig.env('ALIBABA_VL_MODEL')!
: _defaultModel;
if (_apiKey.trim().isEmpty) { if (_apiKey.trim().isEmpty) {
Logger.e(_tag, '未配置 ALIBABA_OPENSPEECH_APP_KEY,图片翻译不可用'); Logger.e(_tag, '未配置 ALIBABA_OPENSPEECH_APP_KEY,图片翻译不可用');
return false; return false;

7
apps/client/lib/data/services/assistant_directive_service.dart

@ -232,6 +232,13 @@ class AssistantDirectiveService extends GetxService {
if (saved != null) { if (saved != null) {
r.synced = true; r.synced = true;
_persist(); _persist();
// ⚠️ 必须 refresh:改的是 RxList **元素内部**的字段,列表本身没变,
// 不通知就没人知道。拾忆页的 ever(reminders) 因此不会触发,而
// ever(items) 那一次在 add() 返回前就已经跑完了(那时 synced 还是
// false)——于是服务端那条已经上屏、本地这条也还在,同一个闹钟两张卡,
// 一直挂到下次因为别的原因重投影为止(2026-09-19 真机实测)。
// syncPending() 那条补传路径本来就 refresh 了,这里是漏的。
reminders.refresh();
} }
} catch (e) { } catch (e) {
// 上传失败不影响本地闹钟——迁移期本地那份仍然是可用的 // 上传失败不影响本地闹钟——迁移期本地那份仍然是可用的

9
apps/client/lib/data/services/ast_service.dart

@ -56,6 +56,15 @@ abstract class AstService {
/// [pcm] 16k mono 16bit PCM little-endian /// [pcm] 16k mono 16bit PCM little-endian
Future<void> pushExternalAudio({required String leg, required Uint8List pcm}); Future<void> pushExternalAudio({required String leg, required Uint8List pcm});
/// 通话翻译 PCM **原生直连桥**(翻译插件这一端)。开着时设备双路 PCM 在原生层
/// 直接进 AST,译文 PCM 直接回设备;[pushExternalAudio] 与 [astTtsFrames] 的 PCM
/// 载荷都不再走 Dart([astTtsFrames] 只剩 final 标记)。
/// 原生不支持返回 false,调用方退回 Dart 路径。
Future<bool> setNativeCallBridge(bool enabled);
/// 桥的计数(两路帧数 / 丢帧 / 门限状态),会话结束时打进日志用。
Future<Map<String, dynamic>> nativeCallBridgeStats();
/// AST 流水线 / E2E 服务合成的 TTS PCM 帧流。每帧包含 leg('A'|'B') + pcm。 /// AST 流水线 / E2E 服务合成的 TTS PCM 帧流。每帧包含 leg('A'|'B') + pcm。
/// 调用方负责把它转发给设备(如 [Jielihome.feedTranslatedAudio])。 /// 调用方负责把它转发给设备(如 [Jielihome.feedTranslatedAudio])。
Stream<AstTtsFrame> get astTtsFrames; Stream<AstTtsFrame> get astTtsFrames;

28
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -148,22 +148,30 @@ class BailianMultimodalService extends GetxService {
final Rx<BailianDialogState> state = BailianDialogState.idle.obs; final Rx<BailianDialogState> state = BailianDialogState.idle.obs;
final RxBool isConnected = false.obs; final RxBool isConnected = false.obs;
String get _apiKey => AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; /// 下面三个解析是 **static**,因为原生客户端(BailianNativeDialog)要用同一套取值:
/// 握手参数在两份实现里必须一模一样,否则「用耳机唤醒」和「在 App 里按住说话」
String get _workspaceId { /// 会连到不同的百炼应用上,而且两边都不报错。
final o = _workspaceIdOverride?.trim() ?? ''; static String resolveApiKey() =>
AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
static String resolveWorkspaceId([String? override]) {
final o = override?.trim() ?? '';
if (o.isNotEmpty) return o; if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim(); final v = (AppConfig.cred('sts_bailian', 'workspace_id', 'ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim();
return v.isEmpty ? _defaultWorkspaceId : v; return v.isEmpty ? _defaultWorkspaceId : v;
} }
String get _appId { static String resolveAppId([String? override]) {
final o = _appIdOverride?.trim() ?? ''; final o = override?.trim() ?? '';
if (o.isNotEmpty) return o; if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_APP_ID') ?? '').trim(); final v = (AppConfig.cred('sts_bailian', 'app_id', 'ALIBABA_BAILIAN_APP_ID') ?? '').trim();
return v.isEmpty ? _defaultAppId : v; return v.isEmpty ? _defaultAppId : v;
} }
String get _apiKey => resolveApiKey();
String get _workspaceId => resolveWorkspaceId(_workspaceIdOverride);
String get _appId => resolveAppId(_appIdOverride);
/// 只有 API Key 是硬要求:workspace / app id 有兜底默认值。 /// 只有 API Key 是硬要求:workspace / app id 有兜底默认值。
/// Key 缺失时不要尝试连接——服务端会先回 task-started 再立刻 task-failed, /// Key 缺失时不要尝试连接——服务端会先回 task-started 再立刻 task-failed,
/// 日志上看起来像"连上了又掉",容易误判成网络问题。 /// 日志上看起来像"连上了又掉",容易误判成网络问题。
@ -293,7 +301,7 @@ class BailianMultimodalService extends GetxService {
// ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**, // ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**,
// 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。 // 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。
if (_hasMcpToken(mcpToken)) if (_hasMcpToken(mcpToken))
for (final tool in _mcpAuthTools) for (final tool in mcpAuthTools)
tool: {'auth_token': mcpToken}, tool: {'auth_token': mcpToken},
if (enableWebSearch) if (enableWebSearch)
'extra_config': {'enable_web_search': true}, 'extra_config': {'enable_web_search': true},
@ -580,7 +588,7 @@ class BailianMultimodalService extends GetxService {
/// ⚠️ 服务端新增需要身份的工具时,这里要同步加一行,否则那个工具永远 /// ⚠️ 服务端新增需要身份的工具时,这里要同步加一行,否则那个工具永远
/// 拿不到令牌、永远返回「身份校验失败」,而且客户端不会有任何报错。 /// 拿不到令牌、永远返回「身份校验失败」,而且客户端不会有任何报错。
/// 服务端工具清单见 apps/services/modules/mcp/tool_*.go。 /// 服务端工具清单见 apps/services/modules/mcp/tool_*.go。
static const List<String> _mcpAuthTools = [ static const List<String> mcpAuthTools = [
'search_meeting_notes', 'search_meeting_notes',
'get_memory_items', 'get_memory_items',
'get_memory_stats', 'get_memory_stats',

324
apps/client/lib/data/services/bailian_native_dialog.dart

@ -0,0 +1,324 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import '../../core/utils/logger.dart';
import '../models/assistant_directive.dart';
import 'bailian_multimodal_service.dart';
/// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。
///
/// 真正的 WebSocket 与音频收发在 `azure_speech` 插件里
/// (`BailianDialogClient.swift` / `.kt`);上行 PCM 由 `bluetooth_manager` 直接
/// 交过去,合成音频原路回耳机,**一个字节都不经过 Dart**。这里只做三件事:
/// 起停、把原生事件翻译成 [BailianEvent]、把门控开关推下去。
///
/// ## 为什么保留 Dart 版
/// [BailianMultimodalService] 仍然服务两种情况:
/// 1. **手机麦那条链路**(EMAI 页按住说话)——音频本来就在 Dart 侧采集,没有下沉的意义;
/// 2. **退路**——插件是老版本(`MissingPluginException`)、或设备不是恒玄(AI PCM 桥
/// 只存在于 bluetooth_manager)时,整条链路退回 Dart,功能不受影响。
///
/// ## ⚠️ 这不修「锁屏断连」
/// iOS 挂起的是整个进程,原生和 Dart 一起停。锁屏能不能活着取决于
/// `UIBackgroundModes=audio` + 确实有音频在播(`BackgroundKeepalive` 干的事),
/// 与代码在哪一层无关。本类解决的是 event loop 抖动与跨界开销。
class BailianNativeDialog {
BailianNativeDialog({String? appId, String? workspaceId})
: _appIdOverride = appId,
_workspaceIdOverride = workspaceId;
static const String _tag = 'BailianNative';
static const MethodChannel _channel =
MethodChannel('azure_speech/bailian_dialog');
static const EventChannel _events =
EventChannel('azure_speech/bailian_dialog_events');
final String? _appIdOverride;
final String? _workspaceIdOverride;
final _eventCtl = StreamController<BailianEvent>.broadcast();
final _downBytesCtl = StreamController<int>.broadcast();
final _micActivityCtl = StreamController<void>.broadcast();
StreamSubscription? _sub;
bool _running = false;
/// 本次会话解出来的指令记到哪个来源名下(与 Dart 版同义)
String directiveSource = 'earphone';
/// 业务事件流。与 [BailianMultimodalService.events] 同一套 [BailianEvent],
/// 所以 `DeviceAiSessionService` 的事件处理一行都不用改。
Stream<BailianEvent> get events => _eventCtl.stream;
/// 下行已写入的**累计**字节数(约 100ms 一条)。
///
/// 原生直连之后 Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数
/// 估算播完时刻。这两件事都只依赖长度,所以只报数字。
Stream<int> get downstreamBytes => _downBytesCtl.stream;
/// 设备上行有声(原生按 500ms 节流上报)。
///
/// ⚠️ **不能把它当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列、
/// 取消播完计时(「真人插话」的语义)。这里只是给空闲计时续命,
/// 混在一起会让 AI 每说一句就被自己的回声打断一次。
Stream<void> get micActivity => _micActivityCtl.stream;
bool get isRunning => _running;
bool get isConfigured =>
BailianMultimodalService.resolveApiKey().trim().isNotEmpty;
String get configHint => isConfigured ? '' : '未配置 ALIBABA_OPENSPEECH_APP_KEY';
/// 这台机器/这个包能不能用原生链路。
///
/// 老包没有这个方法通道 → `MissingPluginException` → 返回 false,调用方退回 Dart。
/// Web 直接排除(压根没有原生插件)。
static Future<bool> isAvailable() async {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return false;
try {
await _channel.invokeMethod<Map>('stats');
return true;
} on MissingPluginException {
return false;
} catch (e) {
Logger.w(_tag, '原生百炼客户端探测失败(退回 Dart): $e');
return false;
}
}
/// 建立会话。参数与 [BailianMultimodalService.start] 一一对应。
Future<bool> start({
String mode = 'duplex',
String? voice,
String? userId,
bool enableWebSearch = true,
int downstreamRate = 16000,
String source = 'earphone',
String? mcpToken,
}) async {
if (!isConfigured) {
Logger.e(_tag, '配置不全,无法启动会话:$configHint');
_eventCtl.add(BailianEvent(BailianEventType.error,
text: configHint, code: 'NOT_CONFIGURED', fatal: true));
return false;
}
await stop();
directiveSource = source;
_sub = _events.receiveBroadcastStream().listen(
_onNativeEvent,
onError: (e) {
Logger.e(_tag, '原生事件流错误: $e');
_eventCtl.add(
BailianEvent(BailianEventType.error, text: '$e', fatal: true));
},
);
try {
final ok = await _channel.invokeMethod<bool>('start', {
'apiKey': BailianMultimodalService.resolveApiKey(),
'appId': BailianMultimodalService.resolveAppId(_appIdOverride),
'workspaceId':
BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride),
'mode': mode,
if (voice != null && voice.trim().isNotEmpty) 'voice': voice,
'userId': userId ?? '',
'enableWebSearch': enableWebSearch,
'downstreamRate': downstreamRate,
if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken,
// 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了
'mcpAuthTools': BailianMultimodalService.mcpAuthTools,
});
_running = ok == true;
if (!_running) await _cancelSub();
return _running;
} on MissingPluginException {
Logger.w(_tag, '原生不支持,退回 Dart 链路');
await _cancelSub();
return false;
} catch (e) {
Logger.e(_tag, '启动原生会话失败: $e');
await _cancelSub();
return false;
}
}
Future<void> stop() async {
if (!_running) {
await _cancelSub();
return;
}
_running = false;
try {
await _channel.invokeMethod<void>('stop');
} catch (e) {
Logger.w(_tag, '停止原生会话失败: $e');
}
await _cancelSub();
}
/// AI 是否正在耳机里播报。播报期间原生把上行换成等长静音。
///
/// **什么时候开关门由 Dart 决定**(`DeviceAiSessionService` 里那套带兜底计时、
/// 非致命错误也要关门的逻辑),这里只是把结论推下去。
Future<void> setGated(bool gated) => _invoke('setGated', {'gated': gated});
Future<void> notifyLocalRespondingStarted() =>
_invoke('localRespondingStarted');
Future<void> notifyLocalRespondingEnded() => _invoke('localRespondingEnded');
Future<void> interrupt() => _invoke('interrupt');
Future<void> beginSpeech() => _invoke('beginSpeech');
Future<void> endSpeech() => _invoke('endSpeech');
Future<void> cancelSpeech() => _invoke('cancelSpeech');
Future<void> sendText(String text, {bool speakDirectly = false}) =>
_invoke('sendText', {'text': text, 'speakDirectly': speakDirectly});
/// 诊断:上/下行字节、出包、丢包、门控状态。桥开着却没声音时先问它。
Future<Map<String, dynamic>> stats() async {
try {
final raw = await _channel.invokeMethod<Map>('stats');
return raw == null ? const {} : Map<String, dynamic>.from(raw);
} catch (e) {
return {'error': '$e'};
}
}
Future<void> _invoke(String method, [Map<String, dynamic>? args]) async {
if (!_running) return;
try {
await _channel.invokeMethod<void>(method, args);
} catch (e) {
Logger.w(_tag, '$method 失败: $e');
}
}
Future<void> _cancelSub() async {
await _sub?.cancel();
_sub = null;
}
// ---------- 事件翻译 ----------
void _onNativeEvent(dynamic raw) {
if (raw is! Map) return;
final name = raw['event']?.toString() ?? '';
final text = raw['text']?.toString() ?? '';
final isFinal = raw['isFinal'] == true;
switch (name) {
case 'started':
_emit(const BailianEvent(BailianEventType.started));
break;
case 'stateChanged':
final s = raw['state']?.toString() ?? '';
_emit(BailianEvent(BailianEventType.stateChanged, state: _mapState(s)));
break;
case 'speechStarted':
_emit(const BailianEvent(BailianEventType.speechStarted));
break;
case 'speechContent':
_emit(BailianEvent(BailianEventType.speechContent,
text: text, isFinal: isFinal));
break;
case 'speechEnded':
_emit(BailianEvent(BailianEventType.speechEnded,
text: text, isFinal: true));
break;
case 'requestAccepted':
_emit(const BailianEvent(BailianEventType.requestAccepted));
break;
case 'respondingStarted':
_emit(const BailianEvent(BailianEventType.respondingStarted));
break;
case 'respondingContent':
_emit(BailianEvent(
BailianEventType.respondingContent,
text: text,
isFinal: isFinal,
finishReason: raw['finishReason']?.toString() ?? '',
directives: _parseDirectives(raw['extraInfo']?.toString(), text),
));
break;
case 'respondingEnded':
_emit(BailianEvent(BailianEventType.respondingEnded,
text: text, isFinal: true));
break;
case 'error':
_emit(BailianEvent(BailianEventType.error,
text: text,
code: raw['code']?.toString(),
fatal: raw['fatal'] == true));
break;
case 'closed':
_running = false;
_emit(const BailianEvent(BailianEventType.closed));
break;
case 'downlink':
final n = (raw['bytes'] as num?)?.toInt() ?? 0;
if (!_downBytesCtl.isClosed) _downBytesCtl.add(n);
break;
case 'micActivity':
// 只走自己的流,不冒充任何协议事件(见 micActivity 的说明)
if (!_micActivityCtl.isClosed) _micActivityCtl.add(null);
break;
case 'overflow':
// 上行拥塞丢包。warn 级:这是「说了没反应」时唯一的证据
Logger.w(_tag, '[AI-BRIDGE] 上行拥塞,原生累计丢包=${raw['dropped']}');
break;
default:
Logger.d(_tag, '未处理的原生事件: $name');
}
}
/// 指令解析**仍然走 Dart 这一份**:`extra_info.tool_calls` 的那些坑
/// (参数是字符串套 JSON、值恒为中文、下午时间要校正)有一批真实 payload 的
/// 金测试守着,原生再写一遍只会让那批测试失去意义。原生把 `extra_info`
/// 原样转成字符串带上来,这里拼回 output 的形状交给同一个解析器。
List<AssistantDirective> _parseDirectives(String? extraInfoJson, String text) {
if (extraInfoJson == null || extraInfoJson.isEmpty) return const [];
try {
final extra = json.decode(extraInfoJson);
final parsed = AssistantDirective.parseFromOutput(
{'extra_info': extra, 'text': text},
source: directiveSource,
);
if (parsed.isNotEmpty) {
// warn 级、且只在真的带了指令时打——「闹钟没定上」只有两种可能
// (百炼没下发 / 端侧没落地),这一行是把两者分开的证据
Logger.w(_tag, '[指令帧] 原生链路解析出 ${parsed.length} 条');
}
return parsed;
} catch (e) {
Logger.w(_tag, 'extra_info 解析失败: $e');
return const [];
}
}
static BailianDialogState _mapState(String s) => switch (s) {
'listening' => BailianDialogState.listening,
'thinking' => BailianDialogState.thinking,
'responding' => BailianDialogState.responding,
_ => BailianDialogState.idle,
};
void _emit(BailianEvent e) {
if (!_eventCtl.isClosed) _eventCtl.add(e);
}
Future<void> dispose() async {
await stop();
await _eventCtl.close();
await _downBytesCtl.close();
await _micActivityCtl.close();
}
}

56
apps/client/lib/data/services/deapsound_ai_service.dart

@ -1,19 +1,46 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/appconfig.dart'; import '../models/appconfig.dart';
import 'ai_service.dart'; import 'ai_service.dart';
/// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。
///
/// 2026-09-17 起只读后台「第三方服务配置」里的 **`llm_qwen`**(通义千问),
/// 与服务端会议总结用的是同一家(服务端走 `llmv_qwen`)。火山(`llm_doubao`)
/// 已从后台删除,这里不再兜底、也不再退回 `OPENAI_*` 那几个老 env 键。
class DeapsoundAIService implements AiService { class DeapsoundAIService implements AiService {
static const String svcId = 'llm_qwen';
late final String baseUrl; late final String baseUrl;
late final String apiKey; late final String apiKey;
late final String model; late final String model;
DeapsoundAIService() { DeapsoundAIService() {
apiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; apiKey = AppConfig.cred(svcId, 'api_key', '') ?? '';
baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? ''; baseUrl = AppConfig.cred(svcId, 'base_url', '') ?? '';
model = AppConfig.env('OPENAI_MODEL') ?? ''; model = AppConfig.cred(svcId, 'model', '') ?? '';
}
/// 测试用:不读 AppConfig,只为验证 [chatUrl] 的拼接
@visibleForTesting
DeapsoundAIService.debugWithBaseUrl(String base)
: baseUrl = base,
apiKey = '',
model = '';
/// 后台 `llm_qwen.base_url` 填的是不带路径的根地址
/// (`https://dashscope.aliyuncs.com/compatible-mode/v1`),这里补上
/// `/chat/completions`;已经带了的原样用。
Uri get chatUrl {
var base = baseUrl.trim();
while (base.endsWith('/')) {
base = base.substring(0, base.length - 1);
}
if (!base.endsWith('/chat/completions')) base = '$base/chat/completions';
return Uri.parse(base);
} }
@override @override
@ -22,7 +49,7 @@ class DeapsoundAIService implements AiService {
String? systemPrompt, String? systemPrompt,
String? userProperties, String? userProperties,
}) async { }) async {
final url = Uri.parse(baseUrl); final url = chatUrl;
final headers = { final headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -59,7 +86,7 @@ class DeapsoundAIService implements AiService {
String? userProperties, String? userProperties,
}) async* { }) async* {
final client = http.Client(); final client = http.Client();
final url = Uri.parse(baseUrl); final url = chatUrl;
final headers = { final headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -72,6 +99,13 @@ class DeapsoundAIService implements AiService {
"stream": true, "stream": true,
}; };
// ⚠️ 凭据来自 user_getappconfig_v3 的 llm_qwen;没拿到时 baseUrl 是空串,
// Uri.parse('') 能过、client.send 才抛 "No host specified"。这里先给一句能看懂的。
if (baseUrl.isEmpty || apiKey.isEmpty || model.isEmpty) {
throw Exception(
'AI 服务未配置($svcId: base_url/api_key/model 缺失),请检查后台「第三方服务配置」');
}
try { try {
final request = http.Request('POST', url) final request = http.Request('POST', url)
..headers.addAll(headers) ..headers.addAll(headers)
@ -79,7 +113,14 @@ class DeapsoundAIService implements AiService {
final response = await client.send(request); final response = await client.send(request);
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Unknown error'); // 把服务商的状态码和错误体带出去:401/403 是 key 不对、400 多半是 model 不存在,
// 全压成 'Unknown error' 就只能靠猜。
String body = '';
try {
body = await response.stream.bytesToString();
} catch (_) {}
if (body.length > 300) body = body.substring(0, 300);
throw Exception('HTTP ${response.statusCode} $body');
} }
await for (final chunk in response.stream await for (final chunk in response.stream
@ -101,7 +142,8 @@ class DeapsoundAIService implements AiService {
} }
} }
} catch (e) { } catch (e) {
throw Exception('Unknown error'); // 原样抛出(上面 HTTP 分支已经带了状态码),不要再包一层 'Unknown error'
rethrow;
} finally { } finally {
client.close(); client.close();
} }

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

@ -9,10 +9,13 @@ import '../../devices/device_hub.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;
import '../utils/background_keepalive.dart';
import '../utils/cue_tone.dart'; import '../utils/cue_tone.dart';
import '../utils/peer_speech_gate.dart' show rmsOfPcm16;
import 'agent_module_defaults.dart'; import 'agent_module_defaults.dart';
import 'assistant_directive_service.dart'; import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart'; import 'bailian_multimodal_service.dart';
import 'bailian_native_dialog.dart';
import 'emai_chat_store.dart'; import 'emai_chat_store.dart';
import 'mcp_token_service.dart'; import 'mcp_token_service.dart';
@ -44,6 +47,22 @@ class DeviceAiSessionService extends GetxService {
/// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, /// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例,
/// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 /// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。
BailianMultimodalService _svc = BailianMultimodalService(); BailianMultimodalService _svc = BailianMultimodalService();
/// 原生百炼客户端。非空 = 本次会话走原生直连:耳机麦 PCM 与合成音频都不经过
/// Dart,这边只收文本、状态、下行字节数。为 null 时整条链路退回 [_svc]。
///
/// ⚠️ 两条路**不会同时开**:[_useNative] 是本次会话的唯一开关,
/// 两边都起来的话上行会被推两份,服务端 VAD 会把它当成一直有人在说话。
BailianNativeDialog? _native;
bool get _useNative => _native != null;
StreamSubscription? _nativeDownSub;
StreamSubscription? _nativeMicSub;
/// 原生上报的是**累计**下行字节数,这里存上一次的值好算增量
int _nativeDownSeen = 0;
/// 下次开原生会话要用的百炼应用(跟着 [_ensureService] 换)
String? _wantNativeAppId;
String? _svcAppId; String? _svcAppId;
/// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块 /// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块
@ -68,6 +87,10 @@ class DeviceAiSessionService extends GetxService {
// 旧实例可能还挂着 WebSocket,先停掉再丢 // 旧实例可能还挂着 WebSocket,先停掉再丢
unawaited(_svc.stop()); unawaited(_svc.stop());
_svc = BailianMultimodalService(appId: want.isEmpty ? null : want); _svc = BailianMultimodalService(appId: want.isEmpty ? null : want);
// 原生那份也跟着换:两个 agent 用的是百炼里不同的应用,共用会串会话
unawaited(_native?.dispose());
_native = null;
_wantNativeAppId = want.isEmpty ? null : want;
_svcAppId = want; _svcAppId = want;
} }
@ -112,10 +135,48 @@ class DeviceAiSessionService extends GetxService {
/// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。 /// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。
bool _uplinkGated = false; bool _uplinkGated = false;
Timer? _gateTimer; Timer? _gateTimer;
/// 门控的兜底计时:开门后无论如何在这个时限内解除。
///
/// ⚠️ 原先门控**只**在 respondingEnded → 播完估算 → _gateTail 这一条链上解除。
/// 任何一轮 AI 出了声却没等到 respondingEnded(非致命 error、WS 抖一下、纯指令帧
/// 收尾),门就永远开着:上行从此全是静音(_onMicPcm 里替换成零),空闲计时也不再
/// 重新武装(_touchActivity 见 _uplinkGated 直接 return),会话活着但是聋的 ——
/// 就是「对着耳机说话,AI 再也不回」。
Timer? _gateSafety;
static const Duration _gateMax = Duration(seconds: 90);
bool _tearingDown = false; bool _tearingDown = false;
static const Duration _gateTail = Duration(milliseconds: 500); static const Duration _gateTail = Duration(milliseconds: 500);
static const Duration _idleTimeout = Duration(seconds: 5); /// 双方多久没动静就自动结束。
///
/// ⚠️ 原来是 5s,而且只有**百炼事件**才刷新它——设备上行 PCM 到了不算:AI 说完、
/// 门控解除之后用户必须在 5 秒内让服务端 VAD 判到说话,否则会话被判「双方无声」
/// 直接结束。锁屏 + 蓝牙上行延迟 + 蜂窝 RTT,这 5 秒非常容易超,
/// 这就是「锁屏后用耳机对话很容易中断」的直接原因(2026-09-20 苹果测试)。
/// 现在放到 15s,并且设备上行里**有声音**(RMS 过阈值)也算活动。
static const Duration _idleTimeout = Duration(seconds: 15);
Timer? _idleTimer; Timer? _idleTimer;
/// 设备上行判「有人在说」的 RMS 阈值(与 PeerSpeechGate 同一档:静音 0~50,
/// 正常说话 1000~8000),以及刷新空闲计时的节流间隔。
static const int _micActivityRms = 600;
static const Duration _micActivityThrottle = Duration(milliseconds: 500);
DateTime? _lastMicActivityAt;
/// 百炼 WebSocket 掉了以后的自动重连(蜂窝网切换、弱网抖动)。
/// 原来 `closed` 一到就整个会话拆掉——用户还戴着耳机在说话。
static const int _wsReconnectMax = 2;
static const List<Duration> _wsReconnectDelays = [
Duration(seconds: 1),
Duration(seconds: 3),
];
int _wsReconnectAttempts = 0;
Timer? _wsReconnectTimer;
/// 设备断连后的观察期:iOS 上 Echo-one 的系统级连接本来就会自行断续,
/// 一抖就拆会话太激进;期间设备又回到 ready 就继续,到期没回来才拆。
static const Duration _deviceDropGrace = Duration(seconds: 3);
Timer? _deviceDropTimer;
DateTime? _firstAudioAt; DateTime? _firstAudioAt;
int _micPackets = 0; int _micPackets = 0;
int _downPackets = 0; int _downPackets = 0;
@ -132,12 +193,27 @@ class DeviceAiSessionService extends GetxService {
Logger.e(_tag, '唤醒事件流错误: $e'); Logger.e(_tag, '唤醒事件流错误: $e');
}); });
_hubSub = hub.sessionEvents.listen((e) { _hubSub = hub.sessionEvents.listen((e) {
if (e.type == DeviceSessionEventType.connectionStateChanged && if (e.type != DeviceSessionEventType.connectionStateChanged) return;
e.connectionState == DeviceConnectionState.disconnected && if (!isActive.value || _session?.deviceId != e.deviceId) return;
isActive.value && if (e.connectionState == DeviceConnectionState.disconnected) {
_session?.deviceId == e.deviceId) { if (_deviceDropTimer != null) return;
Logger.w(_tag, '设备断连,结束 AI 会话'); Logger.w(_tag, '设备断连,观察 ${_deviceDropGrace.inSeconds}s 看它回不回来');
_teardown(notifyDevice: false, playEndTone: false); _deviceDropTimer = Timer(_deviceDropGrace, () {
_deviceDropTimer = null;
if (!isActive.value) return;
Logger.w(_tag, '设备 ${_deviceDropGrace.inSeconds}s 内没回来,结束 AI 会话');
_teardown(notifyDevice: false, playEndTone: false);
});
} else if (e.connectionState == DeviceConnectionState.ready &&
_deviceDropTimer != null) {
_deviceDropTimer!.cancel();
_deviceDropTimer = null;
// 新的 session 对象(插件断开重连会重建),把引用换过来,之后的 ai.state /
// ai.exit 才发得出去;上下行流是插件级的,不用重开。
final fresh = hub.sessions.firstWhereOrNull((s) => s.deviceId == e.deviceId);
if (fresh != null) _session = fresh;
Logger.w(_tag, '设备在观察期内回来了,AI 会话继续');
_touchActivity();
} }
}); });
Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒'); Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒');
@ -168,9 +244,92 @@ class DeviceAiSessionService extends GetxService {
} }
} }
/// 起本次对话。两条路径的参数**必须一致**,所以只在这一处写。
Future<bool> _startDialog() async {
final token = await McpTokenService.get();
if (_useNative) {
return _native!.start(
mode: 'duplex',
downstreamRate: _deviceRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: token,
);
}
return _svc.start(
mode: 'duplex',
downstreamRate: _deviceRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: token,
);
}
/// 试着开原生直连。两端都要开:
/// - azure_speech 那端([BailianNativeDialog])负责 WS 与攒包;
/// - bluetooth_manager 那端(`DeviceFeatures.aiPcmNativeBridge`)负责把耳机麦
/// PCM 从 Dart 的 EventChannel 上摘下来、并接住回灌的合成音频。
///
/// 任一端不支持就整条退回 Dart,**不能只开一半**:只开翻译端的话上行一帧都收不到,
/// 只开设备端的话 PCM 被摘走却没人接,两种都表现为「唤醒了但 AI 不说话」。
Future<BailianNativeDialog?> _tryOpenNativeDialog(DeviceSession session) async {
if (!await BailianNativeDialog.isAvailable()) {
Logger.i(_tag, '原生百炼客户端不可用,走 Dart 链路');
return null;
}
try {
final r = await session.invokeFeature(
DeviceFeatures.aiPcmNativeBridge, {'enabled': true});
if (r['enabled'] != true) {
Logger.i(_tag, '设备端不支持 AI 原生桥,走 Dart 链路');
return null;
}
} on DeviceException catch (e) {
Logger.i(_tag, '设备端不支持 AI 原生桥(${e.code}),走 Dart 链路');
return null;
} catch (e) {
Logger.w(_tag, '开设备端 AI 原生桥失败,走 Dart 链路: $e');
return null;
}
Logger.i(_tag, 'EMAI 走原生直连(耳机 PCM 不经过 Dart)');
return BailianNativeDialog(appId: _wantNativeAppId);
}
/// 告诉服务端「这一轮本地已经播完了」,它据此把对话状态推回 Listening/Idle。
///
/// ⚠️ 这里**不要**去动耳机的下行队列。原生 `AiAudioDownlink.flush()` 的语义是
/// **清空**(`outQueue.clear()`),不是"把尾包发出去"——名字容易读反,
/// 在这里调它等于把每一句回复的尾音切掉。真正要丢弃待播的地方只有
/// 「真人插话」,那条走 `_spk?.discardPending()`,两种链路都有效
/// (下行发送器是同一个原生对象,跟谁喂的 PCM 无关)。
void _notifyLocalRespondingEnded() {
if (_useNative) {
unawaited(_native!.notifyLocalRespondingEnded());
} else {
_svc.notifyLocalRespondingEnded();
}
}
/// 原生只报**累计**下行字节数(约 100ms 一条):PCM 已经直接回了耳机,
/// Dart 这边要做的只有开门控、计时、计数,三件事都只依赖长度。
void _onNativeDownstreamTotal(int total) {
final delta = total - _nativeDownSeen;
_nativeDownSeen = total;
if (delta > 0) _noteDownstream(delta);
}
void _onMicPcm(Uint8List pcm) { void _onMicPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return; if (!isActive.value || pcm.isEmpty) return;
_micPackets++; _micPackets++;
// 用户在说话 = 有活动。只看能量、带节流,静音帧不续命
if (!_uplinkGated && rmsOfPcm16(pcm) >= _micActivityRms) {
final now = DateTime.now();
if (_lastMicActivityAt == null ||
now.difference(_lastMicActivityAt!) >= _micActivityThrottle) {
_lastMicActivityAt = now;
_touchActivity();
}
}
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/包)${_uplinkGated ? ' [门控中]' : ''}');
@ -222,6 +381,7 @@ class DeviceAiSessionService extends GetxService {
return; return;
} }
isActive.value = true; isActive.value = true;
_wsReconnectAttempts = 0;
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持)
_aiStateUnsupported = false; _aiStateUnsupported = false;
_quitAfterPlayout = false; _quitAfterPlayout = false;
@ -231,13 +391,29 @@ class DeviceAiSessionService extends GetxService {
_micAccum.clear(); _micAccum.clear();
await _yieldPhonePage(); await _yieldPhonePage();
// iOS:会话期间保持一个静音的后台音频播放,否则锁屏几秒后进程被挂起,
// BLE 上行与 WebSocket 一起停摆(见 BackgroundKeepalive)。Android 空操作。
await BackgroundKeepalive.acquire(_tag);
// ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。
// 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。
_native = await _tryOpenNativeDialog(session);
try { try {
_mic = await session.openMic(route: AudioRoute.ai); if (_useNative) {
_micSub = _mic!.pcm.listen(_onMicPcm); _eventSub = _native!.events.listen(_onBailianEvent);
_eventSub = _svc.events.listen(_onBailianEvent); _nativeDownSub = _native!.downstreamBytes.listen(_onNativeDownstreamTotal);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 // 设备上行有声只用来续空闲计时,不能当 speechStarted(那会丢弃待播队列)
_audioSub = _svc.audioOut.listen(_onDownstreamPcm); _nativeMicSub = _native!.micActivity.listen((_) => _touchActivity());
} else {
_mic = await session.openMic(route: AudioRoute.ai);
_micSub = _mic!.pcm.listen(_onMicPcm);
_eventSub = _svc.events.listen(_onBailianEvent);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样
_audioSub = _svc.audioOut.listen(_onDownstreamPcm);
}
// 两条路都要开扬声器:它会启动耳机的 AI 下行发送器(G.722 编码与节流)。
// 原生直连时我们不往里写 PCM,但那个发送器必须在跑,否则原生推下去的
// 合成音频没人发。提示音(叮/嘟)两种模式下都还是由 Dart 写。
_spk = await session.openSpeaker(route: AudioRoute.ai); _spk = await session.openSpeaker(route: AudioRoute.ai);
} catch (e) { } catch (e) {
Logger.e(_tag, '打开设备音频通路失败: $e'); Logger.e(_tag, '打开设备音频通路失败: $e');
@ -248,13 +424,7 @@ class DeviceAiSessionService extends GetxService {
await _playCue(CueTone.ding(sampleRate: _deviceRate)); await _playCue(CueTone.ding(sampleRate: _deviceRate));
_touchActivity(); _touchActivity();
final ok = await _svc.start( final ok = await _startDialog();
mode: 'duplex',
downstreamRate: _deviceRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: await McpTokenService.get(),
);
final elapsed = DateTime.now().difference(ackAt); final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) { if (elapsed > _ackDeadline) {
Logger.w( Logger.w(
@ -350,6 +520,14 @@ class DeviceAiSessionService extends GetxService {
case BailianEventType.error: case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
// 网络类的致命错误(socket/连接被掐)先当掉线重连,不当「功能结束」
if (e.fatal && _looksLikeNetworkError(e.text) && _scheduleWsReconnect(e.text)) {
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
break;
}
_store.dropIfEmpty(_streamingUser); _store.dropIfEmpty(_streamingUser);
_streamingUser = null; _streamingUser = null;
_store.dropIfEmpty(_streamingAssistant); _store.dropIfEmpty(_streamingAssistant);
@ -363,7 +541,14 @@ class DeviceAiSessionService extends GetxService {
fromEarphone: true)); fromEarphone: true));
_store.persist(); _store.persist();
} }
if (e.fatal) _teardown(notifyDevice: true); if (e.fatal) {
_teardown(notifyDevice: true);
} else {
// 非致命错误这一轮不会再有 respondingEnded 了,门必须在这里关,
// 否则上行永远是静音(见 _gateSafety 的说明)。
_cancelPlayoutTimer();
_closeGate();
}
break; break;
case BailianEventType.stateChanged: case BailianEventType.stateChanged:
@ -385,7 +570,8 @@ class DeviceAiSessionService extends GetxService {
break; break;
case BailianEventType.closed: case BailianEventType.closed:
if (isActive.value) { if (isActive.value && !_tearingDown) {
if (_scheduleWsReconnect('closed')) break;
Logger.w(_tag, '百炼会话关闭,结束设备 AI'); Logger.w(_tag, '百炼会话关闭,结束设备 AI');
_teardown(notifyDevice: true); _teardown(notifyDevice: true);
} }
@ -396,18 +582,27 @@ class DeviceAiSessionService extends GetxService {
} }
} }
/// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里)
void _onDownstreamPcm(Uint8List pcm) { void _onDownstreamPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return; if (pcm.isEmpty) return;
_noteDownstream(pcm.length);
_spk?.write(pcm);
}
/// 收到 [n] 字节下行音频时要做的记账。两条路径共用:
/// 走 Dart 时由 [_onDownstreamPcm] 调,走原生时由 [_onNativeDownstreamTotal] 调。
/// 开门控、估播完时长、计数——都只依赖字节数,不需要 PCM 本身。
void _noteDownstream(int n) {
if (!isActive.value || n <= 0) return;
if (_firstAudioAt == null) { if (_firstAudioAt == null) {
_firstAudioAt = DateTime.now(); _firstAudioAt = DateTime.now();
_openGate(); _openGate();
} }
_respondingBytes += pcm.length; _respondingBytes += n;
_downPackets++; _downPackets++;
if (_downPackets == 1 || _downPackets % 50 == 0) { if (_downPackets == 1 || _downPackets % 50 == 0) {
Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)'); Logger.i(_tag, '下行给设备 #$_downPackets (${n}B)');
} }
_spk?.write(pcm);
} }
/// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。 /// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。
@ -442,6 +637,47 @@ class DeviceAiSessionService extends GetxService {
); );
} }
static bool _looksLikeNetworkError(String text) {
final t = text.toLowerCase();
const markers = [
'socket', 'connection', 'network', 'timed out', 'timeout', 'reset',
'broken pipe', 'unreachable', 'host lookup', 'websocket', 'closed',
];
return markers.any(t.contains);
}
/// 百炼 WS 掉了:会话还活着、设备还连着,就换条连接接着聊。
/// 返回 false 表示预算用完,调用方按原来的方式结束会话。
bool _scheduleWsReconnect(String reason) {
if (!isActive.value || _tearingDown) return false;
if (_wsReconnectAttempts >= _wsReconnectMax) {
Logger.w(_tag, '百炼 WS 重连 $_wsReconnectMax 次仍失败($reason),放弃');
return false;
}
if (_wsReconnectTimer != null) return true; // 已在排队
final delay = _wsReconnectDelays[_wsReconnectAttempts];
_wsReconnectAttempts++;
Logger.w(_tag, '百炼 WS 掉线($reason),${delay.inSeconds}s 后第 $_wsReconnectAttempts 次重连');
// 重连期间别让空闲计时把会话判死
_idleTimer?.cancel();
_idleTimer = null;
_wsReconnectTimer = Timer(delay, () async {
_wsReconnectTimer = null;
if (!isActive.value || _tearingDown) return;
_cancelPlayoutTimer();
_closeGate();
final ok = await _startDialog();
if (!isActive.value) return;
if (ok) {
Logger.w(_tag, '百炼 WS 第 $_wsReconnectAttempts 次重连成功');
_touchActivity();
} else if (!_scheduleWsReconnect('start failed')) {
_teardown(notifyDevice: true);
}
});
return true;
}
void _touchActivity() { void _touchActivity() {
_idleTimer?.cancel(); _idleTimer?.cancel();
_idleTimer = null; _idleTimer = null;
@ -472,8 +708,18 @@ class DeviceAiSessionService extends GetxService {
void _openGate() { void _openGate() {
_gateTimer?.cancel(); _gateTimer?.cancel();
_gateTimer = null; _gateTimer = null;
_gateSafety?.cancel();
_gateSafety = Timer(_gateMax, () {
if (!_uplinkGated) return;
Logger.w(_tag, '门控开了 ${_gateMax.inSeconds}s 还没等到播完信号,强制解除');
_cancelPlayoutTimer();
_notifyLocalRespondingEnded();
_closeGate();
});
if (_uplinkGated) return; if (_uplinkGated) return;
_uplinkGated = true; _uplinkGated = true;
// 原生直连时逐帧替换静音在原生做,这里只把结论推下去
unawaited(_native?.setGated(true));
_idleTimer?.cancel(); _idleTimer?.cancel();
_idleTimer = null; _idleTimer = null;
Logger.i(_tag, '上行门控开启(AI 播报中)'); Logger.i(_tag, '上行门控开启(AI 播报中)');
@ -482,8 +728,11 @@ class DeviceAiSessionService extends GetxService {
void _closeGate() { void _closeGate() {
_gateTimer?.cancel(); _gateTimer?.cancel();
_gateTimer = null; _gateTimer = null;
_gateSafety?.cancel();
_gateSafety = null;
if (!_uplinkGated) return; if (!_uplinkGated) return;
_uplinkGated = false; _uplinkGated = false;
unawaited(_native?.setGated(false));
Logger.i(_tag, '上行门控解除'); Logger.i(_tag, '上行门控解除');
_touchActivity(); _touchActivity();
} }
@ -510,7 +759,7 @@ class DeviceAiSessionService extends GetxService {
Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)'); Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)');
_playoutTimer = Timer(Duration(milliseconds: remainMs), () { _playoutTimer = Timer(Duration(milliseconds: remainMs), () {
if (!isActive.value) return; if (!isActive.value) return;
_svc.notifyLocalRespondingEnded(); _notifyLocalRespondingEnded();
_gateTimer = Timer(_gateTail, _closeGate); _gateTimer = Timer(_gateTail, _closeGate);
if (_quitAfterPlayout) { if (_quitAfterPlayout) {
_quitAfterPlayout = false; _quitAfterPlayout = false;
@ -573,6 +822,14 @@ class DeviceAiSessionService extends GetxService {
_stopAckTimer = null; _stopAckTimer = null;
_idleTimer?.cancel(); _idleTimer?.cancel();
_idleTimer = null; _idleTimer = null;
_gateSafety?.cancel();
_gateSafety = null;
_wsReconnectTimer?.cancel();
_wsReconnectTimer = null;
_wsReconnectAttempts = 0;
_deviceDropTimer?.cancel();
_deviceDropTimer = null;
_lastMicActivityAt = null;
_cancelPlayoutTimer(); _cancelPlayoutTimer();
_micAccum.clear(); _micAccum.clear();
@ -598,16 +855,37 @@ class DeviceAiSessionService extends GetxService {
_audioSub = null; _audioSub = null;
await _micSub?.cancel(); await _micSub?.cancel();
_micSub = null; _micSub = null;
await _nativeDownSub?.cancel();
_nativeDownSub = null;
await _nativeMicSub?.cancel();
_nativeMicSub = null;
_nativeDownSeen = 0;
await _spk?.close(); await _spk?.close();
_spk = null; _spk = null;
await _mic?.close(); await _mic?.close();
_mic = null; _mic = null;
// 原生直连时两端都要关:只关一端会把耳机麦的 PCM 一直摘在原生里没人收
final nat = _native;
_native = null;
if (nat != null) {
await nat.stop();
final s0 = _session;
if (s0 != null) {
try {
await s0.invokeFeature(
DeviceFeatures.aiPcmNativeBridge, {'enabled': false});
} catch (e) {
Logger.w(_tag, '关设备端 AI 原生桥失败: $e');
}
}
}
await _svc.stop(); await _svc.stop();
final s = _session; final s = _session;
if (wasActive && notifyDevice && s != null) await _sendExit(s); if (wasActive && notifyDevice && s != null) await _sendExit(s);
_session = null; _session = null;
await BackgroundKeepalive.release(_tag);
if (wasActive) { if (wasActive) {
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage(); await _resumePhonePage();

101
apps/client/lib/data/services/device_call_recording_service.dart

@ -40,7 +40,6 @@ class DeviceCallRecordingService extends GetxService {
StreamSubscription<Uint8List>? _peerSub; StreamSubscription<Uint8List>? _peerSub;
/// 暂存的对端帧,等本端帧到齐后一起交织写盘 /// 暂存的对端帧,等本端帧到齐后一起交织写盘
Uint8List? _pendingPeer;
int _frameCount = 0; int _frameCount = 0;
Timer? _silenceCheck; Timer? _silenceCheck;
WavWriter? _wav; WavWriter? _wav;
@ -50,6 +49,20 @@ class DeviceCallRecordingService extends GetxService {
final Stopwatch _stopwatch = Stopwatch(); final Stopwatch _stopwatch = Stopwatch();
bool _busy = false; bool _busy = false;
/// 本采样窗口内的电平峰值。纯字段、不是 Rx——每成一对帧更新它,
/// 由 [_sample] 每 [sampleIntervalMs] 发布进 [level]。
///
/// 只在**退回 Dart 落盘**那条路上用;走原生录音器时电平由原生算好送上来。
double _levelPeak = 0;
/// 原生落盘录音器。非空 = 双路 PCM 一个字节都不经过 Dart
/// (通话是双路 50 帧/s,这条路径原先每秒要跨界 100 次)。
DeviceFileRecorder? _rec;
StreamSubscription<DeviceRecordStats>? _recSub;
/// 已写入的音频字节数(两条路径共用)
int get _dataBytes => _rec?.bytesWritten ?? _wav?.dataBytes ?? 0;
/// 本次录音是不是设备按键发起的。设备侧处于录音态时,停止要额外补一条 /// 本次录音是不是设备按键发起的。设备侧处于录音态时,停止要额外补一条
/// `record.stop` 让它自己也退出来,只发 `call.stop` 不足以清掉设备端的录音状态。 /// `record.stop` 让它自己也退出来,只发 `call.stop` 不足以清掉设备端的录音状态。
bool _deviceRecordingActive = false; bool _deviceRecordingActive = false;
@ -133,14 +146,26 @@ class DeviceCallRecordingService extends GetxService {
if (!await dir.exists()) await dir.create(recursive: true); if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/call_$ts.wav'; _path = '${dir.path}/call_$ts.wav';
_wav = await WavWriter.open(_path!, channels: 2);
_pendingPeer = null; _pairer.clear();
_frameCount = 0; _frameCount = 0;
_peer = await session.openMic(route: AudioRoute.callPeer); // ★ 优先让原生直接落盘:左=本端麦、右=对端,交织与配对都在原生做。
_local = await session.openMic(route: AudioRoute.callLocal); // 双路每秒 100 帧的跨界连同 Dart 侧的写盘队列一起消失。
_peerSub = _peer!.pcm.listen(_onPeer); _rec = await session.openFileRecorder(
_localSub = _local!.pcm.listen(_onLocal); path: _path!,
routes: const [AudioRoute.callLocal, AudioRoute.callPeer],
);
if (_rec != null) {
_recSub = _rec!.stats.listen(_onRecStats);
Logger.i(_tag, '通话录音走原生直接落盘(双路 PCM 不经过 Dart)');
} else {
_wav = await WavWriter.open(_path!, channels: 2);
_peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal);
_peerSub = _peer!.pcm.listen(_onPeer);
_localSub = _local!.pcm.listen(_onLocal);
Logger.i(_tag, '插件不支持原生落盘,退回 Dart 写盘');
}
// 进通话模式,设备才会双路上行。不管是不是 App 主动发起的都补发一次—— // 进通话模式,设备才会双路上行。不管是不是 App 主动发起的都补发一次——
// 只靠设备自己进通话模式时,它未必会把音频回传给 App。 // 只靠设备自己进通话模式时,它未必会把音频回传给 App。
@ -166,6 +191,7 @@ class DeviceCallRecordingService extends GetxService {
isRecording.value = true; isRecording.value = true;
elapsedMs.value = 0; elapsedMs.value = 0;
level.value = 0; level.value = 0;
_levelPeak = 0;
waveform.clear(); waveform.clear();
_stopwatch _stopwatch
..reset() ..reset()
@ -198,7 +224,8 @@ class DeviceCallRecordingService extends GetxService {
Future<void> _stop(bool sendCommand) async { Future<void> _stop(bool sendCommand) async {
_silenceCheck?.cancel(); _silenceCheck?.cancel();
Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 ${_wav?.dataBytes} 字节'); Logger.i(_tag,
'停止通话录音: ${_rec != null ? "原生落盘" : "收到 $_frameCount 帧"},写入 $_dataBytes 字节');
_tick?.cancel(); _tick?.cancel();
_sampleTimer?.cancel(); _sampleTimer?.cancel();
_stopwatch.stop(); _stopwatch.stop();
@ -224,12 +251,13 @@ class DeviceCallRecordingService extends GetxService {
} }
_deviceRecordingActive = false; _deviceRecordingActive = false;
final dataBytes = _wav?.dataBytes ?? 0; final dataBytes = _dataBytes;
await _closeFile(save: true); await _closeFile(save: true);
unawaited(AudioForegroundService.stop()); unawaited(AudioForegroundService.stop());
isRecording.value = false; isRecording.value = false;
elapsedMs.value = 0; elapsedMs.value = 0;
level.value = 0; level.value = 0;
_levelPeak = 0;
waveform.clear(); waveform.clear();
_session = null; _session = null;
@ -249,10 +277,30 @@ class DeviceCallRecordingService extends GetxService {
_path = null; _path = null;
} }
/// 两路各自排队、按字节对齐后成对写入(左=本端麦克风,右=对端)。
///
/// ⚠️ 原先是「只留最后一帧对端、本端来一帧配一帧」:对端连来两帧就丢一帧,
/// 本端来了对端没到就整帧扔掉。两路在 BLE 上是同一个帧里交替的 40 字节块,
/// 到 Dart 侧却经过各自的 broadcast stream,到达顺序并不保证严格交替 ——
/// 结果右声道(对端)被断续抽掉,转写出来只剩自己这一方。
final StereoPairer _pairer = StereoPairer();
/// 原生录音器的统计(约 100ms 一条)。[_frameCount] 也在这里推进——
/// 它驱动「2s 内没收到任何音频就重发 call.start」那条自愈,
/// 走原生路径时 Dart 收不到帧,不接上的话会每次都误判成没上行。
void _onRecStats(DeviceRecordStats s) {
// 双声道交织后 20ms = 1280 字节(单路 640 × 2 声道)。
// 除以 640 会把帧数报成两倍,那条日志就对不上真机抓到的帧数了。
if (s.bytes > 0) _frameCount = s.bytes ~/ 1280;
if (s.level > _levelPeak) _levelPeak = s.level;
}
/// 退回 Dart 落盘时才会跑(插件不支持原生录音器)。
void _onPeer(Uint8List pcm) { void _onPeer(Uint8List pcm) {
if (_wav == null || pcm.isEmpty) return; if (_wav == null || pcm.isEmpty) return;
_frameCount++; _frameCount++;
_pendingPeer = pcm; _pairer.pushRight(pcm);
_flushPairs();
} }
void _onLocal(Uint8List pcm) { void _onLocal(Uint8List pcm) {
@ -261,16 +309,26 @@ class DeviceCallRecordingService extends GetxService {
if (_frameCount <= 3 || _frameCount % 200 == 0) { if (_frameCount <= 3 || _frameCount % 200 == 0) {
Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}');
} }
final peer = _pendingPeer; _pairer.pushLeft(pcm);
_pendingPeer = null; _flushPairs();
if (peer == null) return; }
// 左声道=本端麦克风,右声道=对端声音
_wav!.write(WavWriter.interleave(pcm, peer), onWritten: () { void _flushPairs() {
level.value = WavWriter.rmsLevel(pcm); final pair = _pairer.take();
}); if (pair == null) return;
// 电平只更新窗口峰值(纯字段,不碰 Rx),由 _sample() 每
// [sampleIntervalMs] 发布一次。双路各 50 帧/s,原先每成一对就
// `level.value =` 一次 = 50 次/s 的 Rx 通知,而读它的只有 100ms
// 一次的 _sample()。也不再挂在 onWritten 上:显示不该排在写盘队列后面。
final l = WavWriter.rmsLevel(pair.left);
if (l > _levelPeak) _levelPeak = l;
_wav!.write(WavWriter.interleave(pair.left, pair.right));
} }
void _sample() { void _sample() {
// 发布这一窗口的峰值并清零:这里是 level 唯一的写入点
level.value = _levelPeak;
_levelPeak = 0;
final list = waveform.toList(); final list = waveform.toList();
list.add(level.value); list.add(level.value);
if (list.length > _maxSamples) { if (list.length > _maxSamples) {
@ -288,6 +346,15 @@ class DeviceCallRecordingService extends GetxService {
await _peer?.close(); await _peer?.close();
_local = null; _local = null;
_peer = null; _peer = null;
await _recSub?.cancel();
_recSub = null;
final rec = _rec;
_rec = null;
if (rec != null) {
// 原生停下来时才回填 WAV 头,所以这一步不能跳
await rec.stop(save: save);
return;
}
final w = _wav; final w = _wav;
_wav = null; _wav = null;
if (w == null) return; if (w == null) return;

97
apps/client/lib/data/services/device_recording_service.dart

@ -51,6 +51,19 @@ class DeviceRecordingService extends GetxService {
DateTime? _cmdSentAt; DateTime? _cmdSentAt;
bool _busy = false; bool _busy = false;
/// 本采样窗口内的电平峰值。纯字段、不是 Rx——每帧更新它,
/// 由 [_sample] 每 [sampleIntervalMs] 发布进 [level]。
///
/// 只在**退回 Dart 落盘**那条路上用;走原生录音器时电平由原生算好送上来。
double _levelPeak = 0;
/// 原生落盘录音器。非空 = PCM 一个字节都不经过 Dart。
DeviceFileRecorder? _rec;
StreamSubscription<DeviceRecordStats>? _recSub;
/// 已写入的音频字节数(两条路径共用,用来算时长)
int get _dataBytes => _rec?.bytesWritten ?? _wav?.dataBytes ?? 0;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -121,7 +134,6 @@ class DeviceRecordingService extends GetxService {
if (!await dir.exists()) await dir.create(recursive: true); if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/${session.vendor}_$ts.wav'; _path = '${dir.path}/${session.vendor}_$ts.wav';
_wav = await WavWriter.open(_path!, channels: 1);
_firstPcmLogged = false; _firstPcmLogged = false;
_cmdSentAt = null; _cmdSentAt = null;
@ -140,19 +152,37 @@ class DeviceRecordingService extends GetxService {
'已下发 record.start,写入耗时 ${_cmdSentAt!.difference(t0).inMilliseconds}ms'); '已下发 record.start,写入耗时 ${_cmdSentAt!.difference(t0).inMilliseconds}ms');
} }
_src = await session.openMic(route: AudioRoute.mic); // ★ 优先让原生直接落盘:PCM 不进 Dart,每秒 50 次跨界与 Dart 侧的
_audioSub = _src!.pcm.listen(_onPcm); // 文件写入队列一起消失。插件不支持就返回 null,退回下面的老路径。
_rec = await session.openFileRecorder(
path: _path!,
routes: const [AudioRoute.mic],
);
final int bytesPerMs;
if (_rec != null) {
// 原生每 100ms 报一次 {bytes, level},正好是波形的节奏,
// 不需要再在 Dart 里起采样定时器算电平。
_recSub = _rec!.stats.listen(_onRecStats);
bytesPerMs = 32; // 16k/16bit/mono
Logger.i(_tag, '录音走原生直接落盘(PCM 不经过 Dart)');
} else {
_wav = await WavWriter.open(_path!, channels: 1);
_src = await session.openMic(route: AudioRoute.mic);
_audioSub = _src!.pcm.listen(_onPcm);
bytesPerMs = _src!.format.bytesPerMs;
Logger.i(_tag, '插件不支持原生落盘,退回 Dart 写盘');
}
isRecording.value = true; isRecording.value = true;
elapsedMs.value = 0; elapsedMs.value = 0;
level.value = 0; level.value = 0;
_levelPeak = 0;
waveform.clear(); waveform.clear();
_stopwatch.reset(); _stopwatch.reset();
// 时长按**已写入的数据量**算,不用墙钟:显示值与文件实际播放长度永远一致 // 时长按**已写入的数据量**算,不用墙钟:显示值与文件实际播放长度永远一致
// (恒玄收到 AA 59 后要 1.8~6.2s 才开始上行,还会把等待期缓存的音频补推)。 // (恒玄收到 AA 59 后要 1.8~6.2s 才开始上行,还会把等待期缓存的音频补推)。
final bytesPerMs = _src!.format.bytesPerMs;
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) { _tick = Timer.periodic(const Duration(milliseconds: 200), (_) {
elapsedMs.value = _wav == null ? 0 : _wav!.dataBytes ~/ bytesPerMs; elapsedMs.value = _dataBytes ~/ bytesPerMs;
}); });
_sampleTimer = Timer.periodic( _sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs), const Duration(milliseconds: sampleIntervalMs),
@ -190,7 +220,7 @@ class DeviceRecordingService extends GetxService {
// 等队列写完再关文件,否则最后几帧会丢、WAV 头长度也对不上 // 等队列写完再关文件,否则最后几帧会丢、WAV 头长度也对不上
await _wav?.drain(); await _wav?.drain();
final int dataBytes = _wav?.dataBytes ?? 0; final int dataBytes = _dataBytes;
final int seconds = elapsedMs.value ~/ 1000; final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path; final String? path = _path;
final wallMs = _stopwatch.elapsedMilliseconds; final wallMs = _stopwatch.elapsedMilliseconds;
@ -198,7 +228,7 @@ class DeviceRecordingService extends GetxService {
_tag, _tag,
'录音统计: 音频${(dataBytes / 32000).toStringAsFixed(2)}s 数据${dataBytes}B ' '录音统计: 音频${(dataBytes / 32000).toStringAsFixed(2)}s 数据${dataBytes}B '
'墙钟${(wallMs / 1000).toStringAsFixed(2)}s ' '墙钟${(wallMs / 1000).toStringAsFixed(2)}s '
'写入${_wav?.writtenFrames}帧 丢${_wav?.droppedFrames}帧'); '${_rec != null ? "原生落盘" : "写入${_wav?.writtenFrames}帧 丢${_wav?.droppedFrames}帧"}');
final session = _session; final session = _session;
if (sendCommand && if (sendCommand &&
@ -218,6 +248,7 @@ class DeviceRecordingService extends GetxService {
isRecording.value = false; isRecording.value = false;
elapsedMs.value = 0; elapsedMs.value = 0;
level.value = 0; level.value = 0;
_levelPeak = 0;
waveform.clear(); waveform.clear();
_session = null; _session = null;
@ -237,6 +268,23 @@ class DeviceRecordingService extends GetxService {
_path = null; _path = null;
} }
/// 原生录音器的统计(约 100ms 一条)。电平已经在原生按窗口取过峰值,
/// 这里直接放进 [_levelPeak],由 [_sample] 与 Dart 落盘那条路统一发布。
void _onRecStats(DeviceRecordStats s) {
if (!_firstPcmLogged && s.bytes > 0) {
_firstPcmLogged = true;
if (!_stopwatch.isRunning) _stopwatch.start();
final sent = _cmdSentAt;
Logger.i(
_tag,
'首帧音频到达(原生落盘)'
'${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}'
',计时从此刻开始');
}
if (s.level > _levelPeak) _levelPeak = s.level;
}
/// 退回 Dart 落盘时才会跑(插件不支持原生录音器)。
void _onPcm(Uint8List pcm) { void _onPcm(Uint8List pcm) {
final w = _wav; final w = _wav;
if (w == null || pcm.isEmpty) return; if (w == null || pcm.isEmpty) return;
@ -250,15 +298,25 @@ class DeviceRecordingService extends GetxService {
'${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}' '${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}'
',计时从此刻开始'); ',计时从此刻开始');
} }
// 串行写,绝不在回调里直接 await 文件(见 WavWriter);电平只是显示,不能挡写入 // 电平只更新窗口峰值(纯字段,不碰 Rx),由 _sample() 每
w.write(pcm, onWritten: () { // [sampleIntervalMs] 发布一次。
try { //
_updateLevel(pcm); // ⚠️ 原先是每帧 `level.value = rms(pcm)`:设备 20ms 一帧 = 50 次/s 的 Rx
} catch (_) {} // 通知,而读它的只有 100ms 一次的 _sample(),也就是说每 5 次里有 4 次是
}); // 唤醒了 Obx 又立刻被下一帧覆盖。峰值比"最后一帧"更适合做音浪,顺带
// 把短促的说话尖峰留住。
//
// 也不再挂在 onWritten 上:电平是显示,不该排在文件写入队列后面等。
final l = WavWriter.rmsLevel(pcm);
if (l > _levelPeak) _levelPeak = l;
// 串行写,绝不在回调里直接 await 文件(见 WavWriter)
w.write(pcm);
} }
void _sample() { void _sample() {
// 发布这一窗口的峰值并清零:这里是 level 唯一的写入点
level.value = _levelPeak;
_levelPeak = 0;
final list = waveform.toList(); final list = waveform.toList();
list.add(level.value); list.add(level.value);
if (list.length > _maxSamples) { if (list.length > _maxSamples) {
@ -267,15 +325,20 @@ class DeviceRecordingService extends GetxService {
waveform.assignAll(list); waveform.assignAll(list);
} }
void _updateLevel(Uint8List pcm) {
level.value = WavWriter.rmsLevel(pcm);
}
Future<void> _closeFile({required bool save}) async { Future<void> _closeFile({required bool save}) async {
await _audioSub?.cancel(); await _audioSub?.cancel();
_audioSub = null; _audioSub = null;
await _src?.close(); await _src?.close();
_src = null; _src = null;
await _recSub?.cancel();
_recSub = null;
final rec = _rec;
_rec = null;
if (rec != null) {
// 原生停下来时才回填 WAV 头,所以这一步不能跳
await rec.stop(save: save);
return;
}
final w = _wav; final w = _wav;
_wav = null; _wav = null;
if (w == null) return; if (w == null) return;

84
apps/client/lib/data/services/device_stereo_recorder.dart

@ -14,23 +14,35 @@ import 'wav_writer.dart';
/// 文件放哪、什么时候停都由调用方决定,不归档。 /// 文件放哪、什么时候停都由调用方决定,不归档。
/// 左声道=本端麦克风,右声道=对端声音。 /// 左声道=本端麦克风,右声道=对端声音。
class DeviceStereoRecorder { class DeviceStereoRecorder {
DeviceStereoRecorder({this.onWavePcm}); DeviceStereoRecorder({this.onWavePcm, this.onLevel});
static const String _tag = 'DeviceStereoRecorder'; static const String _tag = 'DeviceStereoRecorder';
/// 每写一对帧回调一次本端 PCM(驱动波形) /// 每写一对帧回调一次本端 PCM(驱动波形)。
/// **只在退回 Dart 落盘那条路上触发**——走原生录音器时 PCM 不进 Dart,
/// 波形改由 [onLevel] 驱动。
final void Function(Uint8List pcm)? onWavePcm; final void Function(Uint8List pcm)? onWavePcm;
/// 电平回调,0~1 的**线性 RMS**(与 onWavePcm 那条路上调用方自己算出来的
/// 口径一致,不带开根的视觉增强)。原生路径约 100ms 一次。
final void Function(double level)? onLevel;
DeviceSession? _session; DeviceSession? _session;
DeviceAudioSource? _local; DeviceAudioSource? _local;
DeviceAudioSource? _peer; DeviceAudioSource? _peer;
StreamSubscription? _localSub; StreamSubscription? _localSub;
StreamSubscription? _peerSub; StreamSubscription? _peerSub;
WavWriter? _wav; WavWriter? _wav;
Uint8List? _pendingPeer;
/// 原生落盘录音器。非空 = 双路 PCM 一个字节都不经过 Dart。
DeviceFileRecorder? _rec;
StreamSubscription<DeviceRecordStats>? _recSub;
/// 两路各自排队、按字节对齐后成对写入,见 [StereoPairer]
final StereoPairer _pairer = StereoPairer();
bool _paused = false; bool _paused = false;
bool get isRunning => _wav != null; bool get isRunning => _wav != null || _rec != null;
/// 开始。没有具备通话双路抓音能力的设备返回 false。 /// 开始。没有具备通话双路抓音能力的设备返回 false。
Future<bool> start(String filePath) async { Future<bool> start(String filePath) async {
@ -42,23 +54,38 @@ class DeviceStereoRecorder {
} }
try { try {
_session = session; _session = session;
_wav = await WavWriter.open(filePath, channels: 2); _pairer.clear();
_pendingPeer = null;
_paused = false; _paused = false;
_peer = await session.openMic(route: AudioRoute.callPeer); // ★ 优先让原生直接落盘(左=本端、右=对端),双路每秒 100 帧的跨界消失
_local = await session.openMic(route: AudioRoute.callLocal); _rec = await session.openFileRecorder(
_peerSub = _peer!.pcm.listen((pcm) { path: filePath,
if (_paused || pcm.isEmpty) return; routes: const [AudioRoute.callLocal, AudioRoute.callPeer],
_pendingPeer = pcm; );
}); if (_rec != null) {
_localSub = _local!.pcm.listen((pcm) { _recSub = _rec!.stats.listen((s) {
if (_paused || pcm.isEmpty) return; if (_paused) return;
final peer = _pendingPeer; // 原生给的 level 是开过根的视觉值,这里平方还原成线性 RMS,
_pendingPeer = null; // 与 onWavePcm 那条路上调用方自己算出来的口径对齐——
if (peer == null) return; // 不还原的话波形会被再开一次根,整条线贴着顶跑
_wav?.write(WavWriter.interleave(pcm, peer), onLevel?.call(s.level * s.level);
onWritten: () => onWavePcm?.call(pcm)); });
}); Logger.i(_tag, '走原生直接落盘(双路 PCM 不经过 Dart)');
} else {
_wav = await WavWriter.open(filePath, channels: 2);
_peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal);
_peerSub = _peer!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return;
_pairer.pushRight(pcm);
_flushPairs();
});
_localSub = _local!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return;
_pairer.pushLeft(pcm);
_flushPairs();
});
Logger.i(_tag, '插件不支持原生落盘,退回 Dart 写盘');
}
await session.invokeFeature(DeviceFeatures.callStart); await session.invokeFeature(DeviceFeatures.callStart);
return true; return true;
} catch (e) { } catch (e) {
@ -68,6 +95,14 @@ class DeviceStereoRecorder {
} }
} }
void _flushPairs() {
final pair = _pairer.take();
if (pair == null) return;
_wav?.write(WavWriter.interleave(pair.left, pair.right));
// 波形是显示,不该排在写盘队列后面等落盘;写失败时也照样要动
onWavePcm?.call(pair.left);
}
void pause() => _paused = true; void pause() => _paused = true;
void resume() => _paused = false; void resume() => _paused = false;
@ -96,6 +131,15 @@ class DeviceStereoRecorder {
Logger.w(_tag, 'call.stop 失败: $e'); Logger.w(_tag, 'call.stop 失败: $e');
} }
} }
await _recSub?.cancel();
_recSub = null;
final rec = _rec;
_rec = null;
if (rec != null) {
// 原生停下来时才回填 WAV 头,所以这一步不能跳
await rec.stop(save: saveFile);
return;
}
final w = _wav; final w = _wav;
_wav = null; _wav = null;
await w?.close(save: saveFile); await w?.close(save: saveFile);

93
apps/client/lib/data/services/meeting/meeting_task_service.dart

@ -54,7 +54,12 @@ class MeetingTaskService extends GetxService {
localImportState.value++; localImportState.value++;
} }
Future<void> addTask( /// 返回任务是否**已受理或已排队**。
///
/// 原来返回 void,调用方(详情页「生成」)不管结果一律把页面切成「等待转写」;
/// 而 `audiourl` 为空、上传队列里又找不到这条(上传项在离开详情页时会被摘掉)时,
/// 这里什么都没做——页面就永远停在占位图上等一个不存在的任务。
Future<bool> addTask(
int id, int id,
String audiourl, String audiourl,
String formlanguage, String formlanguage,
@ -68,27 +73,20 @@ class MeetingTaskService extends GetxService {
(element) => element['id'] == id, (element) => element['id'] == id,
orElse: () => {}, orElse: () => {},
); );
if (uploadData.isNotEmpty) { if (uploadData.isEmpty) {
if (uploadData['audiourl'].isEmpty) { Logger.w('MeetingTask', '记录 $id 没有音频 URL、上传队列里也没有它,无法提交任务');
uploadData['isTask'] = true; return false;
uploadData['formlanguage'] = formlanguage; }
uploadData['tolanguage'] = tolanguage; if (uploadData['audiourl'].isEmpty) {
uploadData['isdistinguishspeaker'] = isdistinguishspeaker; uploadData['isTask'] = true;
uploadData['templateid'] = templateid; uploadData['formlanguage'] = formlanguage;
uploadData['tid'] = tid; uploadData['tolanguage'] = tolanguage;
} else { uploadData['isdistinguishspeaker'] = isdistinguishspeaker;
await submitTask( uploadData['templateid'] = templateid;
id, uploadData['tid'] = tid;
formlanguage, return true; // 上传完成时由 MeetingUploadService 提交
tolanguage,
isdistinguishspeaker,
templateid,
tid,
);
}
} }
} else { return submitTask(
await submitTask(
id, id,
formlanguage, formlanguage,
tolanguage, tolanguage,
@ -97,9 +95,47 @@ class MeetingTaskService extends GetxService {
tid, tid,
); );
} }
return submitTask(
id,
formlanguage,
tolanguage,
isdistinguishspeaker,
templateid,
tid,
);
}
/// 智能总结模板的 tid(后台「Autopilot智能总结」,41 种语言各有一版,按 tid 反查)。
static const String smartTemplateTid = 'General-Autopilot';
/// 记录建的时候已经带了转写(翻译归档,`rtype=VOICETRANSLAT`):
/// 服务端跳过识别,直接用智能模板总结。源=目标语言,连翻译那一段也跳过。
///
/// 模板只有 App 的 41 个界面语言有版本,翻译语种(粤语、繁中…)超出这个集合时
/// 退到英文——否则服务端回 TemplateNotFound,记录停在「未处理」且没人知道为什么。
Future<bool> startSummaryForTranscript(int id, String language) async {
final lang = _templateLanguageFor(language);
return submitTask(id, lang, lang, false, 0, smartTemplateTid);
}
/// 模板语言集合 == App 界面语言集合(服务端按 41 语种各建一份公共模板)。
String _templateLanguageFor(String code) {
final key = code.trim().replaceAll('-', '_');
if (key.isEmpty) return 'en-US';
if (Get.translations.containsKey(key)) return code.trim();
// zh-HK / zh-TW 这种带地区的:先按语言前缀找同语系的界面语言
final prefix = key.split('_').first;
for (final k in Get.translations.keys) {
if (k.split('_').first == prefix) return k.replaceAll('_', '-');
}
return 'en-US';
} }
Future<void> submitTask( /// 返回服务端是否受理。**没受理就不进轮询**:`AuthInterceptor` 在业务错误
/// (无会员 5302、模板不存在…)时把 data 置 null 后 resolve,原来这里不判、
/// 照样把任务塞进 _taskList 并把本地状态写成 1,列表就永远显示「等待转写中」,
/// 而服务端那条记录其实还停在 0。
Future<bool> submitTask(
int id, int id,
String formlanguage, String formlanguage,
String tolanguage, String tolanguage,
@ -107,7 +143,7 @@ class MeetingTaskService extends GetxService {
int templateid, int templateid,
String tid, String tid,
) async { ) async {
await Api.startTaskEchomeet({ final resp = await Api.startTaskEchomeet({
'id': id, 'id': id,
'formlanguage': formlanguage, 'formlanguage': formlanguage,
'tolanguage': tolanguage, 'tolanguage': tolanguage,
@ -115,6 +151,11 @@ class MeetingTaskService extends GetxService {
'templateid': templateid, 'templateid': templateid,
'tid': tid, 'tid': tid,
}); });
if (resp == null) {
Logger.w('MeetingTask',
'任务未被服务端受理 id=$id lang=$formlanguage→$tolanguage tid=$tid,记录保持未处理');
return false;
}
final seconds = await _getMeetingSeconds(id); final seconds = await _getMeetingSeconds(id);
_decrementMeetingIntegral(cost: seconds); _decrementMeetingIntegral(cost: seconds);
_taskList.add({ _taskList.add({
@ -124,6 +165,7 @@ class MeetingTaskService extends GetxService {
await SqfliteApi.setMeetingTaskType(id, 1); await SqfliteApi.setMeetingTaskType(id, 1);
updateListState.value++; updateListState.value++;
_executeTask(); _executeTask();
return true;
} }
Future<void> refreshTask(int id, int templateid, String tid, String tolanguage) async { Future<void> refreshTask(int id, int templateid, String tid, String tolanguage) async {
@ -195,6 +237,11 @@ class MeetingTaskService extends GetxService {
}); });
} }
/// 只把任务从轮询队列摘掉,不打删除接口——记录在服务端已经没了(别的设备删的)。
void removeLocalTask(int id) {
_taskList.removeWhere((element) => element['id'] == id);
}
void startTask(List data) { void startTask(List data) {
_taskList = data; _taskList = data;
_executeTask(); _executeTask();

29
apps/client/lib/data/services/memory_service.dart

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
@ -42,6 +44,8 @@ class MemoryService extends GetxService {
void onInit() { void onInit() {
super.onInit(); super.onInit();
_restore(); _restore();
// 时区只问一次系统,不 await:拿不到就按 _tz() 的退路走,不该拖住启动。
unawaited(warmTz());
} }
// ===================== 本地缓存 ===================== // ===================== 本地缓存 =====================
@ -96,9 +100,30 @@ class MemoryService extends GetxService {
'${d.month.toString().padLeft(2, '0')}-' '${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}'; '${d.day.toString().padLeft(2, '0')}';
/// 本机时区名。拿不到就传空串——服务端会退到它自己的本地时区, /// 本机时区的 IANA 名(`Asia/Shanghai`)。启动时问一次系统,进程内缓存。
/// **不会**退成 UTC(那会让所有时刻静默偏移 8 小时)。 static String? _ianaTz;
/// 问系统要 IANA 时区名。失败不抛,留给 [_tz] 退回缩写。
static Future<void> warmTz() async {
try {
final name = (await FlutterTimezone.getLocalTimezone()).trim();
if (name.isNotEmpty) _ianaTz = name;
} catch (e) {
Logger.w(_tag, '取本机 IANA 时区失败,将退回时区缩写: $e');
}
}
/// 上报给服务端的时区。
///
/// ⚠️ **必须是 IANA 名,不能是 `DateTime.now().timeZoneName`** —— 后者给的是
/// 缩写(国内是 `CST`),服务端 `time.LoadLocation("CST")` 解析不出来、**静默**
/// 退回容器时区。国内用户碰巧没事(容器就是 Asia/Shanghai),海外用户的提醒
/// 时刻会全部按北京时间算,且不报任何错。库里 tz 列至今只有空串和 `CST` 两种值。
///
/// 拿不到 IANA 名时仍退回缩写而不是空串:服务端认得 `CST` 并按上海解释,
/// 比什么都不给多一点信息。
String _tz() { String _tz() {
if (_ianaTz != null) return _ianaTz!;
try { try {
return DateTime.now().timeZoneName; return DateTime.now().timeZoneName;
} catch (_) { } catch (_) {

6
apps/client/lib/data/services/microsoft_translation_service.dart

@ -38,10 +38,10 @@ class MicrosoftTranslationService extends GetxService {
Future<bool> initialize() async { Future<bool> initialize() async {
if (_isInitialized) return true; if (_isInitialized) return true;
try { try {
_subscriptionKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? ''; _subscriptionKey = AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY') ?? '';
_region = AppConfig.env('AZURE_TRANSLATION_REGION') ?? ''; _region = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION') ?? '';
_endpoint = _endpoint =
AppConfig.env('AZURE_TRANSLATION_ENDPOINT') ?? _defaultEndpoint; AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ?? _defaultEndpoint;
if (_subscriptionKey.isEmpty) { if (_subscriptionKey.isEmpty) {
print( print(

39
apps/client/lib/data/services/network/api.dart

@ -1,3 +1,4 @@
import 'api_crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'dio_manager.dart'; import 'dio_manager.dart';
import 'nw_method.dart'; import 'nw_method.dart';
@ -227,10 +228,18 @@ class Api {
} }
//app配置 //app配置
//
// 2026-09-14 起首选加密的 v3:多下发 thirdsvcs(结构化第三方服务,凭据明文、按区域合并),
// 各业务服务经 AppConfig.cred() 优先从它取凭据。整个响应体 AES-CBC 加密(见 api_crypto.dart),
// 密钥 .env 的 GATEWAY_ENCRYPT_KEY;没配则回退明文 v1——功能不断,只是凭据仍走 env 兼容层。
// ⚠️ v3 必须在网关白名单里(splash 未登录就要取配置),见 deploy/app/confs/gateway.yaml.example。
static getappconfig([params]) { static getappconfig([params]) {
final path = ApiCrypto.enabled
? '/api/home/user_getappconfig_v3'
: '/api/home/user_getappconfig';
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
'/api/home/user_getappconfig', path,
params: params, params: params,
); );
} }
@ -271,14 +280,6 @@ class Api {
); );
} }
// 上传会议助手操作记录
static putOperationRecord([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_putrecords',
params: params,
);
}
// 获取会议助手数据 // 获取会议助手数据
static getOperationAllRecord([params]) { static getOperationAllRecord([params]) {
@ -477,14 +478,6 @@ class Api {
); );
} }
static getwakeupvoices([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/user_getwakeupvoices',
params: params,
);
}
static addRecordEchomeet([params]) { static addRecordEchomeet([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
@ -517,6 +510,15 @@ class Api {
); );
} }
//拉一条会议记录的整条数据(含 original/translate/summary)。列表接口只回骨架,详情页进页面时调这个
static getRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_getrecord',
params: params,
);
}
static readRecordEchomeet([params]) { static readRecordEchomeet([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
@ -586,12 +588,13 @@ class Api {
// //
// [cancelToken] 供同传/面对面的**中间结果**翻译使用:说话过程中会连着发好几次, // [cancelToken] 供同传/面对面的**中间结果**翻译使用:说话过程中会连着发好几次,
// 后一次发出时要把前一次撤掉,别让废弃的预览请求占着链路和服务商配额。 // 后一次发出时要把前一次撤掉,别让废弃的预览请求占着链路和服务商配额。
static userTranslate(params, {CancelToken? cancelToken}) { static userTranslate(params, {CancelToken? cancelToken, Duration? timeout}) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
'/api/home/user_translate', '/api/home/user_translate',
params: params, params: params,
cancelToken: cancelToken, cancelToken: cancelToken,
timeout: timeout,
); );
} }
} }

86
apps/client/lib/data/services/network/api_crypto.dart

@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:encrypt/encrypt.dart' as enc;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../../core/utils/logger.dart';
/// 网关「加密接口」的响应解密。
///
/// 服务端(modules/gateway/wservice_comp.go `writeReply`)对声明在 `EncryptMsgs` 里的接口
/// (`user_getappconfig_v2/v3`、`user_getthirdsvcs_v2`、`user_getagents_v2/v3`)把**整个响应体**
/// 做 AES-CBC 加密:key = 网关的 `GATEWAY_ENCRYPT_KEY`(16/24/32 字节),IV 固定 16 个 `'0'`,
/// PKCS5/7 填充,密文 base64;`Content-Type` 改成 `text/plain`,并加响应头 `X-Encrypted: 1`。
/// 这些响应带着第三方服务的**明文凭据**,所以整体加密后才出网关。
///
/// 密钥来自客户端 `.env` 的 `GATEWAY_ENCRYPT_KEY`,须与服务器一致。没配时 [enabled] 为 false,
/// `Api.getappconfig` 会回退到明文 v1——凭据只能走 env 兼容层,`thirdsvcs` 拿不到。
class ApiCrypto {
ApiCrypto._();
static const String _iv = '0000000000000000';
static String get _key => (dotenv.env['GATEWAY_ENCRYPT_KEY'] ?? '').trim();
/// 密钥是否配置且长度合法(AES 只接受 16/24/32 字节)。
static bool get enabled {
final k = _key;
return k.length == 16 || k.length == 24 || k.length == 32;
}
/// 解 base64(AES-CBC-PKCS7) 密文为明文字符串。密钥不合法或密文损坏时抛异常,由调用方决定退路。
static String decrypt(String cipherBase64) {
final key = enc.Key.fromUtf8(_key);
final iv = enc.IV.fromUtf8(_iv);
final aes = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc, padding: 'PKCS7'));
return aes.decrypt64(cipherBase64.trim(), iv: iv);
}
}
/// Dio 拦截器:把 `X-Encrypted: 1` 的响应体解密并 JSON 解析,再交给后面的 [AuthInterceptor]
/// 按业务 JSON 处理。**必须排在 AuthInterceptor 之前**——它一看到不是 JSON 的 body 就当解析失败了。
///
/// 密文的 Content-Type 是 text/plain,Dio 的 `ResponseType.json` 不会去解析它,这里拿到的就是 String。
class ApiDecryptInterceptor extends Interceptor {
static const String _tag = 'ApiCrypto';
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final flag = response.headers.value('x-encrypted');
if (flag != '1') {
handler.next(response);
return;
}
final body = response.data;
if (body is! String || body.isEmpty) {
handler.next(response);
return;
}
if (!ApiCrypto.enabled) {
// 服务端加密了、客户端却没配密钥:这不是网络问题,得让人一眼看到。
Logger.e(_tag, '响应已加密但 .env 未配置 GATEWAY_ENCRYPT_KEY:${response.requestOptions.path}');
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: '响应已加密但客户端未配置 GATEWAY_ENCRYPT_KEY',
));
return;
}
try {
final plain = ApiCrypto.decrypt(body);
response.data = jsonDecode(plain);
handler.next(response);
} catch (e) {
// 密钥不一致时 AES 解出来是乱码、PKCS7 去填充就会失败——报错信息要指向密钥,别让人去查网络。
Logger.e(_tag, '解密失败(多半是 GATEWAY_ENCRYPT_KEY 与服务器不一致):${response.requestOptions.path} $e');
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: '响应解密失败,请检查 GATEWAY_ENCRYPT_KEY 是否与服务器一致',
));
}
}
}

23
apps/client/lib/data/services/network/auth_interceptor.dart

@ -222,11 +222,32 @@ class AuthInterceptor extends Interceptor {
colorText: Colors.red, 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: default:
{ {
// 兜底也不要暴露库的英文原文:原因记进日志,界面给一句能看懂的话。
Logger.w('API',
'请求异常(${err.type} ${err.requestOptions.path}): ${err.message}');
Get.snackbar( Get.snackbar(
'error'.tr, // 错误 'error'.tr, // 错误
err.message ?? 'unknownError'.tr, 'networkErrorRetry'.tr, // 网络错误,请稍后重试
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.withOpacity(0.1), backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red, colorText: Colors.red,

17
apps/client/lib/data/services/network/dio_manager.dart

@ -8,6 +8,7 @@ import 'package:get/get.dart' hide Response, FormData;
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../models/channel_app_model.dart'; import '../../models/channel_app_model.dart';
import '../version_update_service.dart'; import '../version_update_service.dart';
import 'api_crypto.dart';
import 'auth_interceptor.dart'; import 'auth_interceptor.dart';
import 'nw_method.dart'; import 'nw_method.dart';
@ -66,6 +67,9 @@ class DioManager {
// 日志拦截器必须在 AuthInterceptor 前面, // 日志拦截器必须在 AuthInterceptor 前面,
// 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器 // 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器
dio.interceptors.add(_ApiLogInterceptor()); dio.interceptors.add(_ApiLogInterceptor());
// 解密拦截器必须排在 AuthInterceptor 之前:加密接口(user_getappconfig_v3 等)的 body 是 base64 密文,
// AuthInterceptor 一看到不是业务 JSON 就当失败。见 api_crypto.dart。
dio.interceptors.add(ApiDecryptInterceptor());
dio.interceptors.add(AuthInterceptor()); dio.interceptors.add(AuthInterceptor());
} }
@ -73,19 +77,30 @@ class DioManager {
// method:请求方法,NWMethod.POST等 // method:请求方法,NWMethod.POST等
// path:请求地址 // path:请求地址
// params:请求参数 // params:请求参数
/// [timeout] 覆盖本次请求的收发超时(默认走全局的 30s)。
///
/// ⚠️ 实时链路**必须**自己给一个短超时:全局那 30s 是给上传、总结这类慢请求留的,
/// 而同传/面对面是流式的,一句话等 30s 早就没有意义了,只会让后面的句子一起堵住,
/// 表现就是「弱网下卡住不动」。服务端 user_translate 内部的上下文只给 10s,
/// 客户端再等 30s 纯属白等 20s。
Future request<T>( Future request<T>(
NWMethod method, NWMethod method,
String path, { String path, {
Object? params, Object? params,
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
CancelToken? cancelToken, CancelToken? cancelToken,
Duration? timeout,
}) async { }) async {
Response response = await dio.request( Response response = await dio.request(
path, path,
data: params, data: params,
queryParameters: queryParameters, queryParameters: queryParameters,
cancelToken: cancelToken, cancelToken: cancelToken,
options: Options(method: nwMethodValues[method]), options: Options(
method: nwMethodValues[method],
sendTimeout: timeout,
receiveTimeout: timeout,
),
); );
return response.data; return response.data;
} }

55
apps/client/lib/data/services/recording_archive.dart

@ -24,6 +24,10 @@ import 'network/api.dart';
/// - `call_*` 通话录音 /// - `call_*` 通话录音
/// - `trans_call_*` 通话翻译 /// - `trans_call_*` 通话翻译
/// - `trans_holder_*` 同声翻译 / 面对面翻译 /// - `trans_holder_*` 同声翻译 / 面对面翻译
///
/// 翻译那两条还会带上**现成的转写**([archive] 的 `transcript`):
/// 翻译时 ASR 已经逐句出过整句,再让纪要把同一段音频识别一遍既慢又白花算力,
/// 所以纪要那边只做总结(2026-09-18 产品口径)。
class RecordingArchive { class RecordingArchive {
RecordingArchive._(); RecordingArchive._();
@ -34,12 +38,21 @@ class RecordingArchive {
/// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在), /// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在),
/// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西 /// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西
/// 会显示成「刚刚」,用户按时间根本找不到它。 /// 会显示成「刚刚」,用户按时间根本找不到它。
///
/// [transcript] 这段音频**已经有的转写**(通话/同声/面对面翻译边说边出的整句),
/// 形状见 `buildMeetingTranscript`。给了就以 `rtype=VOICETRANSLAT` 建记录:
/// 服务端 `echomeet_starttask` 对这种记录**跳过识别**、只跑总结,
/// 本地也直接写进 `meetingspeaker`,打开纪要立刻能看到原文。
/// [summaryLanguage] 非空且转写非空时,归档完立刻用智能模板起总结任务,
/// 用户不用再进纪要点「生成」。
static Future<int?> archive({ static Future<int?> archive({
required String path, required String path,
required int seconds, required int seconds,
String tag = 'RecordingArchive', String tag = 'RecordingArchive',
bool notify = true, bool notify = true,
DateTime? createdAt, DateTime? createdAt,
List<Map<String, Object?>>? transcript,
String summaryLanguage = '',
}) async { }) async {
try { try {
final file = File(path); final file = File(path);
@ -69,8 +82,11 @@ class RecordingArchive {
} }
final stat = await File(newPath).stat(); final stat = await File(newPath).stat();
final hasTranscript = transcript != null && transcript.isNotEmpty;
final data = <String, Object?>{ final data = <String, Object?>{
'title': fileName, 'title': fileName,
// 本地 type 一律 LOCAL:它只决定列表分组和上传桶(LocalAudio),
// 「有没有现成转写」是服务端 rtype 的事,两者别混。
'type': 'LOCAL', 'type': 'LOCAL',
'seconds': seconds, 'seconds': seconds,
'filepath': newPath, 'filepath': newPath,
@ -79,8 +95,10 @@ class RecordingArchive {
'creationtime': resolveRecordingCreationTime(createdAt), 'creationtime': resolveRecordingCreationTime(createdAt),
}; };
final resp = await Api.addRecordEchomeet({ final resp = await Api.addRecordEchomeet({
'rtype': 'LOCAL', // VOICETRANSLAT 是服务端 StartTask 里「转写已存在,只翻译+总结」的分支
'rtype': hasTranscript ? 'VOICETRANSLAT' : 'LOCAL',
'size': stat.size, 'size': stat.size,
if (hasTranscript) 'original': transcript,
...data, ...data,
}); });
final meetingId = resp?['record']?['id']; final meetingId = resp?['record']?['id'];
@ -89,6 +107,20 @@ class RecordingArchive {
return null; return null;
} }
await SqfliteApi.insertMeeting({'id': meetingId, ...data}); await SqfliteApi.insertMeeting({'id': meetingId, ...data});
if (hasTranscript) {
// 服务端确认过 id 才落本地转写,键与 MeetingTaskService._queryTask
// 从 translate 解出来的行完全一致——总结完成那一拍它会删掉重插一遍。
await SqfliteApi.insertMeetingSpeaker([
for (final seg in transcript)
{
'meetingid': meetingId,
'content': seg['content'],
'starttime': seg['starttime'],
'endtime': seg['endtime'],
'speaker': seg['speaker'] ?? '',
}
]);
}
if (Get.isRegistered<MeetingUploadService>()) { if (Get.isRegistered<MeetingUploadService>()) {
Get.find<MeetingUploadService>() Get.find<MeetingUploadService>()
.addUpload(meetingId, newPath, 'LocalAudio'); .addUpload(meetingId, newPath, 'LocalAudio');
@ -99,13 +131,30 @@ class RecordingArchive {
if (Get.isRegistered<MeetingTaskService>()) { if (Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().updateListState.value++; Get.find<MeetingTaskService>().updateListState.value++;
} }
Logger.i(tag, '已归档到语音记录助手 id=$meetingId title=$fileName'); Logger.i(tag,
'已归档到语音记录助手 id=$meetingId title=$fileName 转写段=${transcript?.length ?? 0}');
if (notify) { if (notify) {
Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr, Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr,
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2)); duration: const Duration(seconds: 2));
} }
return meetingId is int ? meetingId : int.tryParse('$meetingId'); final int? id = meetingId is int ? meetingId : int.tryParse('$meetingId');
// 总结放在列表已经刷新之后:任务一提交状态就会变「等待总结」,
// 用户在纪要首页看到的是「条目出现 → 转成等待态」,而不是凭空多出一条等待中的。
// 失败不影响归档结果(记录和转写都在),用户仍可进纪要手动生成。
if (id != null &&
hasTranscript &&
summaryLanguage.isNotEmpty &&
Get.isRegistered<MeetingTaskService>()) {
try {
final ok = await Get.find<MeetingTaskService>()
.startSummaryForTranscript(id, summaryLanguage);
Logger.i(tag, '自动总结${ok ? '已提交' : '未能提交'} id=$id lang=$summaryLanguage');
} catch (e) {
Logger.w(tag, '自动总结提交失败(记录已保留) id=$id: $e');
}
}
return id;
} catch (e) { } catch (e) {
Logger.e(tag, '归档失败: $e'); Logger.e(tag, '归档失败: $e');
return null; return null;

23
apps/client/lib/data/services/server_translation_service.dart

@ -40,11 +40,23 @@ class ServerTranslationService extends GetxService {
/// [cancelToken] 给同传/面对面的**中间结果**预览翻译用:说话过程中会连发好几次, /// [cancelToken] 给同传/面对面的**中间结果**预览翻译用:说话过程中会连发好几次,
/// 后一次发出时把前一次撤掉。被取消返回 null,且不打 warning——那是预期行为, /// 后一次发出时把前一次撤掉。被取消返回 null,且不打 warning——那是预期行为,
/// 不是失败。 /// 不是失败。
/// 实时链路(同传 / 面对面 / 通话)的翻译超时。
///
/// ⚠️ 不能用全局那 30s:那是给上传、会议总结这类慢请求留的。同传是流式的,
/// 一句话等 30s 早就没有意义,而且会把后面的句子一起堵在同一条链路上——
/// 弱网下的「卡住不动」就是这么来的。
///
/// 取 12s 是有依据的:服务端 user_translate 内部的上下文只给服务商 10s
/// (api_translate.go 的 context.WithTimeout),10s 拿不到结果它自己就返回失败了,
/// 客户端再多等就是纯白等。留 2s 给网络往返。
static const Duration realtimeTimeout = Duration(seconds: 12);
Future<String?> translateText({ Future<String?> translateText({
required String text, required String text,
required String sourceLanguageCode, required String sourceLanguageCode,
required String targetLanguageCode, required String targetLanguageCode,
CancelToken? cancelToken, CancelToken? cancelToken,
Duration? timeout = realtimeTimeout,
}) async { }) async {
final src = text.trim(); final src = text.trim();
if (src.isEmpty) return null; if (src.isEmpty) return null;
@ -53,7 +65,7 @@ class ServerTranslationService extends GetxService {
'text': src, 'text': src,
'from': sourceLanguageCode, 'from': sourceLanguageCode,
'to': targetLanguageCode, 'to': targetLanguageCode,
}, cancelToken: cancelToken); }, cancelToken: cancelToken, timeout: timeout);
if (data is! Map) return null; if (data is! Map) return null;
final out = (data['text'] as String?)?.trim() ?? ''; final out = (data['text'] as String?)?.trim() ?? '';
lastProvider.value = (data['provider'] as String?) ?? ''; lastProvider.value = (data['provider'] as String?) ?? '';
@ -69,6 +81,15 @@ class ServerTranslationService extends GetxService {
if (e is DioException && e.type == DioExceptionType.cancel) return null; if (e is DioException && e.type == DioExceptionType.cancel) return null;
// 服务端错误码见 errorcode.proto 5101-5103: // 服务端错误码见 errorcode.proto 5101-5103:
// 5101 未配置 MT 服务 / 5102 参数不合法 / 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'); Logger.w(_tag, '翻译失败($sourceLanguageCode→$targetLanguageCode): $e');
return null; return null;
} }

10
apps/client/lib/data/services/speech_impl/azure_asr_service.dart

@ -68,12 +68,12 @@ class AzureAsrService extends GetxService implements AsrService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
// final _env = _storage.read("ENV") as Map<String, String>; // final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
_xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? ''; _xunfeiAppId = AppConfig.cred('stt_iflytek', 'app_id', 'XUNFEI_ASR_APP_ID') ?? '';
_xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; _xunfeiAccessKeyId = AppConfig.cred('stt_iflytek', 'access_key_id', 'XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
_xunfeiAccessKeySecret = _xunfeiAccessKeySecret =
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; AppConfig.cred('stt_iflytek', 'access_key_secret', 'XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
throw Exception( throw Exception(

64
apps/client/lib/data/services/speech_impl/azure_ast_service.dart

@ -224,6 +224,17 @@ class AzureAstService extends GetxService implements AstService {
Logger.info('[STS] 服务初始化: ${eventMap['serviceId']} ${eventMap['direction']}'); Logger.info('[STS] 服务初始化: ${eventMap['serviceId']} ${eventMap['direction']}');
break; break;
// 原生直连桥的诊断事件。warn 级:release 的日志门槛是 warning,
// 这两行是真机调门限阈值 / 判断上行有没有拥塞的唯一依据。
case 'peerGateOpened':
Logger.w('AzureAST',
'[CALL-BRIDGE] B 路门限开启(原生):${eventMap['fallback'] == true ? "兜底强开(阈值可能偏高/全程只有串音)" : "检测到对端语音"} rms=${eventMap['rms']}');
break;
case 'nativeBridgeOverflow':
Logger.w('AzureAST',
'[CALL-BRIDGE] 原生上行积压丢帧,累计 ${eventMap['dropped']}(网络/服务端跟不上耳机实时速率)');
break;
default: default:
Logger.info('[STS] 未处理事件: $eventType'); Logger.info('[STS] 未处理事件: $eventType');
break; break;
@ -246,29 +257,29 @@ class AzureAstService extends GetxService implements AstService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
_azureTranslationKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? ''; _azureTranslationKey = AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY') ?? '';
_azureTranslationRegion = AppConfig.env('AZURE_TRANSLATION_REGION') ?? ''; _azureTranslationRegion = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION') ?? '';
_xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? ''; _xunfeiAppId = AppConfig.cred('stt_iflytek', 'app_id', 'XUNFEI_ASR_APP_ID') ?? '';
_xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; _xunfeiAccessKeyId = AppConfig.cred('stt_iflytek', 'access_key_id', 'XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
_xunfeiAccessKeySecret = _xunfeiAccessKeySecret =
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; AppConfig.cred('stt_iflytek', 'access_key_secret', 'XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
_iflytekHost = AppConfig.env('IFLYTEK_ASR_HOST') ?? ''; _iflytekHost = AppConfig.cred('stt_iflytek', 'host', 'IFLYTEK_ASR_HOST') ?? '';
_doubaoAppKey = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? ''; _doubaoAppKey = AppConfig.cred('ast_bytedance', 'app_id', 'VOLC_OPENSPEECH_APP_ID') ?? '';
_doubaoAccessKey = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? ''; _doubaoAccessKey = AppConfig.cred('ast_bytedance', 'access_token', 'VOLC_OPENSPEECH_ACCESS_TOKEN') ?? '';
_doubaoResourceId = _doubaoResourceId =
AppConfig.env('VOLC_OPENSPEECH_TRANSLATION_BIGMODEL') ?? ''; AppConfig.cred('ast_bytedance', 'translation_service_type', 'VOLC_OPENSPEECH_TRANSLATION_BIGMODEL') ?? '';
// 千问端到端:只有 API Key 必须由 .env 提供; // 千问端到端:只有 API Key 必须由 .env 提供;
// 模型名与 WebSocket 地址给默认值,少配两项也能跑起来。 // 模型名与 WebSocket 地址给默认值,少配两项也能跑起来。
// (native 侧是拿到什么就用什么,空字符串会直接导致连不上, // (native 侧是拿到什么就用什么,空字符串会直接导致连不上,
// 所以兜底必须在这里做,不能指望 Config 的默认参数。) // 所以兜底必须在这里做,不能指望 Config 的默认参数。)
_alibabaAppKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; _alibabaAppKey = AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
final aliModel = AppConfig.env('ALIBABA_OPENSPEECH_APP_ID') ?? ''; final aliModel = AppConfig.cred('ast_alibaba', 'app_id', 'ALIBABA_OPENSPEECH_APP_ID') ?? '';
_alibabaAppId = aliModel.trim().isEmpty _alibabaAppId = aliModel.trim().isEmpty
? kAlibabaModelLegacy ? kAlibabaModelLegacy
: aliModel; : aliModel;
final aliUrl = AppConfig.env('ALIBABA_OPENSPEECH_APP_URL') ?? ''; final aliUrl = AppConfig.cred('ast_alibaba', 'ws_url', 'ALIBABA_OPENSPEECH_APP_URL') ?? '';
_alibabaAppURL = aliUrl.trim().isEmpty _alibabaAppURL = aliUrl.trim().isEmpty
? 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime' ? 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime'
: aliUrl; : aliUrl;
@ -505,6 +516,31 @@ class AzureAstService extends GetxService implements AstService {
} }
} }
@override
Future<bool> setNativeCallBridge(bool enabled) async {
try {
final ok = await _channel
.invokeMethod<bool>('setNativeCallBridge', {'enabled': enabled});
Logger.w('AzureAST', '[STS] 原生直连桥 ${enabled ? "开启" : "关闭"}: ${ok ?? false}');
return ok ?? false;
} on MissingPluginException {
return false;
} catch (e) {
Logger.error('setNativeCallBridge($enabled) 失败: $e');
return false;
}
}
@override
Future<Map<String, dynamic>> nativeCallBridgeStats() async {
try {
final raw = await _channel.invokeMethod<Map>('getNativeCallBridgeStats');
return raw == null ? const {} : Map<String, dynamic>.from(raw);
} catch (_) {
return const {};
}
}
@override @override
Future<void> pushExternalAudio({ Future<void> pushExternalAudio({
required String leg, required String leg,

4
apps/client/lib/data/services/speech_impl/azure_tts_service.dart

@ -57,8 +57,8 @@ class AzureTtsService extends GetxService implements TtsService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
// final _env = _storage.read("ENV") as Map<String, String>; // final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
} }
/// 设置事件通道 /// 设置事件通道

60
apps/client/lib/data/services/wav_writer.dart

@ -36,8 +36,12 @@ class WavWriter {
return w; return w;
} }
/// 入队写一块 PCM。[onWritten] 在这一块真正落盘后回调(电平等只读用途)。 /// 入队写一块 PCM。
void write(Uint8List pcm, {void Function()? onWritten}) { ///
/// ⚠️ 这里刻意**没有**"写完回调"。它原本是给电平/波形用的,等于把 UI 挂在
/// 文件写入队列的尾巴上:队列一堵,界面跟着停;写失败时波形还会整段消失。
/// 显示所需的电平在调用方直接从这块 PCM 上算,与落盘无关。
void write(Uint8List pcm) {
if (_closed) return; if (_closed) return;
_chain = _chain.then((_) async { _chain = _chain.then((_) async {
if (_closed) return; if (_closed) return;
@ -45,7 +49,6 @@ class WavWriter {
await _raf.writeFrom(pcm); await _raf.writeFrom(pcm);
dataBytes += pcm.length; dataBytes += pcm.length;
writtenFrames++; writtenFrames++;
onWritten?.call();
} catch (e) { } catch (e) {
droppedFrames++; droppedFrames++;
Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e'); Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e');
@ -134,3 +137,54 @@ class WavWriter {
return out; return out;
} }
} }
/// 一对已对齐的左右声道 PCM(等长、偶数字节)。
class StereoPair {
StereoPair(this.left, this.right);
final Uint8List left;
final Uint8List right;
}
/// 把两路各自到达的 PCM 按字节对齐成对:两路都排队,谁都不丢,
/// 每次取出两边都已有的那一段(长度取较短者、对齐到整样本)。
///
/// 两路只在**开头**可能不齐(先到的一路先攒着),之后按各自到达速率自然对齐;
/// 一路长时间缺席时另一路会一直攒着(上限 [maxPending],超过就丢最旧的,
/// 避免对端根本没数据时本端无限堆积)。
class StereoPairer {
StereoPairer({this.maxPending = 16000 * 2 * 10}); // 10 秒 @16k/16bit
final int maxPending;
final BytesBuilder _left = BytesBuilder(copy: false);
final BytesBuilder _right = BytesBuilder(copy: false);
void pushLeft(Uint8List pcm) => _push(_left, pcm);
void pushRight(Uint8List pcm) => _push(_right, pcm);
void _push(BytesBuilder b, Uint8List pcm) {
b.add(pcm);
if (b.length > maxPending) {
final all = b.takeBytes();
b.add(Uint8List.sublistView(all, all.length - maxPending));
}
}
/// 两边都有数据才返回;否则 null。
StereoPair? take() {
final n = (_left.length < _right.length ? _left.length : _right.length) & ~1;
if (n <= 0) return null;
final l = _left.takeBytes();
final r = _right.takeBytes();
final pair = StereoPair(
Uint8List.sublistView(l, 0, n), Uint8List.sublistView(r, 0, n));
if (l.length > n) _left.add(Uint8List.sublistView(l, n));
if (r.length > n) _right.add(Uint8List.sublistView(r, n));
return pair;
}
void clear() {
_left.clear();
_right.clear();
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save