diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index c0649ba..8335cf9 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -270,7 +270,7 @@ pub(super) async fn maybe_auto_compress( ); // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补: // 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。 - keyword_fallback_commit( + let degraded = keyword_fallback_commit( &session_arc, &conv_id, protect_start, @@ -281,6 +281,16 @@ pub(super) async fn maybe_auto_compress( recall_on.then_some(data_dir.as_path()), ) .await; + // E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发) + if degraded { + emit_ai_chat_event( + &app_handle, + AiChatEvent::AiContextDegraded { + conversation_id: Some(conv_id.to_string()), + reason: "压缩 provider 重建失败,已降级为关键词摘要".into(), + }, + ); + } return; } }; @@ -384,7 +394,7 @@ pub(super) async fn maybe_auto_compress( "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", KEYWORD_FALLBACK_ENABLED, ); - keyword_fallback_commit( + let degraded = keyword_fallback_commit( &session_arc, &conv_id, protect_start, @@ -395,6 +405,17 @@ pub(super) async fn maybe_auto_compress( recall_on.then_some(data_dir.as_path()), ) .await; + // E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑, + // 用专用事件让前端透出降级状态条/提示(不打断流) + if degraded { + emit_ai_chat_event( + &app_handle, + AiChatEvent::AiContextDegraded { + conversation_id: Some(conv_id.to_string()), + reason: "LLM 压缩失败,已降级为关键词摘要".into(), + }, + ); + } } } }) // 闭 catch_unwind 内 async move @@ -444,11 +465,11 @@ async fn keyword_fallback_commit( log_head: &str, head_id: &Option, recall: Option<&Path>, -) { +) -> bool { let inserted = { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(conv_id) { - return; + return false; } let conv = session.conv(conv_id); // 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。 @@ -458,7 +479,7 @@ async fn keyword_fallback_commit( "[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃", log_head ); - return; + return false; } // 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。 conv.messages.set_compressing(false); @@ -495,6 +516,7 @@ async fn keyword_fallback_commit( log_head ); } + inserted } #[cfg(test)] diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 37fb1af..17f5a19 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -317,6 +317,11 @@ pub enum AiChatEvent { /// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。 /// miniapp 仍插摘要气泡(对端发生压缩告知用户)。 AiAutoCompressed { conversation_id: Option, summary: String }, + /// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续 + /// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓 + /// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。 + /// 前端弹一次性状态提示,不中断流。 + AiContextDegraded { conversation_id: Option, reason: String }, /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI diff --git a/src/api/types.ts b/src/api/types.ts index 7331941..b511b03 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -539,6 +539,9 @@ export type AiChatEvent = ({ } | { // 上下文已清空:前端刷新消息列表 + toast 提示 type: 'AiContextCleared' +} | { + // E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流 + type: 'AiContextDegraded'; reason: string }) & { conversation_id?: ConvId } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 70064cb..4d03d10 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -698,6 +698,10 @@ async function initContextEventListeners(): Promise { onError: (_convId, message) => { showToast(t('aiChat.compressError', { msg: message }), 'error') }, + // E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发) + onContextDegraded: (_convId, reason) => { + showToast(t('aiChat.contextDegraded', { reason }), 'warning') + }, }) } diff --git a/src/composables/ai/useAiContext.ts b/src/composables/ai/useAiContext.ts index 124bc7c..6d38436 100644 --- a/src/composables/ai/useAiContext.ts +++ b/src/composables/ai/useAiContext.ts @@ -46,6 +46,7 @@ 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 }, @@ -62,6 +63,11 @@ async function initContextListener(callbacks: { 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, // 避免双重处理流式错误。 diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index aa0f42a..f23892d 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -30,6 +30,8 @@ export interface ContextCallbacks { onCleared?: (conversationId: string) => void /** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */ onError?: (conversationId: string, message: string) => void + /** E2 压缩失败降级(AiContextDegraded):loop 仍在跑,仅提示上下文已降级不中断流 */ + onContextDegraded?: (conversationId: string, reason: string) => void } let _contextCallbacks: ContextCallbacks = {} @@ -344,6 +346,11 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise _contextCallbacks.onCleared?.(event.conversation_id || '') return true + case 'AiContextDegraded': + // E2:压缩失败已走关键词摘要兜底,loop 继续——透出降级提示而非静默失忆 + _contextCallbacks.onContextDegraded?.(event.conversation_id || '', event.reason || '') + return true + default: return false } diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index ce350a6..a0f60a5 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -275,6 +275,7 @@ export default { // B4: one-shot auto-compress notice (loop auto-compress no longer fully silent) autoCompressed: 'Context auto-compressed (earlier messages folded into summary)', compressError: 'Compression failed: {msg}', + contextDegraded: 'Context degraded: {reason} — the AI may be missing some earlier details', clearError: 'Clear failed: {msg}', initError: 'AI panel init failed: {msg} (reopen panel to retry)', initDegraded: 'List load failed, continuing in degraded mode: {msg}', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 9d6e9d1..35042c4 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -276,6 +276,8 @@ export default { // B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默) autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)', compressError: '压缩失败:{msg}', + // E2:压缩失败走关键词摘要兜底,loop 继续——提示降级而非静默失忆 + contextDegraded: '上下文已降级:{reason},AI 可能缺失部分早前细节', clearError: '清空失败:{msg}', initError: 'AI 面板初始化失败:{msg}(重开面板可重试)', initDegraded: '列表加载失败,面板降级继续:{msg}', diff --git a/vite.config.ts b/vite.config.ts index 5b9d305..0d3cbc6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,7 +23,9 @@ export default defineConfig(async () => ({ } : undefined, watch: { - ignored: ["**/src-tauri/**"], + // target/ 同理须忽略:cargo 编译写 .o/.rlib 时 watcher 触发 EBUSY(文件被写锁), + // chokidar 直接抛异常杀掉 dev 进程(2026-10-18 实证:watch sqlite3.o 崩溃) + ignored: ["**/src-tauri/**", "**/target/**"], }, }, }));