diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs new file mode 100644 index 0000000..9f11b62 --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -0,0 +1,228 @@ +//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。 +//! +//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更。 +//! +//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 +//! +//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): +//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要; +//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); +//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 +//! +//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 +//! 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口; +//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, +//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 +//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 +//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发 +//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 +//! +//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve +//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 +//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 +//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 +//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。 + +use std::sync::Arc; + +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Mutex; + +use df_ai::context_helpers::extract_keyword_summary; +use df_ai::provider::{ChatMessage, LlmProvider}; +use df_storage::models::AiProviderRecord; + +use crate::state::{AppState, LlmConcurrency}; + +// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 +use super::super::compress::compress_via_llm; +use super::{AiChatEvent, AiSession}; + +/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 +/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组) +/// 不参与压缩,避免压缩正在使用的活跃消息。 +const PROTECT_COUNT: usize = 6; + +/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 +/// +/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) +/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 +/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 +/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 +const KEYWORD_FALLBACK_ENABLED: bool = true; + +/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 +/// +/// 从 `run_agentic_loop` 抽取,行为零变更。返回值: +/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。 +/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。 +/// +/// 参数沿用原内联代码引用的局部: +/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。 +/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。 +/// - `iteration`:当前轮次(仅用于日志)。 +pub(super) async fn maybe_auto_compress( + session_arc: &Arc>, + conv_id: &str, + app_handle: &AppHandle, + provider: &Box, + provider_config: &AiProviderRecord, + llm_concurrency: &LlmConcurrency, + iteration: usize, +) -> bool { + let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false); + if !prev_compressing { + // 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。 + let (should_compress, protect_start, pre_compress_tokens) = { + let session = session_arc.lock().await; + let mgr = match session.conv_read(&conv_id) { + Some(c) => c, + None => { + tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)"); + return true; + } + }; + let mgr = &mgr.messages; + let protect_start = mgr.len().saturating_sub(PROTECT_COUNT); + let history_tokens = mgr.history_tokens(); + let budget = mgr.budget_limit(); + // 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens + let should = protect_start > 0 + && (budget as u64) * 6 / 10 < history_tokens as u64 + && mgr.has_compressible_messages(protect_start); + (should, protect_start, history_tokens) + }; + + if should_compress { + // emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。 + let ev = AiChatEvent::AiCompressing { + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + let (active_msgs, lang) = { + let mut session = session_arc.lock().await; + let conv = session.conv(&conv_id); + conv.messages.set_compressing(true); + // 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。 + let active_msgs: Vec = conv.messages.messages_mut() + [..protect_start] + .iter() + .filter(|t| t.message.is_active()) + .map(|t| t.message.clone()) + .collect(); + let lang = conv.agent_language.clone() + .unwrap_or_else(|| "zh-CN".to_string()); + (active_msgs, lang) + }; + + // 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 + // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 + // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 + let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { + extract_keyword_summary(&active_msgs) + } else { + String::new() + }; + + // 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 + // 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。 + let compress_outcome: Result, String> = if active_msgs.is_empty() { + Ok(None) + } else { + compress_via_llm( + provider.as_ref(), + provider_config, + active_msgs, + &lang, + conv_id, + llm_concurrency, + ).await.map(Some) + }; + + match compress_outcome { + Ok(Some(summary)) => { + // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 + // compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把 + // [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的 + // active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。 + { + let mut session = session_arc.lock().await; + let conv = session.conv(&conv_id); + let _compressed = conv.messages.compress_old_messages(protect_start); + conv.messages.insert_at(0, ChatMessage::system(&summary)); + conv.messages.set_compressing(false); + } + tracing::info!( + conv_id = %conv_id, + iteration, + pre_tokens = pre_compress_tokens, + "[ai] 自动压缩成功,摘要已插首位" + ); + // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), + // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 + // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 + let ev = AiChatEvent::AiAutoCompressed { + conversation_id: Some(conv_id.to_string()), + summary, + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + } + Ok(None) => { + // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 + session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); + } + Err(e) => { + // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 + // + // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 + // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): + // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, + // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); + // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; + // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 + // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 + // KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) + + // 消息状态不变,降级 build_for_request 裁剪)。 + session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); + tracing::warn!( + conv_id = %conv_id, + error = %e, + keyword_fallback_len = keyword_fallback.len(), + "[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", + KEYWORD_FALLBACK_ENABLED, + ); + if KEYWORD_FALLBACK_ENABLED { + // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 + let inserted = { + let mut session = session_arc.lock().await; + let conv = session.conv(&conv_id); + let _compressed = conv.messages.compress_old_messages(protect_start); + if !keyword_fallback.is_empty() { + conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); + true + } else { + false + } + }; + if inserted { + tracing::info!( + conv_id = %conv_id, + pre_tokens = pre_compress_tokens, + "[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)" + ); + } + } + // BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) + // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有) + // + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm, + // 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。 + } + } + } + } + false +} diff --git a/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs b/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs new file mode 100644 index 0000000..932a28f --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs @@ -0,0 +1,32 @@ +//! Agentic 循环知识生命周期(文档占位)。 +//! +//! ## 为什么没有抽取 +//! +//! `run_agentic_loop` 中知识相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的 +//! 单行调用,不存在可内联抽取的知识逻辑。 +//! +//! ### 已存在的知识模块 +//! +//! 知识注入/提炼实现位于 [`super::super::knowledge_inject`] 模块,`mod.rs` 顶部已通过 +//! `use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};` 引入: +//! +//! - `inject_knowledge_into_prompt` —— 构建请求前把知识库上下文注入 system_prompt。 +//! - `maybe_spawn_extraction` —— 后台知识提炼(需读已落库的对话消息)。 +//! +//! ### mod.rs 中的调用点(均为单行) +//! +//! | 行 | 所在函数 | 调用 | +//! |----|----------|------| +//! | ~1853 | `run_agentic_loop`(正常完成后台 spawn) | `maybe_spawn_extraction(...)` | +//! | ~2092 | `try_continue_agent_loop` | `inject_knowledge_into_prompt(...)` | +//! +//! 注意:`run_agentic_loop` 入口的 system_prompt 构建**不**在 mod.rs 内联调用 +//! `inject_knowledge_into_prompt` —— 知识注入发生在调用方(IPC commands.rs / +//! try_continue_agent_loop),loop 入参拿到的 `system_prompt` 已是注入后的最终值。 +//! 故 mod.rs 内知识相关代码仅有上述 2 处单行调用。 +//! +//! ### 结论 +//! +//! 知识生命周期的抽取工作**早已完成**(knowledge_inject 模块独立)。本文件仅作占位与说明, +//! 无代码可移。`inject_knowledge_into_prompt` 的入口收敛(DRY B 改进)已在调用方完成, +//! 非 mod.rs 职责。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index a89c677..48a6ee2 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -12,8 +12,9 @@ use df_ai::context::TokenEstimator; use df_ai::context::ContextManager; // 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。 // 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。 +// extract_keyword_summary 随压缩逻辑迁至 context_lifecycle.rs(改进3 B 关键词兑底)。 use df_ai::context_helpers::{ - extract_key_info, extract_keyword_summary, should_summarize_tool_result, + extract_key_info, should_summarize_tool_result, PLACEHOLDER_INTEGRITY_ENABLED, }; // 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry) @@ -42,7 +43,7 @@ use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; use super::audit::process_tool_calls; -use super::compress::compress_via_llm; +// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 use super::conversation::{save_conversation, TokenAccumulator}; use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction}; use super::prompt::{build_system_prompt, get_active_provider}; @@ -70,12 +71,9 @@ enum ProviderResolveError { /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。 pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; -/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 -/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组) -/// 不参与压缩,避免压缩正在使用的活跃消息。 -const PROTECT_COUNT: usize = 6; - -/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1) +/// 压缩保护区条数 + 压缩关键词兑底开关已随压缩逻辑迁至 context_lifecycle.rs +/// (PROTECT_COUNT / KEYWORD_FALLBACK_ENABLED,在该模块内部为私有 const)。 +/// /// /// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) + /// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。 @@ -83,14 +81,9 @@ const PROTECT_COUNT: usize = 6; /// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。 pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3; -/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 +/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs +/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// -/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) -/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 -/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 -/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 -pub const KEYWORD_FALLBACK_ENABLED: bool = true; - /// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。 /// /// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result, @@ -250,6 +243,21 @@ use guard::GeneratingGuard; /// 审批超时取消(由 try_continue_agent_loop 入口调用) mod approval_timeout; +// ============================================================ +// 重构:上下文/标题/知识生命周期抽取(行为零变更)。 +// +// - context_lifecycle:F-15 自动压缩逻辑,从 run_agentic_loop 内联块抽取为 +// maybe_auto_compress 函数(mod.rs 调用,返回 bool 表达原 early-return)。 +// - title_lifecycle / knowledge_lifecycle:标题/知识逻辑早已模块化(title.rs / +// knowledge_inject.rs),mod.rs 仅单行调用,无可抽取的内联代码,文件作文档占位。 +// ============================================================ +mod context_lifecycle; +use context_lifecycle::maybe_auto_compress; +#[allow(unused_imports)] +mod title_lifecycle; +#[allow(unused_imports)] +mod knowledge_lifecycle; + // ============================================================ // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 // 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 @@ -1014,182 +1022,22 @@ pub(crate) async fn run_agentic_loop( }); } - // F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 + // F-15 阶段3: 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 // - // 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 - // - // 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): - // ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要; - // ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); - // ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 - // - // 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 - // 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口; - // 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, - // 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 - // 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 - // 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发 - // (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 - // - // 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve - // keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 - // is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 - // 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 - // F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。 - // conv_id 来源:run_agentic_loop 入参。 - let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false); - if !prev_compressing { - // 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。 - let (should_compress, protect_start, pre_compress_tokens) = { - let session = session_arc.lock().await; - let mgr = match session.conv_read(&conv_id) { - Some(c) => c, - None => { - tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)"); - return; - } - }; - let mgr = &mgr.messages; - let protect_start = mgr.len().saturating_sub(PROTECT_COUNT); - let history_tokens = mgr.history_tokens(); - let budget = mgr.budget_limit(); - // 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens - let should = protect_start > 0 - && (budget as u64) * 6 / 10 < history_tokens as u64 - && mgr.has_compressible_messages(protect_start); - (should, protect_start, history_tokens) - }; - - if should_compress { - // emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。 - let ev = AiChatEvent::AiCompressing { - conversation_id: Some(conv_id.clone()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - let (active_msgs, lang) = { - let mut session = session_arc.lock().await; - let conv = session.conv(&conv_id); - conv.messages.set_compressing(true); - // 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。 - let active_msgs: Vec = conv.messages.messages_mut() - [..protect_start] - .iter() - .filter(|t| t.message.is_active()) - .map(|t| t.message.clone()) - .collect(); - let lang = conv.agent_language.clone() - .unwrap_or_else(|| "zh-CN".to_string()); - (active_msgs, lang) - }; - - // 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 - // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 - // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 - let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { - extract_keyword_summary(&active_msgs) - } else { - String::new() - }; - - // 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 - // 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。 - let compress_outcome: Result, String> = if active_msgs.is_empty() { - Ok(None) - } else { - compress_via_llm( - provider.as_ref(), - &provider_config, - active_msgs, - &lang, - &conv_id, - &llm_concurrency, - ).await.map(Some) - }; - - match compress_outcome { - Ok(Some(summary)) => { - // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 - // compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把 - // [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的 - // active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。 - { - let mut session = session_arc.lock().await; - let conv = session.conv(&conv_id); - let _compressed = conv.messages.compress_old_messages(protect_start); - conv.messages.insert_at(0, ChatMessage::system(&summary)); - conv.messages.set_compressing(false); - } - tracing::info!( - conv_id = %conv_id, - iteration, - pre_tokens = pre_compress_tokens, - "[ai] 自动压缩成功,摘要已插首位" - ); - // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), - // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 - // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 - let ev = AiChatEvent::AiAutoCompressed { - conversation_id: Some(conv_id.clone()), - summary, - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } - Ok(None) => { - // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 - session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); - } - Err(e) => { - // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 - // - // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 - // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): - // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, - // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); - // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; - // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 - // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 - // KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) + - // 消息状态不变,降级 build_for_request 裁剪)。 - session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); - tracing::warn!( - conv_id = %conv_id, - error = %e, - keyword_fallback_len = keyword_fallback.len(), - "[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", - KEYWORD_FALLBACK_ENABLED, - ); - if KEYWORD_FALLBACK_ENABLED { - // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 - let inserted = { - let mut session = session_arc.lock().await; - let conv = session.conv(&conv_id); - let _compressed = conv.messages.compress_old_messages(protect_start); - if !keyword_fallback.is_empty() { - conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); - true - } else { - false - } - }; - if inserted { - tracing::info!( - conv_id = %conv_id, - pre_tokens = pre_compress_tokens, - "[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)" - ); - } - } - // BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) - // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有) - // + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm, - // 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。 - } - } - } + // 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return + // (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。 + // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底 / FR-S1 安全)见 + // context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。 + if maybe_auto_compress( + &session_arc, + &conv_id, + &app_handle, + &provider, + &provider_config, + &llm_concurrency, + iteration, + ).await { + return; } // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) diff --git a/src-tauri/src/commands/ai/agentic/title_lifecycle.rs b/src-tauri/src/commands/ai/agentic/title_lifecycle.rs new file mode 100644 index 0000000..d277c06 --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/title_lifecycle.rs @@ -0,0 +1,34 @@ +//! Agentic 循环标题生命周期(文档占位)。 +//! +//! ## 为什么没有抽取 +//! +//! `run_agentic_loop` 中标题相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的 +//! 单行调用,不存在可内联抽取的标题逻辑。 +//! +//! ### 已存在的标题模块 +//! +//! 标题生成实现位于 [`super::super::title`] 模块,`mod.rs` 顶部已通过 +//! `use super::title::{ensure_conversation_title, spawn_ensure_title};` 引入: +//! +//! - `spawn_ensure_title` —— 后台 fire-and-forget 标题生成(不阻塞 Completed emit)。 +//! - `ensure_conversation_title` —— 标题生成(可 await,正常完成路径后台 spawn 内调用)。 +//! +//! ### mod.rs 中的调用点(均为单行) +//! +//! `run_agentic_loop` 内 4 处调用: +//! +//! | 行 | 场景 | 调用 | +//! |----|------|------| +//! | ~893 | 入口 stop(首轮即停) | `spawn_ensure_title(...)` | +//! | ~1469 | MidStream 保文后 | `spawn_ensure_title(...)` | +//! | ~1543 | 流后 stop | `spawn_ensure_title(...)` | +//! | ~1856 | 正常完成后台 spawn | `ensure_conversation_title(...)` | +//! +//! 这些调用点散落在不同的控制流分支(stop/保文/正常完成),每处仅一行,且各自需要 +//! 不同的上下文(`resolved_model` 是否已知、是否在 guard.reset 前/后)。强行抽取为 +//! 单一函数会引入等量的参数透传 + 控制流分支,无净收益且增加间接层。 +//! +//! ### 结论 +//! +//! 标题生命周期的抽取工作**早已完成**(title 模块独立)。本文件仅作占位与说明, +//! 无代码可移。若未来需要统一标题调用上下文,应在 title 模块内封装,而非在此处。