Browse Source

修 iOS EMAI 没有语音播报、连续对话上行断供;接入 MCP 令牌

iOS 上助手一直不出声,和这次改连续对话无关,是播报本来就坏的。三轮真机对照
实测:just_audio 喂的是「长度未知的直播 WAV 流」,AVPlayer 只发一次
`Range: bytes=0-1` 的嗅探请求就判定资源不可播,回 -11850 且 processingState
直接落 idle,一次都没真正起播。声明长度、支持 range、把收到的字节全缓存下来
允许重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以安卓一直是
好的——这就是「安卓有声、苹果没声」的全部原因。

iOS 改走原生 AVAudioEngine 排队播 PCM(PcmSpeakerPlayer),安卓不动。
验证方式:一次性灌 3 秒音频,7ms 喂完但 3708ms 才播完,证明是实时渲染不是丢弃。

连续对话的上行也是断的,服务端一直回 ClientAudioTimeout。两个原因:
- _ensureContinuousMic 被 Started 事件和 _enterContinuous 并发调用,两边都在
  `_micRunning` 置位前通过守卫,同一个 recorder 被 startStream 两遍,第二次把
  第一次那条流顶掉。控制器侧改成同步置位,MicPcmStreamer 内部再加一层 in-flight 去重。
- 开麦前没配好并激活音频会话。会话停在 soloAmbient 且未激活时,startStream
  会**成功返回**、引擎也起得来,但输入 tap 一个 buffer 都不回调,客户端毫无报错。

麦克风和播放器统一到 EmaiAudioSession 一份会话配置:各配各的会在连续对话下
互相覆盖。iOS 固定 voiceChat——record 的 echoCancel 会打开 voice-processing 并
把 mode 改成 voiceChat 持有着,播放这边写回 default 就是两边互相掀桌子。
androidAudioAttributes 仍是 media,别改成 voiceCommunication(华为会静音)。

连续对话交互按要求改:进页面只建会话不开麦,短按开始、再按结束、可反复,
退出即停,10 秒无对话往来自动结束。计时依据必须是对话事件不是音频包——
麦克风没人说话也在稳定出包,拿它续命这个超时永远不会触发。
另修 _enterContinuous 在 reconnect() 一返回就判 `!= ready`:ready 要等服务端回
Started(一次网络往返),几乎每次都命中,于是刚打开的连续对话被当场关掉,
而 Started 稍后到达又会把麦开起来,留下一个界面上看不见的常开麦克风。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
714a05b6d4
  1. 2
      apps/client/lib/core/translations/language/ar_sa.dart
  2. 2
      apps/client/lib/core/translations/language/bg_bg.dart
  3. 2
      apps/client/lib/core/translations/language/cs_cz.dart
  4. 2
      apps/client/lib/core/translations/language/da_dk.dart
  5. 2
      apps/client/lib/core/translations/language/de_de.dart
  6. 2
      apps/client/lib/core/translations/language/el_gr.dart
  7. 2
      apps/client/lib/core/translations/language/en_us.dart
  8. 2
      apps/client/lib/core/translations/language/es_es.dart
  9. 2
      apps/client/lib/core/translations/language/et_ee.dart
  10. 2
      apps/client/lib/core/translations/language/fa_ir.dart
  11. 2
      apps/client/lib/core/translations/language/fi_fi.dart
  12. 2
      apps/client/lib/core/translations/language/fr_fr.dart
  13. 2
      apps/client/lib/core/translations/language/hi_in.dart
  14. 2
      apps/client/lib/core/translations/language/hr_hr.dart
  15. 2
      apps/client/lib/core/translations/language/hu_hu.dart
  16. 2
      apps/client/lib/core/translations/language/id_id.dart
  17. 2
      apps/client/lib/core/translations/language/is_is.dart
  18. 2
      apps/client/lib/core/translations/language/it_it.dart
  19. 2
      apps/client/lib/core/translations/language/ja_jp.dart
  20. 2
      apps/client/lib/core/translations/language/ko_kr.dart
  21. 2
      apps/client/lib/core/translations/language/lt_lt.dart
  22. 2
      apps/client/lib/core/translations/language/lv_lv.dart
  23. 2
      apps/client/lib/core/translations/language/ms_my.dart
  24. 2
      apps/client/lib/core/translations/language/nb_no.dart
  25. 2
      apps/client/lib/core/translations/language/nl_nl.dart
  26. 2
      apps/client/lib/core/translations/language/pl_pl.dart
  27. 2
      apps/client/lib/core/translations/language/pt_pt.dart
  28. 2
      apps/client/lib/core/translations/language/ro_ro.dart
  29. 2
      apps/client/lib/core/translations/language/ru_ru.dart
  30. 2
      apps/client/lib/core/translations/language/sk_sk.dart
  31. 2
      apps/client/lib/core/translations/language/sl_si.dart
  32. 2
      apps/client/lib/core/translations/language/sr_rs.dart
  33. 2
      apps/client/lib/core/translations/language/sv_se.dart
  34. 2
      apps/client/lib/core/translations/language/th_th.dart
  35. 2
      apps/client/lib/core/translations/language/tl_ph.dart
  36. 2
      apps/client/lib/core/translations/language/tr_tr.dart
  37. 2
      apps/client/lib/core/translations/language/uk_ua.dart
  38. 2
      apps/client/lib/core/translations/language/vi_vn.dart
  39. 2
      apps/client/lib/core/translations/language/zh_cn.dart
  40. 2
      apps/client/lib/core/translations/language/zh_hk.dart
  41. 2
      apps/client/lib/core/translations/language/zh_tw.dart
  42. 5
      apps/client/lib/data/services/account_service.dart
  43. 46
      apps/client/lib/data/services/bailian_multimodal_service.dart
  44. 2
      apps/client/lib/data/services/bes_ai_session_service.dart
  45. 61
      apps/client/lib/data/services/mcp_token_service.dart
  46. 67
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  47. 9
      apps/client/lib/data/services/network/api.dart
  48. 67
      apps/client/lib/data/utils/emai_audio_session.dart
  49. 64
      apps/client/lib/data/utils/mic_pcm_streamer.dart
  50. 130
      apps/client/lib/data/utils/pcm_stream_player.dart
  51. 223
      apps/client/lib/modules/emai/controllers/emai_controller.dart
  52. 44
      apps/client/lib/modules/emai/views/emai_view.dart
  53. 134
      apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart
  54. 53
      apps/client/lib/modules/meeting/views/tabs/summary_tab.dart
  55. 43
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  56. 187
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift
  57. 2
      apps/client/pubspec.yaml

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

@ -1770,6 +1770,8 @@ const Map<String, String> arSA = {
'emaiThinking': 'يفكر…', 'emaiThinking': 'يفكر…',
'emaiResponding': 'يرد…', 'emaiResponding': 'يرد…',
'emaiHoldToTalk': 'اضغط مع الاستمرار للتحدث', 'emaiHoldToTalk': 'اضغط مع الاستمرار للتحدث',
'emaiTapForContinuous': 'اضغط للمحادثة المستمرة',
'emaiContinuousOn': 'محادثة مستمرة · اضغط للإيقاف',
'emaiReleaseToSend': 'ارفع إصبعك للإرسال', 'emaiReleaseToSend': 'ارفع إصبعك للإرسال',
'emaiReleaseToCancel': 'ارفع إصبعك للإلغاء', 'emaiReleaseToCancel': 'ارفع إصبعك للإلغاء',
'emaiInputHint': 'قل شيئًا لـ EMAI…', 'emaiInputHint': 'قل شيئًا لـ EMAI…',

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

@ -1832,6 +1832,8 @@ const Map<String, String> bgBG = {
'emaiThinking': 'Мисля…', 'emaiThinking': 'Мисля…',
'emaiResponding': 'Отговарям…', 'emaiResponding': 'Отговарям…',
'emaiHoldToTalk': 'Задръжте, за да говорите', 'emaiHoldToTalk': 'Задръжте, за да говорите',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Пуснете, за да изпратите', 'emaiReleaseToSend': 'Пуснете, за да изпратите',
'emaiReleaseToCancel': 'Пуснете, за да откажете', 'emaiReleaseToCancel': 'Пуснете, за да откажете',
'emaiInputHint': 'Кажете нещо на EMAI…', 'emaiInputHint': 'Кажете нещо на EMAI…',

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

@ -1801,6 +1801,8 @@ const Map<String, String> csCZ = {
'emaiThinking': 'Přemýšlím…', 'emaiThinking': 'Přemýšlím…',
'emaiResponding': 'Odpovídám…', 'emaiResponding': 'Odpovídám…',
'emaiHoldToTalk': 'Podržte a mluvte', 'emaiHoldToTalk': 'Podržte a mluvte',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Uvolněním odešlete', 'emaiReleaseToSend': 'Uvolněním odešlete',
'emaiReleaseToCancel': 'Uvolněním zrušíte', 'emaiReleaseToCancel': 'Uvolněním zrušíte',
'emaiInputHint': 'Řekněte něco EMAI…', 'emaiInputHint': 'Řekněte něco EMAI…',

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

@ -1802,6 +1802,8 @@ const Map<String, String> daDK = {
'emaiThinking': 'Tænker…', 'emaiThinking': 'Tænker…',
'emaiResponding': 'Svarer…', 'emaiResponding': 'Svarer…',
'emaiHoldToTalk': 'Hold nede for at tale', 'emaiHoldToTalk': 'Hold nede for at tale',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Slip for at sende', 'emaiReleaseToSend': 'Slip for at sende',
'emaiReleaseToCancel': 'Slip for at annullere', 'emaiReleaseToCancel': 'Slip for at annullere',
'emaiInputHint': 'Sig noget til EMAI…', 'emaiInputHint': 'Sig noget til EMAI…',

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

@ -2044,6 +2044,8 @@ const Map<String, String> deDE = {
'emaiThinking': 'Denke nach…', 'emaiThinking': 'Denke nach…',
'emaiResponding': 'Antwortet…', 'emaiResponding': 'Antwortet…',
'emaiHoldToTalk': 'Zum Sprechen gedrückt halten', 'emaiHoldToTalk': 'Zum Sprechen gedrückt halten',
'emaiTapForContinuous': 'Tippen für Dauergespräch',
'emaiContinuousOn': 'Dauergespräch · zum Beenden tippen',
'emaiReleaseToSend': 'Loslassen zum Senden', 'emaiReleaseToSend': 'Loslassen zum Senden',
'emaiReleaseToCancel': 'Loslassen zum Abbrechen', 'emaiReleaseToCancel': 'Loslassen zum Abbrechen',
'emaiInputHint': 'Sag etwas zu EMAI…', 'emaiInputHint': 'Sag etwas zu EMAI…',

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

@ -1836,6 +1836,8 @@ const Map<String, String> elGR = {
'emaiThinking': 'Σκέφτομαι…', 'emaiThinking': 'Σκέφτομαι…',
'emaiResponding': 'Απαντώ…', 'emaiResponding': 'Απαντώ…',
'emaiHoldToTalk': 'Κρατήστε πατημένο για ομιλία', 'emaiHoldToTalk': 'Κρατήστε πατημένο για ομιλία',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Αφήστε για αποστολή', 'emaiReleaseToSend': 'Αφήστε για αποστολή',
'emaiReleaseToCancel': 'Αφήστε για ακύρωση', 'emaiReleaseToCancel': 'Αφήστε για ακύρωση',
'emaiInputHint': 'Πείτε κάτι στο EMAI…', 'emaiInputHint': 'Πείτε κάτι στο EMAI…',

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

@ -2012,6 +2012,8 @@ const Map<String, String> enUS = {
'emaiThinking': 'Thinking…', 'emaiThinking': 'Thinking…',
'emaiResponding': 'Responding…', 'emaiResponding': 'Responding…',
'emaiHoldToTalk': 'Hold to talk', 'emaiHoldToTalk': 'Hold to talk',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Release to send', 'emaiReleaseToSend': 'Release to send',
'emaiReleaseToCancel': 'Release to cancel', 'emaiReleaseToCancel': 'Release to cancel',
'emaiInputHint': 'Say something to EMAI…', 'emaiInputHint': 'Say something to EMAI…',

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

@ -2038,6 +2038,8 @@ const Map<String, String> esES = {
'emaiThinking': 'Pensando…', 'emaiThinking': 'Pensando…',
'emaiResponding': 'Respondiendo…', 'emaiResponding': 'Respondiendo…',
'emaiHoldToTalk': 'Mantén pulsado para hablar', 'emaiHoldToTalk': 'Mantén pulsado para hablar',
'emaiTapForContinuous': 'Toca para chat continuo',
'emaiContinuousOn': 'Chat continuo · toca para parar',
'emaiReleaseToSend': 'Suelta para enviar', 'emaiReleaseToSend': 'Suelta para enviar',
'emaiReleaseToCancel': 'Suelta para cancelar', 'emaiReleaseToCancel': 'Suelta para cancelar',
'emaiInputHint': 'Dile algo a EMAI…', 'emaiInputHint': 'Dile algo a EMAI…',

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

@ -1797,6 +1797,8 @@ const Map<String, String> etEE = {
'emaiThinking': 'Mõtlen…', 'emaiThinking': 'Mõtlen…',
'emaiResponding': 'Vastan…', 'emaiResponding': 'Vastan…',
'emaiHoldToTalk': 'Rääkimiseks hoia all', 'emaiHoldToTalk': 'Rääkimiseks hoia all',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Saatmiseks vabasta', 'emaiReleaseToSend': 'Saatmiseks vabasta',
'emaiReleaseToCancel': 'Tühistamiseks vabasta', 'emaiReleaseToCancel': 'Tühistamiseks vabasta',
'emaiInputHint': 'Ütle EMAI-le midagi…', 'emaiInputHint': 'Ütle EMAI-le midagi…',

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

@ -1787,6 +1787,8 @@ const Map<String, String> faIR = {
'emaiThinking': 'در حال فکر کردن…', 'emaiThinking': 'در حال فکر کردن…',
'emaiResponding': 'در حال پاسخ…', 'emaiResponding': 'در حال پاسخ…',
'emaiHoldToTalk': 'برای صحبت نگه دارید', 'emaiHoldToTalk': 'برای صحبت نگه دارید',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'برای ارسال رها کنید', 'emaiReleaseToSend': 'برای ارسال رها کنید',
'emaiReleaseToCancel': 'برای لغو رها کنید', 'emaiReleaseToCancel': 'برای لغو رها کنید',
'emaiInputHint': 'چیزی به EMAI بگویید…', 'emaiInputHint': 'چیزی به EMAI بگویید…',

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

@ -1807,6 +1807,8 @@ const Map<String, String> fiFI = {
'emaiThinking': 'Miettii…', 'emaiThinking': 'Miettii…',
'emaiResponding': 'Vastaa…', 'emaiResponding': 'Vastaa…',
'emaiHoldToTalk': 'Puhu painamalla ja pitämällä', 'emaiHoldToTalk': 'Puhu painamalla ja pitämällä',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Lähetä vapauttamalla', 'emaiReleaseToSend': 'Lähetä vapauttamalla',
'emaiReleaseToCancel': 'Peruuta vapauttamalla', 'emaiReleaseToCancel': 'Peruuta vapauttamalla',
'emaiInputHint': 'Sano jotain EMAIlle…', 'emaiInputHint': 'Sano jotain EMAIlle…',

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

@ -2056,6 +2056,8 @@ const Map<String, String> frFR = {
'emaiThinking': 'Réflexion…', 'emaiThinking': 'Réflexion…',
'emaiResponding': 'Réponse…', 'emaiResponding': 'Réponse…',
'emaiHoldToTalk': 'Maintenir pour parler', 'emaiHoldToTalk': 'Maintenir pour parler',
'emaiTapForContinuous': 'Toucher pour discuter en continu',
'emaiContinuousOn': 'Mode continu · toucher pour arrêter',
'emaiReleaseToSend': 'Relâcher pour envoyer', 'emaiReleaseToSend': 'Relâcher pour envoyer',
'emaiReleaseToCancel': 'Relâcher pour annuler', 'emaiReleaseToCancel': 'Relâcher pour annuler',
'emaiInputHint': 'Dites quelque chose à EMAI…', 'emaiInputHint': 'Dites quelque chose à EMAI…',

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

@ -1806,6 +1806,8 @@ const Map<String, String> hiIN = {
'emaiThinking': 'सोच रहा है…', 'emaiThinking': 'सोच रहा है…',
'emaiResponding': 'जवाब दे रहा है…', 'emaiResponding': 'जवाब दे रहा है…',
'emaiHoldToTalk': 'बोलने के लिए दबाकर रखें', 'emaiHoldToTalk': 'बोलने के लिए दबाकर रखें',
'emaiTapForContinuous': 'निरंतर बातचीत के लिए टैप करें',
'emaiContinuousOn': 'निरंतर बातचीत · रोकने के लिए टैप करें',
'emaiReleaseToSend': 'भेजने के लिए छोड़ें', 'emaiReleaseToSend': 'भेजने के लिए छोड़ें',
'emaiReleaseToCancel': 'रद्द करने के लिए छोड़ें', 'emaiReleaseToCancel': 'रद्द करने के लिए छोड़ें',
'emaiInputHint': 'EMAI से कुछ कहें…', 'emaiInputHint': 'EMAI से कुछ कहें…',

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

@ -1822,6 +1822,8 @@ const Map<String, String> hrHR = {
'emaiThinking': 'Razmišljam…', 'emaiThinking': 'Razmišljam…',
'emaiResponding': 'Odgovaram…', 'emaiResponding': 'Odgovaram…',
'emaiHoldToTalk': 'Držite za govor', 'emaiHoldToTalk': 'Držite za govor',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Pustite za slanje', 'emaiReleaseToSend': 'Pustite za slanje',
'emaiReleaseToCancel': 'Pustite za odustajanje', 'emaiReleaseToCancel': 'Pustite za odustajanje',
'emaiInputHint': 'Recite nešto EMAI-ju…', 'emaiInputHint': 'Recite nešto EMAI-ju…',

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

@ -1824,6 +1824,8 @@ const Map<String, String> huHU = {
'emaiThinking': 'Gondolkodom…', 'emaiThinking': 'Gondolkodom…',
'emaiResponding': 'Válaszolok…', 'emaiResponding': 'Válaszolok…',
'emaiHoldToTalk': 'Tartsd nyomva a beszédhez', 'emaiHoldToTalk': 'Tartsd nyomva a beszédhez',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Engedd el a küldéshez', 'emaiReleaseToSend': 'Engedd el a küldéshez',
'emaiReleaseToCancel': 'Engedd el a megszakításhoz', 'emaiReleaseToCancel': 'Engedd el a megszakításhoz',
'emaiInputHint': 'Mondj valamit az EMAI-nak…', 'emaiInputHint': 'Mondj valamit az EMAI-nak…',

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

@ -1806,6 +1806,8 @@ const Map<String, String> idID = {
'emaiThinking': 'Berpikir…', 'emaiThinking': 'Berpikir…',
'emaiResponding': 'Menjawab…', 'emaiResponding': 'Menjawab…',
'emaiHoldToTalk': 'Tahan untuk bicara', 'emaiHoldToTalk': 'Tahan untuk bicara',
'emaiTapForContinuous': 'Ketuk untuk obrolan kontinu',
'emaiContinuousOn': 'Obrolan kontinu · ketuk untuk berhenti',
'emaiReleaseToSend': 'Lepaskan untuk kirim', 'emaiReleaseToSend': 'Lepaskan untuk kirim',
'emaiReleaseToCancel': 'Lepaskan untuk batal', 'emaiReleaseToCancel': 'Lepaskan untuk batal',
'emaiInputHint': 'Katakan sesuatu ke EMAI…', 'emaiInputHint': 'Katakan sesuatu ke EMAI…',

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

@ -1803,6 +1803,8 @@ const Map<String, String> isIS = {
'emaiThinking': 'Hugsa…', 'emaiThinking': 'Hugsa…',
'emaiResponding': 'Svara…', 'emaiResponding': 'Svara…',
'emaiHoldToTalk': 'Haltu inni til að tala', 'emaiHoldToTalk': 'Haltu inni til að tala',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Slepptu til að senda', 'emaiReleaseToSend': 'Slepptu til að senda',
'emaiReleaseToCancel': 'Slepptu til að hætta við', 'emaiReleaseToCancel': 'Slepptu til að hætta við',
'emaiInputHint': 'Segðu eitthvað við EMAI…', 'emaiInputHint': 'Segðu eitthvað við EMAI…',

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

@ -1844,6 +1844,8 @@ const Map<String, String> itIT = {
'emaiThinking': 'Sto pensando…', 'emaiThinking': 'Sto pensando…',
'emaiResponding': 'Sto rispondendo…', 'emaiResponding': 'Sto rispondendo…',
'emaiHoldToTalk': 'Tieni premuto per parlare', 'emaiHoldToTalk': 'Tieni premuto per parlare',
'emaiTapForContinuous': 'Tocca per chat continua',
'emaiContinuousOn': 'Chat continua · tocca per fermare',
'emaiReleaseToSend': 'Rilascia per inviare', 'emaiReleaseToSend': 'Rilascia per inviare',
'emaiReleaseToCancel': 'Rilascia per annullare', 'emaiReleaseToCancel': 'Rilascia per annullare',
'emaiInputHint': 'Di\' qualcosa a EMAI…', 'emaiInputHint': 'Di\' qualcosa a EMAI…',

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

@ -1841,6 +1841,8 @@ const Map<String, String> jaJP = {
'emaiThinking': '考え中…', 'emaiThinking': '考え中…',
'emaiResponding': '応答中…', 'emaiResponding': '応答中…',
'emaiHoldToTalk': '押しながら話す', 'emaiHoldToTalk': '押しながら話す',
'emaiTapForContinuous': 'タップで連続会話',
'emaiContinuousOn': '連続会話中 · タップで終了',
'emaiReleaseToSend': '離すと送信', 'emaiReleaseToSend': '離すと送信',
'emaiReleaseToCancel': '離すとキャンセル', 'emaiReleaseToCancel': '離すとキャンセル',
'emaiInputHint': 'EMAI に話しかけてみましょう…', 'emaiInputHint': 'EMAI に話しかけてみましょう…',

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

@ -1834,6 +1834,8 @@ const Map<String, String> koKR = {
'emaiThinking': '생각 중…', 'emaiThinking': '생각 중…',
'emaiResponding': '응답 중…', 'emaiResponding': '응답 중…',
'emaiHoldToTalk': '길게 눌러 말하기', 'emaiHoldToTalk': '길게 눌러 말하기',
'emaiTapForContinuous': '탭하여 연속 대화',
'emaiContinuousOn': '연속 대화 중 · 탭하여 종료',
'emaiReleaseToSend': '손을 떼면 전송', 'emaiReleaseToSend': '손을 떼면 전송',
'emaiReleaseToCancel': '손을 떼면 취소', 'emaiReleaseToCancel': '손을 떼면 취소',
'emaiInputHint': 'EMAI에게 말을 걸어 보세요…', 'emaiInputHint': 'EMAI에게 말을 걸어 보세요…',

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

@ -1820,6 +1820,8 @@ const Map<String, String> ltLT = {
'emaiThinking': 'Galvoju…', 'emaiThinking': 'Galvoju…',
'emaiResponding': 'Atsakau…', 'emaiResponding': 'Atsakau…',
'emaiHoldToTalk': 'Laikykite, kad kalbėtumėte', 'emaiHoldToTalk': 'Laikykite, kad kalbėtumėte',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Atleiskite, kad išsiųstumėte', 'emaiReleaseToSend': 'Atleiskite, kad išsiųstumėte',
'emaiReleaseToCancel': 'Atleiskite, kad atšauktumėte', 'emaiReleaseToCancel': 'Atleiskite, kad atšauktumėte',
'emaiInputHint': 'Pasakykite ką nors EMAI…', 'emaiInputHint': 'Pasakykite ką nors EMAI…',

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

@ -1825,6 +1825,8 @@ const Map<String, String> lvLV = {
'emaiThinking': 'Domāju…', 'emaiThinking': 'Domāju…',
'emaiResponding': 'Atbildu…', 'emaiResponding': 'Atbildu…',
'emaiHoldToTalk': 'Turiet, lai runātu', 'emaiHoldToTalk': 'Turiet, lai runātu',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Atlaidiet, lai nosūtītu', 'emaiReleaseToSend': 'Atlaidiet, lai nosūtītu',
'emaiReleaseToCancel': 'Atlaidiet, lai atceltu', 'emaiReleaseToCancel': 'Atlaidiet, lai atceltu',
'emaiInputHint': 'Pasakiet kaut ko EMAI…', 'emaiInputHint': 'Pasakiet kaut ko EMAI…',

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

@ -1810,6 +1810,8 @@ const Map<String, String> msMY = {
'emaiThinking': 'Berfikir…', 'emaiThinking': 'Berfikir…',
'emaiResponding': 'Menjawab…', 'emaiResponding': 'Menjawab…',
'emaiHoldToTalk': 'Tahan untuk bercakap', 'emaiHoldToTalk': 'Tahan untuk bercakap',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Lepaskan untuk hantar', 'emaiReleaseToSend': 'Lepaskan untuk hantar',
'emaiReleaseToCancel': 'Lepaskan untuk batal', 'emaiReleaseToCancel': 'Lepaskan untuk batal',
'emaiInputHint': 'Katakan sesuatu kepada EMAI…', 'emaiInputHint': 'Katakan sesuatu kepada EMAI…',

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

@ -1804,6 +1804,8 @@ const Map<String, String> nbNO = {
'emaiThinking': 'Tenker…', 'emaiThinking': 'Tenker…',
'emaiResponding': 'Svarer…', 'emaiResponding': 'Svarer…',
'emaiHoldToTalk': 'Hold inne for å snakke', 'emaiHoldToTalk': 'Hold inne for å snakke',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Slipp for å sende', 'emaiReleaseToSend': 'Slipp for å sende',
'emaiReleaseToCancel': 'Slipp for å avbryte', 'emaiReleaseToCancel': 'Slipp for å avbryte',
'emaiInputHint': 'Si noe til EMAI…', 'emaiInputHint': 'Si noe til EMAI…',

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

@ -1701,6 +1701,8 @@ const Map<String, String> nlNL = {
'emaiThinking': 'Nadenken…', 'emaiThinking': 'Nadenken…',
'emaiResponding': 'Antwoorden…', 'emaiResponding': 'Antwoorden…',
'emaiHoldToTalk': 'Ingedrukt houden om te praten', 'emaiHoldToTalk': 'Ingedrukt houden om te praten',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Loslaten om te versturen', 'emaiReleaseToSend': 'Loslaten om te versturen',
'emaiReleaseToCancel': 'Loslaten om te annuleren', 'emaiReleaseToCancel': 'Loslaten om te annuleren',
'emaiInputHint': 'Zeg iets tegen EMAI…', 'emaiInputHint': 'Zeg iets tegen EMAI…',

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

@ -1831,6 +1831,8 @@ const Map<String, String> plPL = {
'emaiThinking': 'Myślę…', 'emaiThinking': 'Myślę…',
'emaiResponding': 'Odpowiadam…', 'emaiResponding': 'Odpowiadam…',
'emaiHoldToTalk': 'Przytrzymaj, aby mówić', 'emaiHoldToTalk': 'Przytrzymaj, aby mówić',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Puść, aby wysłać', 'emaiReleaseToSend': 'Puść, aby wysłać',
'emaiReleaseToCancel': 'Puść, aby anulować', 'emaiReleaseToCancel': 'Puść, aby anulować',
'emaiInputHint': 'Powiedz coś do EMAI…', 'emaiInputHint': 'Powiedz coś do EMAI…',

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

@ -2043,6 +2043,8 @@ const Map<String, String> ptPT = {
'emaiThinking': 'A pensar…', 'emaiThinking': 'A pensar…',
'emaiResponding': 'A responder…', 'emaiResponding': 'A responder…',
'emaiHoldToTalk': 'Manter premido para falar', 'emaiHoldToTalk': 'Manter premido para falar',
'emaiTapForContinuous': 'Toque para conversa contínua',
'emaiContinuousOn': 'Conversa contínua · toque para parar',
'emaiReleaseToSend': 'Largar para enviar', 'emaiReleaseToSend': 'Largar para enviar',
'emaiReleaseToCancel': 'Largar para cancelar', 'emaiReleaseToCancel': 'Largar para cancelar',
'emaiInputHint': 'Diz algo ao EMAI…', 'emaiInputHint': 'Diz algo ao EMAI…',

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

@ -1833,6 +1833,8 @@ const Map<String, String> roRO = {
'emaiThinking': 'Mă gândesc…', 'emaiThinking': 'Mă gândesc…',
'emaiResponding': 'Răspund…', 'emaiResponding': 'Răspund…',
'emaiHoldToTalk': 'Ține apăsat pentru a vorbi', 'emaiHoldToTalk': 'Ține apăsat pentru a vorbi',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Eliberează pentru a trimite', 'emaiReleaseToSend': 'Eliberează pentru a trimite',
'emaiReleaseToCancel': 'Eliberează pentru a anula', 'emaiReleaseToCancel': 'Eliberează pentru a anula',
'emaiInputHint': 'Spune-i ceva lui EMAI…', 'emaiInputHint': 'Spune-i ceva lui EMAI…',

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

@ -2019,6 +2019,8 @@ const Map<String, String> ruRU = {
'emaiThinking': 'Думаю…', 'emaiThinking': 'Думаю…',
'emaiResponding': 'Отвечаю…', 'emaiResponding': 'Отвечаю…',
'emaiHoldToTalk': 'Удерживайте, чтобы говорить', 'emaiHoldToTalk': 'Удерживайте, чтобы говорить',
'emaiTapForContinuous': 'Нажмите для непрерывного диалога',
'emaiContinuousOn': 'Непрерывный диалог · нажмите для остановки',
'emaiReleaseToSend': 'Отпустите для отправки', 'emaiReleaseToSend': 'Отпустите для отправки',
'emaiReleaseToCancel': 'Отпустите для отмены', 'emaiReleaseToCancel': 'Отпустите для отмены',
'emaiInputHint': 'Скажите что-нибудь EMAI…', 'emaiInputHint': 'Скажите что-нибудь EMAI…',

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

@ -1814,6 +1814,8 @@ const Map<String, String> skSK = {
'emaiThinking': 'Rozmýšľam…', 'emaiThinking': 'Rozmýšľam…',
'emaiResponding': 'Odpovedám…', 'emaiResponding': 'Odpovedám…',
'emaiHoldToTalk': 'Podržte a hovorte', 'emaiHoldToTalk': 'Podržte a hovorte',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Uvoľnením odošlete', 'emaiReleaseToSend': 'Uvoľnením odošlete',
'emaiReleaseToCancel': 'Uvoľnením zrušíte', 'emaiReleaseToCancel': 'Uvoľnením zrušíte',
'emaiInputHint': 'Povedzte niečo EMAI…', 'emaiInputHint': 'Povedzte niečo EMAI…',

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

@ -1817,6 +1817,8 @@ const Map<String, String> slSI = {
'emaiThinking': 'Razmišljam…', 'emaiThinking': 'Razmišljam…',
'emaiResponding': 'Odgovarjam…', 'emaiResponding': 'Odgovarjam…',
'emaiHoldToTalk': 'Pridržite za govor', 'emaiHoldToTalk': 'Pridržite za govor',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Spustite za pošiljanje', 'emaiReleaseToSend': 'Spustite za pošiljanje',
'emaiReleaseToCancel': 'Spustite za preklic', 'emaiReleaseToCancel': 'Spustite za preklic',
'emaiInputHint': 'Povejte kaj EMAI-ju…', 'emaiInputHint': 'Povejte kaj EMAI-ju…',

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

@ -1813,6 +1813,8 @@ const Map<String, String> srRS = {
'emaiThinking': 'Размишљам…', 'emaiThinking': 'Размишљам…',
'emaiResponding': 'Одговарам…', 'emaiResponding': 'Одговарам…',
'emaiHoldToTalk': 'Држите да бисте говорили', 'emaiHoldToTalk': 'Држите да бисте говорили',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Пустите да пошаљете', 'emaiReleaseToSend': 'Пустите да пошаљете',
'emaiReleaseToCancel': 'Пустите да откажете', 'emaiReleaseToCancel': 'Пустите да откажете',
'emaiInputHint': 'Реците нешто EMAI-ју…', 'emaiInputHint': 'Реците нешто EMAI-ју…',

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

@ -1809,6 +1809,8 @@ const Map<String, String> svSE = {
'emaiThinking': 'Tänker…', 'emaiThinking': 'Tänker…',
'emaiResponding': 'Svarar…', 'emaiResponding': 'Svarar…',
'emaiHoldToTalk': 'Håll in för att tala', 'emaiHoldToTalk': 'Håll in för att tala',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Släpp för att skicka', 'emaiReleaseToSend': 'Släpp för att skicka',
'emaiReleaseToCancel': 'Släpp för att avbryta', 'emaiReleaseToCancel': 'Släpp för att avbryta',
'emaiInputHint': 'Säg något till EMAI…', 'emaiInputHint': 'Säg något till EMAI…',

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

@ -1764,6 +1764,8 @@ const Map<String, String> thTH = {
'emaiThinking': 'กำลังคิด…', 'emaiThinking': 'กำลังคิด…',
'emaiResponding': 'กำลังตอบ…', 'emaiResponding': 'กำลังตอบ…',
'emaiHoldToTalk': 'กดค้างเพื่อพูด', 'emaiHoldToTalk': 'กดค้างเพื่อพูด',
'emaiTapForContinuous': 'แตะเพื่อสนทนาต่อเนื่อง',
'emaiContinuousOn': 'สนทนาต่อเนื่อง · แตะเพื่อหยุด',
'emaiReleaseToSend': 'ปล่อยเพื่อส่ง', 'emaiReleaseToSend': 'ปล่อยเพื่อส่ง',
'emaiReleaseToCancel': 'ปล่อยเพื่อยกเลิก', 'emaiReleaseToCancel': 'ปล่อยเพื่อยกเลิก',
'emaiInputHint': 'พูดอะไรกับ EMAI สักหน่อย…', 'emaiInputHint': 'พูดอะไรกับ EMAI สักหน่อย…',

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

@ -1847,6 +1847,8 @@ const Map<String, String> tlPH = {
'emaiThinking': 'Nag-iisip…', 'emaiThinking': 'Nag-iisip…',
'emaiResponding': 'Sumasagot…', 'emaiResponding': 'Sumasagot…',
'emaiHoldToTalk': 'Pindutin nang matagal para magsalita', 'emaiHoldToTalk': 'Pindutin nang matagal para magsalita',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Bitawan para ipadala', 'emaiReleaseToSend': 'Bitawan para ipadala',
'emaiReleaseToCancel': 'Bitawan para kanselahin', 'emaiReleaseToCancel': 'Bitawan para kanselahin',
'emaiInputHint': 'Magsabi ng kahit ano kay EMAI…', 'emaiInputHint': 'Magsabi ng kahit ano kay EMAI…',

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

@ -1802,6 +1802,8 @@ const Map<String, String> trTR = {
'emaiThinking': 'Düşünüyor…', 'emaiThinking': 'Düşünüyor…',
'emaiResponding': 'Yanıtlıyor…', 'emaiResponding': 'Yanıtlıyor…',
'emaiHoldToTalk': 'Konuşmak için basılı tut', 'emaiHoldToTalk': 'Konuşmak için basılı tut',
'emaiTapForContinuous': 'Sürekli sohbet için dokunun',
'emaiContinuousOn': 'Sürekli sohbet · durdurmak için dokunun',
'emaiReleaseToSend': 'Göndermek için bırak', 'emaiReleaseToSend': 'Göndermek için bırak',
'emaiReleaseToCancel': 'İptal etmek için bırak', 'emaiReleaseToCancel': 'İptal etmek için bırak',
'emaiInputHint': 'EMAI\'ye bir şeyler söyle…', 'emaiInputHint': 'EMAI\'ye bir şeyler söyle…',

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

@ -1820,6 +1820,8 @@ const Map<String, String> ukUA = {
'emaiThinking': 'Думаю…', 'emaiThinking': 'Думаю…',
'emaiResponding': 'Відповідаю…', 'emaiResponding': 'Відповідаю…',
'emaiHoldToTalk': 'Утримуйте, щоб говорити', 'emaiHoldToTalk': 'Утримуйте, щоб говорити',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': 'Відпустіть, щоб надіслати', 'emaiReleaseToSend': 'Відпустіть, щоб надіслати',
'emaiReleaseToCancel': 'Відпустіть, щоб скасувати', 'emaiReleaseToCancel': 'Відпустіть, щоб скасувати',
'emaiInputHint': 'Скажіть щось EMAI…', 'emaiInputHint': 'Скажіть щось EMAI…',

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

@ -1783,6 +1783,8 @@ const Map<String, String> viVN = {
'emaiThinking': 'Đang suy nghĩ…', 'emaiThinking': 'Đang suy nghĩ…',
'emaiResponding': 'Đang trả lời…', 'emaiResponding': 'Đang trả lời…',
'emaiHoldToTalk': 'Giữ để nói', 'emaiHoldToTalk': 'Giữ để nói',
'emaiTapForContinuous': 'Chạm để trò chuyện liên tục',
'emaiContinuousOn': 'Đang liên tục · chạm để dừng',
'emaiReleaseToSend': 'Thả ra để gửi', 'emaiReleaseToSend': 'Thả ra để gửi',
'emaiReleaseToCancel': 'Thả ra để hủy', 'emaiReleaseToCancel': 'Thả ra để hủy',
'emaiInputHint': 'Nói gì đó với EMAI…', 'emaiInputHint': 'Nói gì đó với EMAI…',

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

@ -1827,6 +1827,8 @@ const Map<String, String> zhCN = {
'emaiThinking': '思考中…', 'emaiThinking': '思考中…',
'emaiResponding': '回答中…', 'emaiResponding': '回答中…',
'emaiHoldToTalk': '按住说话', 'emaiHoldToTalk': '按住说话',
'emaiTapForContinuous': '点击开始连续对话',
'emaiContinuousOn': '连续对话中 · 点击结束',
'emaiReleaseToSend': '松开发送', 'emaiReleaseToSend': '松开发送',
'emaiReleaseToCancel': '松开取消', 'emaiReleaseToCancel': '松开取消',
'emaiInputHint': '和 EMAI 说点什么…', 'emaiInputHint': '和 EMAI 说点什么…',

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

@ -1811,6 +1811,8 @@ const Map<String, String> zhHK = {
'emaiThinking': '思考中…', 'emaiThinking': '思考中…',
'emaiResponding': '回答中…', 'emaiResponding': '回答中…',
'emaiHoldToTalk': '按住說話', 'emaiHoldToTalk': '按住說話',
'emaiTapForContinuous': 'Tap for continuous chat',
'emaiContinuousOn': 'Continuous · tap to stop',
'emaiReleaseToSend': '放開傳送', 'emaiReleaseToSend': '放開傳送',
'emaiReleaseToCancel': '放開取消', 'emaiReleaseToCancel': '放開取消',
'emaiInputHint': '同 EMAI 講啲嘢…', 'emaiInputHint': '同 EMAI 講啲嘢…',

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

@ -1811,6 +1811,8 @@ const Map<String, String> zhTW = {
'emaiThinking': '思考中…', 'emaiThinking': '思考中…',
'emaiResponding': '回答中…', 'emaiResponding': '回答中…',
'emaiHoldToTalk': '按住說話', 'emaiHoldToTalk': '按住說話',
'emaiTapForContinuous': '點擊開始連續對話',
'emaiContinuousOn': '連續對話中 · 點擊結束',
'emaiReleaseToSend': '放開傳送', 'emaiReleaseToSend': '放開傳送',
'emaiReleaseToCancel': '放開取消', 'emaiReleaseToCancel': '放開取消',
'emaiInputHint': '和 EMAI 說點什麼…', 'emaiInputHint': '和 EMAI 說點什麼…',

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

@ -11,6 +11,7 @@ import '../../data/models/user_Info.dart';
import 'meeting/meeting_upload_service.dart'; import 'meeting/meeting_upload_service.dart';
import '../../core/utils/toast_throttle.dart'; import '../../core/utils/toast_throttle.dart';
import 'bes_device_auth.dart'; import 'bes_device_auth.dart';
import 'mcp_token_service.dart';
import 'memory_reminder_scheduler.dart'; import 'memory_reminder_scheduler.dart';
import 'memory_service.dart'; import 'memory_service.dart';
@ -43,6 +44,9 @@ class AccountService extends GetxService {
// 换账号后同一台耳机要重新登记到新账号名下, // 换账号后同一台耳机要重新登记到新账号名下,
// 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。 // 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。
BesDeviceAuth.reset(); BesDeviceAuth.reset();
// MCP 令牌是按 uid 签的:不清的话新账号会拿着上一个人的令牌去调 MCP,
// 读到的是别人的会议纪要和待办。
McpTokenService.reset();
// 拾忆是账号数据:不清的话换账号后会看到上一个人的日程、花销和灵感, // 拾忆是账号数据:不清的话换账号后会看到上一个人的日程、花销和灵感,
// 排定的提醒也会继续按上一个账号的安排响。 // 排定的提醒也会继续按上一个账号的安排响。
if (Get.isRegistered<MemoryService>()) { if (Get.isRegistered<MemoryService>()) {
@ -67,6 +71,7 @@ class AccountService extends GetxService {
await _storage.erase(); await _storage.erase();
User.clearUserSession(); User.clearUserSession();
BesDeviceAuth.reset(); BesDeviceAuth.reset();
McpTokenService.reset();
if (Get.isRegistered<MemoryService>()) { if (Get.isRegistered<MemoryService>()) {
MemoryService.to.clear(); MemoryService.to.clear();
} }

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

@ -170,6 +170,7 @@ class BailianMultimodalService extends GetxService {
bool enableWebSearch = true, bool enableWebSearch = true,
int downstreamRate = downstreamSampleRate, int downstreamRate = downstreamSampleRate,
String source = 'emai', String source = 'emai',
String? mcpToken,
}) async { }) async {
if (!isConfigured) { if (!isConfigured) {
Logger.e(_tag, '配置不全,无法启动会话:$configHint'); Logger.e(_tag, '配置不全,无法启动会话:$configHint');
@ -247,13 +248,32 @@ class BailianMultimodalService extends GetxService {
'client_info': { 'client_info': {
'user_id': _safeUserId(userId), 'user_id': _safeUserId(userId),
}, },
// biz_params 是可选块。关掉联网搜索时整块不发—— // biz_params 是可选块,两件事都没有时整块不发——
// 万一应用侧没开这个能力,多带一个字段可能让 Start 直接被拒, // 万一应用侧没开这些能力,多带一个字段可能让 Start 直接被拒,
// 那样整个功能都起不来,比少一个搜索严重得多。 // 那样整个功能都起不来,比少一个搜索严重得多。
if (enableWebSearch) if (enableWebSearch || _hasMcpToken(mcpToken))
'biz_params': { 'biz_params': {
'user_defined_params': { 'user_defined_params': {
'extra_config': {'enable_web_search': true}, // MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具,
// 我们的工具据此认出「这是哪个用户」。
//
// ⚠️ **必须按工具名嵌套**,平铺写 `'auth_token': xxx` 会被静默丢弃。
// 文档只含糊说「mcp 传递参数依照 mcp 服务本身所需的参数」,
// 实测(2026-09-07,四种形状逐个试)才确定是这个形状:
// {"<工具名>": {"auth_token": "..."}}
// 平铺 / 按 MCP 服务名嵌套 / 放 mcp 子节点,三种都收不到。
//
// ⚠️ 这是整条链路上**唯一**能按用户变的通道:客户端连百炼只带
// 百炼的 API Key,百炼调 MCP 的 Authorization 头是控制台里
// 配死的、所有用户共用。不带它,这些工具一律「身份校验失败」。
//
// ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**,
// 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。
if (_hasMcpToken(mcpToken))
for (final tool in _mcpAuthTools)
tool: {'auth_token': mcpToken},
if (enableWebSearch)
'extra_config': {'enable_web_search': true},
}, },
}, },
}, },
@ -531,6 +551,24 @@ class BailianMultimodalService extends GetxService {
} }
/// 协议限制 user_id 不超过 36 字符 /// 协议限制 user_id 不超过 36 字符
/// 需要用户身份的 MCP 工具名。令牌要**按工具名逐个挂**,因为百炼是按这个
/// key 去匹配的(见 start() 里 user_defined_params 的说明)。
///
/// ⚠️ 服务端新增需要身份的工具时,这里要同步加一行,否则那个工具永远
/// 拿不到令牌、永远返回「身份校验失败」,而且客户端不会有任何报错。
/// 服务端工具清单见 apps/services/modules/mcp/tool_*.go。
static const List<String> _mcpAuthTools = [
'search_meeting_notes',
'get_memory_items',
'get_memory_stats',
'get_memory_report',
'get_user_tasks',
'add_user_task',
'cancel_user_task',
];
static bool _hasMcpToken(String? v) => (v ?? '').trim().isNotEmpty;
static String _safeUserId(String? raw) { static String _safeUserId(String? raw) {
final v = (raw ?? '').trim(); final v = (raw ?? '').trim();
if (v.isEmpty) return 'eaimar-guest'; if (v.isEmpty) return 'eaimar-guest';

2
apps/client/lib/data/services/bes_ai_session_service.dart

@ -9,6 +9,7 @@ import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info; import '../models/user_Info.dart' as user_info;
import 'assistant_directive_service.dart'; import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart'; import 'bailian_multimodal_service.dart';
import 'mcp_token_service.dart';
import '../utils/cue_tone.dart'; import '../utils/cue_tone.dart';
import 'bes_bluetooth_service.dart'; import 'bes_bluetooth_service.dart';
import 'emai_chat_store.dart'; import 'emai_chat_store.dart';
@ -220,6 +221,7 @@ class BesAiSessionService extends GetxService {
downstreamRate: _earphoneRate, downstreamRate: _earphoneRate,
userId: _currentUserId(), userId: _currentUserId(),
source: 'earphone', source: 'earphone',
mcpToken: await McpTokenService.get(),
); );
final elapsed = DateTime.now().difference(ackAt); final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) { if (elapsed > _ackDeadline) {

61
apps/client/lib/data/services/mcp_token_service.dart

@ -0,0 +1,61 @@
import '../../core/utils/logger.dart';
import 'network/api.dart';
/// EMAI 会话用的 MCP 令牌。
///
/// 百炼调我们的 MCP 工具(查会议纪要、查待办…)时,需要知道「这是哪个用户」。
/// 整条链路上唯一能按用户变的通道是 Start 指令的
/// `biz_params.user_defined_params` —— 客户端连百炼只带百炼的 API Key,
/// 百炼调 MCP 的 Authorization 头又是控制台里配死的、所有用户共用。
///
/// ⚠️ 这里拿的是服务端 `user_getmcptoken` 签发的**短时效、仅限 MCP 用途**的令牌,
/// 不是登录 JWT —— 这条路要经过百炼(第三方),登录 JWT 等价于账号全部权限。
class McpTokenService {
const McpTokenService._();
static const String _tag = 'McpToken';
static String? _token;
static int _expiresAt = 0;
/// 提前多久就当它过期。会话可能持续很久,卡着过期时刻去用必然中途失效。
static const int _skewSeconds = 300;
static bool get _fresh {
if (_token == null || _token!.isEmpty) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return _expiresAt - _skewSeconds > now;
}
/// 取令牌。缓存未过期就直接用,否则重新申请。
///
/// 拿不到时返回 null 而不是抛异常 —— MCP 工具用不了只是少几项能力,
/// 不该因此让整个 EMAI 会话建立不起来。
static Future<String?> get() async {
if (_fresh) return _token;
try {
final resp = await Api.getMcpToken();
// ⚠️ AuthInterceptor 在业务码 != 0 时是把 data 置 null 后 resolve(不抛),
// 所以这里必须判 null;只 catch 是接不住业务错误的。
if (resp == null) {
Logger.w(_tag, '服务端未签发 MCP 令牌(多半是没配 McpTokenKey)');
return null;
}
final token = (resp['token'] ?? '').toString();
if (token.isEmpty) return null;
_token = token;
_expiresAt = (resp['expiresat'] as num?)?.toInt() ?? 0;
Logger.i(_tag, 'MCP 令牌已更新,过期时刻=$_expiresAt');
return _token;
} catch (e) {
Logger.w(_tag, '申请 MCP 令牌失败(MCP 工具本次不可用): $e');
return null;
}
}
/// 换账号必须清掉,否则新账号会拿着上一个人的令牌去调 MCP。
static void reset() {
_token = null;
_expiresAt = 0;
}
}

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

@ -3,6 +3,7 @@ import 'dart:convert';
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 '../../../core/utils/logger.dart';
import '../db/sqflite_api.dart'; import '../db/sqflite_api.dart';
import '../network/api.dart'; import '../network/api.dart';
import '../../models/user_Info.dart'; import '../../models/user_Info.dart';
@ -160,11 +161,40 @@ class MeetingTaskService extends GetxService {
void _executeTask() async { void _executeTask() async {
if (_isTask) return; if (_isTask) return;
_isTask = true; _isTask = true;
while (_taskList.isNotEmpty) { try {
await Future.delayed(const Duration(seconds: 5)); while (_taskList.isNotEmpty) {
await _queryTask(); await Future.delayed(const Duration(seconds: 5));
try {
await _queryTask();
} catch (e, st) {
// ⚠️ 单轮失败绝不能终止整个轮询。
//
// 原来这里没有 try/catch:_queryTask 里任何一次抛异常都会把 while 掀掉,
// 而 `_isTask = false` 写在循环之后、根本执行不到 —— 于是 _isTask 永远
// 停在 true,开头那句 `if (_isTask) return;` 会挡住之后所有 startTask(),
// **整个进程生命周期内轮询再也起不来,且一声不吭**。
// 2026-09-06 真机就是这么中的:服务端 15 秒就跑完了,App 一直显示「转写中」。
Logger.w('MeetingTask', '本轮任务查询失败,跳过继续: $e\n$st');
}
}
} finally {
// 放 finally:无论正常退出还是异常穿出,标志位都必须复位
_isTask = false;
}
}
/// 把服务端的 translate 解析成 speaker 列表;数据没齐或格式不对返回 null,
/// 由调用方决定「本轮跳过、下轮再来」,而不是抛异常掀掉轮询。
List<Map<String, Object?>>? _decodeSpeakers(dynamic raw) {
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! List || decoded.isEmpty) return null;
return decoded.map((e) => Map<String, Object?>.from(e as Map)).toList();
} catch (e) {
Logger.w('MeetingTask', 'translate 解析失败,本轮跳过: $e');
return null;
} }
_isTask = false;
} }
Future<void> removeTask(int id) async { Future<void> removeTask(int id) async {
@ -189,6 +219,13 @@ class MeetingTaskService extends GetxService {
var response = await Api.getMultitermRecord({ var response = await Api.getMultitermRecord({
'ids': ids, 'ids': ids,
}); });
// ⚠️ 业务错误时 AuthInterceptor 是把 data 置 null 后 **resolve**(不抛),
// 所以这里必须判空。直接 response['records'] 会在 null 上抛
// NoSuchMethodError,配合上面那条就是轮询永久死亡。
if (response == null) {
Logger.w('MeetingTask', '查询任务返回空(业务错误),本轮跳过');
return;
}
List queryList = response['records'] ?? []; List queryList = response['records'] ?? [];
for (var element in queryList) { for (var element in queryList) {
int id = element['id']; int id = element['id'];
@ -202,9 +239,19 @@ class MeetingTaskService extends GetxService {
if (oldTasktype != newTasktype) { if (oldTasktype != newTasktype) {
if (oldTasktype < 3 && newTasktype >= 3) { if (oldTasktype < 3 && newTasktype >= 3) {
// 转写完成 // 转写完成
List<dynamic> decodeList = jsonDecode(element['translate']); //
List<Map<String, Object?>> speakerList = // ⚠️ translate 可能还没落库。服务端 PollTranscribe 是先把 state 推到
decodeList.map((e) => Map<String, Object?>.from(e)).toList(); // AwaitSummarizing 并保存、再 `go finishTranscribe(...)` 在后台写
// translate 的,所以「状态说转写完了、数据还没到」这一拍是必然存在的
// (服务端已加 hideHalfDoneTranscribe 挡在响应侧,这里是第二道保险,
// 老服务端 / 其它入口同样能兜住)。
// 直接 jsonDecode 空串会抛 FormatException,把整个轮询掀掉。
final speakerList = _decodeSpeakers(element['translate']);
if (speakerList == null) {
// 数据还没齐:**本轮不推进 tasktype**,下一轮拿到完整数据再处理。
// 推进了的话 oldTasktype 就跨过 3 了,这段转写文本永远补不回来。
continue;
}
await SqfliteApi.deleteMeetingSpeaker(id); await SqfliteApi.deleteMeetingSpeaker(id);
await SqfliteApi.insertMeetingSpeaker(speakerList); await SqfliteApi.insertMeetingSpeaker(speakerList);
await SqfliteApi.editMeeting( await SqfliteApi.editMeeting(
@ -217,7 +264,11 @@ class MeetingTaskService extends GetxService {
tasktype.value = newTasktype; tasktype.value = newTasktype;
} }
} }
if (oldTasktype < 5 && newTasktype >= 5) { // ⚠️ 判据用「正好是完成/已阅」而不是 >=5:失败态 SummarizFail=10001、
// TranscribeFail=10002 数值上也 >= 5,会被当成「总结完成」,
// 然后拿响应里的空 summary/overview 覆盖本地已有内容 —— 与这次
// 丢总结是同一类数据损失。
if (oldTasktype < 5 && (newTasktype == 5 || newTasktype == 6)) {
// 总结完成 // 总结完成
await SqfliteApi.editMeeting(id, { await SqfliteApi.editMeeting(id, {
'tasktype': newTasktype, 'tasktype': newTasktype,

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

@ -388,6 +388,15 @@ class Api {
); );
} }
//申请 MCP 会话令牌(短时效,放进百炼会话的 biz_params 透传给 MCP 工具)
static getMcpToken() {
return DioManager().request(
NWMethod.post,
'/api/home/user_getmcptoken',
params: {},
);
}
//添加模版数据 //添加模版数据
static addEchomeetTemplates([params]) { static addEchomeetTemplates([params]) {
return DioManager().request( return DioManager().request(

67
apps/client/lib/data/utils/emai_audio_session.dart

@ -0,0 +1,67 @@
import 'dart:io' show Platform;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/utils/logger.dart';
/// EMAI 语音链路(麦克风上行 + TTS 下行)共用的一份 AVAudioSession 配置。
///
/// 为什么要抽出来:麦克风和播放器是两个模块,各自配一次会话就会互相覆盖。
/// 连续对话下两者同时在跑,谁最后写谁生效,表现是「录着录着没声了」或
/// 「播着播着麦死了」。统一到这里,两边写的是同一份配置,重复调用无害。
///
/// ⚠️ **开麦之前必须先调一次**。真机实测:会话停在 `soloAmbient` 且未激活时,
/// `record` 的 `startStream` 会**成功返回**、AVAudioEngine 也起得来,但输入
/// tap 一个 buffer 都不回调 —— 上行一个字节都发不出去,服务端 30 秒后回
/// `ClientAudioTimeout`,而客户端这边没有任何报错,只看到「麦克风采集启动」
/// 那行日志,非常难查。
class EmaiAudioSession {
const EmaiAudioSession._();
static const String _tag = 'EmaiAudioSession';
static bool _configured = false;
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
/// 配置(只做一次)并激活会话。失败只记日志,不抛——
/// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。
static Future<void> ensure() async {
if (!_apply) return;
try {
final session = await AudioSession.instance;
if (!_configured) {
_configured = true;
await session.configure(AudioSessionConfiguration(
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth,
// ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开
// voice-processing,系统随之把 mode 改成 voiceChat 并一直持有
// (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。
// defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。
avAudioSessionMode: Platform.isIOS
? AVAudioSessionMode.voiceChat
: AVAudioSessionMode.defaultMode,
// ⚠️ Android 的 usage 千万别用 voiceCommunication:华为
// (EMUI/HarmonyOS) 对通话流卡得很严,不在 MODE_IN_COMMUNICATION 下建的
// VOICE_COMMUNICATION 音轨会被直接静音,logcat 只留一行
// `AudioTrack: set playback slient`,音轨建得出来、数据也在写,就是没声音。
// ALN-AL80 上实测复现过,用 media 就没这问题。
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.media,
),
// 助手说话时把背景音乐暂停,而不是压低——压低了在嘈杂环境还是听不清
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
));
}
await session.setActive(true);
} catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
}
}
}

64
apps/client/lib/data/utils/mic_pcm_streamer.dart

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:record/record.dart'; import 'package:record/record.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'emai_audio_session.dart';
import 'pcm_stream_player.dart'; import 'pcm_stream_player.dart';
/// 手机麦克风的 PCM 流采集,专供百炼上行使用。 /// 手机麦克风的 PCM 流采集,专供百炼上行使用。
@ -40,9 +41,25 @@ class MicPcmStreamer {
Future<bool> hasPermission() => _recorder.hasPermission(); Future<bool> hasPermission() => _recorder.hasPermission();
Future<bool> start() async { /// 正在进行中的 [start]。并发调用共用同一个 future。
if (_running) return true; ///
/// ⚠️ 这个去重不能省。`_running` 是在 `await startStream()` **返回之后**才置位的,
/// 两个调用方几乎同时进来时都会看到 `_running == false`、都往下走,于是同一个
/// `AudioRecorder` 被 startStream 两遍——第二次把第一次那条流顶掉,上行当场断供。
/// 真机表现是「麦克风采集启动」打两行、上行只出几包就没了,服务端 30 秒后回
/// `ClientAudioTimeout`,客户端不报任何错。
Future<bool>? _startFuture;
Future<bool> start() {
if (_running) return Future.value(true);
return _startFuture ??= _start().whenComplete(() => _startFuture = null);
}
Future<bool> _start() async {
try { try {
// ⚠️ 必须先把会话配好并激活再开麦:会话停在 soloAmbient 且未激活时,
// startStream 会成功返回、引擎也起得来,但输入 tap 一个 buffer 都不回调。
await EmaiAudioSession.ensure();
if (!await _recorder.hasPermission()) { if (!await _recorder.hasPermission()) {
Logger.w(_tag, '没有麦克风权限'); Logger.w(_tag, '没有麦克风权限');
return false; return false;
@ -56,10 +73,24 @@ class MicPcmStreamer {
// duplex 模式下会直接把服务端 VAD 打乱 // duplex 模式下会直接把服务端 VAD 打乱
echoCancel: true, echoCancel: true,
noiseSuppress: true, noiseSuppress: true,
// ⚠️ iOS 上不让 record 碰 AVAudioSession(会话由 EmaiAudioSession 统一配)。
// 它自己那套是 `setCategory(.playAndRecord, options:)` → `setActive(true)`,
// 而 setCategory 会把 mode 重置成 default;紧接着 echoCancel 触发的
// `setVoiceProcessingEnabled(true)` 又把 mode 拽回 voiceChat。这一来一回
// 是一次 AVAudioEngine 配置变更,而 record 没有监听
// `AVAudioEngineConfigurationChange` —— 引擎「还在运行」,输入 tap 却
// 从此不再回调。真机表现:上行送了几十包(两三秒)后彻底停住,
// 没有任何报错,服务端随后回 ClientAudioTimeout。
// 我们先把会话配成 playAndRecord + voiceChat 并激活,voice-processing
// 打开时 mode 已经就位,就不会再触发那次变更。
// (Android 侧忽略 iosConfig,这里不影响。)
// ignore: deprecated_member_use
iosConfig: const IosRecordConfig(manageAudioSession: false),
), ),
); );
_running = true; _running = true;
_buffer.clear(); _buffer.clear();
_startWatchdog();
_sub = stream.listen( _sub = stream.listen(
_onChunk, _onChunk,
onError: (e) => Logger.e(_tag, '录音流错误: $e'), onError: (e) => Logger.e(_tag, '录音流错误: $e'),
@ -73,8 +104,35 @@ class MicPcmStreamer {
} }
} }
/// 最近一次收到原始音频块的时刻。用来发现「流无声死亡」——
/// 麦克风即使没人说话也会持续出包,所以静默超过 [_silenceLimit] 一定是流断了。
DateTime? _lastChunkAt;
Timer? _watchdog;
static const Duration _silenceLimit = Duration(seconds: 3);
void _startWatchdog() {
_watchdog?.cancel();
_lastChunkAt = DateTime.now();
_watchdog = Timer.periodic(const Duration(seconds: 1), (_) {
if (!_running) return;
final last = _lastChunkAt;
if (last == null) return;
if (DateTime.now().difference(last) < _silenceLimit) return;
Logger.e(_tag, '录音流已静默 ${_silenceLimit.inSeconds}s,判定断流,尝试重启');
_lastChunkAt = DateTime.now();
unawaited(_restart());
});
}
Future<void> _restart() async {
await stop();
await start();
}
void _onChunk(Uint8List chunk) { void _onChunk(Uint8List chunk) {
if (chunk.isEmpty) return; if (chunk.isEmpty) return;
_lastChunkAt = DateTime.now();
if (!_levelCtl.isClosed) _levelCtl.add(PcmStreamPlayer.rmsLevel(chunk)); if (!_levelCtl.isClosed) _levelCtl.add(PcmStreamPlayer.rmsLevel(chunk));
_buffer.add(chunk); _buffer.add(chunk);
@ -101,6 +159,8 @@ class MicPcmStreamer {
Future<void> stop() async { Future<void> stop() async {
if (!_running) return; if (!_running) return;
_running = false; _running = false;
_watchdog?.cancel();
_watchdog = null;
await _sub?.cancel(); await _sub?.cancel();
_sub = null; _sub = null;
try { try {

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

@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'emai_audio_session.dart';
/// 流式 PCM 播放器:一边收字节一边出声,用于百炼下行的 TTS 音频。 /// 流式 PCM 播放器:一边收字节一边出声,用于百炼下行的 TTS 音频。
/// ///
@ -35,11 +38,17 @@ class PcmStreamPlayer {
final int sampleRate; final int sampleRate;
final int channels; final int channels;
/// iOS 专用的原生 PCM 播放通道,见 [_iosNative] 的说明
static const MethodChannel _iosChannel =
MethodChannel('azure_speech/pcm_player');
/// iOS 上不能用 just_audio 播这条流(原因见 [_iosNative])
bool get _iosNative => !kIsWeb && Platform.isIOS;
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
_LivePcmSource? _source; _LivePcmSource? _source;
StreamSubscription<PlayerState>? _stateSub; StreamSubscription<PlayerState>? _stateSub;
StreamSubscription<PlaybackEvent>? _errSub; StreamSubscription<PlaybackEvent>? _errSub;
bool _sessionConfigured = false;
/// 当前这轮是否已经建好 source /// 当前这轮是否已经建好 source
bool _active = false; bool _active = false;
@ -51,7 +60,7 @@ class PcmStreamPlayer {
/// begin 期间到达的音频,等 source 建好再补进去,避免开头被吞 /// begin 期间到达的音频,等 source 建好再补进去,避免开头被吞
final _pending = BytesBuilder(copy: false); final _pending = BytesBuilder(copy: false);
bool get isPlaying => _player.playing; bool get isPlaying => _iosNative ? _active : _player.playing;
final _levelCtl = StreamController<double>.broadcast(); final _levelCtl = StreamController<double>.broadcast();
@ -76,52 +85,40 @@ class PcmStreamPlayer {
/// ///
/// 显式 setActive(true) 是把会话按本方的配置重新激活一次, /// 显式 setActive(true) 是把会话按本方的配置重新激活一次,
/// 相当于「录音刚放开,播放把它拿回来」。 /// 相当于「录音刚放开,播放把它拿回来」。
Future<void> _ensureSession() async { Future<void> _ensureSession() => EmaiAudioSession.ensure();
try {
final session = await AudioSession.instance;
if (_sessionConfigured) {
await session.setActive(true);
return;
}
_sessionConfigured = true;
// ⚠️ usage 千万别用 voiceCommunication。
// 华为(EMUI/HarmonyOS)的音频策略对通话流卡得很严:不在
// MODE_IN_COMMUNICATION 下建的 VOICE_COMMUNICATION 音轨会被直接静音,
// logcat 里表现为 `AudioTrack: set playback slient` —— 音轨建得出来、
// 数据也在写,就是没声音,非常难查。ALN-AL80 上实测复现。
//
// 用 media 就没这问题。手机这条路径上录音和播放本来也不并发:
// 按住说话时 startTalk() 会先把播放停掉,所以不需要占通话流来共存。
// (耳机那条路径的下行走 SPP/G.722,压根不经过 AudioTrack。)
await session.configure(const AudioSessionConfiguration(
// iOS 仍要 playAndRecord:按住说话要录音,纯 playback 会把录音会话踢掉。
// 但 mode 从 voiceChat 换成 default —— voiceChat 会上通话处理链,
// 在部分机型上把输出拽到听筒。
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.defaultMode,
androidAudioAttributes: AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.media,
),
// 助手说话时把背景音乐暂停掉,而不是压低——压低了在嘈杂环境里还是听不清
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
));
await session.setActive(true);
} catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试播放): $e');
}
}
/// 开一轮新的播放。并发调用只有第一次生效。 /// 正在进行中的 [begin]。[end] 要等它落地才能判断这一轮有没有音频——
Future<void> begin() async { /// 回复很短时(音频首包和 RespondingEnded 几乎同时到)`_active` 还是 false,
if (_starting) return; /// 不等就会把整轮回复当成「没有音频」直接报完成,声音全丢。
Future<void>? _startFuture;
/// 开一轮新的播放。并发调用共用同一个 future。
Future<void> begin() {
if (_starting) return _startFuture ?? Future<void>.value();
_starting = true; _starting = true;
return _startFuture = _begin();
}
Future<void> _begin() async {
try { try {
if (_active) await _teardown(); if (_active) await _teardown();
await _ensureSession(); await _ensureSession();
if (_iosNative) {
_iosChannel.setMethodCallHandler(_onIosCall);
final ok = await _iosChannel
.invokeMethod<bool>('start', {'sampleRate': sampleRate});
if (ok != true) {
Logger.e(_tag, 'iOS 原生播放器启动失败');
return;
}
_active = true;
if (_pending.length > 0) {
await _iosChannel.invokeMethod('feed', {'data': _pending.takeBytes()});
}
return;
}
final src = _LivePcmSource(_wavHeader()); final src = _LivePcmSource(_wavHeader());
_source = src; _source = src;
_active = true; _active = true;
@ -147,10 +144,6 @@ class PcmStreamPlayer {
Logger.e(_tag, '启动播放失败: $e'); Logger.e(_tag, '启动播放失败: $e');
_active = false; _active = false;
_pending.clear(); _pending.clear();
// 起播失败多半是会话被别人(录音的 voice-processing)占着。
// 复位标志,下一轮重新走完整的 configure + setActive,
// 而不是只补一次 setActive —— 只激活救不回被改过 category/mode 的会话。
_sessionConfigured = false;
} finally { } finally {
_starting = false; _starting = false;
} }
@ -161,6 +154,16 @@ class PcmStreamPlayer {
if (pcm.isEmpty) return; if (pcm.isEmpty) return;
_emitLevel(pcm); _emitLevel(pcm);
if (_iosNative) {
if (_active) {
_iosChannel.invokeMethod('feed', {'data': pcm});
} else {
_pending.add(pcm);
if (!_starting) unawaited(begin());
}
return;
}
final src = _source; final src = _source;
if (_active && src != null) { if (_active && src != null) {
src.add(pcm); src.add(pcm);
@ -174,6 +177,21 @@ class PcmStreamPlayer {
/// 本轮下行结束:关掉输入流,剩余缓冲播完后会触发 [onCompleted]。 /// 本轮下行结束:关掉输入流,剩余缓冲播完后会触发 [onCompleted]。
/// 这一轮一个音频字节都没有时直接报完成——不然调用方会一直等一个不会来的回调。 /// 这一轮一个音频字节都没有时直接报完成——不然调用方会一直等一个不会来的回调。
Future<void> end() async { Future<void> end() async {
if (_iosNative) {
// 起播还在路上就先等它:不等的话短回复会被判成「没有音频」
if (_starting) {
try {
await _startFuture;
} catch (_) {}
}
if (!_active) {
// 这一轮一个字节都没来过:原生侧没东西可播,直接报完成
_emitCompleted();
return;
}
await _iosChannel.invokeMethod('end');
return;
}
final src = _source; final src = _source;
if (src == null || !src.hasData) { if (src == null || !src.hasData) {
await _teardown(); await _teardown();
@ -183,6 +201,12 @@ class PcmStreamPlayer {
await src.close(); await src.close();
} }
/// 原生播放器回调:本轮排队的 PCM 全部播完
Future<dynamic> _onIosCall(MethodCall call) async {
if (call.method == 'onCompleted') _finish();
return null;
}
/// 打断:立刻断流停声,不报完成 /// 打断:立刻断流停声,不报完成
Future<void> stopNow() async { Future<void> stopNow() async {
await _teardown(); await _teardown();
@ -193,12 +217,24 @@ class PcmStreamPlayer {
await _teardown(); await _teardown();
await _levelCtl.close(); await _levelCtl.close();
await _completedCtl.close(); await _completedCtl.close();
if (_iosNative) {
try {
await _iosChannel.invokeMethod('dispose');
} catch (_) {}
return;
}
await _player.dispose(); await _player.dispose();
} }
Future<void> _teardown() async { Future<void> _teardown() async {
_active = false; _active = false;
_pending.clear(); _pending.clear();
if (_iosNative) {
try {
await _iosChannel.invokeMethod('stop');
} catch (_) {}
return;
}
await _stateSub?.cancel(); await _stateSub?.cancel();
_stateSub = null; _stateSub = null;
await _errSub?.cancel(); await _errSub?.cancel();

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

@ -13,6 +13,7 @@ import '../../../data/models/emai_message.dart';
import '../../../data/services/bes_ai_session_service.dart'; import '../../../data/services/bes_ai_session_service.dart';
import '../../../data/services/emai_chat_store.dart'; import '../../../data/services/emai_chat_store.dart';
import '../../../data/utils/mic_pcm_streamer.dart'; import '../../../data/utils/mic_pcm_streamer.dart';
import '../../../data/services/mcp_token_service.dart';
import '../../../data/utils/pcm_stream_player.dart'; import '../../../data/utils/pcm_stream_player.dart';
/// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没", /// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没",
@ -67,6 +68,28 @@ class EmaiController extends GetxController
/// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频 /// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频
final level = 0.0.obs; final level = 0.0.obs;
/// 连续(长期)对话开关。
///
/// 关:`push2talk` —— 按住说话,客户端用 beginSpeech/endSpeech 自己划段。
/// 开:`duplex` —— 麦克风一直开着,切句交给服务端 VAD,用户可以一直说下去。
///
/// ⚠️ 切换必须重连会话:模式是 `start()` 时定死的会话级参数,中途改不了。
///
/// ⚠️ duplex 在手机上能成立的前提是 AEC —— 外放的播报会被自己的麦收回去,
/// 服务端 VAD 会当成用户插话,每次 AI 一开口就被自己打断。
/// [MicPcmStreamer] 已经开了 echoCancel/noiseSuppress,所以不需要像耳机那条
/// 链路那样做「播报期间门控上行」的半双工妥协(见 BesAiSessionService 的注释:
/// 耳机没把下行接进 AEC 参考路径,只能门控)。
///
/// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮
/// 显式开始,再点一下结束,可以反复开关;退出页面时 [onClose] 一并收掉。
///
/// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在
/// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次
/// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期
/// (见 BesAiSessionService)。
final isContinuous = false.obs;
/// 文字输入模式 / 语音输入模式 /// 文字输入模式 / 语音输入模式
final isTextMode = false.obs; final isTextMode = false.obs;
final hasInputText = false.obs; final hasInputText = false.obs;
@ -98,6 +121,15 @@ class EmaiController extends GetxController
Timer? _handshakeTimer; Timer? _handshakeTimer;
static const Duration _handshakeTimeout = Duration(seconds: 15); static const Duration _handshakeTimeout = Duration(seconds: 15);
/// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。
static const Duration _continuousIdleTimeout = Duration(seconds: 10);
/// 空闲计时器。只在连续对话期间跑。
///
/// ⚠️ 计时的依据必须是**对话事件**,不能是上行音频包——连续模式下麦克风
/// 即使没人说话也在稳定出包,拿它续命等于这个超时永远不会触发。
Timer? _idleTimer;
/// 连上之后被动断线时自动重连一次;再断就交给用户手动点 /// 连上之后被动断线时自动重连一次;再断就交给用户手动点
bool _autoRetried = false; bool _autoRetried = false;
@ -139,8 +171,10 @@ class EmaiController extends GetxController
_subs.add(_svc.events.listen(_onEvent)); _subs.add(_svc.events.listen(_onEvent));
_subs.add(_svc.audioOut.listen(_player.feed)); _subs.add(_svc.audioOut.listen(_player.feed));
_subs.add(_mic.packets.listen(_svc.pushAudio)); _subs.add(_mic.packets.listen(_svc.pushAudio));
// ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false,
// 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。
_subs.add(_mic.level.listen((v) { _subs.add(_mic.level.listen((v) {
if (isTalking.value) level.value = v; if (isTalking.value || isContinuous.value) level.value = v;
})); }));
_subs.add(_player.level.listen((v) { _subs.add(_player.level.listen((v) {
if (!isTalking.value) level.value = v; if (!isTalking.value) level.value = v;
@ -162,6 +196,7 @@ class EmaiController extends GetxController
BesAiSessionService.to.detachPhonePage(this); BesAiSessionService.to.detachPhonePage(this);
} }
_handshakeTimer?.cancel(); _handshakeTimer?.cancel();
_cancelIdle();
for (final s in _subs) { for (final s in _subs) {
s.cancel(); s.cancel();
} }
@ -182,6 +217,25 @@ class EmaiController extends GetxController
if (state == AppLifecycleState.paused || if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) { state == AppLifecycleState.inactive) {
if (isTalking.value) _abortTalk(); if (isTalking.value) _abortTalk();
// 连续模式下麦克风常开,切后台必须松开:iOS 后台持麦既费电,也可能被系统
// 直接掐掉会话。
//
// ⚠️ 这里只停麦、**不动 isContinuous**,也不重连。连续是手机端的默认模式,
// 切一次后台就把它降级成「按住说话」的话,用户回来会发现说话没反应,
// 还得自己再点一下——而且重连要一秒多,纯属白费。
if (_micRunning) {
_micRunning = false;
_mic.stop();
level.value = 0;
}
// 后台不计空闲:回来时再重新开始数,否则挂后台十秒就被判定结束了
_cancelIdle();
} else if (state == AppLifecycleState.resumed) {
// 回前台把麦接回来。会话还在(duplex 会话本身没断),只是麦停了。
if (isContinuous.value) {
unawaited(_ensureContinuousMic());
_touchIdle();
}
} }
} }
@ -204,9 +258,13 @@ class EmaiController extends GetxController
} }
linkState.value = EmaiLinkState.connecting; linkState.value = EmaiLinkState.connecting;
// MCP 令牌要在 Start 之前拿到:biz_params 是握手时定死的,
// 会话建起来之后再补就没地方放了。
final mcpToken = await McpTokenService.get();
final ok = await _svc.start( final ok = await _svc.start(
mode: 'push2talk', mode: isContinuous.value ? 'duplex' : 'push2talk',
userId: _currentUserId(), userId: _currentUserId(),
mcpToken: mcpToken,
); );
if (!ok) { if (!ok) {
linkState.value = EmaiLinkState.failed; linkState.value = EmaiLinkState.failed;
@ -285,8 +343,136 @@ class EmaiController extends GetxController
// ---------------- 语音交互(push2talk) ---------------- // ---------------- 语音交互(push2talk) ----------------
/// 切换连续对话。
///
/// 走的是「停会话 → 换模式重连 → 开麦」这条路,因为模式是会话级参数。
/// 重连期间界面会短暂回到「正在连接」,这是预期的。
Future<void> toggleContinuous() async {
if (isContinuous.value) {
await _exitContinuous();
} else {
await _enterContinuous();
}
}
Future<void> _enterContinuous() async {
if (!canTalk) return;
// 手上还有一段按住说话没结束的,按取消收掉——它属于旧模式
await _abortTalk();
isContinuous.value = true;
_buzz(HapticsType.light);
await reconnect();
if (!isContinuous.value) return; // 重连期间用户又关掉了
// ⚠️ 必须等 Started 事件,不能在 reconnect() 一返回就判断状态。
// reconnect() 只负责把 Start 指令发出去,`ready` 要等服务端回 Started 才置位,
// 那是一次网络往返。原来这里直接判 `!= ready`,几乎每次都命中,于是刚打开的
// 连续对话被当场关掉——按钮闪一下就退回「点击开始连续对话」,而 Started 稍后
// 到达时又会把麦克风开起来,留下一个界面上看不见的常开麦克风。
if (!await _waitLinkReady(_handshakeTimeout)) {
// 没连上就别开麦,否则麦克风白开着还没人收
isContinuous.value = false;
// Started 可能已经抢先把麦开起来了,一并收掉,别留孤儿
if (_micRunning) {
_micRunning = false;
await _mic.stop();
level.value = 0;
}
return;
}
await _ensureContinuousMic();
_touchIdle();
}
/// 有对话往来就把空闲计时重新拨到头
void _touchIdle() {
_idleTimer?.cancel();
if (!isContinuous.value) return;
_idleTimer = Timer(_continuousIdleTimeout, _onIdleTimeout);
}
void _cancelIdle() {
_idleTimer?.cancel();
_idleTimer = null;
}
void _onIdleTimeout() {
if (!isContinuous.value) return;
// 助手还在想/还在说(或者本地还在播它的音频)不算空闲——
// 一段长回复中间可能十几秒没有任何新事件,这时结束会把话掐断。
if (isResponding || _player.isPlaying) {
_touchIdle();
return;
}
Logger.i(_tag, '连续对话空闲 ${_continuousIdleTimeout.inSeconds}s,自动结束');
unawaited(_exitContinuous());
}
/// 等握手落地。ready 返回 true;失败/超时返回 false。
Future<bool> _waitLinkReady(Duration timeout) async {
if (linkState.value == EmaiLinkState.ready) return true;
final done = Completer<bool>();
final worker = ever<EmaiLinkState>(linkState, (s) {
if (done.isCompleted) return;
if (s == EmaiLinkState.ready) {
done.complete(true);
} else if (s == EmaiLinkState.failed ||
s == EmaiLinkState.unconfigured ||
s == EmaiLinkState.onEarphone) {
done.complete(false);
}
});
try {
return await done.future.timeout(timeout, onTimeout: () => false);
} finally {
worker.dispose();
}
}
/// 连续模式下把麦克风拉起来(幂等,重复调用无副作用)。
///
/// ⚠️ duplex 下**不发** beginSpeech/endSpeech:切句由服务端 VAD 决定,
/// 客户端再去划段会和它打架(见 BailianMultimodalService.start 的说明)。
Future<void> _ensureContinuousMic() async {
if (!isContinuous.value) return;
if (linkState.value != EmaiLinkState.ready) return;
if (_micRunning) return;
if (!await _mic.hasPermission()) {
isContinuous.value = false;
_pushError('emaiMicPermission'.tr);
return;
}
if (!isContinuous.value) return; // 等权限期间用户关掉了
// ⚠️ 必须在 await 之前同步置位。`Started` 事件和 `_enterContinuous` 会几乎
// 同时调进来,都在这里 await 的话两边都会看到 false、都去开麦,
// 同一个 recorder 被 startStream 两遍,第二次顶掉第一次那条流。
// (MicPcmStreamer 内部也做了一层去重,两层都留着——这里少一次无谓的往返。)
_micRunning = true;
if (!await _mic.start()) {
_micRunning = false;
isContinuous.value = false;
_pushError('emaiMicPermission'.tr);
}
}
/// 连续模式下麦克风是否已经开着。[MicPcmStreamer] 自己不暴露状态,
/// 重复 start 会重开一路采集,必须在这里记账。
bool _micRunning = false;
Future<void> _exitContinuous() async {
isContinuous.value = false;
_cancelIdle();
_buzz(HapticsType.light);
await _mic.stop();
_micRunning = false;
level.value = 0;
await reconnect();
}
Future<void> startTalk() async { Future<void> startTalk() async {
if (!canTalk || isTalking.value) return; if (!canTalk || isTalking.value) return;
// 连续模式下麦克风本来就开着,再走按住说话那套会重复开麦
if (isContinuous.value) return;
// isTalking 必须在任何 await 之前同步置位。 // isTalking 必须在任何 await 之前同步置位。
// 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后 // 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后
@ -294,6 +480,7 @@ class EmaiController extends GetxController
final gen = ++_talkGen; final gen = ++_talkGen;
isTalking.value = true; isTalking.value = true;
isCancelPending.value = false; isCancelPending.value = false;
_talkPressAt = DateTime.now();
_buzz(HapticsType.light); _buzz(HapticsType.light);
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 // 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权
@ -340,11 +527,29 @@ class EmaiController extends GetxController
_buzz(HapticsType.warning); _buzz(HapticsType.warning);
} }
Future<void> endTalk() { /// 按下到松开短于这个时长,算「点一下」而不是「按住说话」。
if (!isTalking.value) return Future.value(); ///
/// 250ms 是取舍出来的:再短容易把用户想按住说话时手抖的一下误判成点击,
/// 再长会让真正的点击有明显延迟。反正短于 250ms 的语音本来也没有内容。
static const Duration _tapThreshold = Duration(milliseconds: 250);
DateTime? _talkPressAt;
Future<void> endTalk() async {
if (!isTalking.value) return;
final pressed = _talkPressAt;
final isTap = !isCancelPending.value &&
pressed != null &&
DateTime.now().difference(pressed) < _tapThreshold;
if (isTap) {
// 点一下 = 切换连续对话。刚才 onPointerDown 已经开了一小段语音,
// 按取消收掉——这半秒不到的音频没有任何内容,发上去只会多一轮空回复。
await _finishTalk(cancel: true);
await toggleContinuous();
return;
}
final cancelled = isCancelPending.value; final cancelled = isCancelPending.value;
_buzz(cancelled ? HapticsType.warning : HapticsType.light); _buzz(cancelled ? HapticsType.warning : HapticsType.light);
return _finishTalk(cancel: cancelled); await _finishTalk(cancel: cancelled);
} }
/// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理 /// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理
@ -363,6 +568,7 @@ class EmaiController extends GetxController
// 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令, // 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令,
// 保证最后半个字也进了服务端 // 保证最后半个字也进了服务端
await _mic.stop(); await _mic.stop();
_micRunning = false;
if (_speechOpen) { if (_speechOpen) {
_speechOpen = false; _speechOpen = false;
@ -424,6 +630,10 @@ class EmaiController extends GetxController
// ---------------- 事件处理 ---------------- // ---------------- 事件处理 ----------------
void _onEvent(BailianEvent e) { void _onEvent(BailianEvent e) {
// 任何一条对话事件都算「还在聊」,把空闲计时拨回去。
// 到这里的事件都是真实往来(SpeechXxx / RespondingXxx / 状态变化),
// 心跳在协议层就被吞掉了,不会误续命。
_touchIdle();
switch (e.type) { switch (e.type) {
case BailianEventType.started: case BailianEventType.started:
_handshakeTimer?.cancel(); _handshakeTimer?.cancel();
@ -432,6 +642,9 @@ class EmaiController extends GetxController
// 新会话,上一条连接里没收尾的语音段跟着作废 // 新会话,上一条连接里没收尾的语音段跟着作废
_speechOpen = false; _speechOpen = false;
linkState.value = EmaiLinkState.ready; linkState.value = EmaiLinkState.ready;
// duplex 会话就绪 = 该开麦了。服务端此刻开始做 VAD,麦克风不开的话
// 它永远收不到音频,界面上就是「已就绪但说什么都没反应」。
if (isContinuous.value) unawaited(_ensureContinuousMic());
break; break;
case BailianEventType.speechStarted: case BailianEventType.speechStarted:

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

@ -379,9 +379,47 @@ class EmaiView extends GetView<EmaiController> {
); );
} }
/// 按住说话。手指上滑离开按钮区域 = 取消这段语音。 /// 语音输入按钮。两种用法共用这一个控件:
///
/// - **点一下** → 进入/退出「连续对话」(服务端 VAD 切句,麦克风一直开着)
/// - **按住不放** → 单句模式(客户端划段),上滑取消
///
/// 点击与按住的区分放在 controller 的 [EmaiController.endTalk] 里做
/// (按下到松开短于 250ms 算点击)。放在那边是因为按下的瞬间就已经开麦并
/// 开了一段语音,判成点击时要把那一段取消掉——这是 controller 的事。
Widget _buildTalkButton(bool isDark) { Widget _buildTalkButton(bool isDark) {
final enabled = controller.canTalk; final enabled = controller.canTalk;
final continuous = controller.isContinuous.value;
// 连续对话中:整条就是一个「点击结束」的开关,不再有按住/上滑那套语义
if (continuous) {
return GestureDetector(
onTap: controller.toggleContinuous,
child: Container(
height: 40.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppPalette.accentDark,
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
EmaiLevelBar(color: Colors.white, level: controller.level.value),
SizedBox(width: 10.w),
Text(
'emaiContinuousOn'.tr,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white),
),
],
),
),
);
}
final talking = controller.isTalking.value; final talking = controller.isTalking.value;
final cancelling = controller.isCancelPending.value; final cancelling = controller.isCancelPending.value;
@ -399,7 +437,9 @@ class EmaiView extends GetView<EmaiController> {
? 'emaiReleaseToCancel'.tr ? 'emaiReleaseToCancel'.tr
: talking : talking
? 'emaiReleaseToSend'.tr ? 'emaiReleaseToSend'.tr
: 'emaiHoldToTalk'.tr; // 空闲态展示「点击开始连续对话」而不是「按住说话」:
// 点击是这个按钮现在的主用法,按住说话仍然可用但不再是主推的
: 'emaiTapForContinuous'.tr;
return Listener( return Listener(
onPointerDown: enabled ? (_) => controller.startTalk() : null, onPointerDown: enabled ? (_) => controller.startTalk() : null,

134
apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart

@ -122,67 +122,87 @@ class _MemoryEditSheetState extends State<MemoryEditSheet> {
color: bg, color: bg,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
), ),
// ⚠️ 必须给高度上限。不设的话内容 + 键盘会超过屏幕,弹窗被路由裁掉,
// 表现就是「上面看不见、下面点不到」。
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: SingleChildScrollView( // ⚠️ 操作栏必须在滚动区**外面**。
child: Column( //
mainAxisSize: MainAxisSize.min, // 原来整块(字段 + 确认/取消)都塞在一个 SingleChildScrollView 里,
crossAxisAlignment: CrossAxisAlignment.start, // 而「备注」正好是最后一个字段:点它弹出键盘后,按钮被顶到可视区之外,
children: [ // 而滚动区已经到底、再怎么滑也够不着 —— 用户改完找不到确认按钮,
Center( // 关掉弹窗修改就丢了(服务端一次 memory_update 都没收到过)。
child: Container( child: Column(
width: 36.w, mainAxisSize: MainAxisSize.min,
height: 4.h, crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: isDark ? Colors.white24 : Colors.black12, Flexible(
borderRadius: BorderRadius.circular(2.r), child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36.w,
height: 4.h,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.black12,
borderRadius: BorderRadius.circular(2.r),
),
),
), ),
), SizedBox(height: 16.h),
), Text(
SizedBox(height: 16.h), (_isEdit ? 'memEditTitle' : 'memAddTitle').tr,
Text( style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
(_isEdit ? 'memEditTitle' : 'memAddTitle').tr, ),
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), SizedBox(height: 16.h),
), _categoryPicker(isDark),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_categoryPicker(isDark), _field(_title, 'memFieldTitle'.tr, isDark, autofocus: !_isEdit),
SizedBox(height: 16.h), SizedBox(height: 12.h),
_field(_title, 'memFieldTitle'.tr, isDark, autofocus: !_isEdit), _dateTimeRow(isDark),
SizedBox(height: 12.h), if (_showAmount) ...[
_dateTimeRow(isDark), SizedBox(height: 12.h),
if (_showAmount) ...[ _field(
SizedBox(height: 12.h), _amount,
_field( 'memFieldAmount'.tr,
_amount, isDark,
'memFieldAmount'.tr, keyboardType:
isDark, const TextInputType.numberWithOptions(decimal: true),
keyboardType: inputFormatters: [
const TextInputType.numberWithOptions(decimal: true), // 只允许数字与至多两位小数:金额存的是「分」,
inputFormatters: [ // 多余位数换算时会被静默截断,不如在输入处就挡住
// 只允许数字与至多两位小数:金额存的是「分」, FilteringTextInputFormatter.allow(
// 多余位数换算时会被静默截断,不如在输入处就挡住 RegExp(r'^\d*\.?\d{0,2}')),
FilteringTextInputFormatter.allow( ],
RegExp(r'^\d*\.?\d{0,2}')), ),
],
if (_showOwner) ...[
SizedBox(height: 12.h),
_field(_owner, 'memFieldOwner'.tr, isDark),
],
if (_showRepeat) ...[
SizedBox(height: 12.h),
_repeatPicker(isDark),
],
if (_showRemind) ...[
SizedBox(height: 12.h),
_remindPicker(isDark),
],
SizedBox(height: 12.h),
_field(_detail, 'memFieldDetail'.tr, isDark, maxLines: 3),
], ],
), ),
], ),
if (_showOwner) ...[ ),
SizedBox(height: 12.h), SizedBox(height: 12.h),
_field(_owner, 'memFieldOwner'.tr, isDark), // 常驻底部,键盘弹起时也一直可见
], _actions(isDark),
if (_showRepeat) ...[ ],
SizedBox(height: 12.h),
_repeatPicker(isDark),
],
if (_showRemind) ...[
SizedBox(height: 12.h),
_remindPicker(isDark),
],
SizedBox(height: 12.h),
_field(_detail, 'memFieldDetail'.tr, isDark, maxLines: 3),
SizedBox(height: 20.h),
_actions(isDark),
],
),
), ),
), ),
); );

53
apps/client/lib/modules/meeting/views/tabs/summary_tab.dart

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:common_utils/common_utils.dart'; import 'package:common_utils/common_utils.dart';
import 'package:fleather/fleather.dart'; import 'package:fleather/fleather.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -66,11 +67,25 @@ class _SummaryTabState extends State<SummaryTab> {
: '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员 : '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员
final markdown = const ParchmentMarkdownCodec() final markdown = const ParchmentMarkdownCodec()
.decode(_controller.meetingDetails.value.summary); .decode(_controller.meetingDetails.value.summary);
final fc = FleatherController(document: markdown);
// 只有用户真的动过编辑器才允许回写。理由见 _saveSummary 的注释。
_summaryDirty = false;
_summaryChangeSub?.cancel();
_summaryChangeSub = fc.document.changes.listen((_) => _summaryDirty = true);
setState(() { setState(() {
_fleatherController = FleatherController(document: markdown); _fleatherController = fc;
}); });
} }
/// 用户是否真的编辑过纪要正文。
///
/// ⚠️ 不能只靠「编码结果和原文不一样」来判断:**空文档编码出来是 `"\n\n"`**,
/// 与空字符串永远不相等,于是只要进过一次详情页、退出时就会把服务端的纪要
/// 覆盖成 `"\n\n"`。2026-09-06 真机丢过一次总结就是这么来的:
/// 客户端轮询死了没拿到总结 → 详情页里是空的 → 用户退出 → dispose 回写空值。
bool _summaryDirty = false;
StreamSubscription? _summaryChangeSub;
void _handleAddressChanged() { void _handleAddressChanged() {
String currentText = _addressController.text; String currentText = _addressController.text;
TextSelection currentSelection = _addressController.selection; TextSelection currentSelection = _addressController.selection;
@ -132,26 +147,38 @@ class _SummaryTabState extends State<SummaryTab> {
} }
void _saveSummary() async { void _saveSummary() async {
if (_fleatherController == null) return;
final markdown = final markdown =
const ParchmentMarkdownCodec().encode(_fleatherController!.document); const ParchmentMarkdownCodec().encode(_fleatherController!.document);
if (markdown != _controller.meetingDetails.value.summary) { final current = _controller.meetingDetails.value.summary;
Api.modifyRecordEchomeet({ // 三道闸,缺一不可:
'id': _controller.id, // 1. 用户没动过编辑器就绝不回写——dispose 是无条件调用的,页面只是被看了
'summary': markdown, // 一眼也会走到这里;
}); // 2. 两边都是空白(含只有换行)时视为没变化——空文档编码是 "\n\n",
SqfliteApi.editMeeting( // 直接和 "" 比永远不等;
_controller.id, // 3. 比较前 trim,避免尾部空行造成的假变化。
{'summary': markdown}, if (!_summaryDirty) return;
); if (markdown.trim().isEmpty && current.trim().isEmpty) return;
_controller.meetingDetails.value.summary = markdown; if (markdown.trim() == current.trim()) return;
_controller.summaryNumberModifications.value++;
} Api.modifyRecordEchomeet({
'id': _controller.id,
'summary': markdown,
});
SqfliteApi.editMeeting(
_controller.id,
{'summary': markdown},
);
_controller.meetingDetails.value.summary = markdown;
_controller.summaryNumberModifications.value++;
_summaryDirty = false;
} }
@override @override
void dispose() { void dispose() {
_personnelListener.dispose(); _personnelListener.dispose();
_saveSummary(); _saveSummary();
_summaryChangeSub?.cancel();
_fleatherController?.dispose(); _fleatherController?.dispose();
_focusNode.dispose(); _focusNode.dispose();
_addressController.dispose(); _addressController.dispose();

43
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -227,6 +227,10 @@ import ble_service
// 音频数据相关 // 音频数据相关
private var audioDataEventChannel: FlutterEventChannel? private var audioDataEventChannel: FlutterEventChannel?
/// EMAI 助手下行 PCM 的外放播放器(iOS 专用,见 PcmSpeakerPlayer 的说明)
private let pcmSpeakerPlayer = PcmSpeakerPlayer()
private var pcmPlayerChannel: FlutterMethodChannel?
internal var audioDataEventSink: FlutterEventSink? internal var audioDataEventSink: FlutterEventSink?
// 通话翻译音频统计(日志用) // 通话翻译音频统计(日志用)
@ -365,12 +369,51 @@ private func sendEvent(_ event: [String: Any], type: EventType) {
astTtsEventChannel.setStreamHandler(BaseEventStreamHandler(instance: instance, eventType: .astTts)) astTtsEventChannel.setStreamHandler(BaseEventStreamHandler(instance: instance, eventType: .astTts))
instance.astTtsEventChannel = astTtsEventChannel instance.astTtsEventChannel = astTtsEventChannel
// EMAI 下行 PCM 播放(iOS 专用):just_audio 在 iOS 放不了直播 PCM 流,
// 详见 PcmSpeakerPlayer。只有方法通道,完成回调用 invokeMethod 回传。
let pcmPlayerChannel = FlutterMethodChannel(
name: "azure_speech/pcm_player", binaryMessenger: registrar.messenger())
pcmPlayerChannel.setMethodCallHandler { [weak instance] call, result in
instance?.handlePcmPlayerMethodCall(call, result: result)
}
instance.pcmPlayerChannel = pcmPlayerChannel
instance.pcmSpeakerPlayer.onCompleted = { [weak instance] in
instance?.pcmPlayerChannel?.invokeMethod("onCompleted", arguments: nil)
}
// 设置讯飞原生日志回调,通过 EventChannel 回传 Dart // 设置讯飞原生日志回调,通过 EventChannel 回传 Dart
instance.azureAsrHelper.nativeLogCallback = { [weak instance] level, message in instance.azureAsrHelper.nativeLogCallback = { [weak instance] level, message in
instance?.sendAsrEvent(["type": "nativeLog", "level": level, "message": message]) instance?.sendAsrEvent(["type": "nativeLog", "level": level, "message": message])
} }
} }
// EMAI 下行 PCM 播放的方法分发
internal func handlePcmPlayerMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "start":
let args = call.arguments as? [String: Any]
let rate = (args?["sampleRate"] as? NSNumber)?.doubleValue ?? 24000
result(pcmSpeakerPlayer.start(sampleRate: rate))
case "feed":
if let args = call.arguments as? [String: Any],
let data = args["data"] as? FlutterStandardTypedData {
pcmSpeakerPlayer.feed(data.data)
}
result(nil)
case "end":
pcmSpeakerPlayer.end()
result(nil)
case "stop":
pcmSpeakerPlayer.stop()
result(nil)
case "dispose":
pcmSpeakerPlayer.dispose()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
// 简化的事件发送方法 // 简化的事件发送方法
internal func sendAsrEvent(_ event: [String: Any]) { internal func sendAsrEvent(_ event: [String: Any]) {
sendEvent(event, type: .asr) sendEvent(event, type: .asr)

187
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift

@ -0,0 +1,187 @@
import AVFoundation
import Foundation
/// 流式 PCM 外放播放器(16bit 小端、单声道)。
///
/// 存在的理由:iOS 上 **just_audio 放不了「长度未知的直播 WAV 流」**。
/// 真机实测(iPhone 12 / iOS 26.6)AVPlayer 拿到 just_audio 代理服务给的
/// chunked 响应后,只发一次 `Range: bytes=0-1` 的嗅探请求就判定资源不可播,
/// 立刻回 `-11850 Operation Stopped` 且 processingState 直接落到 idle,
/// **一次都没真正起播**。声明长度、支持 range、把收到的字节全缓存下来允许
/// 重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以 Android
/// 那条路一直是好的 —— 这就是「安卓有声、苹果没声」的全部原因。
///
/// 这里绕开 AVPlayer + HTTP 代理,直接 AVAudioEngine + AVAudioPlayerNode
/// 排队播放,和 `AzureTtsHelper` 里那套流式播放同源。
///
/// ⚠️ **不碰 AVAudioSession**:会话由 Dart 侧的 audio_session 统一配置
/// (连续对话时录音的 voice-processing 也在同一个会话上)。这里再去
/// setCategory/setActive 就会和录音互相掀桌子。
class PcmSpeakerPlayer {
private let queue = DispatchQueue(label: "azure_speech.pcm_speaker")
private var engine: AVAudioEngine?
private var node: AVAudioPlayerNode?
private var format: AVAudioFormat?
/// 已排队但还没播完的缓冲数
private var pending = 0
/// 上层已经调过 end():pending 归零就算这一轮播完
private var ended = false
/// 本轮代际,stop() 之后迟到的完成回调按代际丢弃
private var generation = 0
/// 一轮音频播完(或本轮压根没有音频)
var onCompleted: (() -> Void)?
// MARK: - 对外接口
func start(sampleRate: Double) -> Bool {
var ok = false
queue.sync {
generation += 1
pending = 0
ended = false
ok = ensureEngine(sampleRate: sampleRate)
}
return ok
}
func feed(_ data: Data) {
queue.async { [weak self] in
guard let self = self else { return }
guard let node = self.node, let engine = self.engine, engine.isRunning else { return }
guard let buffer = self.makeBuffer(from: data) else { return }
let gen = self.generation
self.pending += 1
// ⚠️ 必须用 .dataPlayedBack 而不是默认的 .dataRendered:
// 播完这一轮要顺手停引擎(见 finishIfDone),用 dataRendered 会在
// 声音真正出完之前就回调,尾音被切掉。
node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
self?.queue.async {
guard let self = self, gen == self.generation else { return }
self.pending -= 1
self.finishIfDone()
}
}
if !node.isPlaying { node.play() }
}
}
/// 上层不再喂数据了:已排队的播完就报完成
func end() {
queue.async { [weak self] in
guard let self = self else { return }
self.ended = true
self.finishIfDone()
}
}
/// 打断:立刻停声,不报完成
func stop() {
queue.async { [weak self] in
guard let self = self else { return }
self.generation += 1
self.pending = 0
self.ended = false
self.node?.stop()
self.engine?.stop()
}
}
func dispose() {
queue.async { [weak self] in
guard let self = self else { return }
self.generation += 1
self.node?.stop()
self.engine?.stop()
self.node = nil
self.engine = nil
self.format = nil
}
}
// MARK: - 内部
private func finishIfDone() {
guard ended, pending <= 0 else { return }
ended = false
// ⚠️ 一轮播完必须把引擎停掉,不能让它空跑着占住音频 IO。
// 连续对话下 record 要在同一个会话上开 voice-processing,
// 我们这台引擎还占着的话它会拿不到 IO,报
// `avfaudio error 2003329396`(FourCC 'what')—— 表现就是
// 「助手说完话之后麦克风再也开不起来」。真机上间歇复现过。
// 对象留着不销毁,下一轮 ensureEngine 直接 start() 就好。
node?.stop()
engine?.stop()
let cb = onCompleted
DispatchQueue.main.async { cb?() }
}
/// 采样率变了要整条重建:AVAudioEngine 的连接格式是建链路时定死的
private func ensureEngine(sampleRate: Double) -> Bool {
if let engine = engine, let node = node, let fmt = format,
fmt.sampleRate == sampleRate {
if !engine.isRunning {
do { try engine.start() } catch { tearDown(); return buildEngine(sampleRate: sampleRate) }
}
if !node.isPlaying { node.play() }
return true
}
tearDown()
return buildEngine(sampleRate: sampleRate)
}
private func buildEngine(sampleRate: Double) -> Bool {
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1) else {
return false
}
let e = AVAudioEngine()
let n = AVAudioPlayerNode()
e.attach(n)
e.connect(n, to: e.mainMixerNode, format: fmt)
do {
try e.start()
} catch {
NSLog("[PcmSpeakerPlayer] 引擎启动失败: \(error.localizedDescription)")
return false
}
n.play()
engine = e
node = n
format = fmt
return true
}
private func tearDown() {
node?.stop()
engine?.stop()
node = nil
engine = nil
format = nil
}
/// Int16 小端裸 PCM → Float32 单声道 buffer
private func makeBuffer(from data: Data) -> AVAudioPCMBuffer? {
guard let fmt = format else { return nil }
let frames = data.count / 2
guard frames > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: fmt,
frameCapacity: AVAudioFrameCount(frames)),
let dst = buffer.floatChannelData else { return nil }
buffer.frameLength = AVAudioFrameCount(frames)
data.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
// ⚠️ 不能 bindMemory(to: Int16.self):Data 的起始地址不保证 2 字节对齐,
// 未对齐访问在 arm64 上是未定义行为。逐字节取再拼。
let bytes = base.assumingMemoryBound(to: UInt8.self)
for i in 0..<frames {
let lo = UInt16(bytes[i * 2])
let hi = UInt16(bytes[i * 2 + 1])
let sample = Int16(bitPattern: lo | (hi << 8))
dst[0][i] = Float(sample) / 32768.0
}
}
return buffer
}
}

2
apps/client/pubspec.yaml

@ -1,7 +1,7 @@
name: eaimar name: eaimar
description: "Eaimar - AI Voice Assistant." description: "Eaimar - AI Voice Assistant."
publish_to: "none" publish_to: "none"
version: 1.0.1+5 version: 1.0.1+6
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"

Loading…
Cancel
Save