//! F-15 手动上下文管理 — 清空 / 压缩 //! //! 职责: //! - clearContext():二次确认 → aiApi.clearContext(activeConversationId) //! - compressContext():aiApi.compressContext(activeConversationId, locale) //! + isCompressing ref(loading,防重复点击) //! - initContextListener:注入生命周期回调到 useAiEvents 统一分发器 //! (原独立第二监听器已删除,治本:一个事件源→一个监听器→一个分发器) //! //! 事件由 useAiEvents.handleLifecycleEvent 统一处理: //! AiCompressing → onCompressing(isCompressing=true) //! AiManualCompressed → onManualCompressed(isCompressing=false + toast + 摘要回填) //! AiAutoCompressed → onAutoCompressed(isCompressing=false,静默不弹 toast) //! AiContextCleared → onCleared(toast + 刷新) //! AiError → onError(仅在 isCompressing 时复位 loading + toast) //! //! 耦合: //! - aiApi.clearContext / aiApi.compressContext(IPC) //! - state.activeConversationId(读当前对话) //! - resolveAiLang(压缩语言;与 useAiSend.sendMessage 同源,实现在 aiShared) import { ref } from 'vue' import { aiApi } from '@/api' import { state } from '@/stores/ai' import { resolveAiLang } from './aiShared' import { setContextCallbacks } from './useAiEvents' // 模块级单例 ref,多窗口共享(分离窗口与主窗口共用同一 isCompressing)。 // 已知限制:分离窗口压缩时,主窗口的压缩按钮也显示 loading(反之亦然);非功能 bug, // 后端压缩本身 per-conv 幂等,共享态仅影响按钮 loading 展示; // 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。 const isCompressing = ref(false) // B4 自动压缩可见信号:AiAutoCompressed 到达时置 true,4s 后自灭。输入区 hint 行消费, // 长会话用户对「上下文何时被压缩」有一次性感知(不打断 LLM 流,不弹 toast)。 const autoCompressedFlash = ref(false) let autoCompressedFlashTimer: ReturnType | null = null /** * 注入上下文生命周期回调到统一事件分发器(幂等)。 * * AiChat.vue onMounted 调一次,注入 toast/刷新副作用回调。 * 事件由 useAiEvents 统一监听与分发(原独立第二监听器已删除)。 */ async function initContextListener(callbacks: { onCleared?: (conversationId: string) => void onCompressed?: (conversationId: string, summary: string) => void onError?: (conversationId: string, message: string) => void onContextDegraded?: (conversationId: string, reason: string) => void }): Promise { setContextCallbacks({ onCompressing: () => { isCompressing.value = true }, onManualCompressed: (convId, summary) => { isCompressing.value = false callbacks.onCompressed?.(convId, summary) }, onAutoCompressed: () => { // loop 自动压缩:B4 可见信号——置 flash(输入区 hint 行短暂显示「上下文已自动压缩」), // 4s 自灭;仍不弹 toast、不刷新视图(不打断 LLM 流,消息本就可见非折叠困惑)。 isCompressing.value = false autoCompressedFlash.value = true if (autoCompressedFlashTimer) clearTimeout(autoCompressedFlashTimer) autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000) }, onCleared: (convId) => callbacks.onCleared?.(convId), onContextDegraded: (convId, reason) => { // E2:压缩失败降级为关键词摘要兜底——复位 loading + 上抛 toast(不中断 LLM 流) isCompressing.value = false callbacks.onContextDegraded?.(convId, reason) }, onError: (convId, message) => { // AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast, // 避免双重处理流式错误。 if (isCompressing.value) { isCompressing.value = false callbacks.onError?.(convId, message) } }, }) } /** 释放回调(AiChat.vue onBeforeUnmount 调) */ function stopContextListener(): void { setContextCallbacks({}) } /** * 压缩当前对话上下文。 * * 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错, * 让调用方弹错误 toast;后端处理失败走 AiError 事件 → 分发器复位 + 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, resolveAiLang()) // 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing; // AiManualCompressed 事件也复位(双保险,防事件丢失致按钮永久禁用) 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, // B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭) autoCompressedFlash, // 操作 clearContext, compressContext, // 生命周期 initContextListener, stopContextListener, } }