Files
DevFlow/src/composables/ai/useAiContext.ts
T
lxy 5cebfcd864 新增: 压缩失败降级前端透出与vite看门修复
- E2:LLM压缩失败走关键词摘要兜底时新发AiContextDegraded事件,
  前端toast「上下文已降级」不再静默失忆(实证76730ce8 seq166
  用户被迫原句重发);不发AiError防误判生成结束
- keyword_fallback_commit返回inserted供emit判定,双调用点
  (LLM失败/provider重建失败)透出
- vite watch忽略target/:cargo编译写.o触发EBUSY杀掉dev进程

验收:cargo check 0 error;keyword_fallback 3 passed;vue-tsc EXIT 0
2026-10-19 00:00:55 +08:00

136 lines
5.4 KiB
TypeScript

//! 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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
const convId = state.activeConversationId
if (!convId) return
await aiApi.clearContext(convId)
}
export function useAiContext() {
return {
// 状态
isCompressing,
// B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭)
autoCompressedFlash,
// 操作
clearContext,
compressContext,
// 生命周期
initContextListener,
stopContextListener,
}
}