//! F-15 阶段2: 手动上下文管理 — 清空 / 压缩 //! //! 职责: //! - clearContext():二次确认 → aiApi.clearContext(activeConversationId) //! - compressContext():aiApi.compressContext(activeConversationId, locale) //! + isCompressing ref(loading,防重复点击) //! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase, //! 对齐 useAiEvents.ts 既有 9 事件): //! AiCompressing → isCompressing=true //! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里) //! AiContextCleared → (刷新提示由调用方弹) //! AiError → isCompressing=false + (错误 toast 由调用方弹) //! //! 设计: //! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含 //! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单), //! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。 //! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。 //! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref), //! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用, //! 避免循环依赖(composable import 组件 toast)。 //! - listener 幂等:已注册则直接返回(对齐 useAiEvents.startListener 的并发去重)。 //! //! 耦合: //! - aiApi.clearContext / aiApi.compressContext(IPC) //! - state.activeConversationId(读当前对话) //! - appSettings.get(df-ai-language/df-language)(压缩语言;与 useAiSend.sendMessage 同源) import { ref } from 'vue' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' const appSettings = useAppSettingsStore() /// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false) const isCompressing = ref(false) /// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等 let _unlistenContext: UnlistenFn | null = null /// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染) /// 用对象形式便于选择性注入(只压成功了才需 onCompressed) interface ContextCallbacks { /** 清空完成(AiContextCleared):toast + 刷新消息列表 */ onCleared?: (conversationId: string) => void /** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */ onCompressed?: (conversationId: string, summary: string) => void /** 压缩失败 / 错误(AiError with conversation_id):toast */ onError?: (conversationId: string, message: string) => void } let _callbacks: ContextCallbacks = {} /** * 注册 ai-chat-event 生命周期事件监听器(幂等)。 * * AiChat.vue onMounted 调一次,注入 toast/刷新副作用回调。 * 不能在模块加载时副作用注册(组件未挂载,listener 仍生效但回调为空 → 无意义); * 必须在 AiChat 挂载后注入回调再注册,保证回调已就绪。 * * 事件载荷是任意对象(新 type 未进 AiChatEvent 联合,types.ts 不在本任务白名单), * 这里按 type 字符串结构判定 + conversation_id 路由(仅处理当前活跃对话的事件, * 跨对话事件忽略——清空/压缩仅作用于当前对话,跨对话事件理论不会发生但防御)。 */ async function initContextListener(callbacks: ContextCallbacks = {}): Promise { _callbacks = callbacks if (_unlistenContext) return // 幂等 _unlistenContext = await listen<{ type?: string; conversation_id?: string; summary?: string; message?: string }>( 'ai-chat-event', (e) => { const payload = e.payload if (!payload || typeof payload.type !== 'string') return const convId = payload.conversation_id // 跨对话事件忽略(清空/压缩仅作用于当前活跃对话) if (convId && state.activeConversationId && convId !== state.activeConversationId) return switch (payload.type) { case 'AiCompressing': isCompressing.value = true break case 'AiCompressed': isCompressing.value = false _callbacks.onCompressed?.(convId || '', payload.summary || '') break case 'AiContextCleared': _callbacks.onCleared?.(convId || '') break case 'AiError': // AiError 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡); // 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。 if (isCompressing.value) { isCompressing.value = false _callbacks.onError?.(convId || '', payload.message || '') } break default: // 其他 type(delta/tool/agent round 等)交 useAiEvents 处理,本监听器不触碰 break } }, ) } /** 释放监听器(AiChat.vue onBeforeUnmount 调) */ function stopContextListener(): void { _unlistenContext?.() _unlistenContext = null _callbacks = {} } /** * 解析压缩语言(与 useAiSend.sendMessage 同源逻辑): * - df-ai-language='auto' → df-language(应用主语言) * - 否则用 df-ai-language 指定值 * - 兜底 'zh-CN' */ function resolveLanguage(): string { const raw = appSettings.get('df-ai-language', 'auto') if (raw === 'auto') { return appSettings.get('df-language', 'zh-CN') } return raw } /** * 压缩当前对话上下文。 * * 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错, * 让调用方弹错误 toast;后端处理失败走 AiError 事件 → 监听器复位 + onCompressed/onError。 * * @throws IPC 失败时抛出原错(调用方 toast) */ async function compressContext(): Promise { const convId = state.activeConversationId if (!convId) return if (isCompressing.value) return // 防重复点击 isCompressing.value = true try { await aiApi.compressContext(convId, resolveLanguage()) // 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing; // case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用) isCompressing.value = false } catch (e) { isCompressing.value = false throw e } } /** * 清空当前对话上下文(仅触发 IPC,二次确认由调用方 AiChat.vue 弹)。 * * @throws IPC 失败时抛出原错(调用方 toast) */ async function clearContext(): Promise { const convId = state.activeConversationId if (!convId) return await aiApi.clearContext(convId) } export function useAiContext() { return { // 状态 isCompressing, // 操作 clearContext, compressContext, // 生命周期 initContextListener, stopContextListener, } }