fix(assistant): 按住说话再次按下不再丢失/错乱上一轮状态 - #401
Conversation
_startTurn() 开头无条件清 replyText:上一轮回复还在流式、语音还在播时 用户又按住说话,已经显示的气泡会立刻消失,而 TTS 不受影响照常播—— 现象就是"长回复气泡完全不出现"。回复越长,用户在播放中再按一次的 概率越大,所以只有长回复才容易踩到。 叠加了 1024XEngineer#364 加的 request_id 轮次门控:新一轮换了 request_id 后, 上一轮剩余的流式增量全部被当成"别人的"丢掉,即使气泡真的显示出来, 后续增量也进不来,replyText 永远停在清空前那一刻。 现在 _startTurn 不再清 replyText,气泡留到被新一轮自己的回复覆盖, 或用户点掉/取消为止;门控放宽成"当前轮,或者气泡本来就在显示的 那一轮"都放行,让上一轮的剩余增量能继续更新已经显示的气泡。 dismissReply()/cancelTurn() 里一并清掉这个"气泡属于哪一轮"的记录, 保住 1024XEngineer#364 本来要防的场景——气泡点掉后不会被晚到的旧回复重新弹出来。
同一类问题的另外两处:voice.dialogue.question 完全没有轮次门控, 上一轮被打断后晚到的追问会把已经推进到新一轮的 phase 强行掰回 asking;voice.tts.start/voice.tts.end 同样没有门控(协议本身也不带 request_id,没法照搬 dialogue.reply 那套判断),上一轮语音还没播完 就开始新一轮时两轮音频会同时播,且上一轮迟到的 tts.end 会把新一轮 的 phase 强行掰回 idle。 voice.dialogue.question 补上跟 reply 一样的 request_id 门控。 新增 abandonedAudioId:新一轮开始(或点掉气泡)时如果上一轮语音还 没播完,主动停止播放并记住这个 audio_id;它迟到的 tts.end 到达时 只清记录,不再碰 phase。isCurrentTurnReply 改名 isCurrentTurn,因为 现在不止 reply 一处在用它。 已知局限:voice.tts.start/voice.tts.end 协议本身不带任何轮次标识, 新一轮开始的瞬间如果恰好卡在服务端取消生效前那个协程调度间隙内 (毫秒级窗口),上一轮迟到的 tts.start 仍会被当成合法消息接受。这 道窄缝要彻底堵上需要后端配合,这次范围只做前端,先记录着。
Codecov Report✅ All modified and coverable lines are covered by tests.
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
The review covers the new reply-generation tracking and TTS cancellation handling in AssistantConversationService. The reply/question request gating is coherent, but the abandoned-audio tracking has a concrete multi-turn race that can still reset a newer turn to idle. Focused Jest verification could not run because this checkout lacks the jest-expo preset dependency.
abandonedAudioId 之前是单个值:turn 1 的音频还没等到自己的 tts.end 就被 turn 2 自己的音频顶替、又被 turn 3 打断覆盖掉记录,turn 1 迟到 的 tts.end 落进正常分支,把已经推进到 turn 3 的 phase 错误地掰回 idle(PR 1024XEngineer#401 review 指出)。 改用 Set<string> 记录所有还没等到 tts.end 的放弃 id,命中就从集合 里摘掉;cancelTurn() 里连接整个关闭时顺手清空,避免长会话攒垃圾。 补了对应的多轮打断回归测试。
dismissReply() 里新增的放弃-音频逻辑(abandonedAudioIds.add / 清空 currentAudioId)之前没有测试覆盖——所有已有用例点掉气泡时都没有语音 正在播。补一个用例:语音播放中点掉气泡,确认立即停播且这条 audio_id 被记进放弃集合,迟到的 tts.end 不会把随后开始的新一轮状态掰回 idle。
上一版把 _startTurn() 改成不清 replyText,让气泡跨轮次持续显示。产品 预期不是这样:再次按住说话应该等同于主动点掉气泡(dismissReply)—— 文字立即清空、语音立即停播,然后干净地开始听新一段,不应该让上一轮 内容跟新一轮混在一起。 _startTurn() 里重新加回 this.replyText = null(放弃音频那部分不动)。 删掉 displayedReplyRequestId 字段和相关逻辑——它是上一版"气泡不清空" 设计专用的(让已显示的气泡继续吃旧轮次的流式增量),现在用不上了。 voice.dialogue.reply 门控简化回单纯的 isCurrentTurn(requestId)。
之前 stop() 放在 if (currentAudioId !== null) 里面,只有当前正追踪着 一个 audio_id 时才会调用。长回复的 TTS 可能分好几段下发(一句一个 tts.start/tts.end),如果按下的瞬间恰好卡在上一段 tts.end 和下一段 tts.start 之间,currentAudioId 这时候是 null,stop() 直接被跳过—— 原生播放器完全没收到停止指令,会正常播完当前缓冲区里的音频、甚至 接着播下一段。dismissReply() 里 stop() 本来就是无条件调用的,这处 不一致正是"点气泡能停、按住说话不能停"的根因。 现在 stop() 挪到 if 外面,跟 dismissReply() 保持一致;abandonedAudioIds 的记录仍然只在 currentAudioId 非空时才添加(没有 id 可记就不记)。
playbackChannel 是还没解码写入 AudioTrack 的排队分片,流式播放时它 经常是空的(解码速度快于播放速度),空不代表 AudioTrack 硬件缓冲区 里没有还在播的音频。原实现把它也当"没在播"的判据,导致 stop() 在 这种(很常见的)时刻直接跳过 audioTrack.stop()/flush(),调用方以为 已经停了,实际上已经写进硬件缓冲区的音频还会继续播完。 判据改成只看 isPlaying。这是通过 patch-package 打的补丁,会在 npm install 时自动重新应用。
voice.dialogue.question(比如"删除某一个"触发的"确认要删除吗?")之前 只写 state.speechText,气泡 UI(AssistantVoiceOverlay)只读 replyText—— 两个完全独立的字段,追问从来没被接进气泡显示逻辑。语音照常播(走 voice.tts.start,跟这个无关),但气泡永远不会出现,也没法点掉。这跟 打断/连续按没有关系,是这条消息类型本身漏了这一步。 现在 voice.dialogue.question 也把 speech_text 写进 replyText。
There was a problem hiding this comment.
Reviewed the fixed range 2ed082ddc3ad0e84ce463c7faf56c2c6bce7b48e...883069905e666509091c4881f58127eb81873718} across the conversation service, audio patch, and assistant tests. I did not find an actionable correctness, reliability, security, or compatibility regression introduced by this PR. The added turn gating, abandoned-audio tracking, unconditional playback stop, and question bubble update are consistent with the surrounding callers and contracts.
Verification: git diff --check reports only an existing patch-file trailing-whitespace warning; the focused Jest command could not run because the mounted workspace has no installed Jest dependency and npx requested an unavailable package install.
main 上合并进来的 1024XEngineer#399 给播放操作加了 playbackChain/playbackGeneration 序列化(避免 pushChunk 分片交错、stop 和在途写入竞争)。这条分支里 _startTurn() 开始新一轮时的 stop 调用还是合并前的旧写法,直接调 deps.playback.stop(),绕过了这套序列化——会跟 chainPlayback 里在途的 pushChunk 竞争,且不会让已经排队但还没执行的旧流分片失效。 改成调 stopPlaybackImmediately(),跟 dismissReply()/cancelTurn() 保持 一致。同时修一条现有测试的断言:这次改动让 stop 在每次 _startTurn() 都会被调用一次(即使没有正在播的音频,对应原生层已经修过的空操作 分支),测试原来只预期 dismiss 触发的那一次。
Summary
_startTurn()开始新一轮时清空replyText并停播上一轮语音,行为跟用户点掉气泡(dismissReply)一致,voice.dialogue.reply门控保持只认当前轮,上一轮迟到的增量正常丢弃。voice.dialogue.question补上同样的轮次门控(原来完全没有)。新增
abandonedAudioIds(Set<string>):新一轮开始/点掉气泡时若上一轮语音还没播完就主动停止,并把 audio_id 记进集合;迟到的tts.end命中集合里的 id 时只摘除、不再碰 phase,用集合而不是单个值是因为连续打断多次会导致更早放弃的 id 被覆盖丢失。isCurrentTurnReply改名isCurrentTurn(现在不止 reply 在用它)。已知局限:
voice.tts.start/voice.tts.end协议本身不带 request_id,纯前端没法完全堵上"新一轮开始的瞬间恰好撞上服务端取消生效前那个协程调度间隙"这道窄缝(毫秒级,网络/负载异常时概率上升),已经在播的音频这次能正确处理,这道残留窗口需要后端配合才能彻底解决,不在本次范围。Fixes #400
Test plan
Verification
```bash
cd frontend
npx jest --runInBand tests/unit/features/assistant
```
本地结果:
Test Suites: 11 passed,Tests: 135 passed。