From de049704c65e041b6d078e39321974ee92a873da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 20 Jun 2026 03:54:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=BA=A7=E6=BA=AF=E6=BA=90=20P2=20=E5=88=87=20ai=5Fmessages=20?= =?UTF-8?q?+=20AI=20Chat=20=E8=B7=91=E9=A2=98=E6=94=B9=E8=BF=9B=20P0-P2=20?= =?UTF-8?q?+=20=E6=A0=87=E9=A2=98=E8=AF=8A=E6=96=AD=20+=20=E8=8B=9B?= =?UTF-8?q?=E5=88=BB=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消息级溯源 P2(一次性切读 b + 备份保留): - 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord + switch/export load 切 + fallback 兜底) - 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份) - 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变) AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代): - P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback) - P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+ 工具结果压缩(should_summarize + extract_key_info view-only 不改持久化) - P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点) - title LLM complete 失败诊断日志(助定位返空根因) - 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释) 苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji) df-ai 227 passed + workspace EXIT 0 --- crates/df-ai/src/context.rs | 271 +++++++ crates/df-ai/src/context_helpers.rs | 737 ++++++++++++++++++ crates/df-ai/src/intent.rs | 262 ++++++- crates/df-storage/src/crud/message_repo.rs | 213 +++++ src-tauri/src/commands/ai/agentic/mod.rs | 255 +++++- src-tauri/src/commands/ai/commands/chat.rs | 25 +- .../src/commands/ai/commands/conversation.rs | 547 ++++++++++++- src-tauri/src/commands/ai/conversation.rs | 55 +- src-tauri/src/commands/ai/prompt.rs | 94 ++- src-tauri/src/commands/ai/title.rs | 10 +- src-tauri/src/state.rs | 9 +- 11 files changed, 2434 insertions(+), 44 deletions(-) diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index 1f5926f..5ad94d5 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -41,6 +41,10 @@ pub struct ContextManager { /// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。 /// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。 is_compressing: bool, + /// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic + /// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并 + /// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。 + pending_topic_marker: Option, } impl ContextManager { @@ -51,6 +55,7 @@ impl ContextManager { config, estimator: TokenEstimator::default(), is_compressing: false, + pending_topic_marker: None, } } @@ -68,10 +73,37 @@ impl ContextManager { if message.is_active() { self.history_tokens += tokens; } + // [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且 + // 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。 + // 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。 + let topic: Option = if matches!(message.role, MessageRole::User) { + let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content); + if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) { + Some(intent.as_str().to_string()) + } else { + None + } + } else { + None + }; + // [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user + // 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。 + // 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。 + if matches!(message.role, MessageRole::User) { + if let Some(prev_topic) = self.last_user_topic() { + if let Some(this_topic) = &topic { + if prev_topic != *this_topic { + self.pending_topic_marker = + Some(format!("{}|{}", prev_topic, this_topic)); + } + } + } + } self.messages.push(TrackedMessage { message, token_count: tokens, group, + topic, }); } @@ -80,6 +112,7 @@ impl ContextManager { self.messages.clear(); self.history_tokens = 0; self.is_compressing = false; + self.pending_topic_marker = None; } /// 消息数量 @@ -493,6 +526,28 @@ impl ContextManager { self.last_message_id_by_role(MessageRole::User) } + // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── + + /// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。 + /// + /// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。 + /// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。 + fn last_user_topic(&self) -> Option { + self.messages + .iter() + .rev() + .find(|t| matches!(t.message.role, MessageRole::User)) + .and_then(|t| t.topic.clone()) + } + + /// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。 + /// + /// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。 + /// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。 + pub fn take_topic_marker(&mut self) -> Option { + self.pending_topic_marker.take() + } + // ── F-15 上下文管理增强辅助方法(阶段1 基础,零行为变化)── // // 这些方法供阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic loop @@ -525,6 +580,7 @@ impl ContextManager { message, token_count: tokens, group, + topic: None, }); } @@ -1278,4 +1334,219 @@ mod tests { mgr.push(message); id.expect("新构造消息必有 id") } + + // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── + + #[test] + fn topic_marker_triggers_on_two_different_high_conf_topics() { + // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。 + // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("帮我重构这段代码")); + // 此时只有一条 user,无 marker + assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker"); + mgr.push(ChatMessage::user("创建项目并绑定目录")); + let marker = mgr.take_topic_marker() + .expect("双不同 topic 应置位 marker"); + assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker); + assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker); + // take 后清空(幂等) + assert!(mgr.take_topic_marker().is_none(), "take 后应清空"); + } + + #[test] + fn topic_marker_not_triggered_on_same_topic() { + // 同 topic(code/code)→ 不置位(非切换)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); + mgr.push(ChatMessage::user("再重构另一段代码")); + assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker"); + } + + #[test] + fn topic_marker_not_triggered_when_either_topic_none() { + // 保守:任一 topic 为 None(低置信未标)不置位。 + // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("今天的天气不错啊")); + mgr.push(ChatMessage::user("帮我重构这段代码")); + assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)"); + + // 反向:前一条高置信 + 后一条低置信 + let mut mgr2 = ContextManager::new(cfg(100_000)); + mgr2.push(ChatMessage::user("帮我重构这段代码")); + mgr2.push(ChatMessage::user("嗯嗯好的")); + assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)"); + } + + #[test] + fn topic_marker_not_polluting_compression_or_trim() { + // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常, + // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。 + let mut mgr = ContextManager::new(cfg(80)); + for i in 0..10 { + mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i))); + } + mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker + let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪) + // 裁剪仍正常工作(topic 字段不参与裁剪逻辑) + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed); + assert!(!msgs.is_empty()); + } + + #[test] + fn topic_field_default_none_in_constructors() { + // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖, + // insert_at 始终 None——insert_at 不做主题推断,保守)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.insert_at(0, ChatMessage::system("摘要")); + // insert_at 的 system 消息 topic=None(role 非 User) + assert_eq!(mgr.messages_mut()[0].topic, None); + } + + // ===== 苁刻测:主题切换检测对抗/边界/链式 ===== + + #[test] + fn topic_marker_chained_three_switches_each_triggers() { + // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。 + // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。 + let mut mgr = ContextManager::new(cfg(100_000)); + + // 1. Code + mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code + assert!(mgr.take_topic_marker().is_none(), "首条不触发"); + + // 2. Code → Project(切换) + mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project + let m1 = mgr.take_topic_marker().expect("code→project 应触发"); + assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1); + assert!(mgr.take_topic_marker().is_none(), "take 后清空"); + + // 3. Project → Task(切换) + mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task + let m2 = mgr.take_topic_marker().expect("project→task 应触发"); + assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2); + + // 4. Task → Http(切换,证链式不因中间穿插断) + mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http + let m3 = mgr.take_topic_marker().expect("task→http 应触发"); + assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3); + } + + #[test] + fn topic_marker_low_confidence_chain_never_triggers() { + // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None + assert!(mgr.take_topic_marker().is_none()); + mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None + assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发"); + // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标) + mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 + assert!( + mgr.take_topic_marker().is_none(), + "前一条 topic None,即使本条高置信也不触发(双高置信约束)" + ); + } + + #[test] + fn topic_marker_high_then_low_confidence_never_triggers() { + // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 + assert!(mgr.take_topic_marker().is_none()); + mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None + assert!( + mgr.take_topic_marker().is_none(), + "后一条 topic None,即使前一条高置信也不触发" + ); + } + + #[test] + fn topic_marker_switch_back_to_same_after_different_not_triggered() { + // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。 + // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project + let _ = mgr.take_topic_marker(); + mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同) + let m = mgr.take_topic_marker().expect("project→code 切回应触发"); + assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m); + } + + #[test] + fn topic_field_survives_compress_old_messages() { + // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。 + // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code + mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file + mgr.push(ChatMessage::assistant("ok")); // idx 2 + + // 压缩 [0,2):idx 0/1 标 compressed + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 2); + // topic 字段保留(compress 不触碰) + assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留"); + assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留"); + // status 改为 compressed + assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); + } + + #[test] + fn topic_marker_system_message_in_send_view_under_adequate_budget() { + // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 → + // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。 + // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system + // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker + // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker + let marker = mgr.take_topic_marker().expect("应触发 marker"); + assert!(marker.contains("code") && marker.contains("project")); + + // 调用方据 marker insert system 标记(agentic loop 实际行为) + mgr.insert_at(0, ChatMessage::system("[主题切换标记]")); + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(!trimmed, "充足预算不应裁剪"); + assert!( + msgs.iter().any(|m| m.content.contains("[主题切换标记]")), + "marker system 应进发送视图: {:?}", + msgs.iter().map(|m| m.content.chars().take(15).collect::()).collect::>() + ); + } + + #[test] + fn topic_marker_not_triggered_by_assistant_or_tool_messages() { + // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + assert!(mgr.take_topic_marker().is_none()); + // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User) + mgr.push(ChatMessage::assistant("编译 error 出现了")); + assert!( + mgr.take_topic_marker().is_none(), + "assistant 消息不参与主题推断,不触发 marker" + ); + // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换 + mgr.push(ChatMessage::user("继续重构")); + assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)"); + // 此时末条 user 是 code,切到 project 应触发 + mgr.push(ChatMessage::user("创建项目并绑定目录")); + assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)"); + } + + #[test] + fn topic_marker_take_is_idempotent_and_clears() { + // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); + mgr.push(ChatMessage::user("创建项目并绑定目录")); + let _ = mgr.take_topic_marker().expect("应有 marker"); + // 第二次 take 必 None + assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); + } } diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index 44aa262..e98be18 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -152,6 +152,9 @@ pub struct TrackedMessage { pub message: ChatMessage, pub token_count: u32, pub group: MessageGroup, + /// [P2 改进5] 该消息所属主题标签(意图识别推断,None=未标记)。当前不参与裁剪/压缩, + /// 仅供主题切换检测。向前兼容:TrackedMessage 不 Serialize(只 ChatMessage 落库),无 DB 影响。 + pub topic: Option, } /// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础) @@ -171,6 +174,283 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup { } } +// ============================================================ +// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM) +// ============================================================ + +/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。 +/// +/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心 +/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要 +/// 塞回首条,作为续接锚点。 +/// +/// 策略(纯启发式,无 tokenizer 依赖): +/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。 +/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。 +/// - 去停用词(常见虚词/助词,中英混合)。 +/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。 +/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。 +pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String { + const TOP_N: usize = 10; + const MIN_WORD_LEN: usize = 2; + + // 中英混合停用词表(高频虚词/助词/常见无主题意义词)。 + const STOPWORDS: &[&str] = &[ + // 中文 + "的", "了", "是", "在", "我", "有", "和", "就", "不", "人", "都", "一", "一个", + "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "看", "好", + "自己", "这", "那", "这个", "那个", "什么", "怎么", "可以", "应该", "需要", + "已经", "现在", "然后", "因为", "所以", "但是", "如果", "虽然", "一下", "一些", + // 英文 + "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "to", "of", + "in", "on", "at", "by", "for", "with", "about", "as", "into", "like", "through", + "after", "over", "between", "and", "but", "or", "not", "no", "yes", "if", "then", + "this", "that", "these", "those", "it", "its", "i", "you", "he", "she", "we", + "they", "them", "my", "your", "his", "her", "our", "their", "do", "does", "did", + "have", "has", "had", "will", "would", "can", "could", "should", "may", "might", + "please", "help", "want", "need", "make", "use", "get", + ]; + + use std::collections::HashMap; + + let mut freq: HashMap = HashMap::new(); + for msg in msgs { + // 仅 user 消息参与(assistant/tool 多为操作流水,无主题价值)。 + if !matches!(msg.role, MessageRole::User) { + continue; + } + for token in tokenize(msg.content.as_str()) { + let word = token.trim(); + if word.chars().count() < MIN_WORD_LEN { + continue; + } + if STOPWORDS.contains(&word) { + continue; + } + *freq.entry(word.to_string()).or_insert(0) += 1; + } + } + + if freq.is_empty() { + return String::new(); + } + + let mut entries: Vec<(String, u32)> = freq.into_iter().collect(); + // 排序:频次降序 → 词长降序(长实体优先) → 字典序稳定。 + entries.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| b.0.chars().count().cmp(&a.0.chars().count())) + .then_with(|| a.0.cmp(&b.0)) + }); + + let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect(); + if keywords.is_empty() { + return String::new(); + } + + format!( + "[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}", + keywords.join("、") + ) +} + +/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。 +/// +/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request); +/// 汉字既作为单字词素,又额外产出 2-gram 相邻汉字词素(如"压缩架构"→"压缩"/"缩架"/"架构"), +/// 以捕获 2 字常用词(CR-26 🟡1:此前单字全被 MIN_WORD_LEN=2 滤掉,致"压缩/架构/审批" +/// 这类高频 2 字词在关键词摘要中丢失)。纯启发式,不依赖 jieba(零依赖原则)。 +fn tokenize(s: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut buf = String::new(); + let mut prev_class: u8 = 0; // 0=none,1=han,2=word(字母数字_),3=other + let mut prev_han: Option = None; // [CR-26] 上一个汉字,供 2-gram 滑窗 + for ch in s.chars() { + let class = if ch.is_alphanumeric() && !is_han(ch) { + 2 + } else if is_han(ch) { + 1 + } else if ch == '_' { + 2 + } else { + 3 + }; + if class == 3 { + if !buf.is_empty() { + out.push(std::mem::take(&mut buf)); + } + prev_class = 0; + prev_han = None; // 非汉字边界重置 2-gram 窗口(不跨边界组词) + continue; + } + // 词素切换:汉字单独成词(每个汉字一个词素,便于统计高频字组), + // 但连续拉丁/下划线/数字聚成一个词素(标识符如 build_for_request)。 + if class == 1 { + // 汉字:先冲掉当前 buf(若有),再把单字作为独立词素。 + if !buf.is_empty() { + out.push(std::mem::take(&mut buf)); + } + out.push(ch.to_string()); + // [CR-26 🟡1] 2-gram 汉字滑窗:与前一个汉字(若连续,prev_class==1)组成 2 字词素。 + // "压缩架构"→"压缩"/"缩架"/"架构";"压缩 文件"(空格隔开)→"压缩"/"文件",无跨边界 2-gram。 + if let Some(prev) = prev_han { + if prev_class == 1 { + let mut gram = String::new(); + gram.push(prev); + gram.push(ch); + out.push(gram); + } + } + prev_han = Some(ch); + prev_class = 1; + } else { + // class == 2:拉丁/数字/下划线聚成连续词素。 + // 从汉字切换到拉丁或反向时,先冲掉 buf + 重置 2-gram 窗口。 + if prev_class == 1 { + buf.clear(); + } + prev_han = None; // 离开汉字区,2-gram 窗口重置 + buf.push(ch); + prev_class = 2; + } + } + if !buf.is_empty() { + out.push(buf); + } + out +} + +/// 判定是否汉字(统一码 CJK 区间,粗略覆盖常用范围)。 +fn is_han(ch: char) -> bool { + matches!(ch as u32, + 0x4E00..=0x9FFF // CJK 统一汉字 + | 0x3400..=0x4DBF // CJK 扩展 A + | 0xF900..=0xFAFF // CJK 兼容 + ) +} + +// ============================================================ +// 改进4: 工具结果压缩(view-only 摘要化纯函数) +// ============================================================ + +/// tool_result 内容触发摘要压缩的大小阈值(字节,~512 行典型输出)。 +pub const TOOL_RESULT_SUMMARIZE_BYTES: usize = 2_048; +/// tool_result 占历史 token 比例阈值(占比超此值触发摘要)。 +pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40; +/// extract_key_info 保留的首部行数。 +pub const TOOL_RESULT_HEAD_LINES: usize = 5; +/// extract_key_info 保留的尾部行数。 +pub const TOOL_RESULT_TAIL_LINES: usize = 5; + +/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 +/// +/// - `content_len`:tool_result content 字节数。 +/// - `history_tokens`:当前历史总 token(用于占比判定)。 +/// - `content_tokens`:该 tool_result 自身的 token 估算(供占比)。 +/// +/// 任一阈值触发即压缩。0 history/0 content 不触发(避免除零/空消息误压)。 +pub fn should_summarize_tool_result( + content_len: usize, + history_tokens: u32, + content_tokens: u32, +) -> bool { + if content_len >= TOOL_RESULT_SUMMARIZE_BYTES { + return true; + } + if history_tokens == 0 { + return false; + } + let ratio = content_tokens as f32 / history_tokens as f32; + ratio > TOOL_RESULT_SUMMARIZE_RATIO +} + +/// 提取 tool_result 关键信息(纯函数,无 LLM):保留错误行 + 首/尾各若干行,中间省略。 +/// +/// 策略: +/// - 全文扫一遍,挑出含错误信号的行(`error`/`Error`/`panic`/`失败`/`.rs:N` 文件定位行)。 +/// - 保留首 `TOOL_RESULT_HEAD_LINES` 行 + 末 `TOOL_RESULT_TAIL_LINES` 行(上下文边界)。 +/// - 错误行(在头尾区间外的)额外插入,标注位置。 +/// - 中间大段省略为 `... (省略 N 行) ...`。 +/// +/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。 +pub fn extract_key_info(content: &str, tool_name: &str) -> String { + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return String::new(); + } + + // 短内容不压缩(行数不足以省略)。 + let total = lines.len(); + let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; + if total <= kept_boundary { + return content.to_string(); + } + + let head_end = TOOL_RESULT_HEAD_LINES; + let tail_start = total.saturating_sub(TOOL_RESULT_TAIL_LINES); + + let mut out: Vec = Vec::new(); + out.push(format!("[工具 {} 输出已压缩: 保留首尾 + 错误行, 共 {} 行]", tool_name, total)); + + // 头部。 + for line in &lines[..head_end] { + out.push((*line).to_string()); + } + + // 中部:扫描错误行(head_end..tail_start 区间内)。 + let mut error_lines: Vec<(usize, &str)> = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + if idx < head_end || idx >= tail_start { + continue; + } + if is_error_line(line) { + error_lines.push((idx, *line)); + } + } + + if error_lines.is_empty() { + // 无错误行:单一省略标记。 + let omitted = tail_start - head_end; + out.push(format!("... (省略 {} 行) ...", omitted)); + } else { + // 有错误行:按位置分段省略 + 插入错误行。 + let mut last = head_end; + for (idx, line) in &error_lines { + if *idx > last { + out.push(format!("... (省略 {} 行) ...", idx - last)); + } + out.push(format!("[行 {}] {}", idx + 1, line)); + last = idx + 1; + } + if last < tail_start { + out.push(format!("... (省略 {} 行) ...", tail_start - last)); + } + } + + // 尾部。 + for line in &lines[tail_start..] { + out.push((*line).to_string()); + } + + out.join("\n") +} + +/// 判定一行是否错误信号行(错误信息/panic/失败/源码定位)。 +fn is_error_line(line: &str) -> bool { + let lower = line.to_lowercase(); + if lower.contains("error") || lower.contains("panic") { + return true; + } + if line.contains("失败") || line.contains("错误") || line.contains("异常") { + return true; + } + // 源码定位行:常见 `.rs:NN:` / `.rs:NNN` 格式(rust 编译错误/panic 定位)。 + if line.contains(".rs:") { + return true; + } + false +} + // ============================================================ // 淘汰单元 // ============================================================ @@ -193,3 +473,460 @@ pub const PROTECT_COUNT: usize = 6; /// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。 /// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。 pub const TOOL_MISSING_PREFIX: &str = "tool_missing_"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::ChatMessage; + + // ── 改进3 B: extract_keyword_summary ── + + #[test] + fn keyword_summary_empty_messages() { + assert_eq!(extract_keyword_summary(&[]), ""); + } + + #[test] + fn keyword_summary_only_assistant_returns_empty() { + // 非 user 消息不参与提取。 + let msgs = vec![ + ChatMessage::assistant("build_for_request 的实现"), + ChatMessage::assistant("另一个 assistant 消息"), + ]; + assert_eq!(extract_keyword_summary(&msgs), ""); + } + + #[test] + fn keyword_summary_chinese_extracts_frequent_terms() { + let msgs = vec![ + ChatMessage::user("帮我看看 build_for_request 的实现"), + ChatMessage::assistant("ok"), + ChatMessage::user("build_for_request 怎么裁剪的"), + ChatMessage::user("build_for_request 这个函数有问题"), + ]; + let summary = extract_keyword_summary(&msgs); + assert!(summary.contains("build_for_request"), "应提取高频标识符, 实际: {}", summary); + assert!(summary.contains("上下文压缩失败兜底摘要")); + } + + #[test] + fn keyword_summary_filters_stopwords() { + // 全是停用词/单字 → 返回空。 + let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和")]; + assert_eq!(extract_keyword_summary(&msgs), ""); + } + + #[test] + fn keyword_summary_mixed_cn_en() { + let msgs = vec![ + ChatMessage::user("检查 ContextManager 的 history_tokens"), + ChatMessage::user("ContextManager token 超预算"), + ]; + let summary = extract_keyword_summary(&msgs); + assert!(summary.contains("ContextManager"), "应提取英文标识符, 实际: {}", summary); + // token 频次 1 也应入榜(去停用词后剩余的有意义词)。 + } + + // ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ── + + #[test] + fn tokenize_chinese_2gram_produces_bigram_terms() { + // "压缩架构" 应产出 2-gram 词素 "压缩"/"缩架"/"架构",不再只剩单字。 + let toks = tokenize("压缩架构"); + // 2-gram 入榜 + assert!(toks.contains(&"压缩".to_string()), "应产出 2-gram 压缩, 实际: {:?}", toks); + assert!(toks.contains(&"架构".to_string()), "应产出 2-gram 架构, 实际: {:?}", toks); + // 单字也并存 + assert!(toks.contains(&"压".to_string())); + assert!(toks.contains(&"构".to_string())); + } + + #[test] + fn tokenize_chinese_2gram_not_crossing_boundary() { + // 空格/标点隔开 → 不跨边界组 2-gram("压缩 文件"→"压缩"/"文件",无"缩文")。 + let toks = tokenize("压缩 文件"); + assert!(toks.contains(&"压缩".to_string())); + assert!(toks.contains(&"文件".to_string())); + assert!(!toks.contains(&"缩文".to_string()), "空格边界不应跨字组 2-gram"); + } + + #[test] + fn tokenize_english_identifier_unchanged() { + // 既有英文标识符不被 2-gram 改造:连续拉丁聚成一个词素。 + let toks = tokenize("build_for_request is cool"); + assert!(toks.contains(&"build_for_request".to_string()), "标识符应保持完整, 实际: {:?}", toks); + assert!(toks.contains(&"is".to_string())); + assert!(toks.contains(&"cool".to_string())); + } + + #[test] + fn keyword_summary_chinese_2gram_terms_in_top() { + // CR-26 🟡1 验证:中文 2 字常用词经 2-gram 入榜,不再被 MIN_WORD_LEN=2 滤掉。 + let msgs = vec![ + ChatMessage::user("压缩上下文 token 超预算了"), + ChatMessage::user("架构需要重构压缩逻辑"), + ]; + let summary = extract_keyword_summary(&msgs); + // "压缩"出现两次(2-gram)应入榜;"架构"频次 1 也应有机会。 + assert!( + summary.contains("压缩") || summary.contains("架构"), + "中文 2 字词经 2-gram 应入榜, 实际: {}", + summary + ); + } + + // ── 改进4: should_summarize_tool_result ── + + #[test] + fn should_summarize_short_content_below_byte_threshold() { + // 100 字节 + history 充足,占比低 → 不压缩。 + assert!(!should_summarize_tool_result(100, 10_000, 35)); + } + + #[test] + fn should_summarize_large_bytes_triggers() { + // > 2KB 触发。 + assert!(should_summarize_tool_result(3_000, 100_000, 100)); + } + + #[test] + fn should_summarize_high_ratio_triggers() { + // content 占 history 50% > 40% 阈值触发(content_len 未超 2KB)。 + assert!(should_summarize_tool_result(1_000, 1_000, 500)); + } + + #[test] + fn should_summarize_zero_history_no_panic() { + // 0 history + 小 content → 不压缩且不除零 panic。 + assert!(!should_summarize_tool_result(100, 0, 50)); + } + + // ── 改进4: extract_key_info ── + + #[test] + fn extract_key_info_short_content_unchanged() { + let content = "line1\nline2\nline3"; + assert_eq!(extract_key_info(content, "read_file"), content); + } + + #[test] + fn extract_key_info_empty_returns_empty() { + assert_eq!(extract_key_info("", "read_file"), ""); + } + + #[test] + fn extract_key_info_keeps_head_tail_and_errors() { + let mut lines: Vec = (1..=20).map(|i| format!("line {}", i)).collect(); + // 在中间插入一行 rust 错误定位(第 12 行,索引 11)。 + lines[11] = "error[E0308]: mismatched types at src/foo.rs:42:13".to_string(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "cargo_build"); + assert!(result.contains("已压缩"), "应含压缩标记, 实际: {}", result); + assert!(result.contains("line 1"), "应保留首行"); + assert!(result.contains("line 20"), "应保留末行"); + assert!(result.contains("src/foo.rs:42"), "应保留错误行"); + assert!(result.contains("行 12"), "错误行应标注原始位置"); + assert!(result.contains("省略"), "应含省略标记"); + } + + #[test] + fn extract_key_info_detects_panic_and_failure() { + let lines: Vec = (1..=15) + .map(|i| { + if i == 8 { + "thread main panicked at lib.rs:10".to_string() + } else if i == 9 { + "执行失败".to_string() + } else { + format!("row {}", i) + } + }) + .collect(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "run_cmd"); + assert!(result.contains("panicked")); + assert!(result.contains("lib.rs:10")); + assert!(result.contains("执行失败")); + } + + #[test] + fn extract_key_info_no_errors_single_omission() { + // 中间无错误行 → 单一省略标记。 + let lines: Vec = (1..=20).map(|i| format!("data {}", i)).collect(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "list_dir"); + // 计算省略标记出现次数:中间 10 行省略,应仅 1 个省略段。 + let omission_count = result.matches("省略").count(); + assert_eq!(omission_count, 1, "无错误行应单一省略, 实际 {} 次: {}", omission_count, result); + } + + // ===== 苛刻测:对抗 + 边界 + 极端 ===== + + // ── extract_keyword_summary:对抗/极端 ── + + #[test] + fn keyword_summary_no_user_messages_returns_empty_no_panic() { + // 极端:全 assistant/tool 消息,无 user → 返空串(不 panic) + let msgs = vec![ + ChatMessage::assistant("我读了文件"), + ChatMessage::tool_result("call_1", "{\"ok\":true}"), + ChatMessage::assistant("完成"), + ]; + assert_eq!(extract_keyword_summary(&msgs), ""); + } + + #[test] + fn keyword_summary_all_stopwords_returns_empty() { + // 对抗(全停用词):user 消息全是"的/了/是/在/我"(单字 + 停用词双过滤) + // → 全被滤,返空串 + let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和 就 不 人 都 一 上")]; + assert_eq!(extract_keyword_summary(&msgs), ""); + } + + #[test] + fn keyword_summary_single_long_message_caps_top10() { + // 极端(单条 10K 字 user):top-10 不超 10,高频词正确入榜。 + // 构造 20 个不同高频词各重复 5 次 + 大量填充,验证 take(TOP_N=10) 生效。 + let mut content = String::new(); + // 20 个候选词,每个写 5 次(词频递减可验排序) + let words: Vec<&str> = (0..20).map(|_| "高频词").collect(); + for (i, w) in words.iter().enumerate() { + for _ in 0..5 { + content.push_str(w); + content.push_str(&format!("序号{} ", i)); // 拉丁数字区分不同实例 + } + } + // 填充到 ~10K 字 + while content.chars().count() < 10_000 { + content.push_str("填充内容x "); + } + let msgs = vec![ChatMessage::user(content)]; + let summary = extract_keyword_summary(&msgs); + // 摘要非空 + 关键词数 <= 10(逗号分隔统计) + assert!(summary.contains("上下文压缩失败兜底摘要")); + // 取摘要中关键词行(第二行),按"、"切分数 + let kw_line = summary.lines().nth(1).unwrap_or(""); + let kw_count = kw_line.split('、').count(); + assert!(kw_count <= 10, "top-10 应不超 10, 实际 {} 个: {}", kw_count, kw_line); + assert!(kw_count >= 1, "应至少提取 1 个关键词"); + } + + #[test] + fn keyword_summary_mixed_cn_en_2gram_and_english_both_ranked() { + // 对抗(中英混合 + 2-gram):"read 压缩架构 file" → + // 中文 2-gram("压缩"/"架构") + 英文("read"/"file") 均能被 tokenize 产出 + let toks = tokenize("read 压缩架构 file"); + assert!(toks.contains(&"压缩".to_string()), "应产 2-gram 压缩, 实际: {:?}", toks); + assert!(toks.contains(&"架构".to_string()), "应产 2-gram 架构, 实际: {:?}", toks); + assert!(toks.contains(&"read".to_string())); + assert!(toks.contains(&"file".to_string())); + // 经 extract_keyword_summary(去停用词 + MIN_LEN=2): + // - "read"(4 字母)入榜;"file"(4 字母)入榜 + // - "压缩"/"架构"(2 字 2-gram)入榜 + let msgs = vec![ChatMessage::user("read 压缩架构 file")]; + let summary = extract_keyword_summary(&msgs); + assert!(summary.contains("压缩") || summary.contains("架构"), "中文 2-gram 应入榜: {}", summary); + assert!(summary.contains("read"), "英文 read 应入榜: {}", summary); + assert!(summary.contains("file"), "英文 file 应入榜: {}", summary); + } + + #[test] + fn keyword_summary_pure_punctuation_emoji_returns_empty() { + // 对抗(纯标点/emoji 无实质词):所有字符非字母数字汉字 → tokenize 返空 → 摘要空 + let msgs = vec![ChatMessage::user("🎉,。!?...... 😊 👍")]; + assert_eq!(extract_keyword_summary(&msgs), ""); + } + + // ── should_summarize_tool_result:边界阈值 ── + + #[test] + fn should_summarize_exactly_2048_bytes_triggers() { + // 边界:content_len == TOOL_RESULT_SUMMARIZE_BYTES(2048)→ >= 触发(true) + assert!( + should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES, 100_000, 1), + "刚好 2048 字节(>= 阈值)应触发" + ); + } + + #[test] + fn should_summarize_just_below_2048_no_trigger_when_low_ratio() { + // 边界:content_len = 2047(< 2048)+ 占比极低 → 不触发 + assert!( + !should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES - 1, 100_000, 1), + "2047 字节 + 低占比不应触发" + ); + } + + #[test] + fn should_summarize_zero_history_zero_content_no_panic() { + // 极端:history=0 + content_len=0 + content_tokens=0 → 不触发,不除零 panic + assert!(!should_summarize_tool_result(0, 0, 0)); + } + + #[test] + fn should_summarize_zero_history_below_byte_threshold_no_trigger() { + // 边界:history=0(除零短路返 false)+ content_len < 2048 → 不触发 + // (history=0 时占比分支被短路,只看字节阈值) + assert!(!should_summarize_tool_result(500, 0, 999_999), "history=0 时占比分支短路,字节未超不触发"); + } + + #[test] + fn should_summarize_ratio_exactly_40_percent_no_trigger() { + // 边界(严格 >):占比 == 0.40 不触发(用 > 而非 >=)。 + // content_tokens / history_tokens = 40/100 = 0.40,严格大于判定为 false。 + // 但 f32 精度:40/100=0.4 精确,0.4 > 0.40 → false。 + assert!( + !should_summarize_tool_result(100, 100, 40), + "占比 == 40% 不触发(严格 >), 实际占比 {}", + 40.0_f32 / 100.0_f32 + ); + } + + #[test] + fn should_summarize_ratio_just_above_40_percent_triggers() { + // 边界:占比略超 40% → 触发。content_len < 2048 走占比分支。 + // 41/100 = 0.41 > 0.40 → true + assert!( + should_summarize_tool_result(100, 100, 41), + "占比 41% > 40% 应触发" + ); + } + + // ── extract_key_info:对抗(全错误行/单行超长) ── + + #[test] + fn extract_key_info_all_error_lines_preserved() { + // 对抗(全错误行):行数 > HEAD+TAIL(11),且每行都含 error → 全保留不省略 + let lines: Vec = (1..=15).map(|i| format!("error: failure {}", i)).collect(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "run_cmd"); + // 15 行全含 error,首 5(头) + 末 5(尾)区间外 5 行也全是 error → 全部保留 + for i in 1..=15 { + assert!( + result.contains(&format!("failure {}", i)), + "错误行 failure {} 应保留, result: {}", + i, + result + ); + } + // 不应出现省略(全错误行被插入,无中间省略段) + assert!(!result.contains("省略"), "全错误行不应省略, result: {}", result); + } + + #[test] + fn extract_key_info_single_line_no_newline_unchanged() { + // 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回 + let content = "single line no newline"; + assert_eq!(extract_key_info(content, "read_file"), content); + } + + #[test] + fn extract_key_info_single_huge_line_no_newline_unchanged() { + // 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分) + let content = "x".repeat(50_000); + let result = extract_key_info(&content, "read_file"); + assert_eq!(result, content, "单行无换行应原样返回(即使超长)"); + } + + #[test] + fn extract_key_info_exactly_head_tail_boundary_no_compression() { + // 边界:行数 == HEAD+TAIL(10)→ total <= kept_boundary → 原样返回(不压缩) + let lines: Vec = (1..=10).map(|i| format!("line {}", i)).collect(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "read_file"); + assert_eq!(result, content, "10 行(== HEAD+TAIL)应原样不压缩"); + } + + #[test] + fn extract_key_info_eleven_lines_triggers_compression() { + // 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记 + let lines: Vec = (1..=11).map(|i| format!("line {}", i)).collect(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "read_file"); + assert!(result.contains("已压缩"), "11 行应触发压缩"); + assert!(result.contains("line 1"), "保留首行"); + assert!(result.contains("line 11"), "保留末行"); + } + + #[test] + fn extract_key_info_error_line_outside_boundary_kept_with_index() { + // 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含) + // 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含) + // 错误行在中间区间 → 标注 [行 N] 插入 + let mut lines: Vec = (1..=20).map(|i| format!("norm {}", i)).collect(); + // idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail) + lines[2] = "error in head zone".to_string(); + // idx=18(尾部区间 [15,20))错误行 → 尾部已含 + lines[18] = "error in tail zone".to_string(); + // idx=10(中间)错误行 → 标注插入 + lines[10] = "error in middle".to_string(); + let content = lines.join("\n"); + let result = extract_key_info(&content, "read_file"); + // 中间错误行被标注插入(原始行号 11) + assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result); + // 头/尾错误行原样保留(无 [行 N] 标注) + assert!(result.contains("error in head zone")); + assert!(result.contains("error in tail zone")); + } + + // ── tokenize:对抗(中文长实体/emoji/全角/混合标点) ── + + #[test] + fn tokenize_chinese_long_entity_produces_overlapping_2grams() { + // 对抗(中文长实体):"跨端架构设计"(5 字)→ + // 单字:跨/端/架/构/设/计(6 个?不,5 字 5 个单字) + // 2-gram:跨端/端架/架构/构建/设计(5 个,相邻滑窗) + let toks = tokenize("跨端架构设计"); + // 单字 + for ch in "跨端架构设计".chars() { + assert!(toks.contains(&ch.to_string()), "单字 {} 应产出: {:?}", ch, toks); + } + // 2-gram(相邻滑窗) + assert!(toks.contains(&"跨端".to_string()), "应含 2-gram 跨端: {:?}", toks); + assert!(toks.contains(&"端架".to_string()), "应含 2-gram 端架: {:?}", toks); + assert!(toks.contains(&"架构".to_string()), "应含 2-gram 架构: {:?}", toks); + // 相邻滑窗:构→设 相邻产 2-gram "构设"(非"构建",构建非相邻字) + assert!(toks.contains(&"构设".to_string()), "应含 2-gram 构设(相邻滑窗): {:?}", toks); + assert!(toks.contains(&"设计".to_string()), "应含 2-gram 设计: {:?}", toks); + } + + #[test] + fn tokenize_emoji_and_fullwidth_punctuation_excluded() { + // 对抗(emoji/全角标点):🎉/,/。非字母数字汉字 → class=3 → 不入词素,重置 2-gram 窗口 + let toks = tokenize("架构,🎉设计"); + // "架构"(,前)、"设计"(🎉后)各成 2-gram;全角逗号/emoji 隔断,无"构设"跨边界 2-gram + assert!(toks.contains(&"架构".to_string())); + assert!(toks.contains(&"设计".to_string())); + assert!(!toks.contains(&"构设".to_string()), "emoji/全角标点应隔断 2-gram"); + // emoji 本身不入词素 + assert!(!toks.iter().any(|t| t.contains('🎉')), "emoji 不应入词素: {:?}", toks); + } + + #[test] + fn tokenize_mixed_punctuation_separates_latin_words() { + // 对抗(混合标点分隔):"a,b,c" → 逗号分隔 → "a"/"b"/"c" 三个独立词素 + // (但单字母 a/b/c 因 MIN_WORD_LEN=2 在 summary 阶段被滤,tokenize 层应产出) + let toks = tokenize("a,b,c"); + assert!(toks.contains(&"a".to_string())); + assert!(toks.contains(&"b".to_string())); + assert!(toks.contains(&"c".to_string())); + // 不应有 "abc" 或 "a,b" 这种带标点的聚合 + assert!(!toks.iter().any(|t| t.contains(',')), "标点不应聚入词素: {:?}", toks); + } + + #[test] + fn tokenize_empty_string_returns_empty() { + // 极端:空串 → 空 Vec(不 panic) + let toks = tokenize(""); + assert!(toks.is_empty()); + } + + #[test] + fn tokenize_underscore_keeps_identifier() { + // 边界:下划线属 class=2,标识符 build_for_request 保持完整 + let toks = tokenize("call build_for_request now"); + assert!(toks.contains(&"build_for_request".to_string())); + assert!(toks.contains(&"call".to_string())); + assert!(toks.contains(&"now".to_string())); + } +} diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index 682b08d..d702fe3 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -337,11 +337,11 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// /// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data]; /// Http → [http];Search → [file](含 search_files);Conversation → []; -/// Chat → [];Debug → [file, http](调试常需读文件+查 API);Unknown → [](全量)。 +/// Chat → [];Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);Unknown → [](全量)。 pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { let domains: &[ToolDomain] = match intent { Intent::Code => &[ToolDomain::File, ToolDomain::Http], - Intent::Debug => &[ToolDomain::File, ToolDomain::Http], + Intent::Debug => &[ToolDomain::File, ToolDomain::Http, ToolDomain::Data], Intent::File => &[ToolDomain::File], Intent::Project => &[ToolDomain::Data], Intent::Task => &[ToolDomain::Data], @@ -378,6 +378,34 @@ pub fn suggested_model_tier(_intent: &Intent) -> Option { None } +// ---- 工具子集过滤(agentic loop 接入用,改进2 A) ----------------------------- + +/// 按意图过滤 `tool_defs`:subset 非空只留命中,空返回全量(低置信/Unknown/Chat fallback)。 +/// +/// **接入语义**(改进2 B):agentic loop 在调用 LLM 前用本函数收敛 LLM 可见工具集, +/// 减少跑题(如纯闲聊不暴露文件操作工具)。三重 fallback 保证安全: +/// 1. `tool_subset_for(intent)` 空(Chat/Unknown/Conversation)→ 返回 `all_defs` 全量。 +/// 2. subset 工具名在 `all_defs` 找不到 → 跳过该名(防 registry 漂移导致过滤后为空)。 +/// 3. 调用方再做「过滤后 < 3 条 → 回全量」的兜底(见 agentic loop)。 +/// +/// **关键安全**:filter 仅影响 LLM 可见 tool_defs,**不影响执行**(audit 走 tools_arc +/// get/execute 完整 registry,LLM 即使幻觉一个被滤掉的工具名,audit 也能查到/拒绝)。 +pub fn filter_tool_defs( + all_defs: &[df_ai_core::types::ToolDefinition], + intent: &Intent, +) -> Vec { + let subset = tool_subset_for(intent); + if subset.is_empty() { + return all_defs.to_vec(); + } + let allowed: std::collections::HashSet<&str> = subset.iter().copied().collect(); + all_defs + .iter() + .filter(|d| allowed.contains(d.function.name.as_str())) + .cloned() + .collect() +} + // ---- 单测 ------------------------------------------------------------------- #[cfg(test)] @@ -679,4 +707,234 @@ mod tests { let (i, _) = IntentRecognizer::recognize("hi"); assert_eq!(i, Intent::Chat); } + + // --- filter_tool_defs(改进2 A) --- + + /// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。 + fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition { + df_ai_core::types::ToolDefinition { + tool_type: "function".to_string(), + function: df_ai_core::types::ToolFunction { + name: name.to_string(), + description: String::new(), + parameters: serde_json::json!({}), + }, + } + } + + #[test] + fn filter_subset_empty_returns_all_for_unknown() { + // Unknown → subset 空 → fallback 全量 + let all = vec![tool_def("read_file"), tool_def("create_project")]; + let out = filter_tool_defs(&all, &Intent::Unknown); + assert_eq!(out.len(), all.len(), "Unknown subset 空应回全量"); + } + + #[test] + fn filter_subset_empty_returns_all_for_chat() { + // Chat → subset 空 → fallback 全量(低意图/闲聊不收敛) + let all = vec![tool_def("read_file"), tool_def("write_file")]; + let out = filter_tool_defs(&all, &Intent::Chat); + assert_eq!(out.len(), all.len(), "Chat subset 空应回全量"); + } + + #[test] + fn filter_subset_empty_returns_all_for_conversation() { + let all = vec![tool_def("read_file")]; + let out = filter_tool_defs(&all, &Intent::Conversation); + assert_eq!(out.len(), 1, "Conversation subset 空应回全量"); + } + + #[test] + fn filter_subset_hit_filters_to_matching() { + // File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉 + let all = vec![ + tool_def("read_file"), + tool_def("write_file"), + tool_def("http_request"), // 不在 File domain + ]; + let out = filter_tool_defs(&all, &Intent::File); + assert!(out.iter().any(|d| d.function.name == "read_file")); + assert!(out.iter().any(|d| d.function.name == "write_file")); + assert!( + !out.iter().any(|d| d.function.name == "http_request"), + "File 意图不应含 http_request" + ); + assert_eq!(out.len(), 2); + } + + #[test] + fn filter_subset_drift_skips_missing_names() { + // subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic + // Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具 + let all = vec![ + tool_def("create_project"), // 命中 + tool_def("http_request"), // 不在 Data domain,滤掉 + // 其余 Data domain 工具名在 subset 里但 all 没有 → 跳过 + ]; + let out = filter_tool_defs(&all, &Intent::Project); + assert_eq!(out.len(), 1); + assert_eq!(out[0].function.name, "create_project"); + } + + #[test] + fn filter_all_drift_returns_empty_not_panic() { + // 极端漂移:subset 命中但 all_defs 完全不交集 → 返回空(调用方做 <3 回全量兜底) + let all = vec![tool_def("totally_unknown_tool")]; + let out = filter_tool_defs(&all, &Intent::File); + assert!(out.is_empty(), "全部漂移应返空(交调用方兜底)"); + } + + #[test] + fn filter_preserves_input_order_for_matching() { + // 过滤后顺序应跟 all_defs 一致(filter 保留原序) + let all = vec![ + tool_def("write_file"), + tool_def("read_file"), + tool_def("patch_file"), + tool_def("http_request"), + ]; + let out = filter_tool_defs(&all, &Intent::File); + let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); + assert_eq!(names, vec!["write_file", "read_file", "patch_file"]); + } + + // ===== 苛刻测:对抗 + 边界 + 极端(filter_tool_defs / tool_subset_for) ===== + + #[test] + fn filter_total_drift_subset_names_none_in_registry_returns_empty_no_panic() { + // 对抗:registry 全改名(File subset 工具名一个不在 all_defs) + // → filter 返空 Vec(调用方 <3 回全量兜底)。证不 panic。 + let all = vec![ + tool_def("renamed_read_file_v2"), + tool_def("totally_other_tool"), + tool_def("weird_name_xyz"), + ]; + let out = filter_tool_defs(&all, &Intent::File); + assert!(out.is_empty(), "全漂移应返空(交调用方兜底), 实际: {}", out.len()); + } + + #[test] + fn filter_partial_drift_keeps_only_intersecting() { + // 对抗:subset 含 N 个工具名,all_defs 只命中其中 2 个,其余跳过(不 panic,不补全) + let all = vec![ + tool_def("read_file"), + tool_def("patch_file"), + tool_def("unrelated_thing"), + ]; + let out = filter_tool_defs(&all, &Intent::File); + let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); + assert_eq!(names, vec!["read_file", "patch_file"], "部分漂移只留交集, 顺序跟 all"); + } + + #[test] + fn filter_empty_all_defs_no_panic() { + // 极端:all_defs 空(无工具注册)。无论 intent 怎样都不 panic,返空 Vec。 + let empty: Vec<_> = vec![]; + let out_unknown = filter_tool_defs(&empty, &Intent::Unknown); + let out_file = filter_tool_defs(&empty, &Intent::File); + assert!(out_unknown.is_empty()); + assert!(out_file.is_empty(), "空 registry + File 意图应返空不 panic"); + } + + #[test] + fn filter_debug_subset_spans_three_domains_complete() { + // 对抗(多 domain):Debug subset 跨 File + Http + Data 三 domain, + // 必须同条消息里能同时命中三个 domain 的工具(防 domain 漏挂) + let all = vec![ + // Data domain + tool_def("list_tasks"), + tool_def("create_project"), + tool_def("run_workflow"), + // File domain + tool_def("read_file"), + tool_def("patch_file"), + // Http domain + tool_def("http_request"), + ]; + let out = filter_tool_defs(&all, &Intent::Debug); + let names: Vec = out.iter().map(|d| d.function.name.clone()).collect(); + assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data"); + assert!(names.contains(&"read_file".to_string()), "Debug 必含 File"); + assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http"); + assert_eq!(names.len(), 6, "三 domain 工具全保留, 实际: {:?}", names); + } + + #[test] + fn filter_low_confidence_unknown_subset_empty_falls_back_full() { + // 对抗(低置信):"帮我看看这个"无任何关键词 → recognize 返 Unknown/0.0 + // → tool_subset_for(Unknown) 空 → filter 返全量(fallback 链完整) + let (intent, conf) = IntentRecognizer::recognize("帮我看看这个"); + assert_eq!(intent, Intent::Unknown, "无关键词应 Unknown"); + assert_eq!(conf, 0.0, "置信度应 0.0"); + assert!(tool_subset_for(&intent).is_empty(), "Unknown subset 应空"); + + let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")]; + let out = filter_tool_defs(&all, &intent); + assert_eq!(out.len(), all.len(), "Unknown/低置信应回全量 fallback"); + } + + #[test] + fn subset_all_intents_covered_names_in_registry() { + // 对抗(完整性):遍历所有 Intent 的 subset,每个工具名都能在对应 ToolDomain.tools() 找到。 + // 防 subset 表写错工具名(如 read_filez 笔误)。subset 工具名必须真存在于其声明 domain。 + let all_intents = [ + Intent::Code, + Intent::Debug, + Intent::File, + Intent::Project, + Intent::Task, + Intent::Idea, + Intent::Conversation, + Intent::Search, + Intent::Http, + Intent::Chat, + Intent::Unknown, + ]; + for intent in all_intents { + let subset = tool_subset_for(&intent); + // 全 registry 工具名(三 domain 并集) + let registry: std::collections::HashSet<&str> = ToolDomain::Data + .tools() + .iter() + .chain(ToolDomain::File.tools().iter()) + .chain(ToolDomain::Http.tools().iter()) + .copied() + .collect(); + for name in &subset { + assert!( + registry.contains(*name), + "Intent {:?} subset 含工具名 {} 不在任何 domain 注册表", + intent, + name + ); + } + } + } + + #[test] + fn filter_subset_correct_intent_for_multi_domain_message() { + // 对抗(跨 domain 完整):"调试任务并读文件"意图多 domain, + // 但 recognize 按优先级定单一 intent,filter 据此收敛。 + // 关键:无论 recognize 落哪个 intent,该 intent 的 subset 必须覆盖任务+文件相关工具。 + let (intent, conf) = IntentRecognizer::recognize("调试这个任务"); + // "调试"(Debug/SPECIFIC, 1.0) 优先于 "任务"(Task/ENTITY) + assert_eq!(intent, Intent::Debug); + assert!(conf >= 0.7); + let subset = tool_subset_for(&intent); + // Debug subset 必含 list_tasks(Data) + read_file(File) 防"调试任务"丢工具 + assert!(subset.contains(&"list_tasks"), "Debug 应含 list_tasks(Data domain)"); + assert!(subset.contains(&"read_file"), "Debug 应含 read_file(File domain)"); + } + + #[test] + fn subset_code_and_debug_dedup_http_across_domains() { + // 边界:Code = [File, Http], Debug = [File, Http, Data] + // File + Http 都含 http_request,必须去重(subset 内不重复) + for intent in [Intent::Code, Intent::Debug] { + let s = tool_subset_for(&intent); + let http_count = s.iter().filter(|n| **n == "http_request").count(); + assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent); + } + } } diff --git a/crates/df-storage/src/crud/message_repo.rs b/crates/df-storage/src/crud/message_repo.rs index b7d01f2..ce4c824 100644 --- a/crates/df-storage/src/crud/message_repo.rs +++ b/crates/df-storage/src/crud/message_repo.rs @@ -186,6 +186,61 @@ impl AiMessageRepo { .map_err(storage_err)? } + /// 全量重写对话的消息(单事务 DELETE + INSERT OR IGNORE,原子)。 + /// + /// F-260619-03 批次 B(save_conversation 写路径切 ai_messages)的核心方法: + /// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。 + /// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。 + /// + /// 设计权衡(非 dirty 增量,留 P2.1 优化): + /// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠; + /// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过; + /// - 入参 records 的 conversation_id 应一致(调用方 save_conversation 保证),本方法不校验。 + /// + /// 返回 Ok(()) —— 不返回受影响行数(DELETE + INSERT 两条计数语义混乱,调用方只关心成功)。 + pub async fn replace_conversation( + &self, + conv_id: &str, + records: Vec, + ) -> Result<()> { + let conn = self.conn.clone(); + let conv_id = conv_id.to_owned(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut guard = conn.blocking_lock(); + let tx = guard.transaction().map_err(storage_err)?; + { + // 先删该 conv 全部旧行(全量重写语义) + tx.execute( + "DELETE FROM ai_messages WHERE conversation_id = ?1", + params![conv_id], + ) + .map_err(storage_err)?; + // 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过) + if !records.is_empty() { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO ai_messages + (id, conversation_id, seq, role, content, parts, tool_call_id, + tool_calls, model, status, reasoning_content, timestamp, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + ) + .map_err(storage_err)?; + for rec in &records { + stmt.execute(params![ + rec.id, rec.conversation_id, rec.seq, rec.role, rec.content, + rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, + rec.reasoning_content, rec.timestamp, rec.created_at + ]) + .map_err(storage_err)?; + } + } + } + tx.commit().map_err(storage_err)?; + Ok(()) + }) + .await + .map_err(storage_err)? + } + /// 按 tool_call_id 定点更新消息 content(replace_tool_result_content 用)。 /// /// 返回是否实际更新(0 = 该 tool_call_id 在此对话无对应消息)。 @@ -365,4 +420,162 @@ mod tests { let got = repo.list_by_conversation("c").await.expect("list"); assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改"); } + + // ---------- replace_conversation(F-260619-03 批次 B)---------- + + /// replace_conversation 全量重写:删旧 + 插新原子,list 一致 + #[tokio::test] + async fn replace_conversation_full_rewrite() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + + // 预置旧数据(将被 replace 删除) + repo.insert_batch(vec![ + mk_msg("old_0", "conv", 0, "user", "旧0"), + mk_msg("old_1", "conv", 1, "user", "旧1"), + ]) + .await + .expect("insert old"); + + // replace 成全新内容(完全不同的 id,旧 id 应被删) + let now = now_millis_str(); + let records = vec![ + AiMessageRecord { + id: "new_0".into(), + conversation_id: "conv".into(), + seq: 0, + role: "user".into(), + content: "新0".into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: Some("deepseek-chat".into()), + status: "active".into(), + reasoning_content: Some("思考".into()), + timestamp: Some(1700000000000), + created_at: now.clone(), + }, + AiMessageRecord { + id: "new_1".into(), + conversation_id: "conv".into(), + seq: 1, + role: "assistant".into(), + content: "新1".into(), + parts: None, + tool_call_id: None, + tool_calls: Some(r#"[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]"#.into()), + model: None, + status: "active".into(), + reasoning_content: None, + timestamp: None, + created_at: now, + }, + ]; + repo.replace_conversation("conv", records).await.expect("replace"); + + let got = repo.list_by_conversation("conv").await.expect("list"); + assert_eq!(got.len(), 2, "旧 2 条应被删,新 2 条入"); + assert_eq!(got[0].id, "new_0"); + assert_eq!(got[0].content, "新0"); + assert_eq!(got[0].model.as_deref(), Some("deepseek-chat")); + assert_eq!(got[0].reasoning_content.as_deref(), Some("思考")); + assert_eq!(got[1].id, "new_1"); + assert!(got[1].tool_calls.is_some()); + + // 确认旧 id 已删 + let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect(); + assert!(!ids.contains(&"old_0") && !ids.contains(&"old_1")); + } + + /// replace_conversation 空列表 = 清空该对话消息(单事务 DELETE 不插) + #[tokio::test] + async fn replace_conversation_empty_clears() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + repo.insert_batch(vec![ + mk_msg("m0", "c", 0, "user", "0"), + mk_msg("m1", "c", 1, "user", "1"), + ]) + .await + .expect("insert"); + + repo.replace_conversation("c", vec![]).await.expect("replace empty"); + let got = repo.list_by_conversation("c").await.expect("list"); + assert!(got.is_empty(), "空 records 应清空对话"); + } + + /// replace_conversation 对话隔离:两 conv 并发写不串 + #[tokio::test] + async fn replace_conversation_isolation_between_convs() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + + // 预置 conv_a 与 conv_b + repo.insert_batch(vec![mk_msg("a0", "conv_a", 0, "user", "a0")]) + .await + .expect("insert a"); + repo.insert_batch(vec![mk_msg("b0", "conv_b", 0, "user", "b0")]) + .await + .expect("insert b"); + + // 只 replace conv_a,conv_b 不应被影响 + let now = now_millis_str(); + repo.replace_conversation( + "conv_a", + vec![AiMessageRecord { + id: "a_new".into(), + conversation_id: "conv_a".into(), + seq: 0, + role: "user".into(), + content: "a_new".into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: "active".into(), + reasoning_content: None, + timestamp: None, + created_at: now, + }], + ) + .await + .expect("replace a"); + + let got_a = repo.list_by_conversation("conv_a").await.expect("list a"); + assert_eq!(got_a.len(), 1); + assert_eq!(got_a[0].id, "a_new", "conv_a 应被全量重写"); + + let got_b = repo.list_by_conversation("conv_b").await.expect("list b"); + assert_eq!(got_b.len(), 1, "conv_b 不应被影响"); + assert_eq!(got_b[0].id, "b0"); + assert_eq!(got_b[0].content, "b0"); + } + + /// replace_conversation 幂等性:INSERT OR IGNORE 在同事务内 DELETE 后无残留, + /// 重复 replace 同 id 不报错(DELETE 后表内该 conv 空,INSERT 必然成功) + #[tokio::test] + async fn replace_conversation_idempotent_rerun() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + let now = now_millis_str(); + let rec = || AiMessageRecord { + id: "m0".into(), + conversation_id: "c".into(), + seq: 0, + role: "user".into(), + content: "0".into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: "active".into(), + reasoning_content: None, + timestamp: None, + created_at: now.clone(), + }; + repo.replace_conversation("c", vec![rec()]).await.expect("1st"); + repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); + let got = repo.list_by_conversation("c").await.expect("list"); + assert_eq!(got.len(), 1, "重复 replace 不应叠加"); + } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index b6b8faf..b10903c 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -8,6 +8,13 @@ use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; use df_ai::context::TokenEstimator; +// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。 +// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。 +use df_ai::context_helpers::{ + extract_key_info, extract_keyword_summary, should_summarize_tool_result, +}; +// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry) +use df_ai::intent::{filter_tool_defs, IntentRecognizer}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; // CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) // 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。 @@ -57,6 +64,32 @@ const PROTECT_COUNT: usize = 6; /// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。 pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3; +/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 +/// +/// 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, +/// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info +/// 替换(保留错误行 + 首尾各 5 行)。**仅 messages clone 视图,不改 ContextManager +/// 持久化**(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。 +/// false(关闭):tool_result 原样送 LLM(旧行为)。排障/对比用。 +pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true; + +/// 改进5 常量开关:是否启用主题切换系统标记(默认 true)。 +/// +/// true(默认):push 时末两条 user 消息 topic 都非 None 且不同(双高置信)→ +/// pending_topic_marker 置位 → agentic loop 顶部读取并 insert 一条 system 软提示 +/// `"── 用户已切换话题(从「{old}」到「{new}」),请以新话题为准 ──"`(软提示,不强制 LLM)。 +/// false(关闭):loop 顶部跳过读取/insert(置 false 即可观察无主题标记效果)。 +/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。 +pub const TOPIC_MARKER_ENABLED: bool = true; + // ============================================================ // 重构第一批(2026-06-19):GeneratingGuard 抽离到 guard.rs(纯结构搬迁,行为零变更)。 // run_agentic_loop 内仍 `GeneratingGuard::new(...)`,路径从本模块改 super::guard。 @@ -420,7 +453,55 @@ pub(crate) async fn run_agentic_loop( } _ => resolved_model, }; - let tool_defs = tools_arc.tool_definitions(); + // 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry) + // + // 机制化收敛跑题:取末条 active user 消息 → IntentRecognizer 识别 → 置信 ≥ 阈值 + // 则按 intent domain 过滤工具子集(减少 LLM 在无关工具上分心/误用)。 + // 三重 fallback(改进2 B §可靠): + // 1. 置信 < INTENT_CONF_THRESHOLD(0.7)→ 全量 + // 2. subset 空(Chat/Unknown/Conversation)→ filter_tool_defs 内部回全量 + // 3. 过滤后 < 3 条(疑似漂移/误收敛)→ 回全量 + // 关键安全:filter 仅改 LLM 可见 tool_defs,**不改执行**——audit 走 tools_arc.get/execute + // 完整 registry,LLM 即使幻觉一个被滤掉的工具名,audit 也能查到/拒绝。 + // + // INTENT_CONF_THRESHOLD(常量开关):阈值,低于此值不收敛(回全量)。 + // 注意:conf 截断到 1.0(intent.rs),单关键词 weight=1.0 即达 conf=1.0,故阈值=1.0 + // 时 conf>=1.0 仍过滤(非关闭)。真关闭收敛:置 >1.0(如 1.1)。调低 = 更激进收敛。 + let user_text: String = { + let session = session_arc.lock().await; + session + .conv_read(&conv_id) + .and_then(|c| { + let msgs = c.messages.all_messages_clone(); + msgs.iter() + .rev() + .find(|m| matches!(m.role, df_ai::provider::MessageRole::User)) + .map(|m| m.content.clone()) + }) + .unwrap_or_default() + }; + let (intent, conf) = IntentRecognizer::recognize(&user_text); + const INTENT_CONF_THRESHOLD: f32 = 0.7; + let all_defs = tools_arc.tool_definitions(); + let total = all_defs.len(); // 提前记录全量数(all_defs 将 move 进 tool_defs) + let tool_defs = if conf >= INTENT_CONF_THRESHOLD { + let filtered = filter_tool_defs(&all_defs, &intent); + if filtered.len() < 3 { + all_defs // 兜底:过滤<3(漂移/误收敛)回全量 + } else { + filtered + } + } else { + all_defs // 低置信 fallback 全量 + }; + tracing::info!( + conv_id = %conv_id, + intent = intent.as_str(), + conf, + filtered = tool_defs.len(), + total, + "[ai] 意图收敛工具" + ); // 停止信号副本:stream_llm 与每轮迭代共享读取,避免重复加锁 // notify 同取一份 Arc 引用(B-260615-14):stream_llm select! 监听 notified() 即时唤醒 // F-260616-09 B 批2:取 per_conv 的 stop_flag/notify(设计 §4.2 :446)。 @@ -467,7 +548,7 @@ pub(crate) async fn run_agentic_loop( total_tokens: tokens.total(), }; // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 - save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), None, true).await; // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; @@ -476,6 +557,56 @@ pub(crate) async fn run_agentic_loop( return; } + // 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。 + // + // push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic), + // ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费 + // (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。 + // + // 保守设计: + // - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。 + // - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。 + // - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。 + // topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。 + if TOPIC_MARKER_ENABLED { + let topic_marker_raw: Option = { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(&conv_id) { + tracing::warn!( + stale_conv = %conv_id, + "[ai] conv 已删除,loop 退出(主题标记段入口)" + ); + return; + } + let conv = session.conv(&conv_id); + conv.messages.take_topic_marker() + }; + if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| { + // 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。 + let mut parts = s.splitn(2, '|'); + let old = parts.next()?.to_string(); + let new = parts.next()?.to_string(); + Some((old, new)) + }) { + let marker_text = format!( + "── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──", + old_topic, new_topic + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(&conv_id) { + let conv = session.conv(&conv_id); + conv.messages.insert_at(0, ChatMessage::system(&marker_text)); + tracing::info!( + conv_id = %conv_id, + iteration, + old_topic = %old_topic, + new_topic = %new_topic, + "[ai] 主题切换标记已 insert(软提示,保守双高置信)" + ); + } + } + } + // B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。 // 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧, // 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。 @@ -577,6 +708,15 @@ pub(crate) async fn run_agentic_loop( (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() { @@ -621,15 +761,46 @@ pub(crate) async fn run_agentic_loop( session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); } Err(e) => { - // LLM 失败 → 消息状态完全不变(未改 status / 未扣 token)。 - // set_compressing(false) 复位 + emit AiError(message 不含 api_key)。 - // 不阻塞 loop:继续走下方 build_for_request 原裁剪路径(保最近 6 条)。 + // 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, - "[ai] 自动压缩失败,降级走原裁剪(build_for_request)" + 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)" + ); + } + } let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e), error_type: Some(ErrorType::Unknown), @@ -660,6 +831,68 @@ pub(crate) async fn run_agentic_loop( msgs }; + // 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。 + // + // 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息, + // 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换: + // 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。 + // + // **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图, + // 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。 + // 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。 + // + // TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。 + let messages: Vec = if TOOL_RESULT_COMPRESS_ENABLED { + // 历史总 token 用于占比判定(history_tokens 即 build_for_request 前的快照, + // 此处 messages 已裁剪过,用 estimated_prompt 不准;改用 history_tokens 快照更稳)。 + // 注:用 conv.messages.history_tokens() 快照做占比基准(裁剪前的真相),避免循环依赖。 + let history_tokens_snapshot: u32 = { + let session = session_arc.lock().await; + session + .conv_read(&conv_id) + .map(|c| c.messages.history_tokens()) + .unwrap_or(0) + }; + let est = TokenEstimator::default(); + let mut compressed_bytes: usize = 0; + let mut original_bytes: usize = 0; + let mut summarized_count: usize = 0; + let msgs: Vec = messages + .into_iter() + .map(|mut m| { + if !matches!(m.role, df_ai::provider::MessageRole::Tool) { + return m; + } + let content_len = m.content.len(); + let content_tokens = est.estimate_text(&m.content); + if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { + return m; + } + original_bytes += content_len; + // tool_name:无 tool_calls 关联(本消息是 tool_result,无 name 字段),用 call_id 或占位。 + let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string()); + let compressed = extract_key_info(&m.content, &tool_name); + compressed_bytes += compressed.len(); + summarized_count += 1; + m.content = compressed; + m + }) + .collect(); + if summarized_count > 0 { + tracing::info!( + conv_id = %conv_id, + iteration, + summarized_count, + original_bytes, + compressed_bytes, + "[ai] tool_result view-only 摘要压缩(不改持久化)" + ); + } + msgs + } else { + messages + }; + // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补) // 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body), // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。 @@ -829,7 +1062,7 @@ pub(crate) async fn run_agentic_loop( } } - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // 标题生成后台化(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; @@ -895,7 +1128,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; @@ -920,7 +1153,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) guard.disarm(); @@ -949,7 +1182,7 @@ pub(crate) async fn run_agentic_loop( completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位 guard.disarm(); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached { @@ -979,7 +1212,7 @@ pub(crate) async fn run_agentic_loop( let llm_concurrency = llm_concurrency.clone(); let resolved_model = resolved_model.clone(); tauri::async_runtime::spawn(async move { - save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // 知识提炼:需读已落库的对话消息,故在 save 之后 if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await { tracing::warn!("知识提炼触发失败(非阻断): {}", e); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 9bf84ba..822bd31 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -155,7 +155,7 @@ pub async fn ai_regenerate( } // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) - save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); @@ -402,7 +402,7 @@ pub async fn ai_approve( // 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史, // messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。 if let Some(ref cid) = conv_id { - save_conversation(&state.ai_session, &state.db, cid, None, None).await; + save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } // 审计:拒绝(决策者=human) audit_finalize(&state, &tool_call_id, "rejected", None).await; @@ -504,7 +504,7 @@ pub async fn ai_approve( // 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空, // save 不污染老对话;原 if !recovered 守卫前提不成立已移除。 if let Some(ref cid) = conv_id { - save_conversation(&state.ai_session, &state.db, cid, None, None).await; + save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } // F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传 @@ -576,7 +576,7 @@ pub async fn ai_authorize_dir( }); audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await; if let Some(ref cid) = conv_id { - save_conversation(&state.ai_session, &state.db, cid, None, None).await; + save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } let cont_conv_id = conv_id.clone().unwrap_or_default(); let start_iter = { @@ -635,7 +635,7 @@ pub async fn ai_authorize_dir( conversation_id: conv_id.clone(), }); if let Some(ref cid) = conv_id { - save_conversation(&state.ai_session, &state.db, cid, None, None).await; + save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } let cont_conv_id = conv_id.clone().unwrap_or_default(); let start_iter = { @@ -688,13 +688,20 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { } session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref()); drop(session); - // 真删 DB:清空该对话 messages JSON + 清零 token(保留对话壳),刷新不再恢复(AR-7) + // 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7) + // F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None) + // 旧 clear_messages(置 messages='[]')保留调用:备份列同步清空,防 reload fallback 读旧脏数据。 if let Some(id) = active_id { state .ai_conversations .clear_messages(&id) .await .map_err(err_str)?; + state + .ai_messages + .delete_range(&id, 0, None) + .await + .map_err(err_str)?; } Ok(()) } @@ -740,7 +747,7 @@ pub async fn ai_chat_clear_context( conversation_id }; // 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status) - save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared { conversation_id: Some(conv_id), }); @@ -867,7 +874,7 @@ pub async fn ai_chat_compress_context( conv.messages.insert_at(0, ChatMessage::system(&summary)); conv.messages.set_compressing(false); } - save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed { conversation_id: Some(conv_id), summary, @@ -955,7 +962,7 @@ pub async fn ai_chat_edit( } // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) - save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 26fb0e1..79b1639 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -15,7 +15,8 @@ use std::sync::atomic::Ordering; use tauri::{AppHandle, State}; -use df_ai::provider::ChatMessage; +use df_ai::provider::{ChatMessage, MessageRole}; +use df_storage::models::AiMessageRecord; use df_types::types::new_id; use crate::state::AppState; @@ -26,6 +27,124 @@ use super::super::conversation::save_conversation; use super::super::prompt::get_active_provider; use super::super::title::spawn_ensure_title; +// ============================================================ +// ChatMessage ↔ AiMessageRecord 映射(F-260619-03 消息拆分存储) +// ============================================================ +// +// 读路径(批次 A)核心:`record_to_message` 把 AiMessageRecord(单行)还原成 ChatMessage。 +// 写路径(批次 B)备好但本批次不调:`message_to_record`(save_conversation 写 ai_messages 用)。 +// +// round-trip 严格性: +// - role 枚举 ↔ 小写字符串双向(serialize rename_all=lowercase) +// - parts / tool_calls:JSON 字符串 ↔ Option 双向反序列化 +// - status:None ↔ "active"(对齐 sanitize is_active 归一化) +// - id:record.id 必非空(主键),还原为 ChatMessage.id=Some; +// 反向 ChatMessage.id=None 时兜底 `msg_{conv}_{seq}`(save 写路径用,本批次不触发) +// +// 映射工具放 src-tauri(非 df-ai-core):df-ai-core 不依赖 df-storage(防循环依赖, +// df-storage 的迁移层注释明确不依赖 df-ai-core),只能在上层 src-tauri 桥接两者类型。 + +/// role 字符串 → MessageRole 枚举(小写,对齐 serialize rename_all="lowercase")。 +/// +/// 未知值兜底 User(防御性,实际 DB role 列由 message_to_record 写入,值域受控)。 +fn role_from_str(s: &str) -> MessageRole { + match s { + "system" => MessageRole::System, + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "tool" => MessageRole::Tool, + _ => MessageRole::User, + } +} + +/// MessageRole 枚举 → role 字符串(小写)。 +fn role_to_str(r: &MessageRole) -> &'static str { + match r { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + } +} + +/// AiMessageRecord → ChatMessage(读路径核心:list_by_conversation 还原消息)。 +/// +/// - role 字符串 → 枚举 +/// - parts/tool_calls:JSON 字符串 → Vec 反序列化(空/解析失败 → None) +/// - status:"active" → None(对齐 sanitize is_active 归一化),其他原样保留 +/// - id:record.id 必非空(主键)→ Some +pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage { + let parts = rec + .parts + .as_deref() + .filter(|s| !s.is_empty()) + .and_then(|s| serde_json::from_str(s).ok()); + let tool_calls = rec + .tool_calls + .as_deref() + .filter(|s| !s.is_empty()) + .and_then(|s| serde_json::from_str(s).ok()); + // status "active" → None(归一化,对齐 ChatMessage 默认语义 None=正常可见) + let status = if rec.status == "active" { + None + } else { + Some(rec.status.clone()) + }; + ChatMessage { + id: Some(rec.id.clone()), + role: role_from_str(&rec.role), + content: rec.content.clone(), + parts, + tool_call_id: rec.tool_call_id.clone(), + tool_calls, + model: rec.model.clone(), + status, + reasoning_content: rec.reasoning_content.clone(), + timestamp: rec.timestamp, + } +} + +/// ChatMessage → AiMessageRecord(写路径:save_conversation 全量重写 ai_messages 用)。 +/// +/// 调用方传入 conversation_id + seq(对话内序号,按索引)。 +/// - id None 兜底:`msg_{conv}_{seq}`(老数据无 id 时,落库时补主键) +/// - role 枚举 → 小写字符串 +/// - parts/tool_calls → JSON 字符串 +/// - status None → "active"(归一化,对齐 record 默认列值) +/// - created_at 调用方传入(save_conversation 用对话 created_at 或 now) +pub fn message_to_record( + msg: &ChatMessage, + conversation_id: &str, + seq: i64, + created_at: &str, +) -> AiMessageRecord { + let id = msg.id.clone().unwrap_or_else(|| format!("msg_{}_{}", conversation_id, seq)); + let parts = msg + .parts + .as_ref() + .map(|p| serde_json::to_string(p).unwrap_or_default()); + let tool_calls = msg + .tool_calls + .as_ref() + .map(|t| serde_json::to_string(t).unwrap_or_default()); + let status = msg.status.clone().unwrap_or_else(|| "active".to_string()); + AiMessageRecord { + id, + conversation_id: conversation_id.to_string(), + seq, + role: role_to_str(&msg.role).to_string(), + content: msg.content.clone(), + parts, + tool_call_id: msg.tool_call_id.clone(), + tool_calls, + model: msg.model.clone(), + status, + reasoning_content: msg.reasoning_content.clone(), + timestamp: msg.timestamp, + created_at: created_at.to_string(), + } +} + // ============================================================ // 对话管理 // ============================================================ @@ -61,7 +180,7 @@ pub async fn ai_conversation_create( }; if let Some(ref oc) = old_conv { if old_has_msgs { - save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await; + save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None, true).await; } } @@ -130,10 +249,36 @@ pub async fn ai_conversation_switch( .map_err(err_str)? .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; - let messages: Vec = serde_json::from_str(&record.messages) - .map_err(|e| format!("解析消息失败: {}", e))?; + // F-260619-03 消息拆分存储(批次 A 读路径):优先从 ai_messages 表加载(每条消息一行), + // 替代旧 ai_conversations.messages 整对话 JSON 反序列化。映射 Vec + // → Vec → restore_from_messages。 + // + // fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]` + // (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。 + // 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。 + let records = state.ai_messages.list_by_conversation(&conversation_id).await + .map_err(err_str)?; + let messages: Vec = if !records.is_empty() { + records.iter().map(record_to_message).collect() + } else { + // 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法) + let has_legacy = record.messages != "[]" && !record.messages.is_empty(); + if has_legacy { + tracing::warn!( + "ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={}): \ + 老库未迁移或批次 B 写路径未上线", + conversation_id + ); + serde_json::from_str(&record.messages) + .map_err(|e| format!("解析消息失败: {}", e))? + } else { + Vec::new() + } + }; - let messages_json = record.messages.clone(); + // 由 records/chat_messages 重序列化返回前端(前端契约不变,仍吃 JSON 字符串)。 + let messages_json = serde_json::to_string(&messages) + .map_err(|e| format!("序列化消息失败: {}", e))?; let title = record.title.clone(); // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。 // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过, @@ -308,8 +453,22 @@ pub async fn ai_conversation_export( .map_err(err_str)? .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; - let messages: Vec = serde_json::from_str(&record.messages) - .map_err(|e| format!("解析消息失败: {}", e))?; + // F-260619-03 批次 A:导出读路径同样切读 ai_messages 表(与 switch 一致), + // fallback 旧 messages JSON 列(老库未迁移/坏数据)。空对话 → 空 messages。 + let records = state.ai_messages.list_by_conversation(&conversation_id).await + .map_err(err_str)?; + let messages: Vec = if !records.is_empty() { + records.iter().map(record_to_message).collect() + } else if record.messages != "[]" && !record.messages.is_empty() { + tracing::warn!( + "导出:ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={})", + conversation_id + ); + serde_json::from_str(&record.messages) + .map_err(|e| format!("解析消息失败: {}", e))? + } else { + Vec::new() + }; let body = match fmt { "markdown" => { @@ -352,3 +511,377 @@ pub async fn ai_conversation_export( Ok(body) } + +// ============================================================ +// 测试:ChatMessage ↔ AiMessageRecord 映射 round-trip +// ============================================================ +#[cfg(test)] +mod tests { + use super::*; + use df_ai::provider::{ChatMessage, ContentPart, MessageRole, ToolCall, ToolCallFunction}; + + fn base_msg() -> ChatMessage { + ChatMessage { + id: Some("msg_test_1".into()), + role: MessageRole::User, + content: "你好".into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: None, + reasoning_content: None, + timestamp: Some(1700000000000), + } + } + + /// 纯文本消息 round-trip:status None ↔ "active" 归一化 + #[test] + fn roundtrip_plain_text_status_none() { + let msg = base_msg(); + let rec = message_to_record(&msg, "conv_x", 0, "2026-01-01T00:00:00Z"); + assert_eq!(rec.id, "msg_test_1"); + assert_eq!(rec.conversation_id, "conv_x"); + assert_eq!(rec.seq, 0); + assert_eq!(rec.role, "user"); + assert_eq!(rec.status, "active", "None 应归一化为 active"); + assert!(rec.parts.is_none()); + assert!(rec.tool_calls.is_none()); + + let back = record_to_message(&rec); + assert_eq!(back.id.as_deref(), Some("msg_test_1")); + assert_eq!(back.status, None, "active 应还原为 None"); + assert_eq!(back.content, "你好"); + assert_eq!(back.timestamp, Some(1700000000000)); + assert!(matches!(back.role, MessageRole::User)); + } + + /// 多模态消息(parts 含 Image base64)round-trip + #[test] + fn roundtrip_multimodal_parts() { + let mut msg = base_msg(); + msg.parts = Some(vec![ + ContentPart::Text { text: "看这张图".into() }, + ContentPart::Image { + url: None, + base64: Some("iVBORw0KGgo=".into()), + media_type: Some("image/png".into()), + alt: None, + }, + ]); + let rec = message_to_record(&msg, "conv_m", 1, "ts"); + // parts 应序列化成 JSON 字符串 + assert!(rec.parts.is_some()); + let back = record_to_message(&rec); + assert_eq!(back.parts, msg.parts, "parts Image base64 双向一致"); + } + + /// assistant 工具调用消息(tool_calls 多条)round-trip + #[test] + fn roundtrip_tool_calls() { + let mut msg = base_msg(); + msg.role = MessageRole::Assistant; + msg.model = Some("deepseek-chat".into()); + msg.tool_calls = Some(vec![ + ToolCall { + id: "call_1".into(), + call_type: "function".into(), + function: ToolCallFunction { + name: "read_file".into(), + arguments: r#"{"path":"a.rs"}"#.into(), + }, + }, + ToolCall { + id: "call_2".into(), + call_type: "function".into(), + function: ToolCallFunction { + name: "write_file".into(), + arguments: r#"{"path":"b.rs"}"#.into(), + }, + }, + ]); + let rec = message_to_record(&msg, "conv_t", 2, "ts"); + assert_eq!(rec.role, "assistant"); + assert_eq!(rec.model.as_deref(), Some("deepseek-chat")); + assert!(rec.tool_calls.is_some()); + let back = record_to_message(&rec); + assert_eq!(back.tool_calls.as_ref().map(|v| v.len()), Some(2)); + // ToolCall 无 PartialEq,经 JSON 字符串比对(round-trip 一致) + assert_eq!( + serde_json::to_string(&back.tool_calls).unwrap(), + serde_json::to_string(&msg.tool_calls).unwrap(), + "tool_calls 多条双向一致" + ); + assert_eq!(back.model.as_deref(), Some("deepseek-chat")); + } + + /// tool 消息(tool_call_id)round-trip + #[test] + fn roundtrip_tool_message() { + let mut msg = base_msg(); + msg.role = MessageRole::Tool; + msg.content = "文件内容...".into(); + msg.tool_call_id = Some("call_abc".into()); + let rec = message_to_record(&msg, "conv_tool", 3, "ts"); + assert_eq!(rec.role, "tool"); + assert_eq!(rec.tool_call_id.as_deref(), Some("call_abc")); + let back = record_to_message(&rec); + assert!(matches!(back.role, MessageRole::Tool)); + assert_eq!(back.tool_call_id.as_deref(), Some("call_abc")); + } + + /// status 各值("truncated"/"compressed")round-trip(非 active 原样保留) + #[test] + fn roundtrip_status_non_active_preserved() { + let mut msg = base_msg(); + msg.status = Some("truncated".into()); + let rec = message_to_record(&msg, "c", 0, "ts"); + assert_eq!(rec.status, "truncated"); + let back = record_to_message(&rec); + assert_eq!(back.status.as_deref(), Some("truncated")); + + // compressed + msg.status = Some("compressed".into()); + let rec = message_to_record(&msg, "c", 0, "ts"); + assert_eq!(rec.status, "compressed"); + } + + /// id=None 兜底:`msg_{conv}_{seq}` + #[test] + fn roundtrip_id_none_fallback() { + let mut msg = base_msg(); + msg.id = None; + let rec = message_to_record(&msg, "conv_y", 5, "ts"); + assert_eq!(rec.id, "msg_conv_y_5", "id None 应兜底 msg_conv_seq"); + // 读回:id 来自 record.id(非空)→ Some + let back = record_to_message(&rec); + assert_eq!(back.id.as_deref(), Some("msg_conv_y_5")); + } + + /// reasoning_content round-trip + #[test] + fn roundtrip_reasoning_content() { + let mut msg = base_msg(); + msg.role = MessageRole::Assistant; + msg.reasoning_content = Some("思考过程...".into()); + let rec = message_to_record(&msg, "c", 0, "ts"); + assert_eq!(rec.reasoning_content.as_deref(), Some("思考过程...")); + let back = record_to_message(&rec); + assert_eq!(back.reasoning_content.as_deref(), Some("思考过程...")); + } + + /// 完整多字段混合 round-trip(覆盖全字段一致性) + #[test] + fn roundtrip_full_fields() { + let mut msg = base_msg(); + msg.role = MessageRole::Assistant; + msg.content = "结果".into(); + msg.parts = Some(vec![ContentPart::Text { text: "t".into() }]); + msg.tool_calls = Some(vec![ToolCall { + id: "c1".into(), + call_type: "function".into(), + function: ToolCallFunction { + name: "f".into(), + arguments: "{}".into(), + }, + }]); + msg.model = Some("m".into()); + msg.status = Some("compressed".into()); + msg.reasoning_content = Some("r".into()); + msg.timestamp = Some(123); + let rec = message_to_record(&msg, "conv_full", 7, "ts_full"); + let back = record_to_message(&rec); + assert_eq!(back.id.as_deref(), Some("msg_test_1")); + assert!(matches!(back.role, MessageRole::Assistant)); + assert_eq!(back.content, "结果"); + assert_eq!(back.parts, msg.parts); + assert_eq!( + serde_json::to_string(&back.tool_calls).unwrap(), + serde_json::to_string(&msg.tool_calls).unwrap() + ); + assert_eq!(back.model.as_deref(), Some("m")); + assert_eq!(back.status.as_deref(), Some("compressed")); + assert_eq!(back.reasoning_content.as_deref(), Some("r")); + assert_eq!(back.timestamp, Some(123)); + } + + // ============================================================ + // F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原) + // ============================================================ + // + // 覆盖 save_conversation 写路径核心链路(不依赖 AiSession 夹具): + // Vec --message_to_record--> Vec + // --replace_conversation--> ai_messages 表 + // --list_by_conversation--> Vec + // --record_to_message--> Vec + // 验证:长对话 50+ 轮全字段(parts/tool_calls/status/id/timestamp/reasoning_content)一致。 + + #[tokio::test] + async fn batch_b_save_load_roundtrip_50_rounds_full_fields() { + use df_storage::crud::AiMessageRepo; + use df_storage::db::Database; + + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + + // 构造 50 轮 user/assistant/tool 混合消息(共 150 条),覆盖全字段 + let conv_id = "conv_rt"; + let created_at = "2026-06-20T00:00:00Z"; + let original: Vec = (0..150) + .map(|i| { + let role = i % 3; + let mut m = match role { + 0 => ChatMessage { + id: Some(format!("u_{i}")), + role: MessageRole::User, + content: format!("用户提问 {i},带中文"), + parts: Some(vec![ContentPart::Text { text: format!("片 {i}") }]), + timestamp: Some(1_700_000_000_000 + i), + ..base_msg() + }, + 1 => ChatMessage { + id: Some(format!("a_{i}")), + role: MessageRole::Assistant, + content: format!("助手回答 {i}"), + model: Some("deepseek-chat".into()), + reasoning_content: Some(format!("思考 {i}")), + tool_calls: Some(vec![ToolCall { + id: format!("call_{i}"), + call_type: "function".into(), + function: ToolCallFunction { + name: "read_file".into(), + arguments: format!(r#"{{"path":"{i}.rs"}}"#), + }, + }]), + status: if i % 6 == 0 { + Some("compressed".into()) + } else { + None + }, + timestamp: Some(1_700_000_000_000 + i), + ..base_msg() + }, + _ => ChatMessage { + id: Some(format!("t_{i}")), + role: MessageRole::Tool, + content: format!("工具结果 {i}:大段内容"), + tool_call_id: Some(format!("call_{}", i - 1)), + timestamp: Some(1_700_000_000_000 + i), + ..base_msg() + }, + }; + m.id = Some(format!("msg_{conv_id}_{i}")); + m + }) + .collect(); + + // 写路径:映射 + replace_conversation(全量重写) + let records: Vec<_> = original + .iter() + .enumerate() + .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, created_at)) + .collect(); + repo.replace_conversation(conv_id, records) + .await + .expect("replace"); + + // 读路径:list + 还原 + let got_records = repo.list_by_conversation(conv_id).await.expect("list"); + assert_eq!(got_records.len(), 150, "应读回全部 150 条"); + let restored: Vec = + got_records.iter().map(record_to_message).collect(); + + // 全字段逐一比对 + assert_eq!(restored.len(), original.len()); + for (i, (orig, back)) in original.iter().zip(restored.iter()).enumerate() { + assert_eq!(back.id, orig.id, "id 不一致 @ {i}"); + // role 枚举比对(MessageRole 非 Copy,用 std::mem::discriminant 判变体相等) + assert_eq!( + std::mem::discriminant(&back.role), + std::mem::discriminant(&orig.role), + "role 不一致 @ {i}" + ); + assert_eq!(back.content, orig.content, "content 不一致 @ {i}"); + assert_eq!(back.parts, orig.parts, "parts 不一致 @ {i}"); + assert_eq!(back.tool_call_id, orig.tool_call_id, "tool_call_id 不一致 @ {i}"); + // tool_calls 无 PartialEq → JSON 比对 + assert_eq!( + serde_json::to_string(&back.tool_calls).unwrap(), + serde_json::to_string(&orig.tool_calls).unwrap(), + "tool_calls 不一致 @ {i}" + ); + assert_eq!(back.model, orig.model, "model 不一致 @ {i}"); + assert_eq!(back.status, orig.status, "status 不一致 @ {i}"); + assert_eq!( + back.reasoning_content, orig.reasoning_content, + "reasoning_content 不一致 @ {i}" + ); + assert_eq!(back.timestamp, orig.timestamp, "timestamp 不一致 @ {i}"); + } + + // seq 顺序校验 + for (i, rec) in got_records.iter().enumerate() { + assert_eq!(rec.seq, i as i64, "seq 应连续递增 @ {i}"); + assert_eq!(rec.conversation_id, conv_id, "conversation_id 应一致 @ {i}"); + } + } + + /// save 全量重写后再次 save 变更内容(模拟 compress/edit 后下轮 save 覆盖) + #[tokio::test] + async fn batch_b_save_overwrite_reflects_inmemory_change() { + use df_storage::crud::AiMessageRepo; + use df_storage::db::Database; + + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + let conv_id = "conv_overwrite"; + let created_at = "ts"; + + // 第一轮:3 条消息 + let v1: Vec = (0..3) + .map(|i| ChatMessage { + id: Some(format!("m_{i}")), + role: MessageRole::User, + content: format!("v1_{i}"), + ..base_msg() + }) + .collect(); + let recs: Vec<_> = v1 + .iter() + .enumerate() + .map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at)) + .collect(); + repo.replace_conversation(conv_id, recs).await.expect("save v1"); + + // 第二轮:内存变化——压缩成 1 条(status=compressed)+ 删除 2 条 + 新增 1 条 + let v2: Vec = vec![ + ChatMessage { + id: Some("m_summary".into()), + role: MessageRole::Assistant, + content: "压缩摘要".into(), + status: Some("compressed".into()), + ..base_msg() + }, + ChatMessage { + id: Some("m_new".into()), + role: MessageRole::User, + content: "压缩后新提问".into(), + ..base_msg() + }, + ]; + let recs2: Vec<_> = v2 + .iter() + .enumerate() + .map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at)) + .collect(); + repo.replace_conversation(conv_id, recs2).await.expect("save v2"); + + // 读回:应完全是 v2,v1 的 3 条已删 + let got = repo.list_by_conversation(conv_id).await.expect("list"); + assert_eq!(got.len(), 2, "v2 全量重写后应只剩 2 条"); + assert_eq!(got[0].id, "m_summary"); + assert_eq!(got[0].status, "compressed"); + assert_eq!(got[1].id, "m_new"); + assert_eq!(got[1].content, "压缩后新提问"); + } +} diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 27b3600..5adfff5 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -9,6 +9,7 @@ use df_storage::db::Database; use crate::commands::now_millis; +use super::commands::message_to_record; use super::AiSession; /// Token 用量累加器(agent loop 生命周期内各轮叠加) @@ -130,15 +131,22 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart]) /// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响) /// -/// 写 messages + updated_at + 累加 token 用量 + 首次落库的 model;标题由 ensure_conversation_title 单独生成。 +/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 + +/// 首次落库的 model;标题由 ensure_conversation_title 单独生成。 /// token 走累加模式:upsert 读旧值叠加,保证审批暂停→恢复跨 loop 实例不覆盖丢失。 /// model 仅首次落库写入 + 旧记录缺值时补填(不覆盖历史已存值,兼容本次改造前的老对话)。 +/// +/// F-260619-03 批次 B:写路径切 ai_messages(全量重写,replace_conversation)。 +/// 内存 ContextManager 是运行时真相源,save 是同步点——每轮全量回写 ai_messages, +/// compress/replace/edit/clear_context 内存改 status/content 后下轮 save 自动覆盖。 +/// 旧 messages JSON 列**保留不写**(作备份,防 reload fallback 读旧脏数据),不赋新值也不置空。 pub(crate) async fn save_conversation( session_arc: &Arc>, db: &Arc, conv_id: &str, usage: Option<&df_ai::provider::TokenUsage>, model: Option<&str>, + touch_updated_at: bool, ) { // 取 messages + 懒创建首次落库所需的 provider_id/created_at // 工具结果(content)超 50KB 时截断头尾各 ~20KB + 中段标注,防大体量结果(read_file 1MB 洞 / @@ -149,7 +157,7 @@ pub(crate) async fn save_conversation( // loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。 // conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端 // 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。 - let (messages_json, provider_id, created_at) = { + let (persist_msgs, provider_id, created_at) = { let mut session = session_arc.lock().await; let mut msgs = session.conv(conv_id).messages.all_messages_clone(); for m in &mut msgs { @@ -161,18 +169,34 @@ pub(crate) async fn save_conversation( } } ( - serde_json::to_string(&msgs).unwrap_or_else(|_| "[]".to_string()), + msgs, session.active_provider_id.clone(), session.active_conv_created_at.clone(), ) }; + // F-260619-03 批次 B:映射 Vec → Vec(带 seq 索引 + conv_id) + // 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。 + // created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。 + let now = now_millis(); + let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone()); + let records: Vec = persist_msgs + .iter() + .enumerate() + .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at)) + .collect(); + let conv_repo = AiConversationRepo::new(db); + let msg_repo = df_storage::crud::AiMessageRepo::new(db); match conv_repo.get_by_id(conv_id).await { Ok(Some(mut rec)) => { - // 已落库:更新 messages + updated_at;token 累加(读旧值+新值,跨 loop 实例防覆盖) - rec.messages = messages_json; - rec.updated_at = now_millis(); + // 已落库:更新对话元数据(token/model/updated_at);messages JSON 列**不赋新值**(保留旧值作备份)。 + // updated_at 按 touch_updated_at 条件改(用户活跃=true 反映最后活跃, + // 系统摘要/压缩=false 防会话时间分组"昨天→今天"跳变); + // token 累加(读旧值+新值,跨 loop 实例防覆盖)。 + if touch_updated_at { + rec.updated_at = now_millis(); + } if let Some(u) = usage { rec.prompt_tokens = accumulate_tokens(rec.prompt_tokens, u.prompt_tokens); rec.completion_tokens = accumulate_tokens(rec.completion_tokens, u.completion_tokens); @@ -187,13 +211,21 @@ pub(crate) async fn save_conversation( if !list.iter().any(|x| x == m) { list.push(m.to_string()); } rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string())); } + // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at if let Err(e) = conv_repo.update_full(&rec).await { - tracing::warn!("更新对话失败 {conv_id}: {e}"); + tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); + } + // 消息拆分存储:全量重写 ai_messages + if let Err(e) = msg_repo.replace_conversation(conv_id, records).await { + tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}"); } } Ok(None) => { // 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空) - let now = now_millis(); + // messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径), + // 同时写 ai_messages(新读路径真相源)。 + let messages_json = serde_json::to_string(&persist_msgs).unwrap_or_else(|_| "[]".to_string()); + let conv_created = created_at.unwrap_or_else(|| now.clone()); let rec = df_storage::models::AiConversationRecord { id: conv_id.to_string(), title: None, @@ -205,12 +237,17 @@ pub(crate) async fn save_conversation( pinned: false, prompt_tokens: usage.map(|u| u.prompt_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64), - created_at: created_at.unwrap_or_else(|| now.clone()), + created_at: conv_created.clone(), updated_at: now, }; if let Err(e) = conv_repo.insert(rec).await { tracing::warn!("落库对话失败 {conv_id}: {e}"); } + // 消息拆分存储:首次落库同样全量写 ai_messages + // (records 已用 conv_created 作 created_at,与对话记录一致) + if let Err(e) = msg_repo.replace_conversation(conv_id, records).await { + tracing::warn!("首次写 ai_messages 失败 {conv_id}: {e}"); + } } Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"), } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index fb61b07..c8607f3 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -68,7 +68,12 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) - Briefly explain your intent before executing actions\n\ - Ask for clarification if the user's intent is unclear\n\ - Prefer using tools to complete actions rather than just describing steps\n\ - - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n", + - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\ + ## Focus\n\ + - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ + - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ + - Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\ + - Do not proactively expand context (files/data) that is irrelevant to the current request.\n", "\n## Current Projects\n", "\n## Current Tasks\n", ), @@ -86,7 +91,12 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) - 执行操作前简要说明你的意图\n\ - 如果不确定用户意图,先提问\n\ - 优先使用工具完成操作,而不是只描述步骤\n\ - - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n", + - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ + ## 聚焦准则\n\ + - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ + - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ + - 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\ + - 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n", "\n## 当前项目\n", "\n## 当前任务\n", ), @@ -168,6 +178,9 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str { - Be concise; prefer bullet points.\n\ - Drop small talk and transient pleasantries; keep only technically load-bearing facts.\n\ - Preserve file paths, identifiers, and error messages verbatim.\n\ + - Must preserve core topic words, entity names, and technical terms the user \ + repeatedly mentions; they are anchors for continuing the conversation and losing \ + them breaks the context thread.\n\ - Do NOT invent facts not present in the conversation.\n\ - Output the four sections only, no preamble or extra commentary.", _ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\ @@ -189,7 +202,84 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str { - 简洁,优先用要点。\n\ - 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\ - 文件路径、标识符、错误信息等照原样保留。\n\ + - 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\ - 不要编造对话中没有的事实。\n\ - 只输出上述四段内容,不要前言、解释或额外评论。", } } + +#[cfg(test)] +mod tests { + use super::*; + + // 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段 + #[test] + fn system_prompt_zh_has_focus_section() { + let (prefix, _, _) = system_prompt_parts("zh-CN"); + assert!( + prefix.contains("聚焦准则"), + "中文 system prompt 应含聚焦准则段,实际: {}", + prefix + ); + // 独立段标题,非稀释在行为准则里 + assert!(prefix.contains("## 聚焦准则")); + // 核心条款抽样 + assert!(prefix.contains("始终围绕用户当前请求的核心目标")); + assert!(prefix.contains("也绝不谎报成功")); + } + + // 改进1 系统提示聚焦段:英文版含「Focus」独立段 + #[test] + fn system_prompt_en_has_focus_section() { + let (prefix, _, _) = system_prompt_parts("en"); + assert!( + prefix.contains("Focus"), + "英文 system prompt 应含 Focus 段,实际: {}", + prefix + ); + assert!(prefix.contains("## Focus")); + assert!(prefix.contains("never falsely report success")); + assert!(prefix.contains("core goal of the user's current request")); + } + + // 兜底:lang 未匹配回落中文,聚焦段仍存在 + #[test] + fn system_prompt_unknown_lang_falls_back_zh_with_focus() { + let (prefix, _, _) = system_prompt_parts("fr"); + assert!(prefix.contains("聚焦准则")); + } + + #[test] + fn compress_prompt_unaffected_by_focus_addition() { + // 压缩 prompt 是独立函数,聚焦段改动不应波及 + let zh = compress_prompt("zh-CN"); + assert!(zh.contains("意图")); + let en = compress_prompt("en"); + assert!(en.contains("Intent")); + } + + // 改进3 A: 压缩 prompt 主题保留段(锚点词防上下文断裂) + #[test] + fn compress_prompt_zh_keeps_topic_anchor_clause() { + let zh = compress_prompt("zh-CN"); + assert!( + zh.contains("主题词"), + "中文 compress_prompt 应含主题词/锚点保留要求,实际: {}", + zh + ); + assert!(zh.contains("锚点")); + assert!(zh.contains("上下文断裂")); + } + + #[test] + fn compress_prompt_en_keeps_topic_anchor_clause() { + let en = compress_prompt("en"); + assert!( + en.contains("anchors"), + "英文 compress_prompt 应含 anchors 保留要求,实际: {}", + en + ); + assert!(en.contains("topic words")); + assert!(en.contains("breaks the context")); + } +} diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index bca026a..2bb67b6 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -177,7 +177,15 @@ async fn generate_title_via_llm( // F-09 B 批5: per_conv 改 HashMap,标题针对本对话,用 conv_id 共享限流槽。 let _global_permit = llm_concurrency.acquire_global().await; let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await; - let resp = provider.complete(request).await.ok()?; + let resp = match provider.complete(request).await { + Ok(r) => r, + Err(e) => { + // 诊断:标题 LLM complete 失败原因(网络/模型/超时)。ensure :121 防御已保留 extract 兜底, + // 此日志助定位"为何标题未 LLM 精炼生成"(complete 失败 vs resp.text 空走 clean 兜底"新对话")。 + tracing::warn!("标题 LLM complete 失败(conv_id={}, model={}): {}", conv_id, model, e); + return None; + } + }; Some(clean_title(&resp.text)) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 0671a16..6bb6cf4 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -11,9 +11,9 @@ use tokio::sync::{Mutex, RwLock, Semaphore}; use df_ai::ai_tools::AiToolRegistry; use df_storage::crud::{ - AiConversationRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo, KnowledgeEventsRepo, - KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, SettingsRepo, TaskRepo, - WorkflowRepo, + AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo, + KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, + SettingsRepo, TaskRepo, WorkflowRepo, }; use df_storage::db::Database; use df_workflow::eventbus::EventBus; @@ -261,6 +261,8 @@ pub struct AppState { pub ai_providers: AiProviderRepo, /// AI 对话历史 Repo pub ai_conversations: AiConversationRepo, + /// AI 消息 Repo(F-260619-03 消息拆分存储:ai_messages 表,读路径批次 A 切读用) + pub ai_messages: AiMessageRepo, /// AI 工具执行审计 Repo pub ai_tool_executions: AiToolExecutionRepo, /// AI 工具注册表 @@ -484,6 +486,7 @@ impl AppState { node_executions: NodeExecutionRepo::new(&db), ai_providers: AiProviderRepo::new(&db), ai_conversations: AiConversationRepo::new(&db), + ai_messages: AiMessageRepo::new(&db), ai_tool_executions: AiToolExecutionRepo::new(&db), ai_session: Arc::new(Mutex::new(AiSession::new())), knowledge: KnowledgeRepo::new(&db),