Files
DevFlow/src/composables/ai/useAiWindow.ts
绝尘 7d5402951b 新增: 多会话并发前端 UI 阶段3/4(F-09 收尾)
后端 per-conv 并发已就绪(d899c58 + CR-03~07),前端呈现层收尾:

阶段3 多会话生成态:
- stores/ai.ts generatingConvId 单值 → generatingConvs:Set + add/remove/isGenerating helper
- useAiEvents 5 处迁移(Completed/Error/路由标记改 per-conv Set 操作,
  AiError 精确清出错会话非全局清空,修原 = null 误清其他会话)
- ConversationSidebar 多会话并行生成脉冲指示(isGenerating 各会话独立)

阶段4 独立窗口多会话:
- useAiWindow label ai-detached → ai-detached-{convId}(per-conv 独立窗口)
- localStorage 单 key → per-conv key(防多会话串扰,读路径兼容旧 key 回退)

vue-tsc EXIT 0 + generatingConvId 零代码残留(仅注释)
2026-06-19 20:15:37 +08:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 窗口分离模式 — 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<boolean> {
// 幂等:已生成中态不再重建(防主面板 + 分离窗口共享 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<boolean>('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-<convId>' → 清对应快照;旧 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,
}
}