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