修复: 切会话残片覆盖回复与收尾工具卡丢失

- flushCurrentText 加非流式守卫,非流式只清不写
- 后台会话终止即清流式态,残片不跨轮引爆
- 异常收尾尾部工具气泡折进轨迹卡不再蒸发
- 停止与看门狗超时先保文再复位
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent e1dd0bc3a3
commit 524120b110
5 changed files with 26 additions and 2 deletions
+4 -1
View File
@@ -889,7 +889,10 @@ const renderItems = computed<RenderItem[]>(() => {
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break } if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
} }
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1] const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg)) // 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项) // 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) { if (traceMsgs.length > 0) {
const key = 'trace-' + traceMsgs[0].id const key = 'trace-' + traceMsgs[0].id
+10
View File
@@ -382,6 +382,7 @@ export const switchingConvs = new Set<string>()
// 本模块函数运行时惰性取最新 state。 // 本模块函数运行时惰性取最新 state。
export interface AiStateShape { export interface AiStateShape {
currentText: string currentText: string
streaming: boolean
messages: AiMessage[] messages: AiMessage[]
activeConversationId: string | null activeConversationId: string | null
} }
@@ -434,6 +435,15 @@ export function flushCurrentText(): void {
resetDelta() resetDelta()
return return
} }
// 非流式守卫:currentText 仅在流式进行中有意义。终态收尾后的迟到 delta、后台会话
// 完成后切回残留的缓冲,都会让 streaming=false 时 currentText 仍非空;此刻回填会把
// 上一轮已完成回复整条覆盖为残片(用户实测「空白内容覆盖了之前一大堆内容」),
// 故非流式一律只清不写。合法回填点(收尾/新轮/停止/超时)均在 streaming 翻 false 前调用。
if (!s.streaming) {
s.currentText = ''
resetDelta()
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复) // 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复)
for (let i = s.messages.length - 1; i >= 0; i--) { for (let i = s.messages.length - 1; i >= 0; i--) {
const m = s.messages[i] const m = s.messages[i]
+4
View File
@@ -97,6 +97,10 @@ export function handleEvent(event: AiChatEvent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成) // 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '') convStates.delete(convId || '')
// 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
clearConvStreamState(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
+3
View File
@@ -505,6 +505,9 @@ async function sendQueuedNow(index: number) {
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。 * 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
*/ */
async function stopChat() { async function stopChat() {
// 本地复位前先回填已流出文本:streaming 翻 false 后流式分块即清,迟到的 AiCompleted
// 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。
flushCurrentText()
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死) setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
clearStreamWatchdog() // 清残留看门狗 clearStreamWatchdog() // 清残留看门狗
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。 // UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
+5 -1
View File
@@ -13,7 +13,7 @@
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates } from './aiShared' import { nextMsgId, convStates, flushCurrentText } from './aiShared'
import type { MessageId } from '@/api/types' import type { MessageId } from '@/api/types'
import { forceResetStreaming } from './streamingGuard' import { forceResetStreaming } from './streamingGuard'
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
@@ -67,6 +67,10 @@ export function onStreamTimeout(convId?: string) {
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。 // 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`) console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
// 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming
// 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。
// flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。
if (!convId || convId === state.activeConversationId) flushCurrentText()
// 彻底清理三处状态源,保证状态机收敛到 idle // 彻底清理三处状态源,保证状态机收敛到 idle
forceResetStreaming('onStreamTimeout(无数据包底)', convId) forceResetStreaming('onStreamTimeout(无数据包底)', convId)
if (convId) { if (convId) {