现象 / Symptom
划词追问(QA)浮窗在 LLM 流式输出过程中,关闭 / 取消 / 重开 流程存在多个观察可见的异常:
- Esc 没用:流式生成中按 Esc,浮窗界面消失,但 LLM 在后端仍持续生成(也仍在烧 token / 计费)。
- 跨会话漏字:取消后立刻重新打开浮窗 + 按 Option 开始第二轮提问,旧会话的 LLM chunk 仍会被推到新窗口,新气泡里"幽灵地"出现上一轮的剩余答案。
- 过渡帧丢失:从"录音"到"思考中"的中间过渡("识别中"/"loading"那一帧)UI 没渲染——用户体验上是"录音状态突然跳到完整答案",中间"识别中"的反馈空了几百毫秒到几秒。
复现
- 选段长文,Cmd+Shift+; 弹浮窗
- 按 Option,说几句长问题让 LLM 答 30+ 秒
- 流式刚开始(前 1-3 个气泡 chunk 已出)→ 按 Esc 关浮窗
- 立刻 Cmd+Shift+; 重开浮窗 → Option 录音问新问题
- 观察:新会话气泡里会冒出旧 LLM 的尾段;后端日志会看到旧 LLM 流持续发完整篇
根因
| 问题 |
文件:行 |
解释 |
| Esc 走错路由 |
coordinator.rs:744 handle_window_hotkey_event |
前端 QA 浮窗的 Esc → 后端无条件 cancel_session(inner)(dictation 取消),从不查 qa_state.panel_visible |
| SSE 流无 cancel hook |
polish.rs:286 chat_completion_history_streaming |
流式 loop while response.chunk().await 不查 qa_state.cancelled,没 tokio::select! abort signal;仅在 loop 结束后才有一次 cancelled 检查 |
on_delta 无 session_id 守卫 |
coordinator.rs:1916 |
on_delta 闭包仅捕获 Arc<Inner>,emit 时不验当前 qa_state.session_id 是否 == 启动时的;旧会话 chunk 漏进新会话 |
| loading kind 后端发但前端不处理 |
coordinator.rs:1815 推 {kind:"loading"},但 QaPanel.tsx:51 switch 5 个 case 都不匹配;types.ts:105 QaStateKind 联合也没 'loading' |
|
影响
- token 浪费:用户取消的流式仍计费到完整长度
- 气泡污染:新会话出现旧字
- 过渡反馈丢失:录音→思考中那一帧用户看不到反馈,体感"中断"
- 平台:三平台(macOS/Windows/Linux)都受影响(流式逻辑跨平台)
建议 fix
- Esc 路由:在
handle_window_hotkey_event Esc 分支先查 panel_visible → 是 → cancel_qa_session + close_qa_panel;否 → 现行 dictation cancel
- SSE 取消:注入
Arc<AtomicBool> 或 tokio_util::sync::CancellationToken,loop 每帧检查;cancel_qa_session flip 它
- session_id 守卫:on_delta 闭包捕获
let captured_id = inner.qa_state.lock().session_id;,每次 emit 前比对当前 id,不等就跳过
- loading 帧:后端去掉 loading 帧(直接发 thinking),或前端
QaPanel.tsx switch 加 case 'loading': setStatus('thinking'); break; + types.ts QaStateKind 加 'loading'
现象 / Symptom
划词追问(QA)浮窗在 LLM 流式输出过程中,关闭 / 取消 / 重开 流程存在多个观察可见的异常:
复现
根因
coordinator.rs:744 handle_window_hotkey_eventcancel_session(inner)(dictation 取消),从不查qa_state.panel_visiblepolish.rs:286 chat_completion_history_streamingwhile response.chunk().await不查qa_state.cancelled,没tokio::select!abort signal;仅在 loop 结束后才有一次 cancelled 检查on_delta无 session_id 守卫coordinator.rs:1916Arc<Inner>,emit 时不验当前qa_state.session_id是否 == 启动时的;旧会话 chunk 漏进新会话coordinator.rs:1815推{kind:"loading"},但QaPanel.tsx:51switch 5 个 case 都不匹配;types.ts:105QaStateKind联合也没'loading'影响
建议 fix
handle_window_hotkey_eventEsc 分支先查panel_visible→ 是 →cancel_qa_session+close_qa_panel;否 → 现行 dictation cancelArc<AtomicBool>或tokio_util::sync::CancellationToken,loop 每帧检查;cancel_qa_sessionflip 它let captured_id = inner.qa_state.lock().session_id;,每次 emit 前比对当前 id,不等就跳过QaPanel.tsxswitch 加case 'loading': setStatus('thinking'); break;+types.tsQaStateKind加'loading'