Repository navigation
fix(assistant): 按住说话点掉回复后,上一轮回复会重现并挡住语音条 - #364
Merged
Merged
Conversation
每一轮按住说话生成自增的 request_id 随 voice.stream.start 发出,服务端会把它 回显到该轮的 voice.dialogue.reply 上;handleMessage 据此认轮次,对不上的直接 丢弃。上一轮被 interrupt 后迟到的回复不会再把已经点掉的气泡弹回来——气泡是一层 全屏 Pressable,会挡住语音条,用户得多点一次才能说话。 只 gate voice.dialogue.reply 这一条:错误信封是 startTurn() 唯一的解套途径, voice.stream.started 是 streamId 的唯一来源,voice.command.result 代表服务端 已提交的写入,按轮次丢弃它们的代价都远大于显示一条过期气泡。request_id 为 null/undefined 时一律放行——后端 model_dump() 把缺省值序列化成 null 而非省略 字段,只判 undefined 会把正常回复误吞。 Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced Aug 27, 2026
yyy-router
pushed a commit
that referenced
this pull request
Aug 27, 2026
* fix(assistant): 按住说话长回复不再在语音播放中被清空 _startTurn() 开头无条件清 replyText:上一轮回复还在流式、语音还在播时 用户又按住说话,已经显示的气泡会立刻消失,而 TTS 不受影响照常播—— 现象就是"长回复气泡完全不出现"。回复越长,用户在播放中再按一次的 概率越大,所以只有长回复才容易踩到。 叠加了 #364 加的 request_id 轮次门控:新一轮换了 request_id 后, 上一轮剩余的流式增量全部被当成"别人的"丢掉,即使气泡真的显示出来, 后续增量也进不来,replyText 永远停在清空前那一刻。 现在 _startTurn 不再清 replyText,气泡留到被新一轮自己的回复覆盖, 或用户点掉/取消为止;门控放宽成"当前轮,或者气泡本来就在显示的 那一轮"都放行,让上一轮的剩余增量能继续更新已经显示的气泡。 dismissReply()/cancelTurn() 里一并清掉这个"气泡属于哪一轮"的记录, 保住 #364 本来要防的场景——气泡点掉后不会被晚到的旧回复重新弹出来。 * fix(assistant): 按住说话再按一次不再打断上一轮的语音状态和追问 同一类问题的另外两处: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 仍会被当成合法消息接受。这 道窄缝要彻底堵上需要后端配合,这次范围只做前端,先记录着。 * fix(assistant): 放弃的音频 id 改用集合,防止连续打断时互相覆盖 abandonedAudioId 之前是单个值:turn 1 的音频还没等到自己的 tts.end 就被 turn 2 自己的音频顶替、又被 turn 3 打断覆盖掉记录,turn 1 迟到 的 tts.end 落进正常分支,把已经推进到 turn 3 的 phase 错误地掰回 idle(PR #401 review 指出)。 改用 Set<string> 记录所有还没等到 tts.end 的放弃 id,命中就从集合 里摘掉;cancelTurn() 里连接整个关闭时顺手清空,避免长会话攒垃圾。 补了对应的多轮打断回归测试。 * test(assistant): 补上点掉气泡时语音正在播放的覆盖 dismissReply() 里新增的放弃-音频逻辑(abandonedAudioIds.add / 清空 currentAudioId)之前没有测试覆盖——所有已有用例点掉气泡时都没有语音 正在播。补一个用例:语音播放中点掉气泡,确认立即停播且这条 audio_id 被记进放弃集合,迟到的 tts.end 不会把随后开始的新一轮状态掰回 idle。 * fix(assistant): 再次按住说话改回清空气泡并停播,跟点掉气泡一致 上一版把 _startTurn() 改成不清 replyText,让气泡跨轮次持续显示。产品 预期不是这样:再次按住说话应该等同于主动点掉气泡(dismissReply)—— 文字立即清空、语音立即停播,然后干净地开始听新一段,不应该让上一轮 内容跟新一轮混在一起。 _startTurn() 里重新加回 this.replyText = null(放弃音频那部分不动)。 删掉 displayedReplyRequestId 字段和相关逻辑——它是上一版"气泡不清空" 设计专用的(让已显示的气泡继续吃旧轮次的流式增量),现在用不上了。 voice.dialogue.reply 门控简化回单纯的 isCurrentTurn(requestId)。 * fix(assistant): 再按住说话时 stop() 改成无条件调用,不再被漏掉 之前 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 可记就不记)。 * fix(patch): AudioPlaybackManager.stopPlayback() 空队列也要真的停播 playbackChannel 是还没解码写入 AudioTrack 的排队分片,流式播放时它 经常是空的(解码速度快于播放速度),空不代表 AudioTrack 硬件缓冲区 里没有还在播的音频。原实现把它也当"没在播"的判据,导致 stop() 在 这种(很常见的)时刻直接跳过 audioTrack.stop()/flush(),调用方以为 已经停了,实际上已经写进硬件缓冲区的音频还会继续播完。 判据改成只看 isPlaying。这是通过 patch-package 打的补丁,会在 npm install 时自动重新应用。 * fix(assistant): 确认类追问也要写进 replyText,不然气泡永远不出现 voice.dialogue.question(比如"删除某一个"触发的"确认要删除吗?")之前 只写 state.speechText,气泡 UI(AssistantVoiceOverlay)只读 replyText—— 两个完全独立的字段,追问从来没被接进气泡显示逻辑。语音照常播(走 voice.tts.start,跟这个无关),但气泡永远不会出现,也没法点掉。这跟 打断/连续按没有关系,是这条消息类型本身漏了这一步。 现在 voice.dialogue.question 也把 speech_text 写进 replyText。 * fix(assistant): 新一轮开始的 stop 接入 playbackChain/generation 序列化 main 上合并进来的 #399 给播放操作加了 playbackChain/playbackGeneration 序列化(避免 pushChunk 分片交错、stop 和在途写入竞争)。这条分支里 _startTurn() 开始新一轮时的 stop 调用还是合并前的旧写法,直接调 deps.playback.stop(),绕过了这套序列化——会跟 chainPlayback 里在途的 pushChunk 竞争,且不会让已经排队但还没执行的旧流分片失效。 改成调 stopPlaybackImmediately(),跟 dismissReply()/cancelTurn() 保持 一致。同时修一条现有测试的断言:这次改动让 stop 在每次 _startTurn() 都会被调用一次(即使没有正在播的音频,对应原生层已经修过的空操作 分支),测试原来只预期 dismiss 触发的那一次。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
变更说明
AssistantConversationService(前端)在_startTurn()里给每一轮按住说话生成一个自增的
request_id(ptt-turn-N),随voice.stream.start一起发出。后端_request_id_of()拿到后会存进StreamContext,deliver_reply_text()再把它回显到该轮的
voice.dialogue.reply上——这两处都是既有实现,后端不用改。handleMessage()的voice.dialogue.reply分支据此认轮次:request_id跟当前这一轮对不上的直接丢弃,不再写
replyText。上一轮被interrupt后迟到的回复不会再把已经点掉的气泡弹回来,也就不会再用那层全屏 Pressable 挡住语音条、逼用户
多点一次才能说话。
两个刻意的取舍:
voice.dialogue.reply这一条消息,其余(传输错误信封、voice.stream.started、voice.command.result、voice.tts.*)全部照原路走。错误信封是
_startTurn()里await started唯一的解套途径(后端"A stream is already active"/"Audio frame is empty"这类错误会带上旧流的request_id),voice.stream.started是streamId的唯一来源,voice.command.result代表服务端已提交的写入——按轮次丢弃它们的代价都远大于显示了一条过期气泡。
request_id为null或undefined时一律放行。后端model_dump()把缺省的request_id序列化成 JSONnull而不是省略字段,只判undefined会把这类回复全部误吞。
turnRequestId的赋值紧挨着connection.send(),没有放在_startTurn()开头:connect()(含 2s 定位 race)和requestPermission()(权限弹框)都可能停留数秒,提前换 id 会让这段窗口里仍在跑的上一轮流被当成"别人的"而误丢自己的回复。
测试计划
npm run check(前端,exit 0;lint + prettier + tsc + vitest + jest,68 suites / 647 tests 全绿)voice.dialogue.reply不再写replyText(核心场景);request_id为null的回复仍然正常显示(防"修过头"把正常回复吞掉);request_id的传输错误信封仍能让startTurn()解套并进入 error 态(护栏:防止以后有人把这道判断提到
handleMessage()开头,那样会让按住说话永久卡死)。undefined),重新跑这几条新测试,均按预期失败;改回修复后全部转绿——证明测试确实在验证这次改动。
影响面
改动只落在
AssistantConversationService一个类里,后端和消息契约都没动,连续对话(
AssistantContinuousConversationService)不受影响——它本来就用request_id按轮路由,TTS 侧另有
audio_id守卫。