//! 面板与持久化 — togglePanel/toggleMaximize/cyclePanelMode + UI 持久化(restoreUiState/persistUiState) //! + token 开关 + providers/skills/clearChat //! //! 模块级私有: //! - 启动恢复:模块加载时 restoreUiState()(从 appSettings 读上次 UI 布局) //! - watch appSettings.cache['df-ai-ui']:loadAll() 异步填充后再次应用 UI 布局 //! //! 耦合: //! - togglePanel/toggleMaximize/toggleArchivedFold/toggleSidebar 调 persistUiState import { watch } from 'vue' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { setStreaming } from './streamingGuard' import { nextMsgId } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiProviderConfig } from '@/api/types' const appSettings = useAppSettingsStore() /** 侧栏宽度可拖拽范围(UX-2025-16)。120=窄屏友好,280=宽屏舒适 */ export const SIDEBAR_WIDTH_MIN = 120 export const SIDEBAR_WIDTH_MAX = 280 export const SIDEBAR_WIDTH_DEFAULT = 200 /** clamp 侧栏宽度到合法区间;非法/缺省值回默认(防 NaN/0/负数污染 state) */ function clampSidebarWidth(v: any): number { const n = typeof v === 'number' && Number.isFinite(v) ? v : SIDEBAR_WIDTH_DEFAULT return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n)) } /** * 将缓存的 UI 布局对象安全应用到模块级 state(仅 boolean 字段覆盖,其余忽略)。 * restoreUiState 与 watch 回调共用,消除重复赋值。 * detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false */ function applyUiState(s: any) { if (!s) return if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen if (typeof s.maximized === 'boolean') state.maximized = s.maximized if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed if (s.foldedGroups && typeof s.foldedGroups === 'object') { state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups } } // 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底) state.sidebarWidth = clampSidebarWidth(s.sidebarWidth) } // 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state) // F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验 // DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。 function restoreUiState() { applyUiState(appSettings.get('df-ai-ui', null)) const savedProvider = appSettings.get('df-ai-active-provider', null) if (savedProvider) state.activeProvider = savedProvider } // 循环依赖兜底:stores/ai ↔ useAiPanel 互 import,顶层 restoreUiState 执行时 state 可能尚未 // 初始化(TDZ)。冷启动靠 appSettings 缓存为空 → applyUiState(null) 提前 return 绕过访问; // 但 HMR 热重载 stores/ai.ts 时 appSettings 已有缓存,restoreUiState 真正访问 state 触发 // ReferenceError: Cannot access 'state' before initialization。对齐 stores/ai.ts __bindMessages // 既存模式:try 失败则微任务延迟重试,待 state 就绪后落库恢复。 try { restoreUiState() } catch { Promise.resolve().then(restoreUiState) } // loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次) watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any)) /** 持久化 UI 布局到 appSettings(debounced 写 SQLite) */ export function persistUiState() { void appSettings.set('df-ai-ui', { panelOpen: state.panelOpen, maximized: state.maximized, sidebarOpen: state.sidebarOpen, archivedCollapsed: state.archivedCollapsed, foldedGroups: state.foldedGroups, // 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘 sidebarWidth: state.sidebarWidth, }) } /** * 设置侧栏宽度(clamp 后写 state,不持久化)。 * 拖拽 mousemove 高频调用,持久化由调用方在 mouseup 时一次 persistUiState() 完成, * 避免每像素触发 debounced SQLite 写。 */ export function setSidebarWidth(px: number) { state.sidebarWidth = clampSidebarWidth(px) } /** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */ function togglePanel() { state.panelOpen = !state.panelOpen // 关闭面板时同步退出最大化,避免
被 v-show 隐藏导致其他页面无法交互 if (!state.panelOpen) state.maximized = false persistUiState() } /** 最大化/还原侧栏宽度 */ function toggleMaximize() { state.maximized = !state.maximized persistUiState() } /** * Ctrl+I 三态循环(关闭 → 分栏打开 → 最大化 → 关闭)。 * * 与 togglePanel(二态开关)区分:本函数仅供全局快捷键 Ctrl+I 用;UI 按钮(侧栏 AI 按钮 / * TopBar 关闭钮)与审批浮层激活仍走 togglePanel,保持"开/关"二态语义不被破坏。 * * 循环方向逐级增强再回关闭,贴合"要用 AI → 专注看 AI → 用完了"心智: * 关闭(panelOpen=false) → 分栏打开(panelOpen=true, maximized=false) * 分栏打开(maximized=false) → 最大化(maximized=true,全宽占满 main) * 最大化(maximized=true) → 关闭(panelOpen=false, maximized=false) * * detached 模式 no-op:分离窗口下主窗口不渲染面板,循环无意义;分离窗口自身走系统标题栏最大化。 */ function cyclePanelMode() { // 分离窗口模式:主窗口无面板,三态循环无意义(no-op;关闭分离窗口时 destroyed 钩子自恢复 panelOpen) if (state.detached) return if (!state.panelOpen) { state.panelOpen = true state.maximized = false } else if (!state.maximized) { state.maximized = true } else { state.panelOpen = false state.maximized = false } persistUiState() } // ── Providers / Skills / 清屏 ── /** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */ async function loadProviders() { const list = await aiApi.listProviders() state.providers = list // F-260616-10: 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步) // CR-34-1: 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) { const def = list.find((p: AiProviderConfig) => p.is_default) if (def) state.activeProvider = def.id } } /** 拉取本机技能列表(用于 `/` 联想,静默失败) */ async function loadSkills() { try { state.skills = await aiApi.listSkills() } catch { // 静默失败,不影响主流程 } } /** 切换活跃 provider(后端 + 本地 state + 持久化) */ async function setProvider(providerId: string) { await aiApi.setProvider(providerId) state.activeProvider = providerId // F-260616-10: 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式) void appSettings.set('df-ai-active-provider', providerId) } /** 清空当前会话消息(后端 + 本地 state) */ async function clearChat() { try { await aiApi.clearChat() state.messages = [] state.currentText = '' state.pendingApprovals = [] setStreaming(false, { reason: 'clearChat' }) } catch (e) { // A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。 // 后端裁决「先停 loop → DB 单事务 → 内存清」:DB 失败时内存不动(回滚一致),故此处 // 只推错误气泡告知用户(对齐 useAiConversations.pushConvOpFail 模式),不吞异常日志。 // 注意:不 rethrow——confirmClearChat 无 try,裸抛会进 unhandledrejection 且用户无感; // 错误气泡已足够反馈,且状态保持未清(与后端内存一致)。 const msg = e instanceof Error ? e.message : String(e) state.messages.push({ id: `conv-op-fail-${nextMsgId()}`, role: 'assistant', content: t('aiChat.clearError', { msg }), isError: true, timestamp: Date.now(), } as AiMessage) console.error('[AI] 清空对话失败:', e) } } export function useAiPanel() { return { togglePanel, toggleMaximize, cyclePanelMode, loadProviders, loadSkills, setProvider, clearChat, // UX-2025-16:侧栏宽度拖拽 setSidebarWidth, persistUiState, } }