Skip to content

fix(assistant): 按住说话点掉回复后,上一轮回复会重现并挡住语音条 - #364

Merged
yyy-router merged 1 commit into
1024XEngineer:mainfrom
LUPENGHAN:worktree-other-bugfix
Aug 24, 2026
Merged

yyy-router merged 1 commit into
1024XEngineer:mainfrom
LUPENGHAN:worktree-other-bugfix

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

变更说明

  • 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 挡住语音条、逼用户
    多点一次才能说话。

两个刻意的取舍:

  • 只 gate 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 序列化成 JSON null 而不是省略字段,只判 undefined 会把这类回复
    全部误吞。

turnRequestId 的赋值紧挨着 connection.send(),没有放在 _startTurn() 开头:
connect()(含 2s 定位 race)和 requestPermission()(权限弹框)都可能停留数秒,
提前换 id 会让这段窗口里仍在跑的上一轮流被当成"别人的"而误丢自己的回复。

测试计划

  • npm run check(前端,exit 0;lint + prettier + tsc + vitest + jest,68 suites / 647 tests 全绿)
  • 新增 3 条回归测试,并验证过测试本身能抓出问题:
    • 上一轮迟到的 voice.dialogue.reply 不再写 replyText(核心场景);
    • request_id 为 null 的回复仍然正常显示(防"修过头"把正常回复吞掉);
    • 带旧 request_id 的传输错误信封仍能让 startTurn() 解套并进入 error 态
      (护栏:防止以后有人把这道判断提到 handleMessage() 开头,那样会让按住说话永久卡死)。
    • 验证方式:把源码修复分别临时还原回旧逻辑(去掉 gate / 只判 undefined),重新跑
      这几条新测试,均按预期失败;改回修复后全部转绿——证明测试确实在验证这次改动。
  • 真机人工验证:按住说话 → 点掉气泡 → 再按住说话,确认旧回复不再重现、语音条不被挡

影响面

改动只落在 AssistantConversationService 一个类里,后端和消息契约都没动,连续对话
(AssistantContinuousConversationService)不受影响——它本来就用 request_id 按轮
路由,TTS 侧另有 audio_id 守卫。

每一轮按住说话生成自增的 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

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yyy-router yyy-router left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@yyy-router
yyy-router merged commit 26a7fd7 into 1024XEngineer:main Aug 24, 2026
5 checks passed

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本次改动中,voice.stream.start 的逐轮 request_id 与后端 StreamContext/voice.dialogue.reply 回显链路保持一致;过滤仅作用于文字回复,未影响启动确认、错误解套、命令落库与 TTS 状态路径。未发现需要阻塞合并的正确性或回归问题。

已验证:目标 Jest 套件(21 tests)、TypeScript 类型检查、改动文件 ESLint/Prettier,以及固定 SHA 范围的 git diff --check 均通过。

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 触发的那一次。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants