- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod - AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation - 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n - 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
//! 面板与持久化 — 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<any>('df-ai-ui', null))
|
|
const savedProvider = appSettings.get<string | null>('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
|
|
// 关闭面板时同步退出最大化,避免 <main> 被 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,
|
|
}
|
|
}
|