Skip to content

fix(assistant): 按住说话再次按下不再丢失/错乱上一轮状态 - #401

Merged
yyy-router merged 10 commits into
1024XEngineer:mainfrom
LUPENGHAN:fix/ptt-reply-overwritten-on-turn-restart
Aug 27, 2026
Merged

fix(assistant): 按住说话再次按下不再丢失/错乱上一轮状态#401
yyy-router merged 10 commits into
1024XEngineer:mainfrom
LUPENGHAN:fix/ptt-reply-overwritten-on-turn-restart

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

_startTurn() 开始新一轮时清空 replyText 并停播上一轮语音,行为跟用户点掉气泡(dismissReply)一致,voice.dialogue.reply 门控保持只认当前轮,上一轮迟到的增量正常丢弃。

voice.dialogue.question 补上同样的轮次门控(原来完全没有)。

新增 abandonedAudioIdsSet<string>):新一轮开始/点掉气泡时若上一轮语音还没播完就主动停止,并把 audio_id 记进集合;迟到的 tts.end 命中集合里的 id 时只摘除、不再碰 phase,用集合而不是单个值是因为连续打断多次会导致更早放弃的 id 被覆盖丢失。

isCurrentTurnReply 改名 isCurrentTurn(现在不止 reply 在用它)。

已知局限:voice.tts.start/voice.tts.end 协议本身不带 request_id,纯前端没法完全堵上"新一轮开始的瞬间恰好撞上服务端取消生效前那个协程调度间隙"这道窄缝(毫秒级,网络/负载异常时概率上升),已经在播的音频这次能正确处理,这道残留窗口需要后端配合才能彻底解决,不在本次范围。

Fixes #400

Test plan

  • 语音播放中再按住说话:气泡立即清空、语音立即停播
  • 上一轮迟到的流式增量到达:不会把已经清空的气泡重新填回去
  • 气泡点掉后收到晚到的旧回复:不重新弹出(fix(assistant): 按住说话点掉回复后,上一轮回复会重现并挡住语音条 #364 不回归)
  • 上一轮迟到的追问:不会把新一轮的 phase 改写
  • 连续按住说话多次:更早放弃的音频迟到的收尾消息不会把最新一轮的状态掰回 idle

Verification

```bash
cd frontend
npx jest --runInBand tests/unit/features/assistant
```

本地结果:Test Suites: 11 passedTests: 135 passed

_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

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Flag Coverage Δ
backend 96.59% <ø> (ø)
frontend 90.38% <100.00%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...istant/application/AssistantConversationService.ts 95.63% <100.00%> (+1.15%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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.

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.

Comment thread frontend/src/features/assistant/application/AssistantConversationService.ts Outdated
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)。
@LUPENGHAN
LUPENGHAN marked this pull request as draft August 27, 2026 08:09
之前 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。
@LUPENGHAN
LUPENGHAN marked this pull request as ready for review August 27, 2026 08:49

@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.

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 触发的那一次。
@yyy-router
yyy-router merged commit c13dafd into 1024XEngineer:main Aug 27, 2026
5 checks passed
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.

按住说话:再次按下会丢失/错乱上一轮的回复、语音状态和追问

3 participants