修复: CR-50 RED(useAiContext事件case改PascalCase+compress兜底复位)

This commit is contained in:
2026-06-17 01:21:47 +08:00
parent de9809688e
commit fb4b067728

View File

@@ -4,15 +4,16 @@
//! - clearContext():二次确认 → aiApi.clearContext(activeConversationId) //! - clearContext():二次确认 → aiApi.clearContext(activeConversationId)
//! - compressContext():aiApi.compressContext(activeConversationId, locale) //! - compressContext():aiApi.compressContext(activeConversationId, locale)
//! + isCompressing ref(loading,防重复点击) //! + isCompressing ref(loading,防重复点击)
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件: //! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase,
//! ai_compressing → isCompressing=true //! 对齐 useAiEvents.ts 既有 9 事件):
//! ai_compressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里) //! AiCompressing → isCompressing=true
//! ai_context_cleared → (刷新提示由调用方弹) //! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里)
//! ai_error → isCompressing=false + (错误 toast 由调用方弹) //! AiContextCleared → (刷新提示由调用方弹)
//! AiError → isCompressing=false + (错误 toast 由调用方弹)
//! //!
//! 设计: //! 设计:
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含 //! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
//! ai_compressing/ai_compressed/ai_context_cleared,因 types.ts 不在本任务白名单), //! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。 //! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。 //! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref), //! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
@@ -33,7 +34,7 @@ import { state } from '@/stores/ai'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
/// 压缩中 loading(防重复点击;ai_compressing→true / ai_compressed|ai_error→false) /// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false)
const isCompressing = ref(false) const isCompressing = ref(false)
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等 /// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
@@ -42,11 +43,11 @@ let _unlistenContext: UnlistenFn | null = null
/// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染) /// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染)
/// 用对象形式便于选择性注入(只压成功了才需 onCompressed) /// 用对象形式便于选择性注入(只压成功了才需 onCompressed)
interface ContextCallbacks { interface ContextCallbacks {
/** 清空完成(ai_context_cleared):toast + 刷新消息列表 */ /** 清空完成(AiContextCleared):toast + 刷新消息列表 */
onCleared?: (conversationId: string) => void onCleared?: (conversationId: string) => void
/** 压缩成功(ai_compressed):toast + 渲染摘要 system 消息 */ /** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */
onCompressed?: (conversationId: string, summary: string) => void onCompressed?: (conversationId: string, summary: string) => void
/** 压缩失败 / 错误(ai_error with conversation_id):toast */ /** 压缩失败 / 错误(AiError with conversation_id):toast */
onError?: (conversationId: string, message: string) => void onError?: (conversationId: string, message: string) => void
} }
let _callbacks: ContextCallbacks = {} let _callbacks: ContextCallbacks = {}
@@ -74,18 +75,18 @@ async function initContextListener(callbacks: ContextCallbacks = {}): Promise<vo
// 跨对话事件忽略(清空/压缩仅作用于当前活跃对话) // 跨对话事件忽略(清空/压缩仅作用于当前活跃对话)
if (convId && state.activeConversationId && convId !== state.activeConversationId) return if (convId && state.activeConversationId && convId !== state.activeConversationId) return
switch (payload.type) { switch (payload.type) {
case 'ai_compressing': case 'AiCompressing':
isCompressing.value = true isCompressing.value = true
break break
case 'ai_compressed': case 'AiCompressed':
isCompressing.value = false isCompressing.value = false
_callbacks.onCompressed?.(convId || '', payload.summary || '') _callbacks.onCompressed?.(convId || '', payload.summary || '')
break break
case 'ai_context_cleared': case 'AiContextCleared':
_callbacks.onCleared?.(convId || '') _callbacks.onCleared?.(convId || '')
break break
case 'ai_error': case 'AiError':
// ai_error 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡); // AiError 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡);
// 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。 // 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。
if (isCompressing.value) { if (isCompressing.value) {
isCompressing.value = false isCompressing.value = false
@@ -125,7 +126,7 @@ function resolveLanguage(): string {
* 压缩当前对话上下文。 * 压缩当前对话上下文。
* *
* 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错, * 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错,
* 让调用方弹错误 toast;后端处理失败走 ai_error 事件 → 监听器复位 + onCompressed/onError。 * 让调用方弹错误 toast;后端处理失败走 AiError 事件 → 监听器复位 + onCompressed/onError。
* *
* @throws IPC 失败时抛出原错(调用方 toast) * @throws IPC 失败时抛出原错(调用方 toast)
*/ */
@@ -136,7 +137,9 @@ async function compressContext(): Promise<void> {
isCompressing.value = true isCompressing.value = true
try { try {
await aiApi.compressContext(convId, resolveLanguage()) await aiApi.compressContext(convId, resolveLanguage())
// 成功 IPC 仅表示请求受理;真正压缩完成由 ai_compressed 事件复位 isCompressing // 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
// case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
isCompressing.value = false
} catch (e) { } catch (e) {
isCompressing.value = false isCompressing.value = false
throw e throw e