//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain //! //! 模块级私有(不进 reactive): //! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener //! //! 耦合: //! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 + //! invoke('ai_is_generating') 核对后端真值(主面板刷新 + 分离窗口恢复共用同款逻辑) //! - reattachPanel/detachPanel 调 useAiPanel.persistUiState import { invoke } from '@tauri-apps/api/core' import { state } from '@/stores/ai' import { nextMsgId } from './aiShared' import { persistUiState } from './useAiPanel' // ── 主窗口事件跟随 unlistener ── let _unlistenMove: (() => void) | null = null let _unlistenResize: (() => void) | null = null /** * F-09 阶段4:多会话独立窗口的 per-conv key/label 生成器。 * * 旧实现用单 label 'ai-detached' + 单 key df-ai-gen/df-ai-text,多会话并发下冲突 * (会话 A 的窗口快照被会话 B 覆盖,getByLabel 只能存一个窗口)。现按 convId 命名空间 * 隔离:每个会话独立窗口 + 独立 localStorage 快照,互不串扰。 * 单会话场景:convId 固定,key/label 退化为单值,行为等价。 */ const DETACHED_LABEL_PREFIX = 'ai-detached-' const GEN_KEY_PREFIX = 'df-ai-gen-' const TEXT_KEY_PREFIX = 'df-ai-text-' function detachedLabel(convId: string): string { return DETACHED_LABEL_PREFIX + convId } function genKey(convId: string): string { return GEN_KEY_PREFIX + convId } function textKey(convId: string): string { return TEXT_KEY_PREFIX + convId } /** 分离 AI 面板到独立窗口(若该会话窗口已存在则聚焦);快照当前生成态供分离窗口接管 */ async function detachPanel(convId?: string) { const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const targetConv = convId || state.activeConversationId || '' const label = targetConv ? detachedLabel(targetConv) : 'ai-detached' const existing = await WebviewWindow.getByLabel(label) if (existing) { await existing.setFocus() return } // 快照当前生成态,供分离窗口接管(保持正在进行的对话) // F-09:用 per-conv key 写入(多会话各自快照互不覆盖) if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) { localStorage.setItem(genKey(targetConv), targetConv) localStorage.setItem(textKey(targetConv), state.currentText) } // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 // F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。 // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清, // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。 state.streaming = false if (targetConv) state.generatingConvs.delete(targetConv) const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : '' const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep const win = new WebviewWindow(label, { url, title: 'DevFlow AI', width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致 height: 700, minWidth: 360, minHeight: 400, center: true, decorations: true, }) win.once('tauri://created', () => { state.panelOpen = false persistUiState() }) win.once('tauri://error', (e) => { console.error('[AI] 窗口创建失败:', e) state.detached = false }) win.once('tauri://destroyed', () => { state.detached = false state.docked = false // R3: X 关分离窗口后主面板自动恢复,无需手动 Ctrl+I state.panelOpen = true stopFollowMain() }) state.detached = true } /** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。 * F-09:多会话并发下可能存在多个 detached 窗口,全部关闭并清各自 per-conv 快照。 */ async function reattachPanel() { stopFollowMain() const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') // 关闭所有 ai-detached-* 前缀的窗口(含旧版单 label 'ai-detached' 兼容) const all = await WebviewWindow.getAll() for (const w of all) { const lbl = w.label if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) { try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ } } } state.detached = false state.docked = false state.panelOpen = true persistUiState() // F-09:清所有 per-conv 快照(分离窗口已全关,快照无消费方) cleanupAllDetachedSnapshots() } /** 清理所有 df-ai-gen-* / df-ai-text-* 快照(分离窗口全关 / 整轮收尾时调用) */ function cleanupAllDetachedSnapshots(): void { const keysToRemove: string[] = [] for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i) if (!k) continue if (k.startsWith(GEN_KEY_PREFIX) || k.startsWith(TEXT_KEY_PREFIX) || k === 'df-ai-gen' || k === 'df-ai-text') { keysToRemove.push(k) } } for (const k of keysToRemove) localStorage.removeItem(k) } /** * 重建生成中态(分离窗口 resumeInDetached 与主面板 onMounted 恢复共用)。 * * 读 localStorage df-ai-gen-${convId}/df-ai-text-${convId}(F-09 per-conv key),核对后端 ai_is_generating 真值后重建: * 1. state.currentText = df-ai-text-${convId}(已生成文本) * 2. push 占位 assistant 气泡(content='') * 3. state.streaming = true * 4. state.generatingConvs.add(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; convId?: string }): 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-${convId} 快照(F-09 per-conv key)。 const gen = fromMainPanel ? state.activeConversationId : (opts?.convId && localStorage.getItem(genKey(opts.convId))) || localStorage.getItem('df-ai-gen') if (!gen) return false // 核对后端 ai_is_generating 真值 // F-260616-09 B 批4(决策 e):传 gen(目标 conv)精确查,不读全局单值。 let backendGenerating = false try { backendGenerating = await invoke('ai_is_generating', { conversationId: gen }) } catch (e) { console.error('[AI] restoreGeneratingState: 核对 ai_is_generating 失败,跳过重建生成态', e) return false } if (!backendGenerating) { // 后端已不再生成,清残留快照(仅分离窗口快照路径),不重建 if (!fromMainPanel) { if (opts?.convId) { localStorage.removeItem(genKey(opts.convId)) localStorage.removeItem(textKey(opts.convId)) } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') } return false } // 恢复已生成文本并补占位,后续 delta 继续追加 // 主面板无 df-ai-text-* 快照,保持 currentText 当前值(空),仅靠占位气泡 + streaming 态接收后续 delta if (!fromMainPanel) { state.currentText = (opts?.convId && localStorage.getItem(textKey(opts.convId))) || localStorage.getItem('df-ai-text') || '' } state.messages.push({ id: `ai-${nextMsgId()}`, role: 'assistant', content: '', timestamp: Date.now(), }) state.streaming = true state.generatingConvs.add(gen) return true } /** 分离窗口挂载时接管主窗口当前对话(含生成中态) */ async function resumeInDetached(convId: string | null) { // R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId // (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。 // F-09: 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。 await restoreGeneratingState({ convId: convId || undefined }) } /** 关闭分离窗口自身(分离窗口侧调用)。 * F-09:从当前 webview 的 label 反推 convId,清自己的 per-conv 快照。 */ async function closeDetachedWindow() { state.docked = false stopFollowMain() const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') const currentWin = getCurrentWebviewWindow() const lbl = currentWin.label // per-conv label 'ai-detached-' → 清对应快照;旧 label 'ai-detached' → 清遗留单 key if (lbl.startsWith(DETACHED_LABEL_PREFIX)) { const convId = lbl.slice(DETACHED_LABEL_PREFIX.length) localStorage.removeItem(genKey(convId)) localStorage.removeItem(textKey(convId)) } else { localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') } await currentWin.close() } /** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */ async function dockDetached() { // 取消吸附 if (state.docked) { state.docked = false stopFollowMain() return } try { await syncToMain() state.docked = true startFollowMain() } catch (e) { console.error('[AI] 吸附失败:', e) } } /** 吸附态 AI 窗口宽度/间隙常量(U-260618) */ const AI_DOCK_WIDTH = 600 const AI_DOCK_GAP = 4 // 防 syncToMain 改主窗口 size 触发 onResized 递归回调 let _isSyncing = false /** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。 * U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度, * 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */ async function syncToMain() { if (_isSyncing) return _isSyncing = true try { const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi') const { currentMonitor } = await import('@tauri-apps/api/window') const mainWin = await WebviewWindow.getByLabel('main') const aiWin = getCurrentWebviewWindow() if (!mainWin) return const monitor = await currentMonitor() const screenWidth = monitor?.size.width ?? 0 // 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口) if (await mainWin.isMaximized()) { await mainWin.unmaximize() await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸 } let pos = await mainWin.outerPosition() let size = await mainWin.innerSize() // 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕 if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) { const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x if (newMainWidth >= 400) { await mainWin.setSize(new PhysicalSize(newMainWidth, size.height)) await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定 pos = await mainWin.outerPosition() size = await mainWin.innerSize() } } await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y)) await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height)) } catch (e) { console.error('[AI] syncToMain 失败:', e) } finally { _isSyncing = false } } /** 启动主窗口 move/resize 跟随(吸附时调用) */ async function startFollowMain() { stopFollowMain() const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const mainWin = await WebviewWindow.getByLabel('main') if (!mainWin) return _unlistenMove = await mainWin.onMoved(() => { void syncToMain() }) _unlistenResize = await mainWin.onResized(() => { void syncToMain() }) } /** 停止主窗口跟随 */ function stopFollowMain() { _unlistenMove?.() _unlistenResize?.() _unlistenMove = null _unlistenResize = null } export function useAiWindow() { return { detachPanel, reattachPanel, resumeInDetached, restoreGeneratingState, closeDetachedWindow, dockDetached, syncToMain, startFollowMain, stopFollowMain, } }