From 3055b06734532fa4c4f12446a5168c6930c796a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 17 Jun 2026 18:52:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=B8=BB=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=88=B7=E6=96=B0=E5=90=8E=E6=B5=81=E5=BC=8F=E6=81=A2?= =?UTF-8?q?=E5=A4=8D(restoreGeneratingState=20=E5=8A=A0=20fromMainPanel=20?= =?UTF-8?q?=E7=BB=95=E8=BF=87=20localStorage=20=E5=BF=AB=E7=85=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AiChat.vue | 12 +++- src/composables/ai/useAiWindow.ts | 96 +++++++++++++++++++++---------- 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index c4f379a..cf020b0 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -2179,10 +2179,20 @@ onMounted(async () => { }) // F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新) await initContextEventListeners() - // 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话 + // 恢复生成中态(刷新后/分离窗口接管): + // - 分离模式(detached=true):resumeInDetached 接管主窗口当前对话 + // - 主面板模式(detached=false):restoreGeneratingState 恢复主面板自身的生成态 + // (UX-260617-13: 主面板刷新后无等价恢复链 → streaming=false + 无占位气泡 + 后端 + // 事件不进流式分支,退化为非流式。现对齐 resumeInDetached 同款恢复链)。 + // 两者互斥(detached 三元决定),不会双重恢复;restoreGeneratingState 内幂等守卫(state.streaming 已 true 则跳过)。 + // 在 startListener(L0 ai-client-ready 握手)之后调用,握手仍先于恢复逻辑生效。 if (props.detached) { const params = new URLSearchParams(location.hash.split('?')[1] || '') await store.resumeInDetached(params.get('conv')) + } else { + // fromMainPanel=true: 主面板刷新无 df-ai-gen 快照(detachPanel 未触发), + // 用 activeConversationId + ai_is_generating 真值守卫恢复(绕过 localStorage 路径) + await store.restoreGeneratingState({ fromMainPanel: true }) } console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`) }) diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index abe21a5..e570f5a 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -4,7 +4,8 @@ //! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener //! //! 耦合: -//! - resumeInDetached 调 aiShared.nextMsgId 占位 ai 气泡 + invoke('ai_is_generating') 核对后端真值 +//! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 + +//! invoke('ai_is_generating') 核对后端真值(主面板刷新 + 分离窗口恢复共用同款逻辑) //! - reattachPanel/detachPanel 调 useAiPanel.persistUiState import { invoke } from '@tauri-apps/api/core' @@ -83,39 +84,75 @@ async function reattachPanel() { localStorage.removeItem('df-ai-text') } +/** + * 重建生成中态(分离窗口 resumeInDetached 与主面板 onMounted 恢复共用)。 + * + * 读 localStorage df-ai-gen/df-ai-text,核对后端 ai_is_generating 真值后重建: + * 1. state.currentText = df-ai-text(已生成文本) + * 2. push 占位 assistant 气泡(content='') + * 3. state.streaming = true + * 4. state.generatingConvId = df-ai-gen + * + * 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。 + * 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false + * (见 detachPanel 注释),分离窗口用各自 webview JS context 独立 state;主面板刷新后 + * state 从单例初值 streaming=false 起步,本函数安全重建。 + * + * R4: 先核对 ai_is_generating 真值才重建,避免 localStorage 残留导致假气泡 + + * 130s 后 watchdog 假超时。后端 false 时清残留快照返回。 + * + * @returns true=已重建;false=未恢复(无快照/后端未生成/核对失败/已生成中) + */ +export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean }): Promise { + // 幂等:已生成中态不再重建(防主面板 + 分离窗口共享 state 时双重恢复; + // 主面板与分离窗口 state 各自独立,但稳妥起见仍守卫) + if (state.streaming) return false + const fromMainPanel = opts?.fromMainPanel ?? false + // 主面板刷新场景:detachPanel 未触发 → df-ai-gen 不存在。 + // 此时用 state.activeConversationId 作为生成中会话(刷新前 loadConversations 已恢复), + // 无 df-ai-text 快照 → currentText 从空开始(刷新前已生成部分丢失,刷新固有代价; + // 后端后续 AiTextDelta 仍会追加,核心是恢复 streaming 态 + 占位气泡消除假死)。 + // 分离窗口场景:读 detachPanel 写入的 df-ai-gen 快照。 + const gen = fromMainPanel ? state.activeConversationId : localStorage.getItem('df-ai-gen') + if (!gen) return false + // 核对后端 ai_is_generating 真值 + let backendGenerating = false + try { + backendGenerating = await invoke('ai_is_generating') + } catch (e) { + console.error('[AI] restoreGeneratingState: 核对 ai_is_generating 失败,跳过重建生成态', e) + return false + } + if (!backendGenerating) { + // 后端已不再生成,清残留快照(仅分离窗口快照路径),不重建 + if (!fromMainPanel) { + localStorage.removeItem('df-ai-gen') + localStorage.removeItem('df-ai-text') + } + return false + } + // 恢复已生成文本并补占位,后续 delta 继续追加 + // 主面板无 df-ai-text 快照,保持 currentText 当前值(空),仅靠占位气泡 + streaming 态接收后续 delta + if (!fromMainPanel) { + state.currentText = localStorage.getItem('df-ai-text') || '' + } + state.messages.push({ + id: `ai-${nextMsgId()}`, + role: 'assistant', + content: '', + timestamp: Date.now(), + }) + state.streaming = true + state.generatingConvId = gen + return true +} + /** 分离窗口挂载时接管主窗口当前对话(含生成中态) */ async function resumeInDetached(convId: string | null) { // R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId // (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。 void convId - const gen = localStorage.getItem('df-ai-gen') - if (gen) { - // R4: 先核对后端 ai_is_generating 真值,generating=true 才重建生成态, - // 避免 df-ai-gen 残留导致假气泡 + 130s 后 watchdog 假超时 - let backendGenerating = false - try { - backendGenerating = await invoke('ai_is_generating') - } catch (e) { - console.error('[AI] resumeInDetached: 核对 ai_is_generating 失败,跳过重建生成态', e) - return - } - if (!backendGenerating) { - // 后端已不再生成,清残留快照,不重建 - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') - return - } - // 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加 - state.currentText = localStorage.getItem('df-ai-text') || '' - state.messages.push({ - id: `ai-${nextMsgId()}`, - role: 'assistant', - content: '', - timestamp: Date.now(), - }) - state.streaming = true - state.generatingConvId = gen - } + await restoreGeneratingState() } /** 关闭分离窗口自身(分离窗口侧调用) */ @@ -182,6 +219,7 @@ export function useAiWindow() { detachPanel, reattachPanel, resumeInDetached, + restoreGeneratingState, closeDetachedWindow, dockDetached, syncToMain,