Files
DevFlow/src/composables/ai/useAiWindow.ts
T
lxy 3818a63522 新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
2026-10-11 07:38:00 +08:00

534 lines
24 KiB
TypeScript

//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
//! + 跨窗切会话(emitDetachedOpenConversation/registerDetachedOpenConvListener)
//!
//! 模块级私有(不进 reactive):
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
//! - _openConvListenerRegistered: 跨窗切会话监听注册标记(幂等)
//!
//! 耦合:
//! - 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 { useAppSettingsStore } from '@/stores/appSettings'
import { nextMsgId, getConvState, convStates } from './aiShared'
import type { MessageId } from '@/api/types'
import { setStreaming } from './streamingGuard'
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
}
/** 分离窗口 label 判定(含旧版单 label 'ai-detached' 兼容) */
function isDetachedLabel(label: string): boolean {
return label === 'ai-detached' || label.startsWith(DETACHED_LABEL_PREFIX)
}
// ── 跨窗切会话(UX走查 2026-10-03 P1-1)──
// 主/分离窗口是独立 JS context(state 互不同步),主窗口点「关联会话」跳转时分离窗不跟随。
// 约定:发起方 emit 全局事件广播到全部 webview,仅分离窗(按 label 判定)注册消费,
// 主窗口 label 'main' 不注册,不会自发自收。
const OPEN_CONV_EVENT = 'ai-open-conversation'
/** 发起方(主窗口)侧:广播跨窗切会话事件;无分离窗存活时事件落空 */
export async function emitDetachedOpenConversation(convId: string): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(OPEN_CONV_EVENT, { convId })
} catch (e) {
console.error('[AI] 跨窗切会话事件发送失败:', e)
}
}
// ── V11b(2026-10-05)跨窗收回分离窗:reattachPanel 的 UI 入口 ──
// 病灶:reattachPanel(关闭全部分离窗+回主面板)全仓零 UI 调用——分离窗内「关闭」按钮走
// closeDetachedWindow(仅关窗不恢复主面板),用户想收回只能手动重开主面板。主/分离窗是
// 独立 JS context,分离窗按钮无法直调主窗 store;走 Tauri 全局事件广播(对齐 OPEN_CONV_EVENT
// 模式):分离窗 emit → 主窗(label 判定)监听 → 执行 reattachPanel。
const REATTACH_EVENT = 'ai-reattach-panel'
/** 发起方(分离窗)侧:广播收回事件,主窗口消费 */
export async function emitReattachToMain(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(REATTACH_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗收回事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册收回监听。handler=reattachPanel(由 AiChat 挂载时注入,归窗口域) */
let _reattachListenerRegistered = false
export function registerMainReattachListener(handler: () => Promise<void>): void {
if (_reattachListenerRegistered) return
_reattachListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (getCurrentWebviewWindow().label !== 'main') return
await listen<Record<string, never>>(REATTACH_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口收回监听注册失败:', e)
_reattachListenerRegistered = false
}
})()
}
// ── P1(2026-10-10)分离窗口「去设置」跨窗直达:分离窗无 Settings 路由,原实现仅 toast
// 提示用户「去主窗口设置」,链路断裂(分离窗长时间工作场景下改配置须手动找窗口)。
// 现走事件广播(对齐 REATTACH_EVENT 模式):分离窗 emit → 主窗口 listen → 显示/聚焦
// 主窗 + router.push('/settings')。
const SETTINGS_EVENT = 'ai-open-settings'
/** 发起方(分离窗)侧:广播「打开设置」事件,主窗口消费 */
export async function emitOpenSettings(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(SETTINGS_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗打开设置事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册「打开设置」监听。handler 由 AiChat 注入(显示主窗 + 导航) */
let _settingsListenerRegistered = false
export function registerMainOpenSettingsListener(handler: () => Promise<void>): void {
if (_settingsListenerRegistered) return
_settingsListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (getCurrentWebviewWindow().label !== 'main') return
await listen<Record<string, never>>(SETTINGS_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口设置监听注册失败:', e)
_settingsListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
let _openConvListenerRegistered = false
export function registerDetachedOpenConvListener(handler: (convId: string) => Promise<void>): void {
if (_openConvListenerRegistered) return
_openConvListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (!isDetachedLabel(getCurrentWebviewWindow().label)) return
await listen<{ convId?: string }>(OPEN_CONV_EVENT, async (ev) => {
const convId = ev.payload?.convId
if (!convId) return
await handler(convId)
try { await getCurrentWebviewWindow().setFocus() } catch { /* 窗口关闭竞态忽略 */ }
})
} catch (e) {
_openConvListenerRegistered = false
console.error('[AI] 注册跨窗切会话监听失败:', e)
}
})()
}
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
}
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
// 用 per-conv key 写入(多会话各自快照互不覆盖)
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
if (state.streaming && targetConv) {
const cs = getConvState(targetConv)
const gen = cs === 'generating' || cs === 'stopping' || cs === 'compressed'
if (gen) {
localStorage.setItem(genKey(targetConv), targetConv)
localStorage.setItem(textKey(targetConv), state.currentText)
}
}
// V11c: 种子分离窗活跃会话 KV——分离窗 AiChat 挂载同样跑 loadConversations(),
// 读按窗口域分流的 'df-ai-active-conv-detached'(独立 JS context 不与主窗共 pinia state),
// 不种子则分离窗启动回落列表首条而非主窗正看的会话。目标 conv 为空(无活跃会话)跳过。
if (targetConv) {
void useAppSettingsStore().set('df-ai-active-conv-detached', targetConv)
}
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
// 仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
if (targetConv) convStates.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, // 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
height: 700,
minWidth: 360,
minHeight: 400,
center: true,
decorations: true,
// V11a(2026-10-05):默认不置顶——原 true 会让分离窗盖住其他应用(VSCode/浏览器)妨碍工作;
// 用户开过置顶的偏好走 AiChat.vue 的 df-ai-always-on-top KV 恢复链(分离窗挂载时
// setAlwaysOnTop(saved)),默认改 false 不影响已开置顶用户,只改变首次/未开者的初始态。
alwaysOnTop: false,
})
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
// 时序注(2026-10-05 租约模型):原此处同步调 stopListener() 提前拆主窗 listener,消除
// detached=true 到 AiChat 实际卸载的窗口期竞态——但同步拆全局单例正是双实例互踩根源,
// 租约模型下删除:主窗 v-if 立即失效 → onBeforeUnmount 归还租约(最后一份才真拆)。
// 竞态窗口期(毫秒级)旧 listener 多活无害:事件按 conversation_id 路由,分离窗口独立注册
// (BUG-260624-02 已实证审批事件无缺口),主窗残听仅多收一次同事件,无串台。
//
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
// 分离窗口 ToolCardList 的 listener 同样收到。主窗 listener 由租约归还(见上方时序注)释清,
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
}
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
* 多会话并发下可能存在多个 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 (isDetachedLabel(lbl)) {
try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ }
}
}
state.detached = false
state.docked = false
state.panelOpen = true
persistUiState()
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
void useAppSettingsStore().remove('df-ai-active-conv-detached')
// 时序注(租约模型):原此处显式 stopListener 防「分离窗旧 listener 与主面板重挂载重叠」——
// 但分离窗 listener 活在分离窗自己的 JS context,主窗此处拆的只是本 context 已归还空的
// listener(跨 webview 隔离,原调用实为 no-op 式心理安慰)。租约归还链已完整覆盖,删除。
// 清所有 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. convStates.set(df-ai-gen, 'generating')(批4 双轨收口:替代旧 generatingConvs.add)
*
* 幂等:若 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') || ''
}
// 双占位去重:末条已是空 assistant 占位(本会话流式占位)则复用,不重复 push。
const lastMsg = state.messages[state.messages.length - 1]
const lastIsStreamingPlaceholder = !!lastMsg
&& lastMsg.role === 'assistant'
&& !lastMsg.content?.trim()
&& !lastMsg.isError
&& !(lastMsg.toolCalls && lastMsg.toolCalls.length)
if (!lastIsStreamingPlaceholder) {
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
}
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
// 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。
// setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。
convStates.set(gen, 'generating')
return true
}
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
async function resumeInDetached(convId: string | null) {
// R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId
// (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。
// 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。
await restoreGeneratingState({ convId: convId || undefined })
}
/** 关闭分离窗口自身(分离窗口侧调用)。
* 从当前 webview 的 label 反推 convId,清自己的 per-conv 快照。 */
async function closeDetachedWindow() {
state.docked = false
stopFollowMain()
// 时序注(租约模型):原此处关闭前先 stopListener 防竞态——同上,租约归还链
// (本窗口 AiChat onBeforeUnmount)已完整覆盖,强拆会使租约计数与实际状态失配,删除。
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 窗口宽度/间隙常量*/
const AI_DOCK_WIDTH = 600
const AI_DOCK_GAP = 4
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
let _isSyncing = false
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
let _syncTimer: ReturnType<typeof setTimeout> | null = null
const SYNC_DEBOUNCE_MS = 150
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
*
* 设计原则(本次优化重点):
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
* (往左偏移或缩宽),不动主窗口。
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
*/
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
const pos = await mainWin.outerPosition()
const size = await mainWin.innerSize()
const maximized = await mainWin.isMaximized()
// 计算目标位置(默认贴主窗口右侧 + GAP)
let targetX = pos.x + size.width + AI_DOCK_GAP
let targetY = pos.y
let targetHeight = size.height
let targetWidth = AI_DOCK_WIDTH
if (maximized) {
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
if (screenWidth > 0) {
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
}
// 最大化时 targetHeight = size.height, height > height 恒 false,
// 原三元是无操作死代码,已移除。
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
}
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
} catch (e) {
console.error('[AI] syncToMain 失败:', e)
} finally {
_isSyncing = false
}
}
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
function scheduleSync() {
if (_syncTimer) clearTimeout(_syncTimer)
_syncTimer = setTimeout(() => {
_syncTimer = null
void syncToMain()
}, SYNC_DEBOUNCE_MS)
}
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
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(() => { scheduleSync() })
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
}
/** 停止主窗口跟随 + 清防抖计时器 */
function stopFollowMain() {
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
_unlistenMove?.()
_unlistenResize?.()
_unlistenMove = null
_unlistenResize = null
}
export function useAiWindow() {
return {
detachPanel,
reattachPanel,
resumeInDetached,
restoreGeneratingState,
closeDetachedWindow,
dockDetached,
syncToMain,
startFollowMain,
stopFollowMain,
}
}