- 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
136 lines
5.4 KiB
TypeScript
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,
|
|
}
|
|
}
|