diff --git a/crates/df-ai/src/namespace_store.rs b/crates/df-ai/src/namespace_store.rs index 0203115..5f2a4da 100644 --- a/crates/df-ai/src/namespace_store.rs +++ b/crates/df-ai/src/namespace_store.rs @@ -157,11 +157,18 @@ impl NamespaceStore { } } -/// 简单的基于长度的哈希(唯一性由 content 长度 + 前 16 字符保证) -/// 用于构建引用路径中的 key 段,不需要密码学强度 +/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。 +/// +/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、 +/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key` +/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。 +/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。 fn simple_hash(content: &str) -> String { - let prefix: String = content.chars().take(16).collect(); - format!("{}_{}", content.len(), prefix) + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + format!("{:016x}", hasher.finish()) } #[cfg(test)] @@ -226,6 +233,18 @@ mod tests { assert_eq!(r1, r2); } + #[test] + fn store_content_with_slash_in_prefix_reads_back() { + // 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep + // 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断 + // → read None,原文丢失。hex hash 修复后 store→read 正确取回。 + let mut ns = NamespaceStore::new(100_000); + let content = "foo/bar/baz/qux\nline2\nline3"; + let path = ns.store("list_directory", content); + assert!(is_namespace_ref(&path)); + assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string())); + } + #[test] fn eviction_oldest_removed_when_over_limit() { let mut ns = NamespaceStore::new(100); // 极小容量 diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index a84805f..a21a394 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -102,7 +102,7 @@ pub(super) async fn maybe_auto_compress( let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber透传 miniapp let _ = app_handle.state::().ai_event_bus.publish_event(ev); - let (active_msgs, lang) = { + let (mut active_msgs, lang) = { let mut session = session_arc.lock().await; let conv = session.conv(&conv_id); conv.messages.set_compressing(true); @@ -118,6 +118,22 @@ pub(super) async fn maybe_auto_compress( (active_msgs, lang) }; + // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 + // 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到 + // "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(重启后 namespace + // 清空/残留引用)→ 保留占位符降级,非崩溃。 + { + let session = session_arc.lock().await; + for m in &mut active_msgs { + if df_ai::namespace_store::is_namespace_ref(&m.content) { + let path = m.content.clone(); + if let Some(original) = session.namespace_store.read_only(&path) { + m.content = original.to_string(); + } + } + } + } + // 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 8a97a1e..defc7a6 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1209,7 +1209,21 @@ pub(crate) async fn run_agentic_loop( return; } }; - let (history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); + let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); + // T2-修复(方案A):发 LLM 前展开 namespace 引用为真实内容(view-only,仅本轮请求视图)。 + // push 时(audit/mod.rs)read_file/list_directory/grep/diff_files 等大结果被 + // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 + // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 + // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 + // read_only None(重启后 namespace 清空 / 跨会话残留引用)→ 保留占位符降级,非崩溃。 + for m in &mut history_msgs { + if df_ai::namespace_store::is_namespace_ref(&m.content) { + let path = m.content.clone(); + if let Some(original) = session.namespace_store.read_only(&path) { + m.content = original.to_string(); + } + } + } let mut msgs = vec![ChatMessage::system(&system_prompt)]; msgs.extend(history_msgs); msgs