From a30f599fe2211c6d0c51f6b8f0f0232fe77a6353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 26 Oct 2026 01:36:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E7=AE=A1=E7=90=86=E4=B8=8E=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录) --- crates/df-ai/src/anthropic_compat.rs | 100 +- crates/df-ai/src/anthropic_helpers.rs | 57 +- .../df-storage/src/crud/conversation_repo.rs | 57 +- crates/df-storage/src/crud/message_repo.rs | 351 ++++++++ .../commands/ai/agentic/approval_timeout.rs | 2 +- .../commands/ai/agentic/context_lifecycle.rs | 175 +++- src-tauri/src/commands/ai/agentic/helpers.rs | 4 +- src-tauri/src/commands/ai/agentic/mod.rs | 144 +-- .../src/commands/ai/agentic/stream_relay.rs | 40 + src-tauri/src/commands/ai/audit/cache.rs | 176 +--- src-tauri/src/commands/ai/audit/finalize.rs | 2 +- src-tauri/src/commands/ai/audit/mod.rs | 201 ++--- src-tauri/src/commands/ai/commands/chat.rs | 434 ++------- .../src/commands/ai/commands/conversation.rs | 2 +- src-tauri/src/commands/ai/commands/mod.rs | 7 +- src-tauri/src/commands/ai/commands/search.rs | 342 +++++++ src-tauri/src/commands/ai/devflow_md.rs | 304 ++++--- src-tauri/src/commands/ai/event_bus.rs | 2 +- src-tauri/src/commands/ai/mod.rs | 59 +- src-tauri/src/commands/ai/prompt.rs | 257 +++++- src-tauri/src/commands/ai/remote_bridge.rs | 42 +- .../src/commands/ai/tool_failure_memory.rs | 14 + src-tauri/src/commands/ai/tool_registry.rs | 44 +- src-tauri/src/commands/ai/tools/file.rs | 11 +- src-tauri/src/commands/ai/tools/history.rs | 363 ++++++++ src-tauri/src/commands/ai/tools/mod.rs | 3 + src-tauri/src/feishu_channel.rs | 242 ++++- src-tauri/src/lib.rs | 10 +- src-tauri/src/media_store.rs | 23 + src/App.vue | 68 +- src/api/ai.ts | 23 +- src/api/types.ts | 37 +- src/components/AiChat.vue | 28 +- src/components/ToolCard.vue | 70 +- src/components/ToolCardList.vue | 38 +- src/components/ToolResultBody.vue | 852 ++---------------- src/components/ai/DirAuthDialog.vue | 212 ----- src/components/ai/HelpRequiredCard.vue | 4 +- src/components/ai/MessageList.vue | 8 +- src/components/ai/TaskBoundaryCard.vue | 2 +- src/composables/ai/aiShared.ts | 138 +-- src/composables/ai/useAiConversations.ts | 24 +- src/composables/ai/useAiEvents.ts | 8 +- src/composables/ai/useAiLifecycleEvents.ts | 4 +- src/composables/ai/useAiPendingState.ts | 42 +- src/composables/ai/useAiStreamingEvents.ts | 63 +- src/composables/ai/useAiToolEvents.ts | 11 +- src/composables/ai/useToolCardHeader.ts | 6 +- src/i18n/en/ai.ts | 1 - src/i18n/en/aiChat.ts | 20 - src/i18n/en/aiTool.ts | 11 - src/i18n/zh-CN/ai.ts | 1 - src/i18n/zh-CN/aiChat.ts | 20 - src/i18n/zh-CN/aiTool.ts | 10 - src/main.ts | 7 +- src/mocks/tauri-shim.ts | 1 - src/router/index.ts | 7 - src/styles/global.css | 6 +- src/utils/time.ts | 2 +- 59 files changed, 2651 insertions(+), 2541 deletions(-) create mode 100644 src-tauri/src/commands/ai/commands/search.rs create mode 100644 src-tauri/src/commands/ai/tools/history.rs delete mode 100644 src/components/ai/DirAuthDialog.vue diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 0e90d88..e954e0f 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -806,12 +806,21 @@ impl LlmProvider for AnthropicCompatProvider { other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"), } } + // 口径归一(G1,P0-A/u-claw 三口径,与 anthropic_helpers message_start 同表): + // anthropic input_tokens 只含未缓存输入,不含 cache_read/creation 两段; + // 落库 prompt_tokens 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt), + // 故 prompt = input + read + creation,total = prompt + output。 + // cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价。 + let prompt_tokens = resp + .usage + .input_tokens + .saturating_add(resp.usage.cache_read()) + .saturating_add(resp.usage.cache_creation_input_tokens); let usage = TokenUsage { - prompt_tokens: resp.usage.input_tokens, + prompt_tokens, completion_tokens: resp.usage.output_tokens, - total_tokens: resp.usage.input_tokens + resp.usage.output_tokens, - // anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价 - prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens, + total_tokens: prompt_tokens.saturating_add(resp.usage.output_tokens), + prompt_cache_hit_tokens: resp.usage.cache_read(), prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens, reasoning_tokens: 0, }; @@ -1016,6 +1025,47 @@ mod tests { ) } + /// 辅助:构造带缓存分项的 message_start 事件(cache_read/cache_creation,P0-A 三口径) + fn message_start_with_cache(input_tokens: u32, cache_read: u32, cache_creation: u32) -> String { + format!( + r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"cache_read_input_tokens":{},"cache_creation_input_tokens":{},"output_tokens":0}}}}}}"#, + input_tokens, cache_read, cache_creation + ) + } + + /// 辅助:构造智谱 anthropic 兼容端点形态的 message_start(隐式缓存命中走嵌套 + /// prompt_tokens_details.cached_tokens,无顶层 cache 字段——官方缓存文档口径) + fn message_start_zhipu_nested(input_tokens: u32, cached_tokens: u32) -> String { + format!( + r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"prompt_tokens_details":{{"cached_tokens":{}}},"output_tokens":0}}}}}}"#, + input_tokens, cached_tokens + ) + } + + /// 智谱嵌套形态回退:顶层 cache 字段缺席时从 prompt_tokens_details.cached_tokens + /// 取命中量,prompt=input+cached(隐式缓存无写入分项,miss=0) + #[test] + fn anthropic_zhipu_nested_cache_tokens_fallback() { + let mut acc: Option = None; + let c = apply_anthropic_event(&message_start_zhipu_nested(500, 4000), &mut acc); + let u = c.usage.expect("usage 应随 message_start 带出"); + assert_eq!(u.prompt_cache_hit_tokens, 4000); + assert_eq!(u.prompt_cache_miss_tokens, 0); + // 口径归一:prompt = input + cached + assert_eq!(u.prompt_tokens, 4500); + } + + /// 顶层优先:官方 Anthropic 端点回顶层字段时嵌套值(即便异常并存)不覆盖顶层 + #[test] + fn anthropic_top_level_cache_read_wins_over_nested() { + let mut acc: Option = None; + let frame = r#"{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_read_input_tokens":200,"prompt_tokens_details":{"cached_tokens":999},"output_tokens":0}}}"#; + let c = apply_anthropic_event(frame, &mut acc); + let u = c.usage.expect("usage 应随 message_start 带出"); + assert_eq!(u.prompt_cache_hit_tokens, 200); + assert_eq!(u.prompt_tokens, 300); + } + /// 辅助:构造 message_delta 事件(output_tokens 为累计值) fn message_delta(output_tokens: u32) -> String { format!( @@ -1135,6 +1185,48 @@ mod tests { assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None"); } + /// 三口径映射锁定(G1,P0-A):anthropic input_tokens 不含缓存两段,落库 prompt_tokens + /// 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt)→ prompt = input + read + + /// creation;message_delta 的 total 重算基于累加器 prompt(已含缓存段)自动继承新口径。 + #[test] + fn anthropic_message_start_cache_three_caliber_prompt_sums_cache_segments() { + let mut acc: Option = None; + let c = apply_anthropic_event(&message_start_with_cache(100, 64, 36), &mut acc); + let u0 = c.usage.expect("message_start 应带出初始 usage 快照"); + assert_eq!(u0.prompt_tokens, 200, "prompt = input(100)+read(64)+creation(36)"); + assert_eq!(u0.prompt_cache_hit_tokens, 64, "cache_read → hit 原样映射"); + assert_eq!(u0.prompt_cache_miss_tokens, 36, "cache_creation → miss 原样映射"); + assert_eq!(u0.total_tokens, 200, "初始 total 同口径"); + + // message_delta 累计 output 后 total 重算:继承含缓存段 prompt 基数(200+30) + let _ = apply_anthropic_event(&message_delta(30), &mut acc); + let a = acc.as_ref().unwrap(); + assert_eq!(a.prompt_tokens, 200, "delta 不改 prompt"); + assert_eq!(a.total_tokens, 230, "total = prompt(含缓存两段)+output"); + } + + /// 缺失容错(G2,P0-A):端点 200 但 usage 键整体缺失/usage 对象缺计数字段时, + /// 不再整帧反序列化失败硬炸(三路走查 #3「有效文本回复整体丢弃」),全零兜底 + /// 下行走既有估算轨(round_usage_effective prompt==0 → 估算打标),对齐 OpenAI 臂。 + #[test] + fn anthropic_response_missing_or_partial_usage_deserializes_not_fatal() { + // usage 键整体缺失 + let resp: AnthropicResponse = serde_json::from_str( + r#"{"id":"msg_1","model":"claude-x","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn"}"#, + ) + .expect("usage 键缺失不应炸帧"); + assert_eq!(resp.usage.input_tokens, 0, "缺 usage → 全零兜底走估算轨"); + assert_eq!(resp.usage.output_tokens, 0); + + // usage 对象存在但缺计数字段(部分网关只回 cache 分项) + let resp2: AnthropicResponse = serde_json::from_str( + r#"{"id":"msg_2","model":"claude-x","content":[],"usage":{"cache_read_input_tokens":7}}"#, + ) + .expect("usage 缺 input/output 字段不应炸帧"); + assert_eq!(resp2.usage.input_tokens, 0); + assert_eq!(resp2.usage.cache_read_input_tokens, 7, "已有分项不丢"); + } + /// content_block_start (tool_use) 带 id+name #[test] fn anthropic_content_block_start_tool_use() { diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index 0e6ca03..23c50f2 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -69,6 +69,10 @@ pub(crate) struct AnthropicResponse { // Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning #[allow(dead_code)] stop_reason: Option, + /// usage 整段 default(G2):端点不回 usage 键时不再 missing-field 炸帧,全零兜底 + /// 下行走既有估算轨(round_usage_effective)。字段级容错在 AnthropicUsage 侧, + /// 此处兜「usage 键整体缺失」——两层合起来才覆盖「端点不回 usage」全形态。 + #[serde(default)] pub usage: AnthropicUsage, } @@ -88,9 +92,15 @@ pub(crate) struct AnthropicContentBlock { pub input: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] pub(crate) struct AnthropicUsage { + /// 计数字段全部 default 容错(G2,P0-A 口径统一):部分网关 200 但 usage 缺失/缺字段, + /// 必填会让整帧反序列化失败 → 有效回复整体丢弃(三路走查 #3/#5)。缺省 0 后全零 + /// TokenUsage 下行走既有估算兜底轨(round_usage_effective,prompt==0 → 估算打标), + /// 与 OpenAI 臂的 serde default 容错(openai_helpers.rs:139-157)对齐。 + #[serde(default)] pub input_tokens: u32, + #[serde(default)] pub output_tokens: u32, /// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。 /// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。 @@ -102,6 +112,32 @@ pub(crate) struct AnthropicUsage { /// 非 cache 场景无此字段 → serde default 0。 #[serde(default)] pub cache_read_input_tokens: u32, + /// 智谱 anthropic 兼容端点的隐式缓存(自动生效,命中半价)不回顶层 cache 字段, + /// 命中量走 OpenAI 风格嵌套 prompt_tokens_details.cached_tokens(智谱官方缓存文档 + /// 口径)。serde 容错读入,消费侧经 [`Self::cache_read`] 回退取用——缺此层时 + /// GLM 链路账本/命中率恒 0(实测 54 条观测全零的根因)。 + #[serde(default)] + pub prompt_tokens_details: Option, +} + +/// OpenAI 风格嵌套的缓存命中分项(智谱 anthropic 兼容端点口径)。 +#[derive(Debug, Default, Deserialize)] +pub(crate) struct PromptTokensDetails { + #[serde(default)] + pub cached_tokens: u32, +} + +impl AnthropicUsage { + /// 缓存命中读取量:顶层 cache_read_input_tokens 非零优先(官方 Anthropic 端点), + /// 智谱嵌套形态(顶层缺省 0)回退;两处都不存在时 0。 + pub fn cache_read(&self) -> u32 { + if self.cache_read_input_tokens > 0 { + return self.cache_read_input_tokens; + } + self.prompt_tokens_details + .as_ref() + .map_or(0, |d| d.cached_tokens) + } } // ============================================================ @@ -155,15 +191,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option) -> std::result::Result 50 万,或 role 过滤后 +/// LIKE 检索 P95 实测 > 500ms 时,再评估升级 FTS5 虚表(V54+ 建虚表 + 一次性回填, +/// 检索接口签名不变)。当前生产库实测(32,349 行 / content 39.5MB):role 过滤后单关键词 +/// LIKE 113ms、双关键词 127ms,桌面场景百毫秒级无体感;且中文 unicode61 按字切分召回 +/// 质量反不如子串 LIKE,仓内 idea_repo/conversation_repo 两处既有「不上 FTS5」决策先例。 + +/// 关键词检索单条命中。 +/// +/// `snippet` 为命中片段:content 中首个关键词命中点前后各约 200 字,整体截 500 字 +/// (截断处以 … 标记),避免全文回灌撑爆调用方上下文。 +#[derive(Debug, Clone)] +pub struct MessageSearchHit { + pub message_id: String, + pub conversation_id: String, + pub seq: i64, + pub role: String, + pub timestamp: Option, + pub snippet: String, +} + +/// 会话摘要降级链数据源(无参列会话时逐会话取用)。 +/// +/// `last_system` = 该会话最近一条 role=system 消息(压缩摘要/关键词锚点的载体, +/// 四段式「## 意图/## 决策…」);`first_user` = 首条 user 消息。调用方按 +/// 「最近 system 摘要 > 首条 user」降级链取会话概要,两级皆空(空会话)则无摘要。 +#[derive(Debug, Clone)] +pub struct ConversationSummarySource { + pub last_system: Option, + pub first_user: Option, +} + +/// 命中片段窗口:命中点前保留上下文与片段总长(按字符计,中文一字一符)。 +const SNIPPET_CONTEXT_CHARS: usize = 200; +const SNIPPET_MAX_CHARS: usize = 500; + +/// 从 content 提取命中片段:首个关键词命中点前后各约 200 字,整体截 500 字。 +/// +/// 大小写不敏感定位(与 SQLite LIKE 的 ASCII 大小写不敏感对齐);分词产物在 LIKE 层 +/// 已命中,理论必有命中点,兜底未找到时退回首部截断。全程按字符迭代操作,无字节 +/// 索引切边界的乱码风险。 +fn search_snippet(content: &str, keywords: &[String]) -> String { + let ellipsis = |s: &str, head_cut: bool, tail_cut: bool| { + format!("{}{}{}", if head_cut { "…" } else { "" }, s, if tail_cut { "…" } else { "" }) + }; + let lower = content.to_lowercase(); + let hit_char = keywords.iter().find_map(|kw| lower.find(&kw.to_lowercase())).map(|byte| { + // 命中字节位 → 字符位(to_lowercase 极端 Unicode 下字符数可能漂移, + // 但仅影响窗口中心偏移,chars().skip 的迭代本身无越界风险) + lower[..byte].chars().count() + }); + let Some(hit_char) = hit_char else { + let head: String = content.chars().take(SNIPPET_MAX_CHARS).collect(); + return ellipsis(&head, false, content.chars().count() > SNIPPET_MAX_CHARS); + }; + let start = hit_char.saturating_sub(SNIPPET_CONTEXT_CHARS); + let body: String = content.chars().skip(start).take(SNIPPET_MAX_CHARS).collect(); + ellipsis(&body, start > 0, content.chars().count() > start + SNIPPET_MAX_CHARS) +} + // ============================================================ // AiMessageRepo // ============================================================ @@ -375,6 +443,156 @@ impl AiMessageRepo { .await .map_err(storage_err)? } + + /// 跨会话关键词检索(P1-A):LIKE OR 逐词命中任一即入候选,timestamp DESC,limit/offset 分页。 + /// + /// - 分词在调用方(src-tauri 层 extract_query_keywords)完成,本层只收关键词数组, + /// 不反向依赖 src-tauri(依赖方向铁律,对齐 KnowledgeRepo.search_keywords_scoped 先例); + /// - role 恒过滤 user+assistant+system(拍板④:tool 结果占内容量 86% 且噪音大,默认排除); + /// - `conversation_id` 传入时限定单会话(两跳式深查第二跳); + /// - LIKE 转义与 `ESCAPE '|'` 复用 [`super::idea_repo::escape_like`] 单一实现, + /// 关键词含 %/_/| 时按字面匹配; + /// - 返回带命中片段(500 字截断,见 [`search_snippet`]),全文不出存储层; + /// - status 不过滤:compressed 消息原文完整保留(ai_messages = 全量账本),照常可检索。 + /// + /// FTS5 升级触发条件见模块顶部注释(留档,>50 万行或 P95>500ms 再评估)。 + pub async fn search_keywords( + &self, + keywords: &[String], + conversation_id: Option<&str>, + limit: usize, + offset: usize, + ) -> Result> { + if keywords.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.clone(); + let conv_id = conversation_id.map(|s| s.to_owned()); + // 每个关键词占一个编号占位符;conversation_id 占 ?(n+1),LIMIT/OFFSET 依次后推。 + // 动态 WHERE 拼接模式对齐 AiToolExecutionRepo::list_by_query 先例。 + let mut patterns: Vec = Vec::with_capacity(keywords.len()); + let mut where_parts: Vec = Vec::with_capacity(keywords.len()); + for (i, kw) in keywords.iter().enumerate() { + let n = i + 1; + patterns.push(format!("%{}%", escape_like(kw))); + where_parts.push(format!("content LIKE ?{n} ESCAPE '|'")); + } + let mut extra: Vec = Vec::new(); + if let Some(c) = &conv_id { + extra.push(c.clone()); + } + // 编号必须连续(SQLite 参数计数取最大 ?N,跳号会产生未绑定空洞报错): + // 无 conv 条件时 LIMIT/OFFSET 紧跟关键词占位符之后。 + let conv_n = patterns.len() + 1; + let (limit_n, offset_n) = if conv_id.is_some() { + (conv_n + 1, conv_n + 2) + } else { + (conv_n, conv_n + 1) + }; + let limit_i = limit as i64; + let offset_i = offset as i64; + let conv_clause = if conv_id.is_some() { + format!(" AND conversation_id = ?{conv_n}") + } else { + String::new() + }; + let sql = format!( + "SELECT id, conversation_id, seq, role, timestamp, content FROM ai_messages \ + WHERE role IN ('user','assistant','system'){conv_clause} AND ({}) \ + ORDER BY timestamp DESC LIMIT ?{limit_n} OFFSET ?{offset_n}", + where_parts.join(" OR "), + ); + let keywords_owned: Vec = keywords.to_vec(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + // 参数序列:关键词 pattern × n [+ conv_id] + limit + offset + let mut param_refs: Vec<&dyn rusqlite::ToSql> = + patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + for e in &extra { + param_refs.push(e); + } + param_refs.push(&limit_i); + param_refs.push(&offset_i); + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + let content: String = row.get("content")?; + Ok(MessageSearchHit { + message_id: row.get("id")?, + conversation_id: row.get("conversation_id")?, + seq: row.get("seq")?, + role: row.get("role")?, + timestamp: row.get("timestamp")?, + snippet: search_snippet(&content, &keywords_owned), + }) + }) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + + /// 会话摘要降级链数据源(无参列最近会话用):一批会话各取「最近一条 system 消息」与 + /// 「首条 user 消息」content,逐会话单行返回(无消息的会话无行,调用方按无摘要兜底)。 + /// + /// 关联子查询走 idx_ai_messages_conv(conversation_id, seq) 索引,批量一次查询替代 + /// 逐会话 list_by_conversation 全量物化(276 条/会话均值,10 会话省 ~340KB 物化)。 + pub async fn summary_sources( + &self, + conversation_ids: &[String], + ) -> Result> { + if conversation_ids.is_empty() { + return Ok(HashMap::new()); + } + let conn = self.conn.clone(); + // IN 占位符逐个编号(?1..?n) + let placeholders: Vec = + (1..=conversation_ids.len()).map(|n| format!("?{n}")).collect(); + let sql = format!( + "SELECT m.conversation_id, \ + (SELECT s.content FROM ai_messages s \ + WHERE s.conversation_id = m.conversation_id AND s.role = 'system' \ + ORDER BY s.seq DESC LIMIT 1) AS last_system, \ + (SELECT u.content FROM ai_messages u \ + WHERE u.conversation_id = m.conversation_id AND u.role = 'user' \ + ORDER BY u.seq ASC LIMIT 1) AS first_user \ + FROM ai_messages m \ + WHERE m.conversation_id IN ({}) \ + GROUP BY m.conversation_id", + placeholders.join(","), + ); + let ids: Vec = conversation_ids.to_vec(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = + ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + let rows = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(( + row.get::<_, String>("conversation_id")?, + ConversationSummarySource { + last_system: row.get("last_system")?, + first_user: row.get("first_user")?, + }, + )) + }) + .map_err(storage_err)?; + let mut out = HashMap::new(); + for r in rows { + let (id, source) = r.map_err(storage_err)?; + out.insert(id, source); + } + Ok(out) + }) + .await + .map_err(storage_err)? + } } // ============================================================ @@ -801,4 +1019,137 @@ mod tests { assert_eq!(got3.len(), 1); assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条"); } + + // ---------- search_keywords(P1-A 跨会话检索) ---------- + + /// role 过滤(tool 默认排除)+ timestamp DESC 排序 + conversation_id 限定深查。 + #[tokio::test] + async fn search_keywords_filters_role_and_orders_desc() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + let mut u0 = mk_msg("u0", "conv_a", 0, "user", "怎么部署服务"); + u0.timestamp = Some(1000); + let mut a0 = mk_msg("a0", "conv_a", 1, "assistant", "部署 分三步走"); + a0.timestamp = Some(2000); + let mut hit_tool = mk_msg("t0", "conv_a", 2, "tool", "部署流程的 tool 结果"); + hit_tool.timestamp = Some(3000); + let mut u1 = mk_msg("u1", "conv_b", 0, "user", "旧会话也提过部署"); + u1.timestamp = Some(500); + repo.insert_batch(vec![u0, a0, hit_tool, u1]) + .await + .expect("insert"); + + // tool role 默认排除:命中 3 条(两 conv 的 user/assistant),tool 结果不入检索面 + let hits = repo + .search_keywords(&["部署".to_string()], None, 20, 0) + .await + .expect("search"); + assert_eq!(hits.len(), 3, "tool role 应被排除: {:?}", hits.iter().map(|h| &h.role).collect::>()); + // timestamp DESC:conv_a(3000 之外的 2000/1000 应在 conv_b 100 之前) + assert_eq!(hits[0].message_id, "a0"); + assert_eq!(hits[1].message_id, "u0"); + + // conversation_id 限定:只回该会话命中(两跳式深查第二跳) + let scoped = repo + .search_keywords(&["部署".to_string()], Some("conv_b"), 20, 0) + .await + .expect("search scoped"); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].conversation_id, "conv_b"); + + // offset 分页:跳过第一条 + let paged = repo + .search_keywords(&["部署".to_string()], None, 20, 1) + .await + .expect("search paged"); + assert_eq!(paged.len(), 2); + assert_eq!(paged[0].message_id, "u0"); + } + + /// LIKE 通配符按字面匹配(%/_ 不扩大命中面)+ 命中片段以关键词为中心截 500 字。 + #[tokio::test] + async fn search_keywords_escapes_wildcards_and_centers_snippet() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + let long_prefix = "前".repeat(600); // 命中点前 600 字:窗口应前推 200 字且带 … + let long_tail = "后".repeat(600); + repo.insert_batch(vec![ + mk_msg("m0", "c", 0, "user", &format!("{long_prefix}配额100%封顶{long_tail}")), + mk_msg("m1", "c", 1, "user", "配额100x封顶,百分号不应误命中"), + mk_msg("m2", "c", 2, "user", "变量名 quota_limit 带下划线"), + mk_msg("m3", "c", 3, "user", "quotaXlimit 下划线不该当通配命中"), + ]) + .await + .expect("insert"); + + // % 按字面:只命中 m0,不命中 100x + let pct = repo + .search_keywords(&["100%".to_string()], None, 20, 0) + .await + .expect("search %"); + assert_eq!(pct.len(), 1, "100% 不应通配命中 100x: {:?}", pct.iter().map(|h| &h.message_id).collect::>()); + assert_eq!(pct[0].message_id, "m0"); + + // _ 按字面:只命中 quota_limit,不命中 quotaXlimit + let under = repo + .search_keywords(&["quota_limit".to_string()], None, 20, 0) + .await + .expect("search _"); + assert_eq!(under.len(), 1); + assert_eq!(under[0].message_id, "m2"); + + // 片段:命中点前约 200 字,总长 500+省略号,两端截断带 … + let snip = &pct[0].snippet; + assert!(snip.contains('…'), "两端截断应带省略号: {snip}"); + assert!(snip.contains("100%封顶"), "片段应以命中点为中心: {snip}"); + let body = snip.trim_matches('…'); + assert!( + body.chars().count() <= 500, + "片段正文应截 500 字,实际 {}", + body.chars().count() + ); + assert!(snip.starts_with('…'), "命中点前 600 字,片段应带前省略号"); + } + + /// 空关键词防御:直返空集不发 SQL(LIKE %% 恒真会全表命中)。 + #[tokio::test] + async fn search_keywords_empty_keywords_returns_empty() { + 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", "任意内容")]) + .await + .expect("insert"); + let hits = repo.search_keywords(&[], None, 20, 0).await.expect("search empty"); + assert!(hits.is_empty(), "空关键词应返空集"); + } + + // ---------- summary_sources(摘要降级链数据源) ---------- + + /// 每会话取「最近一条 system」与「首条 user」;无消息会话无行;批量一次查询。 + #[tokio::test] + async fn summary_sources_takes_last_system_and_first_user() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + repo.insert_batch(vec![ + mk_msg("u0", "c1", 0, "user", "首个问题"), + mk_msg("a0", "c1", 1, "assistant", "回复"), + mk_msg("s0", "c1", 2, "system", "旧锚点"), + mk_msg("s1", "c1", 3, "system", "## 意图\n- 最新压缩摘要"), + mk_msg("u1", "c2", 0, "user", "c2 的唯一用户消息"), + ]) + .await + .expect("insert"); + + let out = repo + .summary_sources(&["c1".to_string(), "c2".to_string(), "c3".to_string()]) + .await + .expect("summary_sources"); + assert_eq!(out.len(), 2, "无消息的 c3 不应有行"); + let c1 = &out["c1"]; + assert_eq!(c1.last_system.as_deref(), Some("## 意图\n- 最新压缩摘要"), "应取 seq 最大的 system"); + assert_eq!(c1.first_user.as_deref(), Some("首个问题")); + let c2 = &out["c2"]; + assert!(c2.last_system.is_none(), "无 system 消息应返 None(降级链走首条 user)"); + assert_eq!(c2.first_user.as_deref(), Some("c2 的唯一用户消息")); + } } diff --git a/src-tauri/src/commands/ai/agentic/approval_timeout.rs b/src-tauri/src/commands/ai/agentic/approval_timeout.rs index 924f303..dc7615f 100644 --- a/src-tauri/src/commands/ai/agentic/approval_timeout.rs +++ b/src-tauri/src/commands/ai/agentic/approval_timeout.rs @@ -3,7 +3,7 @@ //! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。 //! //! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用, -//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。 +//! 覆盖所有审批恢复路径(ai_approve / ai_continue_loop 等)。 //! //! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟), //! 0 表示禁用超时。 diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index 8335cf9..5d3ee4b 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -2,7 +2,9 @@ //! //! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。 //! -//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 +//! 触发条件(默认):history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 +//! P0-B 水位判据(KV `df-ai-compress-watermark`,默认关=上式逐字节不变;开=替换为水位 +//! 主判据 history+sys > ratio×budget,见 [`compress_should_trigger`])。 //! //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; @@ -87,6 +89,50 @@ async fn reset_compressing_after_panic(session_arc: &Arc>, conv } } +/// 压缩触发判据(P0-B Phase 1 纯函数收口,便于单测;设计=temp/分析-P0B-压缩水位判据-2026-10-21)。 +/// +/// - **开关关(默认)**:逐字节等价现行判据 `available*6/10 < history`(available=budget−sys, +/// 即原内联表达式)——分支前置,不进任何新逻辑。 +/// - **开关开**:**替换**为水位主判据 `history+sys > ratio×budget`(分子=估算 history+sys, +/// 与现行口径同源;分母=budget_limit 模型感知,与 build_for_request 裁剪硬门同轴,免坐标 +/// 错位;严格大于,恰等于阈值不触发)。0.6 判据仅在开关关时生效;体积兜底=build_for_request +/// 硬门(1.0×available 视图裁剪,永在),不在判据层并联。 +/// - 保护区门(protect_start>0,最近 PROTECT_COUNT 条不参与)与「区外有可压缩消息」门在 +/// 两种模式下同样生效,不走分支。 +/// +/// 红线(分析 §4.3):水位触发点 ratio×budget + 单轮最大增长必须 < available=budget−sys。 +/// ratio 经 `parse_watermark_ratio` 钳位 [0.3,0.95] 保证 headroom ≥ 5% budget。开关开为替换 +/// 语义:若口径异常令水位线 ≥ available,自动压缩不再触发,唯一防线=build_for_request 硬门 +/// (视图裁到 available 内,永在)——水位判据不会越过裁剪门,只是失去提前接住的机会。 +/// +/// S1 叠批次挂点:S1 分批压缩(C_high/C_low 批次语义)与本判据同轴同点,后续在本函数 +/// 水位分支上叠加批次参数,不另起第二处判据(分析 §4.3 实施序);本批不实施 S1。 +fn compress_should_trigger( + watermark_on: bool, + ratio: f32, + protect_start: usize, + history_tokens: u32, + budget: u32, + sys_tokens: u32, + has_compressible: bool, +) -> bool { + debug_assert!( + (super::super::prompt::MIN_WATERMARK_RATIO..=super::super::prompt::MAX_WATERMARK_RATIO) + .contains(&ratio), + "ratio 须经 parse_watermark_ratio 钳位后传入", + ); + if !watermark_on { + // 关:现行判据逐字节等价(触发阈值 0.6*available,available=budget−sys_tokens)。 + return protect_start > 0 + && (budget.saturating_sub(sys_tokens) as u64) * 6 / 10 < history_tokens as u64 + && has_compressible; + } + // 开:水位主判据(总入账 history+sys 超 ratio×budget;严格大于,恰等于不触发)。 + // 替换语义:0.6 判据仅在开关关时生效,不在判据层并联(体积兜底=build_for_request 硬门)。 + let watermark = history_tokens as u64 + sys_tokens as u64 > (budget as f32 * ratio) as u64; + protect_start > 0 && watermark && has_compressible +} + /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 @@ -116,6 +162,12 @@ pub(super) async fn maybe_auto_compress( ) -> bool { let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false); if !prev_compressing { + // P0-B 水位判据配置(KV 双键,锁外读一次;默认关=现行判据逐字节不变, + // 对齐 compress_recall 同点零新增锁的读法)。 + let (wm_on, ratio) = { + let st = app_handle.state::(); + super::super::prompt::compress_watermark_cfg(&st).await + }; // 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。 let (should_compress, protect_start, pre_compress_tokens) = { let session = session_arc.lock().await; @@ -129,11 +181,17 @@ pub(super) async fn maybe_auto_compress( let mgr = &mgr.messages; let protect_start = mgr.len().saturating_sub(PROTECT_COUNT); let history_tokens = mgr.history_tokens(); - let available = mgr.budget_limit().saturating_sub(sys_tokens); - // 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens - let should = protect_start > 0 - && (available as u64) * 6 / 10 < history_tokens as u64 - && mgr.has_compressible_messages(protect_start); + // 触发判据收口 compress_should_trigger:开关关=原 0.6 判据逐字节等价; + // 开=水位主判据(ratio×budget,替换原 0.6 判据)。 + let should = compress_should_trigger( + wm_on, + ratio, + protect_start, + history_tokens, + mgr.budget_limit(), + sys_tokens, + mgr.has_compressible_messages(protect_start), + ); (should, protect_start, history_tokens) }; @@ -638,3 +696,108 @@ mod guard_tests { ); } } + +// P0-B 水位判据契约(纯函数级,对齐 guard_tests 风格): +// ①开关关=现行判据逐值等价 ②开=水位边界 ±1 token(严格大于才触发)③替换语义:开档 +// 0.6 判据不再生效(触发点后移)④保护区/可压缩门两模式同效。 +// ratio 钳位契约见 prompt.rs tests(parse_watermark_ratio)。 +#[cfg(test)] +mod watermark_tests { + use super::compress_should_trigger; + + const BUDGET: u32 = 102_000; // 128K 窗口:budget=(128K−8K)×0.85 + const SYS: u32 = 8_192; + + /// ①开关关:全输入域网格遍历,与现行内联表达式逐值同价(零行为变化的回归证明)。 + #[test] + fn watermark_off_equals_legacy_expression() { + for budget in [0u32, 1_000, BUDGET, u32::MAX] { + for sys in [0u32, 100, SYS, u32::MAX] { + for history in [0u32, 999, 56_000, u32::MAX] { + for protect in [0usize, 1, 6] { + for compressible in [false, true] { + let available = budget.saturating_sub(sys); + let legacy = protect > 0 + && (available as u64) * 6 / 10 < history as u64 + && compressible; + assert_eq!( + compress_should_trigger( + false, 0.70, protect, history, budget, sys, compressible, + ), + legacy, + "关分支必须与现行判据逐值等价(b={budget},sys={sys},h={history})" + ); + } + } + } + } + } + } + + /// ②开关开:水位边界 ±1 token——恰等于阈值不触发(严格大于),超 1 token 触发。 + #[test] + fn watermark_on_boundary_exact_no_over_yes() { + // ratio=0.5 在 f32 精确(阈值=500,无浮点歧义),硬编码锁语义。 + let (budget, sys) = (1_000u32, 100u32); + assert!( + !compress_should_trigger(true, 0.5, 6, 400, budget, sys, true), + "恰等于阈值(400+100=500)不应触发" + ); + assert!( + compress_should_trigger(true, 0.5, 6, 401, budget, sys, true), + "超 1 token(401+100=501)应触发" + ); + // 默认 0.70:阈值与实现同式计算(自洽),典型 sys=8K 配置直接锁边界 + // (替换语义下无 0.6 兜底干扰,水位线即触发下界)。 + let threshold = (BUDGET as f32 * 0.70) as u64; + let at = (threshold - SYS as u64) as u32; + assert!(!compress_should_trigger(true, 0.70, 6, at, BUDGET, SYS, true)); + assert!(compress_should_trigger(true, 0.70, 6, at + 1, BUDGET, SYS, true)); + } + + /// ③替换语义:开关开时原 0.6 判据不再生效——历史超 0.6 线但未到水位线不触发 + /// (即设计「触发点后移」的行为差;0.6 判据只在开关关时生效)。 + #[test] + fn watermark_on_replaces_legacy_criterion() { + // history=60_000:> 0.6 线 56284(关档此处已触发);< 水位线(71400−8192=63208)。 + let history = 60_000u32; + assert!( + compress_should_trigger(false, 0.70, 6, history, BUDGET, SYS, true), + "前提自检:同点位关档按 0.6 已触发" + ); + assert!( + !compress_should_trigger(true, 0.70, 6, history, BUDGET, SYS, true), + "开档 0.6 判据不再并联,未到水位线不触发" + ); + } + + /// ③补:sys 占比大时水位线早于 0.6 兜底线——水位分支真正改变决策的点位 + /// (同点位开关关不触发,证两分支可分)。 + #[test] + fn watermark_on_fires_where_legacy_would_not() { + let (sys, history) = (60_000u32, 20_000u32); // 水位线≈11.4K history < 20K < 兜底线 25.2K + let watermark = history as u64 + sys as u64 > (BUDGET as f32 * 0.70) as u64; + let legacy = ((BUDGET - sys) as u64) * 6 / 10 < history as u64; + assert!(watermark && !legacy, "前提自检:该点位水位触发而 0.6 未触发"); + assert!(compress_should_trigger(true, 0.70, 6, history, BUDGET, sys, true)); + assert!( + !compress_should_trigger(false, 0.70, 6, history, BUDGET, sys, true), + "同点位关=现行判据不触发" + ); + } + + /// ④保护区与可压缩门:两种模式下同样生效(不走分支,任何 token 量都拦得住)。 + #[test] + fn protect_and_compressible_gates_hold_in_both_modes() { + for wm in [false, true] { + assert!( + !compress_should_trigger(wm, 0.70, 0, u32::MAX, BUDGET, 0, true), + "保护区门(protect_start=0)必须生效" + ); + assert!( + !compress_should_trigger(wm, 0.70, 6, u32::MAX, BUDGET, 0, false), + "可压缩门(区外无可压缩)必须生效" + ); + } + } +} diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 1304788..871b5b9 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -20,8 +20,8 @@ use super::conv_state::ConvState; /// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。 /// -/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning / -/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning): +/// 抽自同构骨架(insert_destructive_loop_warning / insert_stall_warning / +/// insert_tool_call_over_limit_warning 等;原 audit::cache insert_listing_bypass_warning 已退役): /// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。 /// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。 /// diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 82f20a6..ed75d10 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -180,20 +180,22 @@ pub const MAX_GOALS: usize = 5; /// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。 pub const SKILL_LIST_INJECT_ENABLED: bool = true; -/// 技能清单注入最大条数(默认 120)。 +/// 技能清单注入最大条数(默认 60)。 /// /// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。 /// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能, /// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见), /// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。 -/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token, -/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。 -pub const SKILL_LIST_MAX_ITEMS: usize = 120; +/// 渐进披露(D2):清单只做导航,单条「名称+40字描述」索引卡片(约 20 token/条), +/// 60 条 ≈ 1.2K token,注入位于 system 稳定段(跨轮缓存命中,只付一次成本); +/// 未列出的技能仍可经 read_skill 按名按需读取,不因清单减半而不可用。 +pub const SKILL_LIST_MAX_ITEMS: usize = 60; -/// 技能清单单条描述截断长度(默认 80 字符)。 +/// 技能清单单条描述截断长度(默认 40 字符)。 /// -/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。 -pub const SKILL_DESC_MAX_CHARS: usize = 80; +/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号; +/// 全文按需经 read_skill 读取(渐进披露:清单导航,详情懒取)。 +pub const SKILL_DESC_MAX_CHARS: usize = 40; /// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。 /// @@ -2464,12 +2466,12 @@ async fn process_round_tools( let round_tool_call_count = tool_calls_acc.len(); // 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent) let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await; - // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, - // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 + // 挂起审批统计诊断(原 DIRAUTH eprintln 降级为 tracing::debug, + // 避免污染 stderr,保留排障能力(RUST_LOG=debug 可见)。 tracing::debug!( conv_id = %conv_id, pending_count, - "[AI-DIRAUTH-DIAG] agentic loop 收到 pending" + "[AI-PENDING-DIAG] agentic loop 收到 pending" ); // F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出, @@ -3135,6 +3137,16 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro stop_kind: Some("error".to_string()), }, ); + // V50 终局态(P1-B 方案甲,2026-10-21):Fatal=错误终局,补落 status=error——此前 + // error 全仓零写点(与 completed 同属半闭环缺口)。事件侧 AiAgentFinished 已广播 + // status=error,持久层在此对齐,完成侧对称写点在 emit_ai_completed_once。 + // 未落库会话影响 0 行属预期(懒创建),失败仅记日志。 + { + let app_state = app_handle.state::(); + if let Err(e) = app_state.ai_conversations.set_status(conv_id, "error").await { + tracing::warn!(conv_id = %conv_id, error = %e, "[ai] Fatal 写 status=error 失败"); + } + } // P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。 let brief: String = error.chars().take(160).collect(); crate::commands::ai::notify::notify_session_event( @@ -3200,6 +3212,11 @@ pub(crate) async fn emit_ai_completed_once( } // V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合; // 进程内 loop 死亡的残留 running 由启动扫描兜底)。 + // V50 收尾单点接驳(P1-B 方案甲,2026-10-21):本 helper 是全部正常出口统一 emit 单点, + // 此处顺写 status=completed —— 此前 V50 只写 active/incomplete 两态,completed 全仓零 + // 写点=半闭环(终局态建模只完成一半)。MidStream 保文/截断、用户手动 stop 均经本出口, + // 都算「会话如何结束」的完成侧收束(V50 语义注释 migrations.rs migrate_v50:status 管 + // 「会话如何结束」)。错误侧对称写点在 emit_fatal_error(status=error)。 { let app_state = app_handle.state::(); let db2 = app_state.db.clone(); @@ -3207,6 +3224,10 @@ pub(crate) async fn emit_ai_completed_once( tokio::spawn(async move { let repo = df_storage::crud::AiConversationRepo::new(&db2); let _ = repo.set_loop_state(&cid, None).await; + // 未落库会话影响 0 行属预期(懒创建,首插 save 随行带默认 active),失败仅记日志 + if let Err(e) = repo.set_status(&cid, "completed").await { + tracing::warn!(conv_id = %cid, error = %e, "[ai] 收敛出口写 status=completed 失败"); + } }); } // B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器 @@ -3303,7 +3324,13 @@ fn completed_event( } } -// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 六段拼接 ── +// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 段序拼接 ── +// +// **段序=缓存友好原则(2026-10-23 拍板)**:按稳定性递减排列——恒静段(环境/定位/纪律/技能, +// 进程级)→会话恒定段(devflow.md 投影)→loop 级段(目标钉扎/工作流 DAG)→最易变段(工作上下文, +// 每条任务指令重建)压尾。依据=前缀缓存连续匹配:易变段插中间会使其后恒静段+全部历史陪葬 +// (实证:新任务后首条命中率跌至 16-33%,25K-38K token 全价重写/次,见 +// temp/分析-提示词段序缓存友好性-2026-10-23.md)。重排只动拼接次序,各段内容逐字未变。 // // 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。 // N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。 @@ -3343,44 +3370,6 @@ async fn build_runtime_prompt( let mut system_prompt = base_prompt; - // G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。 - if GOAL_PIN_ENABLED { - let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect(); - if !active_goals.is_empty() { - let goal_lines: Vec = active_goals.iter().enumerate().map(|(i, g)| { - let trimmed = g.text.trim(); - let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect(); - let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS { - format!("{}…", truncated) - } else { - truncated - }; - format!("{}. {}", i + 1, truncated) - }).collect(); - let goals_text = goal_lines.join("\n"); - system_prompt = if GOAL_INJECT_BANNER { - format!( - "{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}", - system_prompt, goals_text - ) - } else { - format!("{}\n\n{}", system_prompt, goals_text) - }; - tracing::info!( - conv_id = %conv_id, - count = goals.len(), - first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""), - "[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt", - goals.len() - ); - } - } - - // T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。 - if !working_injection.is_empty() { - system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection); - } - // EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是 // loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。 let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt(); @@ -3393,7 +3382,7 @@ async fn build_runtime_prompt( "- 所有操作都通过工具完成,用户不参与执行\n", "- 优先使用开发工具 IPC,非必要不写独立脚本\n", "- 脚本需要审批通过才执行,会拖慢工作流\n", - "- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n", + "- 已有 80+ 工具覆盖绝大多数场景,先查工具列表再决定\n", "- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n", "- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n", "- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作", @@ -3440,6 +3429,51 @@ async fn build_runtime_prompt( } } + // 项目规则文件投影(devflow.md/CLAUDE.md):会话级恒定段 —— 内容经 devflow_md 模块 + // 按会话缓存(同会话只读一次盘)。段序按缓存友好原则排列:恒静段(环境/定位/纪律/技能) + // 之后、loop 级易变段(目标/DAG/工作上下文)之前——跨任务指令时目标/工作上下文重建, + // 失效面止于它们自身,本段及之前的全部前缀照常命中;工作目录无规则文件时原样返回 + //(缺失不影响既有组装)。 + let mut system_prompt = + crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await; + + // G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。 + if GOAL_PIN_ENABLED { + let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect(); + if !active_goals.is_empty() { + let goal_lines: Vec = active_goals.iter().enumerate().map(|(i, g)| { + let trimmed = g.text.trim(); + let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect(); + let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS { + format!("{}…", truncated) + } else { + truncated + }; + format!("{}. {}", i + 1, truncated) + }).collect(); + let goals_text = goal_lines.join("\n"); + system_prompt = if GOAL_INJECT_BANNER { + // 目标切换仲裁(2026-10-24 用户实证「切换目标两三轮后失忆回老目标」): + // 钉扎是 system 级常驻,优先级天然压过对话流——用户中途口头换向后, + // 老目标会持续把模型拉回。仲裁行显式声明「最新对话意志 > 登记目标」, + // 保住钉扎防漂移价值的同时不压制用户切换权。 + format!( + "{}\n\n## 当前目标(登记的活跃任务,全程锚定,所有动作须服务于它们;若用户最近消息已明确表达目标切换或新方向,以用户最新指令为准并顺势推进,勿回拉旧目标)\n{}", + system_prompt, goals_text + ) + } else { + format!("{}\n\n{}", system_prompt, goals_text) + }; + tracing::info!( + conv_id = %conv_id, + count = goals.len(), + first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""), + "[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt", + goals.len() + ); + } + } + // T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。 if let Some(ref dag_summary) = workflow_dag_summary { system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary); @@ -3450,11 +3484,11 @@ async fn build_runtime_prompt( ); } - // 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、 - // 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量, - // 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。 - let system_prompt = - crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await; + // T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。压尾:每条任务指令 + // 重建,排最后使前缀缓存失效面止于本段,不拖累恒静段/会话段/目标/DAG。 + if !working_injection.is_empty() { + system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection); + } (system_prompt, goals) } diff --git a/src-tauri/src/commands/ai/agentic/stream_relay.rs b/src-tauri/src/commands/ai/agentic/stream_relay.rs index 28cb1ec..4b98faa 100644 --- a/src-tauri/src/commands/ai/agentic/stream_relay.rs +++ b/src-tauri/src/commands/ai/agentic/stream_relay.rs @@ -828,3 +828,43 @@ mod midstream_reentry_tests { ); } } + +// P0-A 口径统一单测:回退轨 round_usage_effective 两态钉死——端点缺 usage(累加器 None → +// 全零)走估算打标不静默零;真值(含三口径分项)原样透传不误标。两臂流式主链共用此单点派生。 +#[cfg(test)] +mod usage_caliber_tests { + use super::round_usage_effective; + use df_ai::provider::TokenUsage; + + /// 缺失回退(openai 臂缺 usage 形态,老端点/网关剥 include_usage):全零 TokenUsage → + /// prompt 走 estimated_prompt 兜底 + is_estimated=true(消息级落库标注),completion 不估算。 + #[test] + fn round_usage_effective_missing_usage_falls_back_to_estimate() { + let (usage, estimated) = round_usage_effective(&TokenUsage::default(), 1234); + assert!(estimated, "全零 usage 必须打估算标,不静默零入库"); + assert_eq!(usage.prompt_tokens, 1234, "prompt 走估算兜底"); + assert_eq!(usage.completion_tokens, 0, "completion 不估算(拍板点3,标注已如实)"); + assert_eq!(usage.total_tokens, 1234, "total = 估算 prompt + completion"); + } + + /// 真值透传(三口径映射,两臂同轨):端点回真值 → prompt(全量输入,含缓存)/hit/miss/ + /// reasoning 原样透传落库,is_estimated=false 不误标。 + #[test] + fn round_usage_effective_real_usage_passes_through_three_caliber() { + let real = TokenUsage { + prompt_tokens: 100, + completion_tokens: 30, + total_tokens: 130, + prompt_cache_hit_tokens: 64, + prompt_cache_miss_tokens: 36, + reasoning_tokens: 12, + }; + let (usage, estimated) = round_usage_effective(&real, 9999); + assert!(!estimated, "真值不误标估算"); + assert_eq!(usage.prompt_tokens, 100, "真值 prompt 不被估算覆盖"); + assert_eq!(usage.prompt_cache_hit_tokens, 64, "hit 分项透传"); + assert_eq!(usage.prompt_cache_miss_tokens, 36, "miss 分项透传"); + assert_eq!(usage.reasoning_tokens, 12, "reasoning 分项透传"); + assert_eq!(usage.total_tokens, 130, "total 原样"); + } +} diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index 4a6bb00..f3afce8 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -399,155 +399,9 @@ fn sort_object_keys(v: &mut serde_json::Value) { } } -// ============================================================ -// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒) -// -// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls` -// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判** -// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版: -// - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中 -// 已用 list_directory 列过的目录路径(精确子串) 才判绕行; -// - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批, -// 误报最多多一条提示,不阻断工作流; -// - 未命中 → None,零行为变更。 -// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result -// 同源(读 assistant tool_calls 的 list_directory args.path)。 -// ============================================================ - -/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。 -/// -/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字 -/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。 -fn contains_word(haystack: &str, needle: &str) -> bool { - let bytes = haystack.as_bytes(); - let n = needle.as_bytes(); - if n.is_empty() || n.len() > bytes.len() { - return false; - } - let max_start = bytes.len() - n.len(); - let mut i = 0; - while i <= max_start { - if &bytes[i..i + n.len()] == n { - let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); - let next_idx = i + n.len(); - let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric(); - if prev_ok && next_ok { - return true; - } - } - i += 1; - } - false -} - -/// 列举类命令检测(纯函数,词边界匹配防误判)。 -/// -/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大 -/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。 -fn contains_listing_command(command: &str) -> bool { - let lower = command.to_ascii_lowercase(); - contains_word(&lower, "get-childitem") - || contains_word(&lower, "gci") - || contains_word(&lower, "ls") -} - -/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory) -/// 已列过的目录路径(去重,保留原始大小写)。 -/// -/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path, -/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据 -/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。 -async fn listed_directory_paths( - session_arc: &Arc>, - conv_id: &str, -) -> Vec { - use df_ai::provider::MessageRole; - crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); - let session = session_arc.lock().await; - let Some(conv) = session.conv_read(conv_id) else { return Vec::new() }; - let mut dirs: Vec = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for m in conv.messages.iter() { - if !matches!(m.role, MessageRole::Assistant) { - continue; - } - let Some(tcs) = m.tool_calls.as_ref() else { continue }; - for tc in tcs { - if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) { - continue; - } - let Ok(args) = serde_json::from_str::(&tc.function.arguments) else { - continue; - }; - let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue }; - if seen.insert(p.to_string()) { - dirs.push(p.to_string()); - } - } - } - dirs -} - -/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。 -/// -/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。 -/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖 -/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。 -pub(crate) async fn detect_listing_bypass( - session_arc: &Arc>, - conv_id: &str, - args: &serde_json::Value, -) -> Option { - // 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。 - let command = args.get("command").and_then(|v| v.as_str())?.trim(); - if command.is_empty() { - return None; - } - // 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。 - if !contains_listing_command(command) { - return None; - } - let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or(""); - // 3) 扫描会话已列过的目录路径。 - let listed_dirs = listed_directory_paths(session_arc, conv_id).await; - if listed_dirs.is_empty() { - return None; - } - // 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。 - for dir in &listed_dirs { - if dir.is_empty() { - continue; - } - if command.contains(dir.as_str()) - || (!working_dir.is_empty() && working_dir.contains(dir.as_str())) - { - return Some(dir.clone()); - } - } - None -} - -/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。 -/// -/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。 -/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。 -pub(crate) async fn insert_listing_bypass_warning( - session_arc: &Arc>, - conv_id: &str, - dir: &str, -) { - let warn_text = format!( - "⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。", - dir - ); - crate::commands::ai::agentic::helpers::insert_system_notice( - session_arc, - conv_id, - &warn_text, - &format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir), - ) - .await; -} +// AC-2 ③ 目录列举绕行检测/警告已退役(2026-10-25):仅告警无动作,噪音>价值, +// 误报(正常 ls 用途)反误导 LLM。原 detect_listing_bypass/insert_listing_bypass_warning +// 整链删除;F-05 高危同参缓存复用(本文件其余部分)不受影响。 #[cfg(test)] mod tests { @@ -625,30 +479,6 @@ mod tests { } - #[test] - fn listing_command_detects_powershell_and_unix() { - assert!(contains_listing_command("Get-ChildItem -Recurse")); - assert!(contains_listing_command("gci -Force")); - assert!(contains_listing_command("ls -la /tmp")); - } - - #[test] - fn listing_command_avoids_false_positives() { - assert!(!contains_listing_command("git log")); - assert!(!contains_listing_command("else")); - assert!(!contains_listing_command("glass")); - assert!(!contains_listing_command("cd /tmp")); - assert!(!contains_listing_command("Get-ChildItem2")); - } - - #[test] - fn contains_word_boundary_behavior() { - // "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中 - assert!(contains_word("get-childitem -recurse", "get-childitem")); - assert!(!contains_word("else", "ls")); - assert!(!contains_word("glass", "ls")); - } - // ── cache_hit_warning 目录专用提示 ── #[test] diff --git a/src-tauri/src/commands/ai/audit/finalize.rs b/src-tauri/src/commands/ai/audit/finalize.rs index 8ca8dbb..cefd8c8 100644 --- a/src-tauri/src/commands/ai/audit/finalize.rs +++ b/src-tauri/src/commands/ai/audit/finalize.rs @@ -36,7 +36,7 @@ pub(crate) async fn audit_finalize( rec.status = status.to_string(); rec.decided_by = Some("human".to_string()); rec.executed_at = Some(now_millis()); - // V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入, + // V50 纯执行耗时:审批路径由调用方(ai_approve)在真执行窗口测量后传入, // 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。 rec.duration_ms = duration_ms; if let Some(r) = result { diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index adc9105..ecdd770 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -13,7 +13,7 @@ use df_storage::db::Database; use super::event_bus::emit_ai_chat_event; use crate::state::{SettingsNorm, log_slow_lock}; -use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; +use super::{AiChatEvent, AiSession, ToolCallDraft}; use super::tools::entity_resolve; use crate::commands::ai::is_destructive_command; @@ -35,7 +35,7 @@ mod diff; // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 // 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越 pub(crate) mod path_auth; -pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY}; +pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass}; // 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志 pub(super) use path_auth::is_blacklist_bypassed; // 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩 @@ -45,7 +45,7 @@ pub(super) use path_auth::{set_blacklist_bypass, BLACKLIST_BYPASS_TEST_LOCK}; // approval(audit/approval.rs):审批门控逻辑。 // 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。 mod approval; -use approval::{detect_retry_count, handle_approval_tool}; +use approval::handle_approval_tool; // record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。 // 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls / @@ -82,7 +82,7 @@ pub(crate) use finalize::audit_finalize; // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // 第三批从本文件抽离,行为零变更。 mod cache; -pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key}; +pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -171,7 +171,7 @@ async fn deny_sub_agent_tool( } /// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。 -/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话 +/// 不 insert pending_approvals、不 emit AiApprovalRequired——边界会话 /// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的 /// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed /// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk, @@ -312,7 +312,7 @@ fn upgrade_blocked_hint(blocked: &str) -> String { /// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。 /// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。 /// -/// 审批执行窗口(ai_approve / ai_authorize_dir)复用本函数:审批暂停时主 loop 已 return、 +/// 审批执行窗口(ai_approve)复用本函数:审批暂停时主 loop 已 return、 /// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建) /// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话 /// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。 @@ -445,7 +445,7 @@ pub(crate) async fn execute_with_heartbeat( // ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ── // 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve / - // ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行 + // ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行 // 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把 // 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装 // (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话, @@ -613,7 +613,7 @@ pub(crate) async fn execute_with_heartbeat( /// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。 /// /// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve / -/// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行 +/// ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行 /// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把 /// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装 /// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话, @@ -661,6 +661,26 @@ fn abort_when_invalid( static ARMED_TIMEOUT_IDS: std::sync::OnceLock>> = std::sync::OnceLock::new(); +/// 审批挂起到期时刻(ms Unix 时间戳):`approval_timeout_minutes` 配置换算,前端倒计时的单一真值 +/// (前端本地自动拒绝定时器已退役 2026-10-25,超时收敛只有后端 arm_approval_timeout_timers + +/// cleanup_expired_approvals 惰性兜底)。mins=0(禁用超时)→ None,前端不显倒计时。 +pub(crate) fn approval_expires_at_millis( + state: &crate::state::AppState, + created_at: std::time::SystemTime, +) -> Option { + let mins = state + .approval_timeout_minutes + .load(std::sync::atomic::Ordering::SeqCst); + if mins == 0 { + return None; + } + let ms = created_at + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_millis() as i64; + Some(ms + (mins as i64) * 60_000) +} + /// 为当前所有待审批武装「到点主动收敛」定时任务。 /// /// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。 @@ -956,26 +976,21 @@ pub(crate) async fn process_tool_calls( // ── 文件工具路径授权预校验 ── // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) - // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): + // 逐条预校验(persistent + 会话 session_allowed_dirs + 黑名单): // - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C) - // - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired), - // 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B) // - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程 - // 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。 + // path_auth 白名单挂起链(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir)已退役 + // (2026-10-25):bypass 恒开使 NeedsAuth 不可达,仅保留防御性硬拒兜底。 let allowed_snapshot = app_handle .state::() .allowed_dirs .clone(); let allowed_guard = allowed_snapshot.read().await.clone(); - // 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起 - // 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。 - // 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。 - // get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。 - let path_auth_bypass = app_handle - .state::() - .settings - .get_bool_norm(PATH_AUTH_BYPASS_KEY, false) - .await; + // 授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),原机制见 git 历史。 + // 存量 KV(df-ai-path-auth-bypass,含显式 false)一律忽略,bypass 恒开: + // 主会话全放行,子代理仍走 KeepBlacklist(黑名单安全线不因退役解除)。 + // PATH_AUTH_BYPASS_KEY 壳保留在 path_auth.rs 作恢复点,前端管理入口已撤。 + let path_auth_bypass = true; // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 path_auth::set_blacklist_bypass(path_auth_bypass); @@ -987,15 +1002,14 @@ pub(crate) async fn process_tool_calls( } else { PathBypass::Off }; - // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) - let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); + // 收集 Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); // SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。 // 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注—— // 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。 let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new(); // 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理) - let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new(); + let boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); for (idx, draft, args) in drafts { // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), @@ -1003,33 +1017,14 @@ pub(crate) async fn process_tool_calls( match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) { FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::NeedsAuth(req) => { - // SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。 + // path_auth 白名单挂起链已退役(2026-10-25):bypass 恒开(All/KeepBlacklist) + // 下本分支不可达。防御性硬拒兜底(bypass 若回退为 Off,未授权路径拒绝而非静默放行)。 + let _ = (idx, args, req); + let reason = "路径授权机制已退役,未授权路径拒绝".to_string(); if is_sub_agent { - sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth")); - continue; - } - // 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权 - // 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired); - // 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。 - if let Some(b) = &boundary { - if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) { - authorized_drafts.push((idx, draft, args)); - } else { - let uncovered: Vec = req.dirs.iter() - .filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d)) - .map(|d| d.to_string_lossy().to_string()) - .collect(); - boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", ")))); - } - continue; - } - // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 - // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 - // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 - if auto_exec_mode == "all" { - authorized_drafts.push((idx, draft, args)); + sub_agent_path_denied.push((draft, reason, "needs-auth")); } else { - path_auth_pending.push((draft, args, req)); + path_denied.push((draft, reason)); } } FileToolAuthOutcome::Denied(reason) => { @@ -1092,86 +1087,8 @@ pub(crate) async fn process_tool_calls( ).await; } - // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 - // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 - // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 - // - // detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外, - // 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。 - for (draft, args, req) in path_auth_pending { - pending_count += 1; - // L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。 - // ai_authorize_dir 消费时遍历所有 dirs 写白名单。 - let dir_str = req.dirs.first() - .map(|d| d.to_string_lossy().to_string()) - .unwrap_or_default(); - let path_str = req.raw_paths.first().cloned().unwrap_or_default(); - // 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 - let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await; - if retry_count >= 1 { - let skip_msg = format!( - "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", - draft.id - ); - // 短 lock 段:push tool_result(纯写) - { - let __lock_t = std::time::Instant::now(); - let mut session = session_arc.lock().await; - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); - log_slow_lock(__lock_t, "process_tool_calls:305"); - } - let ev = AiChatEvent::AiToolCallCompleted { - id: draft.id.clone(), - result: serde_json::Value::String(skip_msg.clone()), - conversation_id: Some(conv_id.to_string()), - }; - emit_ai_chat_event(&app_handle, ev); - let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); - audit_tool_call( - &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, - "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), - current_message_id, None, - ).await; - continue; - } - // 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await) - { - let __lock_t = std::time::Instant::now(); - let mut session = session_arc.lock().await; - session.pending_approvals.insert( - draft.id.clone(), - PendingApproval { - tool_call_id: draft.id.clone(), - tool_name: draft.name.clone(), - arguments: args.clone(), - conversation_id: Some(conv_id.to_string()), - recovered: false, - // 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 - kind: ApprovalKind::Path(req), - retry_count, - created_at: Some(std::time::SystemTime::now()), - }, - ); - // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); - log_slow_lock(__lock_t, "process_tool_calls:325"); - } - tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); - let ev = AiChatEvent::AiDirAuthRequired { - id: draft.id.clone(), - tool: draft.name.clone(), - path: path_str, - dir: dir_str, - conversation_id: Some(conv_id.to_string()), - }; - emit_ai_chat_event(&app_handle, ev); - let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); - audit_tool_call( - &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, - "pending", risk_level, None, None, - current_message_id, None, - ).await; - } + // Phase B(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir 挂起链)已随 path_auth + // 退役整体删除(2026-10-25):pending_approvals 单表现在只有 Risk kind 挂起。 // 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result) // @@ -1273,15 +1190,7 @@ pub(crate) async fn process_tool_calls( } } } - // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 - // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, - // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 - // 执行/审批(不硬拒,防误判阻断正常命令)。易误判详见 cache.rs detect_listing_bypass 文档。 - if draft.name == "run_command" { - if let Some(dir) = detect_listing_bypass(session_arc, conv_id, &args).await { - insert_listing_bypass_warning(session_arc, conv_id, &dir).await; - } - } + // AC-2 ③ 目录列举绕行警告已退役(2026-10-25):仅告警无动作,噪音>价值(误报反误导 LLM)。 handle_approval_tool( draft, args, risk_level, &auto_exec_mode, session_arc, conv_id, tools_arc, &audit_repo, @@ -1700,19 +1609,13 @@ pub(crate) async fn process_tool_calls( } - // 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 + // 单表 pending_approvals 统计 pending 总数(path_auth 退役后单 Risk kind)。 // 短 lock 段:读 pending_approvals 统计(纯读,无 await) - let (path_count, risk_count, pending_approvals_len) = { + let pending_approvals_len = { let session = session_arc.lock().await; - let len = session.pending_approvals.len(); - let (p, r) = session.pending_approvals.values() - .fold((0usize, 0usize), |(p, r), a| match a.kind { - ApprovalKind::Path(_) => (p + 1, r), - ApprovalKind::Risk { .. } => (p, r + 1), - }); - (p, r, len) + session.pending_approvals.len() }; - tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len, path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)"); + tracing::debug!(pending_count, pending_approvals_len, "process_tool_calls 返回(挂起审批统计)"); pending_count } @@ -1724,7 +1627,7 @@ mod tests { /// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。 /// /// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发 - /// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。 + /// NeedsAuth 申请(对齐 read_file),不像 search_files 被硬拒。 /// 锁定此差异:grep 与 read_file 同款授权弹窗语义。 #[test] fn test_grep_unauthorized_path_triggers_auth_not_denied() { diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 9035d4c..34842a1 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -39,9 +39,11 @@ use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::sanitize; use super::super::conversation::save_conversation; use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; -use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type}; +use super::super::prompt::{ + build_system_prompt_parts_with_user, cache_enabled, cache_split_provider_type, +}; -use super::super::{AiChatEvent, ApprovalKind, SessionState}; +use super::super::{AiChatEvent, SessionState}; /// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER /// "需要用户审批,等待确认")就地替换为终态文本。 @@ -386,8 +388,24 @@ pub async fn ai_regenerate( let conv_id = conversation_id.clone(); // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type); + // 渐进披露(D1)门信号:regenerate 重跑末条 user,以其文本判文档策略全文是否附带 + // (取法同 knowledge_inject 末条 active user 口径:反向扫描 + is_active 过滤)。 + let doc_user_text = { + let session = state.ai_session.lock().await; + session + .conv_read(&conv_id) + .and_then(|c| { + c.messages + .all_messages_clone() + .into_iter() + .rev() + .find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active()) + }) + .map(|m| m.content) + .unwrap_or_default() + }; let (mut system_prompt, mut volatile) = - build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await; + build_system_prompt_parts_with_user(&state, &lang, &[], &[], &doc_user_text, cache_on).await; // 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。 // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复: // 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active, @@ -492,6 +510,49 @@ pub async fn ai_is_generating( Ok(state.conv_states.is_active(&target)) } +/// 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治): +/// WebView2 原生行为会把拖入 textarea 的文本文件内容直接插入输入框——前端接管全部 +/// 文件 drop/paste 后,非图片文件经此 IPC 落盘 attachments/(hash 去重),返回本机 +/// 路径供前端插引用行,AI 经 read_file 按需读取(与飞书文件入站同范式,体验一致)。 +#[tauri::command] +pub async fn ai_save_dropped_file( + state: State<'_, AppState>, + file_name: String, + data: Vec, +) -> Result { + if data.is_empty() { + return Err("文件内容为空".to_string()); + } + // 对齐飞书入站文件上限:防超大件烧磁盘 + const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024; + if data.len() > MAX_ATTACHMENT_BYTES { + return Err(format!("文件超过 20MB 上限({} 字节)", data.len())); + } + let name = sanitize_file_name(&file_name); + let store = crate::media_store::MediaStore::new(state.data_dir.clone(), None); + let path = store.store_attachment(&data, &name).await?; + Ok(path.to_string_lossy().to_string()) +} + +/// 文件名清洗:去路径穿越与非法字符(对齐 media_store::sanitize_name 语义,本地复用 +/// 防跨模块私有依赖;空名兜底"unnamed")。 +fn sanitize_file_name(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + let trimmed = cleaned.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + "unnamed".to_string() + } else { + trimmed + } +} + // ============================================================ // F-260801 Phase2:anthropic 协议 url→base64 预拉 // ============================================================ @@ -965,7 +1026,7 @@ pub(crate) async fn spawn_agentic_session( // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type); let (mut system_prompt, mut volatile) = - build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; + build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await; @@ -993,6 +1054,20 @@ pub(crate) async fn spawn_agentic_session( system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; } } + // 飞书渠道交付引导(#958 生产端配套,2026-10-21):出站回传消费端在渠道侧 + // (extract_delivery_paths/reply_deliveries),但 prompt 层此前零处教 AI 写 [交付] 标记行, + // AI 不会自发写标记,链路空转(用户实证「不会用飞书发文件」)。落点=spawn 链渠道标识 + // 透传判定(source.channel,#958 溯源同参),零新管道;拼装与 aug_seg 同构——缓存模式并 + // 入 volatile 尾、非缓存直拼 system,均在稳定段之后,BE3 指纹键不含此段零缓存失效。 + // 文案本体归渠道模块单一事实源(DELIVERY_GUIDANCE_PROMPT,与解析器/回传/拦截同文件演化)。 + if source.as_ref().map(|s| s.channel.as_str()) == Some(crate::feishu_channel::SOURCE_CHANNEL_FEISHU) { + let guidance = crate::feishu_channel::DELIVERY_GUIDANCE_PROMPT; + if cache_on { + volatile = append_volatile(volatile, guidance); + } else { + system_prompt = format!("{}\n\n---\n{}", system_prompt, guidance); + } + } let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None }; // 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。 @@ -1235,9 +1310,8 @@ pub async fn ai_approve( let approval = match session.pending_approvals.remove(&tool_call_id) { Some(a) => a, None => { - // 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。 - // 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表, - // 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。 + // 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove。 + // 无挂起则查审计表(幂等,已处理返成功)。 // 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错) // 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把 // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。 @@ -1272,16 +1346,6 @@ pub async fn ai_approve( return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status)); } }; - // 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。 - // 若前端误调 ai_approve 消费 kind==Path 挂起(应走 ai_authorize_dir)→ Err 防误调, - // 避免把路径授权挂起当 Risk 审批执行(会跳过白名单写入,语义错乱)。 - // 兜底/回退:误调返回 Err 前不回滚 insert(前端拿 Err 自行刷新 pending,等价原「未找到」语义)。 - if !matches!(approval.kind, ApprovalKind::Risk { .. }) { - return Err(format!( - "tool_call_id={} 非普通审批(路径授权请用 ai_authorize_dir)", - tool_call_id - )); - } // recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。 let _recovered = approval.recovered; @@ -1495,324 +1559,17 @@ pub async fn ai_approve( /// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。 -/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。 -/// -/// 仅做词法判断(取前两字符 `:`),不做 canonicalize(避免 IO 卡死——这正是本批 -/// 防卡死预检的目标)。UNC 路径 `\\server\share` 返 None(跨盘语义不适用)。 -fn drive_letter(path: &str) -> Option { - let bytes = path.as_bytes(); - if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { - let drive = (bytes[0] as char).to_ascii_uppercase(); - return Some(format!("{}:", drive)); - } - None -} - -/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。 -/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。 -fn workspace_drive() -> Option { - std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string())) -} - -/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。 -/// -/// 仅对涉及文件移动/删除的工具(delete_file / rename_file)有意义——这些工具的目标位 -/// (.trash 或 rename 的 to)锚定 workspace_root,源在另一盘时走 copy+remove 降级。 -/// 其他文件工具(read_file/write_file)无跨盘移动语义,不提示。 -fn cross_drive_hint(tool_name: &str, raw_paths: &[String]) -> Option { - // 仅文件移动/删除类工具涉及跨盘(源在 A 盘 → 目标 .trash/to 在 workspace 盘) - if !matches!(tool_name, "delete_file" | "rename_file" | "move_file") { - return None; - } - let ws_drive = workspace_drive()?; - let cross_drives: Vec<&String> = raw_paths.iter() - .filter(|p| drive_letter(p).map_or(false, |d| d != ws_drive)) - .collect(); - if cross_drives.is_empty() { - return None; - } - Some(format!( - "⚠ 跨盘提示:路径 {:?} 与工作区({})不同盘,工具内部走 copy+remove 降级(非原子,大文件较慢,失败可能留残,已内置回滚)", - cross_drives, ws_drive - )) -} - -/// 把跨盘提示追加到 tool_result(保留原结果,仅追加提示行)。 -/// -/// - String 结果:追加 `\n<提示>`。 -/// - Object 结果:追加 `cross_drive_note` 字段(不覆盖原字段)。 -/// - 其他类型:包成 Object 的 `result` + `cross_drive_note` 双字段。 -fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json::Value { - match result { - serde_json::Value::String(s) => serde_json::Value::String(format!("{}\n{}", s, note)), - serde_json::Value::Object(mut map) => { - map.insert("cross_drive_note".to_string(), serde_json::Value::String(note.to_string())); - serde_json::Value::Object(map) - } - other => serde_json::json!({ - "result": other, - "cross_drive_note": note, - }), - } -} - -/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。 -/// -/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending -/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 -/// -/// `decision`(三授权档 + 拒): -/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复 -/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复 -/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复 -/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop -/// -/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result → -/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。 -#[tauri::command] -pub async fn ai_authorize_dir( - app: AppHandle, - state: State<'_, AppState>, - tool_call_id: String, - decision: String, -) -> Result { - // F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug, - // 避免污染 stderr(用户可见)。 - tracing::debug!( - tool_call_id = %tool_call_id, - decision = %decision, - "[AI-AUTHZ] ai_authorize_dir 入口" - ); - // 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。 - let approval = { - let __lock_t864 = std::time::Instant::now(); - let mut session = state.ai_session.lock().await; - let v = match session.pending_approvals.remove(&tool_call_id) { - Some(a) => a, - None => { - // 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。 - drop(session); - // 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复): - // path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留 - // 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端 - // pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时 - // emit AiApprovalResult{approved:false} 让前端收敛(幂等)。 - match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { - Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => { - let ev = AiChatEvent::AiApprovalResult { - id: tool_call_id.clone(), - approved: false, - conversation_id: rec.conversation_id.clone(), - }; - emit_ai_chat_event(&app, ev); - return Ok(format!("已处理({})", rec.status)); - } - _ => {} - } - return Err(format!("未找到路径授权挂起: {}", tool_call_id)); - } - }; - log_slow_lock(__lock_t864, "ai_authorize_dir:864"); - v - }; - // 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。 - // 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。 - // 兜底/回退:Err 前 re-insert 回 pending_approvals 保持挂起态(原「未找到」会丢挂起致卡死, - // re-insert 保守兜底防误调导致 pending 丢失 + loop 永挂)。 - let req = match approval.kind.clone() { - ApprovalKind::Path(req) => req, - ApprovalKind::Risk { .. } => { - // 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。 - let __lock_t878 = std::time::Instant::now(); - let mut session = state.ai_session.lock().await; - session.pending_approvals.insert(tool_call_id.clone(), approval); - log_slow_lock(__lock_t878, "ai_authorize_dir:878"); - return Err(format!( - "tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", - tool_call_id - )); - } - }; - // L1 补丁:req.dirs 含所有未授权父目录(rename_file from/to 不同目录时多项)。 - // once/always 遍历全部写入白名单,确保工具执行时 handler 内 resolve 全放行。 - let dir_strs: Vec = req.dirs.iter() - .map(|d| d.to_string_lossy().to_string()) - .collect(); - let conv_id = approval.conversation_id.clone(); - let args = approval.arguments.clone(); - - // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ── - if decision == "deny" { - // BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 - let err_msg = serde_json::json!({ - "status": "rejected", - "reason": "path_auth_denied", - "message": "用户拒绝路径授权" - }).to_string(); - { - let __lock_t903 = std::time::Instant::now(); - let mut session = state.ai_session.lock().await; - if let Some(ref cid) = conv_id { - session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg); - } - log_slow_lock(__lock_t903, "ai_authorize_dir:903"); - } - let ev_completed = AiChatEvent::AiToolCallCompleted { - id: tool_call_id.clone(), - result: serde_json::Value::String(err_msg.clone()), - conversation_id: conv_id.clone(), - }; - emit_ai_chat_event(&app, ev_completed); - let ev_approval = AiChatEvent::AiApprovalResult { - id: tool_call_id.clone(), - approved: false, - conversation_id: conv_id.clone(), - }; - emit_ai_chat_event(&app, ev_approval); - audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await; - if let Some(ref cid) = conv_id { - 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 = { - let __lock_t930 = std::time::Instant::now(); - let session = state.ai_session.lock().await; - let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); - log_slow_lock(__lock_t930, "ai_authorize_dir:930"); - v - }; - try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; - return Ok("rejected".to_string()); - } - - // ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)── - // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。 - // deny 已在上方早 return,此处仅 once/session/always。 - match decision.as_str() { - "once" => { - for d in &dir_strs { - state.add_once_allowed_dir(d.clone()).await; - } - tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)"); - } - "session" => { - for d in &dir_strs { - state.add_session_allowed_dir(d.clone()).await; - } - tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)"); - } - "always" => { - for d in &dir_strs { - if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await { - tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e); - // 降级:写入会话临时授权,保本路径本会话可用 - state.add_session_allowed_dir(d.clone()).await; - } - } - tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)"); - } - _ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗) - } - - // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ── - // 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute - tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前"); - // 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档, - // execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。 - // V50 纯执行耗时:只测真执行窗口,不含等待人工批准。 - let exec_started = std::time::Instant::now(); - let exec_result: anyhow::Result = if approval.tool_name == "run_workflow" { - super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await - } else { - // 授权执行窗口复用 execute_with_heartbeat(对齐 ai_approve,详见其执行段注释): - // 授权挂起期主 loop 心跳已停,慢文件工具(如跨盘 copy+remove 降级)整窗静默 - // → 前端看门狗误杀;附带补齐 run_command 实时流式与超时 clamp(原恒 60s)。 - execute_with_heartbeat( - &state.ai_tools, &approval.tool_name, args.clone(), - &app, conv_id.as_deref().unwrap_or_default(), &tool_call_id, - ).await - }; - let exec_ms = exec_started.elapsed().as_millis() as i64; - tracing::debug!( - ok = exec_result.is_ok(), - status = if exec_result.is_ok() { "completed" } else { "failed" }, - "[AI-AUTHZ] execute 后" - ); - let (audit_status, mut result_val) = match &exec_result { - Ok(val) => ("completed", val.clone()), - Err(e) => ("failed", serde_json::Value::String(e.to_string())), - }; - // 阶段4(容错/恢复):跨盘预检 → tool_result 提示。 - // 检测授权路径(req.raw_paths 规范化前原值)与 workspace_root 是否跨盘(Windows 不同盘符, - // 如 C盘授权路径 → E盘 workspace)。跨盘时文件工具(delete_file→.trash / rename_file from→to) - // 内部走 copy+remove 降级(非原子,大文件慢),提示 LLM 知晓成本 + 失败留残风险。 - // 提示追加到 result_val(tool_result),不阻断执行(降级已能处理,仅告知)。 - if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) { - result_val = append_cross_drive_note(result_val, ¬e); - } - audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await; - if exec_result.is_ok() { - emit_data_changed(&app, &approval.tool_name); - // MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。 - // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload, - // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。 - if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") { - state.reload_allowed_dirs().await; - } - } - - // once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。 - // 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。 - state.clear_once_allowed_dirs().await; - - // 替换占位 tool_result 为真实结果 - { - let __lock_t1018 = std::time::Instant::now(); - let mut session = state.ai_session.lock().await; - if let Some(ref cid) = conv_id { - session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string()); - } - log_slow_lock(__lock_t1018, "ai_authorize_dir:1018"); - } - let ev_completed = AiChatEvent::AiToolCallCompleted { - id: tool_call_id.clone(), - result: result_val.clone(), - conversation_id: conv_id.clone(), - }; - emit_ai_chat_event(&app, ev_completed); - let ev_approval = AiChatEvent::AiApprovalResult { - id: tool_call_id.clone(), - approved: true, - conversation_id: conv_id.clone(), - }; - emit_ai_chat_event(&app, ev_approval); - if let Some(ref cid) = conv_id { - 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 = { - let __lock_t1044 = std::time::Instant::now(); - let session = state.ai_session.lock().await; - let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); - log_slow_lock(__lock_t1044, "ai_authorize_dir:1044"); - v - }; - try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; - Ok(audit_status.to_string()) -} - /// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用) -/// -/// 阶段4:`kind` 字段透传(`risk` / `path`),前端 switchConversation 恢复 pendingApprovals 时 -/// 按 kind 渲染——risk 类显 approve/reject,path 类显 once/always/deny(对齐阶段3b 统一审批模型)。 #[derive(Debug, Serialize)] pub struct PendingToolCallInfo { pub tool_call_id: String, pub conversation_id: Option, - /// 阶段4:审批类型,前端按 kind 渲染不同决策按钮。 - /// 'risk' = 普通 RiskLevel 审批(approve/reject);'path' = 路径授权(once/always/deny)。 + /// 审批类型,恒 'risk'(path_auth 已退役,2026-10-25)。字段保留作前端协议兼容。 #[serde(rename = "kind")] pub kind: &'static str, + /// 后端超时收敛到期时刻(ms Unix 时间戳,前端倒计时单一真值);None=禁用超时/异常无 created_at。 + #[serde(rename = "expires_at_millis")] + pub expires_at_millis: Option, } /// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态) @@ -1823,21 +1580,17 @@ pub async fn ai_pending_tool_calls( ) -> Result, String> { let __lock_t1071 = std::time::Instant::now(); let session = state.ai_session.lock().await; - // 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。 - // 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染 - // (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。 - // (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。) + // 单 pending_approvals 表按 conversation_id 过滤;kind 恒 'risk'(path_auth 已退役)。 let list = session .pending_approvals .values() .filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str())) .map(|a| PendingToolCallInfo { + expires_at_millis: a.created_at + .and_then(|c| crate::commands::ai::audit::approval_expires_at_millis(state.inner(), c)), tool_call_id: a.tool_call_id.clone(), conversation_id: a.conversation_id.clone(), - kind: match a.kind { - ApprovalKind::Risk { .. } => "risk", - ApprovalKind::Path(_) => "path", - }, + kind: "risk", }) .collect(); log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071"); @@ -1900,6 +1653,11 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { // pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。 finalize_pending_placeholders(&mut *session, id, "会话已清除"); session.conv(id).messages.clear(); + // V45 边界随代同亡(P1-B 代史收尾,2026-10-21):硬清空=旧任务代终局,内存边界同步清。 + // per_conv.boundary 是真相源(save 每轮随行覆写 DB),不清则下轮 save 会把 DB 侧刚在 + // clear_conversation_atomic 归位的 boundary_json=NULL 重新覆写成旧边界,旧任务约束 + // (风险上限/目录白名单/禁破坏性)泄漏给同壳新任务。 + session.conv(id).boundary = None; // 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(id.as_str())); } @@ -2215,7 +1973,7 @@ pub async fn ai_chat_edit( // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type); let (mut system_prompt, mut volatile) = - build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await; + build_system_prompt_parts_with_user(&state, &lang, &[], &[], &new_message, cache_on).await; // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。 // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user, @@ -2485,7 +2243,7 @@ pub async fn ai_chat_force_send( // prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type); let (mut system_prompt, mut volatile) = - build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; + build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index e9a7c29..6908449 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -705,7 +705,7 @@ pub async fn ai_conversation_switch( // // 陈旧过滤根因修复(2026-08,治"审批待办关不了"): // 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**—— - // 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态), + // 审批处理时(ai_approve)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态), // 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。 // 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡, // 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目, diff --git a/src-tauri/src/commands/ai/commands/mod.rs b/src-tauri/src/commands/ai/commands/mod.rs index 32a8e87..ab16c2d 100644 --- a/src-tauri/src/commands/ai/commands/mod.rs +++ b/src-tauri/src/commands/ai/commands/mod.rs @@ -9,8 +9,10 @@ //! - [`config`] — skills + config 域 4 个 IPC(ai_list_skills + ai_set_concurrency_config //! + ai_set_agent_max_iterations + ai_set_agent_max_retries);`ai_list_skills` 复用 //! `super::super::skills::{skills_cached, SkillInfo}`(skills.rs 缓存+扫盘,config 仅 IPC 包装) +//! - [`search`] — search 域 1 个 IPC(search_messages 跨会话消息检索,P1-A)+ 共享内核 +//! (keyword_search / recent_conversation_briefs,AI 工具 history_search 同源复用) //! -//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config}::*` +//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config,search}::*` //! 把各域 IPC 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*` //! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 //! @@ -32,3 +34,6 @@ pub use self::provider::*; pub mod config; #[allow(unused_imports)] pub use self::config::*; +pub mod search; +#[allow(unused_imports)] +pub use self::search::*; diff --git a/src-tauri/src/commands/ai/commands/search.rs b/src-tauri/src/commands/ai/commands/search.rs new file mode 100644 index 0000000..0328740 --- /dev/null +++ b/src-tauri/src/commands/ai/commands/search.rs @@ -0,0 +1,342 @@ +//! search 域 IPC — 跨会话消息检索(P1-A,对标 u-claw #5) +//! +//! `search_messages` IPC 与 AI 工具 `history_search`(tools/history.rs)共用本文件内核 +//! ([`keyword_search`] / [`recent_conversation_briefs`]):同一 Repo 查询两条薄包装, +//! DRY 无双轨(设计:分析-P1A-跨会话检索-2026-10-21 §三③)。 +//! +//! 依赖方向:分词在 command 层完成(extract_query_keywords,与 knowledge_search 同源), +//! 关键词数组传 Repo——df-storage 在依赖下层,不能反向依赖 src-tauri。 +//! LIKE 起步 + role 过滤 user+assistant+system(tool 结果默认排除,拍板④); +//! FTS5 升级条件留档在 message_repo.rs 搜索方法头(拍板⑥,>50 万行或 P95>500ms 再评估)。 + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use tauri::State; + +use df_storage::crud::{AiConversationRepo, AiMessageRepo, MessageSearchHit}; +use df_storage::db::Database; + +use crate::commands::ai::audit::truncate_chars_ellipsis; +use crate::commands::ai::knowledge_inject::extract_query_keywords; +use crate::state::AppState; + +/// IPC 分页:默认条数与上限(分析 §4.2:默认 20,帽 50)。 +const SEARCH_DEFAULT_LIMIT: usize = 20; +const SEARCH_MAX_LIMIT: usize = 50; + +/// 会话概要截断长度(拍板⑤降级链产物统一口径:截 200 字)。 +const CONVERSATION_SUMMARY_CHARS: usize = 200; + +/// 关键词检索命中 DTO:会话摘要元信息(id/title/updated_at)+ 命中片段(Repo 层已截 500 字)。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct MessageSearchDto { + pub conversation_id: String, + pub title: Option, + pub updated_at: String, + /// 子代理轨迹标记(sub_agent);主会话为 null + pub origin: Option, + pub archived: bool, + pub message_id: String, + pub seq: i64, + pub role: String, + pub timestamp: Option, + pub snippet: String, +} + +/// 会话概要 DTO(无参列最近会话)。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct ConversationBrief { + pub id: String, + pub title: Option, + /// 摘要降级链产物(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字;空会话为空串 + pub summary: String, + pub updated_at: String, + pub origin: Option, + pub archived: bool, +} + +/// 关键词检索内核(IPC 与 AI 工具共用):分词 → Repo LIKE 检索 → 会话元信息补全。 +/// +/// query 分词后无有效关键词(纯停用词/纯标点)返空 Vec,调用方按空结果给指引文案 +/// (禁裸空数组,tools/knowledge.rs 同惯例)。 +pub(crate) async fn keyword_search( + db: &Arc, + query: &str, + conversation_id: Option<&str>, + limit: usize, + offset: usize, +) -> Result, String> { + let keywords = extract_query_keywords(query); + if keywords.is_empty() { + return Ok(Vec::new()); + } + let hits = AiMessageRepo::new(db) + .search_keywords(&keywords, conversation_id, limit, offset) + .await + .map_err(|e| e.to_string())?; + enrich_with_conversations(db, hits).await +} + +/// 命中列表补会话元信息:按去重后的会话 id 逐个 PK 查询(hit 上限几十条、会话数更少, +/// 子毫秒级 PK lookup,不值得为此加批量 Repo 面)。会话已删的孤儿消息按空元信息兜底。 +async fn enrich_with_conversations( + db: &Arc, + hits: Vec, +) -> Result, String> { + let conv_repo = AiConversationRepo::new(db); + // 元信息三元组:(title, updated_at, origin, archived) + let mut metas: HashMap, String, Option, bool)> = HashMap::new(); + let conv_ids: HashSet = hits.iter().map(|h| h.conversation_id.clone()).collect(); + for conv_id in conv_ids { + let record = conv_repo.get_by_id(&conv_id).await.map_err(|e| e.to_string())?; + if let Some(r) = record { + metas.insert(conv_id, (r.title, r.updated_at, r.origin, r.archived)); + } + } + Ok(hits + .into_iter() + .map(|h| { + let (title, updated_at, origin, archived) = metas + .get(&h.conversation_id) + .cloned() + .unwrap_or((None, String::new(), None, false)); + MessageSearchDto { + conversation_id: h.conversation_id, + title, + updated_at, + origin, + archived, + message_id: h.message_id, + seq: h.seq, + role: h.role, + timestamp: h.timestamp, + snippet: h.snippet, + } + }) + .collect()) +} + +/// 最近会话概要列表(无参降级数据源):与 ai_conversation_list 同源 list_all, +/// 排除子代理轨迹(origin='sub_agent',对齐侧栏口径),按 updated_at DESC 取前 limit。 +/// +/// 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 +/// [`CONVERSATION_SUMMARY_CHARS`] 字;两级皆空(空会话)为空串。 +pub(crate) async fn recent_conversation_briefs( + db: &Arc, + limit: usize, +) -> Result, String> { + let records = AiConversationRepo::new(db) + .list_all() + .await + .map_err(|e| e.to_string())?; + let mut mains: Vec<_> = records.into_iter().filter(|r| r.origin.is_none()).collect(); + // updated_at 毫秒串数值降序(「最近」=最近活跃,非最近创建);脏数据按 0 沉底 + mains.sort_by_key(|r| std::cmp::Reverse(r.updated_at.parse::().unwrap_or(0))); + mains.truncate(limit); + let sources = AiMessageRepo::new(db) + .summary_sources(&mains.iter().map(|r| r.id.clone()).collect::>()) + .await + .map_err(|e| e.to_string())?; + Ok(mains + .into_iter() + .map(|r| { + let summary = sources + .get(&r.id) + .and_then(|s| match (&s.last_system, &s.first_user) { + (Some(sys), _) if !sys.trim().is_empty() => Some(sys.clone()), + (_, Some(usr)) if !usr.trim().is_empty() => Some(usr.clone()), + _ => None, + }) + .map(|s| truncate_chars_ellipsis(&s, CONVERSATION_SUMMARY_CHARS)) + .unwrap_or_default(); + ConversationBrief { + id: r.id, + title: r.title, + summary, + updated_at: r.updated_at, + origin: r.origin, + archived: r.archived, + } + }) + .collect()) +} + +/// 跨会话消息全文检索(P1-A 后端搜索面,F4-1 前端侧栏升级的预留 IPC)。 +/// +/// 命中片段 Repo 层已截 500 字防全文回灌;role 默认排除 tool(拍板④); +/// query 分词后无有效关键词返空 results(前端空态自理)。 +#[tauri::command] +pub async fn search_messages( + state: State<'_, AppState>, + query: String, + conversation_id: Option, + limit: Option, + offset: Option, +) -> Result, String> { + keyword_search( + &state.db, + &query, + conversation_id.as_deref(), + limit.unwrap_or(SEARCH_DEFAULT_LIMIT).min(SEARCH_MAX_LIMIT), + offset.unwrap_or(0), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use df_storage::crud::AiMessageRepo; + use df_storage::db::Database; + use df_storage::models::{AiConversationRecord, AiMessageRecord}; + + async fn test_db() -> Arc { + Arc::new(Database::open_in_memory().await.unwrap()) + } + + async fn ins_conv(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>, updated_at: &str) { + AiConversationRepo::new(db) + .insert(AiConversationRecord { + id: id.into(), + title: title.map(|s| s.into()), + messages: "[]".into(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: None, + completion_tokens: None, + pinned_goals: None, + pending_approvals: None, + created_at: "100".into(), + updated_at: updated_at.into(), + origin: origin.map(|s| s.into()), + boundary_json: None, + loop_state: None, + status: "active".into(), + working_context_json: None, + }) + .await + .unwrap(); + } + + fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord { + AiMessageRecord { + id: format!("m_{conv}_{seq}"), + conversation_id: conv.into(), + seq, + role: role.into(), + content: content.into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: "active".into(), + reasoning_content: None, + timestamp: Some(timestamp), + created_at: "100".into(), + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + is_estimated: None, + source: None, + } + } + + /// 关键词检索内核:分词命中 + 会话元信息(title/origin/archived)补全; + /// tool role 不入检索面;纯停用词 query 返空。 + #[tokio::test] + async fn keyword_search_enriches_conversation_meta() { + let db = test_db().await; + ins_conv(&db, "c1", Some("部署踩坑复盘"), None, "200").await; + ins_conv(&db, "c2", None, Some("sub_agent"), "300").await; + AiMessageRepo::new(&db) + .insert_batch(vec![ + msg("c1", 0, "user", "上次部署服务踩了端口占用的坑", 100), + msg("c1", 1, "tool", "部署 tool 结果正文", 200), + msg("c2", 0, "assistant", "子代理轨迹里的部署结论", 300), + ]) + .await + .unwrap(); + + let hits = keyword_search(&db, "帮我看看部署相关的历史", None, 20, 0) + .await + .unwrap(); + // tool 结果排除(user+assistant 命中,tool 不入),两会话各一条 + assert_eq!(hits.len(), 2, "tool 应排除: {:?}", hits.iter().map(|h| &h.role).collect::>()); + let c1 = hits.iter().find(|h| h.conversation_id == "c1").unwrap(); + assert_eq!(c1.title.as_deref(), Some("部署踩坑复盘"), "命中应补会话标题"); + assert!(c1.snippet.contains("部署"), "片段应含命中词: {}", c1.snippet); + // 子代理轨迹会话同样可检索(origin 透出供 AI 识别轨迹) + let c2 = hits.iter().find(|h| h.conversation_id == "c2").unwrap(); + assert_eq!(c2.origin.as_deref(), Some("sub_agent")); + + // 纯停用词/客套话 query → 分词后无有效关键词 → 空集(调用方给指引) + let empty = keyword_search(&db, "帮我看看一下这个", None, 20, 0).await.unwrap(); + assert!(empty.is_empty()); + } + + /// 无参列最近会话:摘要降级链(最近 system 压缩摘要 > 首条 user)、updated_at DESC、 + /// 子代理轨迹排除、limit 截断。 + #[tokio::test] + async fn recent_briefs_summary_fallback_chain() { + let db = test_db().await; + // c_old:更新最早(100);c_user:无 system,走首条 user 兜底(200); + // c_sum:有压缩摘要,优先于 user(300);c_sub:子代理轨迹排除(400);c_tail:超 limit 沉底(50) + ins_conv(&db, "c_old", Some("最早会话"), None, "100").await; + ins_conv(&db, "c_user", Some("无摘要会话"), None, "200").await; + ins_conv(&db, "c_sum", Some("有摘要会话"), None, "300").await; + ins_conv(&db, "c_sub", Some("子代理轨迹"), Some("sub_agent"), "400").await; + ins_conv(&db, "c_tail", Some("超limit沉底"), None, "50").await; + let repo = AiMessageRepo::new(&db); + repo.insert_batch(vec![ + msg("c_old", 0, "user", "c_old 首条", 1), + msg("c_user", 0, "user", "c_user 的首条用户消息,应作摘要兜底", 2), + msg("c_sum", 0, "user", "c_sum 首条不应入选", 3), + msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要应优先", 4), + ]) + .await + .unwrap(); + + let briefs = recent_conversation_briefs(&db, 4).await.unwrap(); + let ids: Vec<&str> = briefs.iter().map(|b| b.id.as_str()).collect(); + assert_eq!( + ids, + vec!["c_sum", "c_user", "c_old", "c_tail"], + "updated_at DESC 且子代理轨迹排除: {ids:?}" + ); + let c_sum = briefs.iter().find(|b| b.id == "c_sum").unwrap(); + assert!(c_sum.summary.contains("c_sum 压缩摘要应优先"), "system 摘要优先: {}", c_sum.summary); + assert!(!c_sum.summary.contains("不应入选"), "user 消息不得越过 system 摘要"); + let c_user = briefs.iter().find(|b| b.id == "c_user").unwrap(); + assert!(c_user.summary.contains("首条用户消息"), "无 system 时首条 user 兜底: {}", c_user.summary); + let c_old = briefs.iter().find(|b| b.id == "c_old").unwrap(); + // c_old 只有一条 user?其摘要也应来自首条 user(链路兜底) + assert!(c_old.summary.contains("c_old 首条"), "仅 user 消息时同样兜底: {}", c_old.summary); + } + + /// 摘要截断:超 200 字的 system 摘要截断加省略号(拍板⑤统一口径)。 + #[tokio::test] + async fn recent_briefs_truncates_long_summary() { + let db = test_db().await; + ins_conv(&db, "c_long", Some("长摘要会话"), None, "100").await; + let long_summary = format!("## 意图\n- {}", "长".repeat(300)); + AiMessageRepo::new(&db) + .insert_batch(vec![msg("c_long", 0, "system", &long_summary, 1)]) + .await + .unwrap(); + + let briefs = recent_conversation_briefs(&db, 10).await.unwrap(); + assert_eq!(briefs.len(), 1); + let summary_chars = briefs[0].summary.chars().count(); + assert_eq!( + summary_chars, CONVERSATION_SUMMARY_CHARS + 1, + "截 200 字 + 省略号,实际 {summary_chars}" + ); + assert!(briefs[0].summary.ends_with('…')); + } +} diff --git a/src-tauri/src/commands/ai/devflow_md.rs b/src-tauri/src/commands/ai/devflow_md.rs index a9f33ad..c30a490 100644 --- a/src-tauri/src/commands/ai/devflow_md.rs +++ b/src-tauri/src/commands/ai/devflow_md.rs @@ -1,7 +1,8 @@ //! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。 //! //! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读—— -//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单); +//! - `devflow.md`:agent 生态自己的项目认知文件,目录式渐进披露——纪律类分区 +//! (标题含 纪律/规范/必读/铁律)全文保留,其余分区收敛为单行目录; //! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。 //! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。 //! @@ -19,7 +20,7 @@ //! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空, //! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。 //! -//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询 +//! 裁剪纪律:注入的 token 才是成本——分区按字符上限截断 + 总闸 3000 收口,路标条目数被查询 //! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断 //! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。 @@ -35,16 +36,19 @@ use crate::state::AllowedDirs; // ── 裁剪上限(字符数,中文一字一符) ── // -// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、 -// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。 +// 分区上限兜住单分区体量,总上限兜住整段体量。纪律类分区全文保留(完整性优先), +// 其余分区收敛为单行目录后体量可忽略;总闸 3000 为实际生效的预算闸,分区上限是 +// 叠加前的第一道结构兜底。 /// 规范纪律分区上限 const DISCIPLINE_MAX_CHARS: usize = 4000; -/// 领单分区上限 -const CLAIM_MAX_CHARS: usize = 2000; +/// 目录行首行摘要上限 +const INDEX_SUMMARY_MAX_CHARS: usize = 100; /// CLAUDE.md 并入段上限 const CLAUDE_MD_MAX_CHARS: usize = 4000; /// 投影段总上限(各分区叠加后的二次硬闸) -const SECTION_MAX_CHARS: usize = 6000; +const SECTION_MAX_CHARS: usize = 3000; +/// 目录块尾部总引导(≤60 字符):提示被收敛分区的全文获取方式 +const INDEX_TAIL_HINT: &str = "以上收敛分区全文用 read_file 读 devflow.md 原文件获取"; /// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空 /// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。 @@ -61,9 +65,9 @@ const WAYMARK_GLOBAL_MAX: usize = 10; /// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态 const WAYMARK_TITLE_MAX_CHARS: usize = 60; -/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡) -const CLAIM_HEADING_KEY: &str = "领单"; -const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"]; +/// 纪律类分区识别的标题关键字(命中 → 全文保留,禁收敛;其余分区收敛为目录行)。 +/// contains 直判不引正则;中文关键字无大小写,ASCII 侧统一小写后比对(大小写不敏感)。 +const DISCIPLINE_HEADING_KEYS: [&str; 4] = ["纪律", "规范", "必读", "铁律"]; static SECTION_CACHE: OnceLock>> = OnceLock::new(); @@ -116,6 +120,10 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option, conv_id: &str, kb: Option= SECTION_CACHE_MAX { cache.clear(); @@ -209,16 +218,28 @@ fn read_trimmed(path: &Path) -> Option { .filter(|s| !s.is_empty()) } -/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。 +/// 拼装规则文件投影段(纯函数):分区分类(纪律全文 / 其余目录行)、上限截断、 +/// 优先级注明都在这里定形。CLAUDE.md 侧恒全文并入(目录式只作用于 devflow.md 侧)。 /// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。 -fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option { +fn build_section(parts: &[Partition], claude: Option<&str>, devflow_path: Option<&str>) -> Option { let claude = claude.map(str::trim).filter(|c| !c.is_empty()); - if discipline.is_empty() && claiming.is_empty() && claude.is_none() { + // 分类:标题空(扁平回退)或命中纪律关键字 → 全文保留;其余收敛为目录行 + let mut discipline: Vec<&str> = Vec::new(); + let mut index: Vec = Vec::new(); + for p in parts { + if p.title.is_empty() || is_discipline_heading(&p.title) { + discipline.push(p.body.as_str()); + } else { + index.push(render_index_line(p, devflow_path)); + } + } + let discipline = discipline.join("\n"); + if discipline.is_empty() && index.is_empty() && claude.is_none() { return None; } - let has_devflow = !discipline.is_empty() || !claiming.is_empty(); + let has_devflow = !discipline.is_empty() || !index.is_empty(); let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)"); - // 优先级只在双文件并存时注明,单文件无冲突可注明 + // 优先级只在双文件并存时注明,单文件无冲突不注明 if has_devflow && claude.is_some() { sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准"); } @@ -226,18 +247,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti if !discipline.is_empty() { sec.push_str("\n\n### 规范纪律(devflow.md)\n"); - sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS)); + sec.push_str(&cap_chars(&discipline, DISCIPLINE_MAX_CHARS)); } - if !claiming.is_empty() { - sec.push_str("\n\n### 领单纪律(devflow.md)\n"); - sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS)); + if !index.is_empty() { + sec.push_str("\n\n### 其他分区目录(devflow.md)\n"); + for line in &index { + sec.push_str(line); + sec.push('\n'); + } + sec.push_str(INDEX_TAIL_HINT); } if let Some(cla) = claude { sec.push_str("\n\n### 项目规范(CLAUDE.md)\n"); sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS)); } - // 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次 + // 总上限二次硬闸:各分区叠加后仍可能超,整段再截一次 if sec.chars().count() > SECTION_MAX_CHARS { let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect(); return Some(format!("{}\n…(投影段超出总上限已截断)", cut)); @@ -245,6 +270,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti Some(sec) } +/// 目录行(单行收敛):`## 标题` + 首行摘要(≤100 字符) + read_file 按需全文提示。 +/// 标题不截断——它是 read_file 定位原文件标题的锚点,截断会破坏按标题检索。 +fn render_index_line(p: &Partition, devflow_path: Option<&str>) -> String { + let first = p.body.lines().next().unwrap_or("").trim(); + let count = first.chars().count(); + let mut summary: String = first.chars().take(INDEX_SUMMARY_MAX_CHARS).collect(); + if count > INDEX_SUMMARY_MAX_CHARS { + summary.push('…'); + } + let mut line = format!("## {} | {summary}", p.title); + if let Some(path) = devflow_path { + line.push_str(&format!(" (全文按需: read_file {path} 定位「{}」)", p.title)); + } + line +} + /// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。 fn cap_chars(s: &str, max: usize) -> String { if s.chars().count() <= max { @@ -256,15 +297,17 @@ fn cap_chars(s: &str, max: usize) -> String { // ── devflow.md 分区解析 ── // -// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的 -// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类 -// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按 -// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。 +// 目录式投影:每个 markdown 标题块取为一个分区(块内更深层子标题视为块内子结构 +// 并入正文)。分类 = 标题含纪律关键字(纪律/规范/必读/铁律)→ 全文保留(完整性 +// 优先,agent 行为底线禁收敛);其余分区收敛为「标题 + 首行摘要 + read_file 定位」 +// 单行目录。更深层纪律关键字标题在非纪律容器内强制开新块(防容器吞并);首个 +// 命中标题前的前言不属任何分区,裁掉;全文无标题(扁平写法)回退为整文件按规范纪律 +// 处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。 -#[derive(Clone, Copy, PartialEq)] -enum Partition { - Discipline, - Claim, +/// devflow.md 单个分区:标题 + 正文(trim 后,不含起始标题行;标题空 = 扁平回退块) +struct Partition { + title: String, + body: String, } /// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。 @@ -281,30 +324,32 @@ fn heading_of(line: &str) -> Option<(usize, &str)> { Some((level, text)) } -fn classify_heading(text: &str) -> Option { - if text.contains(CLAIM_HEADING_KEY) { - return Some(Partition::Claim); - } - if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) { - return Some(Partition::Discipline); - } - None +/// 标题是否命中纪律关键字(命中 → 全文保留,禁收敛)。 +fn is_discipline_heading(text: &str) -> bool { + let lower = text.to_lowercase(); + DISCIPLINE_HEADING_KEYS.iter().any(|k| lower.contains(k)) } -/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。 -fn extract_partitions(content: &str) -> (String, String) { - let mut discipline: Vec = Vec::new(); - let mut claiming: Vec = Vec::new(); - // 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题) - let mut cur: Option<(Partition, usize, String)> = None; +/// 从 devflow.md 全文提取分区列表(纯函数,未截断):每个标题开启一个分区, +/// 块内更深层子标题并入正文;空白正文的标题块跳过。 +fn extract_partitions(content: &str) -> Vec { + let mut parts: Vec = Vec::new(); + // 当前块:(起始标题级别, 标题, 正文);正文不含起始标题行(投影段会重加小标题) + let mut cur: Option<(usize, String, String)> = None; for line in content.lines() { let heading = heading_of(line); - // 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块 - let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv); + // 块边界:块内遇到不深于起始层级的标题收块;更深层纪律关键字标题仅在 + // 非纪律容器块内强制开新块(防浅层容器标题吞并后续纪律分区),纪律块内 + // 的更深层纪律子标题(如「### 提交规范」)仍属块内子结构,连行保留 + let boundary = matches!( + (&cur, &heading), + (Some((lv, cur_title, _)), Some((h_lv, text))) + if *h_lv <= *lv + || (h_lv > lv && is_discipline_heading(text) && !is_discipline_heading(cur_title)) + ); if boundary { - let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在"); - push_block(kind, body, &mut discipline, &mut claiming); + push_block(cur.take(), &mut parts); } else if let Some((_, _, body)) = cur.as_mut() { // 更深层级标题属块内子结构,连行保留 body.push_str(line); @@ -312,31 +357,29 @@ fn extract_partitions(content: &str) -> (String, String) { continue; } if let Some((lv, text)) = heading { - if let Some(kind) = classify_heading(text) { - cur = Some((kind, lv, String::new())); - } + cur = Some((lv, text.to_string(), String::new())); } } - if let Some((kind, _, body)) = cur.take() { - push_block(kind, body, &mut discipline, &mut claiming); - } + push_block(cur.take(), &mut parts); - let dev = discipline.join("\n").trim().to_string(); - let claim = claiming.join("\n").trim().to_string(); - if dev.is_empty() && claim.is_empty() { - return (content.trim().to_string(), String::new()); + if parts.is_empty() { + // 扁平回退:全文无任何标题 → 整文件按规范纪律(标题空串,渲染时归纪律区) + let flat = content.trim(); + if !flat.is_empty() { + return vec![Partition { title: String::new(), body: flat.to_string() }]; + } } - (dev, claim) + parts } -fn push_block(kind: Partition, body: String, discipline: &mut Vec, claiming: &mut Vec) { - let trimmed = body.trim(); - if trimmed.is_empty() { - return; - } - match kind { - Partition::Discipline => discipline.push(trimmed.to_string()), - Partition::Claim => claiming.push(trimmed.to_string()), +/// 收块入分区列表(纯函数):空白正文跳过(渲染不出可读内容的块不入段)。 +fn push_block(block: Option<(usize, String, String)>, parts: &mut Vec) { + if let Some((_, title, body)) = block { + let trimmed = body.trim(); + if trimmed.is_empty() { + return; + } + parts.push(Partition { title, body: trimmed.to_string() }); } } @@ -387,41 +430,46 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - // ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ── + // ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明;纪律全文,领单收敛目录行) ── #[tokio::test] async fn both_files_devflow_priority() { let dir = temp_project_dir(); - write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]"); + write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单\n- 领单后改 [→]"); write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目"); let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await; - let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入"); - let claim_pos = out.find("领单后改").expect("领单分区须注入"); + let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须全文注入"); + let idx_pos = out.find("## 领单 |").expect("领单分区须收敛为目录行"); let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入"); - assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)"); + assert!(dev_pos < idx_pos && idx_pos < claude_pos, "顺序:纪律全文 → 目录行 → CLAUDE.md"); assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级"); assert!(out.contains("### 规范纪律(devflow.md)")); - assert!(out.contains("### 领单纪律(devflow.md)")); + assert!(out.contains("### 其他分区目录(devflow.md)")); + assert!(out.contains("read_file"), "目录行须带 read_file 定位提示"); assert!(out.contains("### 项目规范(CLAUDE.md)")); std::fs::remove_dir_all(&dir).ok(); } - // ── 超长裁剪:分区上限 + 总上限生效并注明 ── + // ── 超长裁剪:总闸 3000 收口生效;分区上限与摘要上限机制仍在 ── #[test] fn caps_truncate_with_marker() { - let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100); - let section = build_section(&long, "", None).expect("非空输入必有段"); - let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or(""); - assert!(discipline_part.contains("超上限已截断"), "分区超限须注明"); - let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap(); - assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限"); - - // 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸 - let discipline = "a".repeat(DISCIPLINE_MAX_CHARS); - let claiming = "b".repeat(CLAIM_MAX_CHARS); + // 总闸:纪律 4000 压线 + CLAUDE.md 超量,叠加必超 3000 总闸 + let discipline = Partition { title: "规范纪律".to_string(), body: "a".repeat(DISCIPLINE_MAX_CHARS) }; let claude = "c".repeat(CLAUDE_MD_MAX_CHARS); - let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段"); + let section = build_section(&[discipline], Some(&claude), None).expect("非空输入必有段"); assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len()); assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明"); + + // 分区上限机制(cap_chars 纯函数):超限截断并注明 + let capped = cap_chars(&"a".repeat(50), 10); + assert!(capped.contains("超上限已截断"), "分区超限须注明"); + + // 目录行摘要上限:只取首行,超 100 字符截断注省略号 + let body = format!("- {}\n- 第二行不进摘要", "词".repeat(101)); + let line = render_index_line(&Partition { title: "领单".to_string(), body }, None); + assert!(line.contains('…'), "摘要超限须截断注省略号"); + assert!(!line.contains("第二行"), "目录行只取首行摘要"); + let summary = line.split(" | ").nth(1).unwrap_or(""); + assert!(summary.chars().count() <= INDEX_SUMMARY_MAX_CHARS + 1, "摘要 ≤100 字符(+省略号)"); } // ── 稳定尾段:前段逐字保留,投影段独占尾部 ── @@ -454,14 +502,12 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - // ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ── + // ── 分区解析:全标题块入分区 / 子标题保留 / 纪律恒开新块 / 扁平回退 ── #[test] fn partition_extraction() { let content = "\ # 项目认知 -前言段落应被裁掉。 - ## 规范纪律 - 纪律条目一 @@ -469,26 +515,41 @@ mod tests { - 子标题下的纪律条目 ## 看板摘要 -- 未命中关键字的块应被裁掉 +- 看板条目应收敛为目录行 -## 领单纪律 +## 领单 - 领单条目一 "; - let (dev, claim) = extract_partitions(content); - assert!(dev.contains("纪律条目一"), "规范纪律块须命中"); - assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留"); - assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉"); - assert_eq!(claim, "- 领单条目一", "领单分区须独立提取"); + let parts = extract_partitions(content); + assert_eq!(parts.len(), 3, "空白正文标题块跳过,三个实体分区入场"); + assert_eq!(parts[0].title, "规范纪律"); + assert!(parts[0].body.contains("纪律条目一"), "纪律分区正文命中"); + assert!(parts[0].body.contains("子标题下的纪律条目"), "块内子标题结构须保留"); + assert!(!parts[0].body.contains("看板条目"), "后续分区不得并入纪律正文"); + assert!(is_discipline_heading(&parts[0].title), "纪律关键字命中 → 全文类"); + assert_eq!((parts[1].title.as_str(), parts[2].title.as_str()), ("看板摘要", "领单")); + assert!( + !is_discipline_heading("领单") && !is_discipline_heading("看板摘要"), + "非关键字分区 → 目录类" + ); - // 扁平文件(无命中标题)回退:整文件按规范纪律 - let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n"); - assert_eq!(dev, "- 扁平规则一\n- 扁平规则二"); - assert!(claim.is_empty()); + // 扁平文件(无任何标题)回退:整文件按规范纪律(标题空串) + let parts = extract_partitions("- 扁平规则一\n- 扁平规则二\n"); + assert_eq!(parts.len(), 1); + assert!(parts[0].title.is_empty()); + assert_eq!(parts[0].body, "- 扁平规则一\n- 扁平规则二"); // 深层标题起始的块:同级才截断 - let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n"); - assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断"); - assert!(!dev.contains("其他块内容")); + let parts = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n"); + assert_eq!(parts.len(), 2, "深层起始块到同级标题截断,各自成块"); + assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("领单", "其他")); + + // 纪律块防吞并:浅层非纪律容器标题不得吞并后续纪律分区(全文底线); + // 纪律块内的更深纪律子标题仍属子结构(见首例「### 提交规范」) + let parts = extract_partitions("# 总标题\n前言一句\n\n## 规范纪律\n- 纪律正文\n"); + assert_eq!(parts.len(), 2, "容器块与纪律块各自独立"); + assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("总标题", "规范纪律")); + assert_eq!(parts[1].body, "- 纪律正文", "纪律正文不得被容器吞并"); } // ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ── @@ -502,15 +563,34 @@ mod tests { assert_eq!(work_dir_of(&empty), None, "无授权目录返 None"); } - // ── 构建纯函数:全空 None / 单区缺省不产生空小节 ── + // ── 构建纯函数:全空 None / 目录行形态与总引导 / 纪律全文直入 / 空区不产生空小节 ── #[test] fn build_section_edge_cases() { - assert!(build_section("", "", None).is_none(), "三路全空须 None"); - assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失"); - let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段"); - assert!(only_claim.contains("### 领单纪律(devflow.md)")); - assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节"); - assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级"); + assert!(build_section(&[], None, None).is_none(), "三路全空须 None"); + assert!(build_section(&[], Some(" "), None).is_none(), "空白 CLAUDE.md 视同缺失"); + let only_idx = build_section( + &[Partition { title: "领单".to_string(), body: "- 领单规则".to_string() }], + None, + Some("E:/p/devflow.md"), + ) + .expect("目录分区非空须有段"); + assert!(only_idx.contains("### 其他分区目录(devflow.md)")); + assert!( + only_idx.contains("## 领单 | - 领单规则 (全文按需: read_file E:/p/devflow.md 定位「领单」)"), + "目录行形态:标题 + 首行摘要 + read_file 定位" + ); + assert!(only_idx.ends_with(INDEX_TAIL_HINT), "收敛在场须带尾部总引导"); + assert!(!only_idx.contains("### 规范纪律"), "空纪律区不得产生空小节"); + assert!(!only_idx.contains("冲突以"), "单文件不得注明双文件优先级"); + + let disc = build_section( + &[Partition { title: "自主修复纪律".to_string(), body: "- 明确 bug 直接修".to_string() }], + None, + None, + ) + .expect("纪律分区非空须有段"); + assert!(disc.contains("### 规范纪律(devflow.md)\n- 明确 bug 直接修"), "纪律正文全文直入"); + assert!(!disc.contains(INDEX_TAIL_HINT), "无收敛分区时不得出现总引导"); } // ============================================================ diff --git a/src-tauri/src/commands/ai/event_bus.rs b/src-tauri/src/commands/ai/event_bus.rs index 3240173..72dcba9 100644 --- a/src-tauri/src/commands/ai/event_bus.rs +++ b/src-tauri/src/commands/ai/event_bus.rs @@ -385,7 +385,7 @@ mod tests { let events = vec![ serde_json::json!({"type": "approval_required", "conversation_id": "c1"}), serde_json::json!({"type": "compressed", "conversation_id": "c1"}), - serde_json::json!({"type": "dir_auth_required", "conversation_id": "c1"}), + serde_json::json!({"type": "max_rounds", "conversation_id": "c1"}), serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}), ]; for event in &events { diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 17f5a19..e885ad2 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -45,6 +45,7 @@ pub mod fetch_search; pub mod download_file; pub(crate) mod generate_image; pub mod get_app_config; +pub mod knowledge_distill; pub mod knowledge_inject; // 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知 pub mod lease; @@ -178,7 +179,11 @@ pub enum AiChatEvent { /// 需要人工审批 /// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff), /// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。 - AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option, conversation_id: Option }, + AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option, conversation_id: Option, + /// 后端超时收敛到期时刻(ms Unix 时间戳)。前端审批倒计时单一真值(前端已无本地自动拒绝定时器,2026-10-25)。 + /// None = 超时被禁用(approval_timeout_minutes=0),前端不显倒计时。 + #[serde(default)] + expires_at_millis: Option }, /// 审批结果 AiApprovalResult { id: String, approved: bool, conversation_id: Option }, /// AI 响应完成 @@ -288,19 +293,6 @@ pub enum AiChatEvent { /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。 AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option }, - /// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。 - /// - /// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。 - /// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并), - /// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。 - /// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。 - AiDirAuthRequired { - id: String, - tool: String, - path: String, - dir: String, - conversation_id: Option, - }, /// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记), /// 前端可据此刷新消息视图(归档段折叠/隐藏)。 AiContextCleared { conversation_id: Option }, @@ -698,16 +690,11 @@ pub struct AiSession { pub active_conversation_id: Option, /// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at) pub active_conv_created_at: Option, - /// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。 + /// 挂起的审批(单真相源单表,键=tool_call_id,`conversation_id` 是业务语义)。 /// - /// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap, - /// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`); - /// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单), - /// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。 - /// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。 - /// - /// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。 - /// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。 + /// path_auth 审批链已退役(2026-10-25):原 Path/Risk 双 kind 合并设计回退为单 Risk kind, + /// `ai_approve` 是唯一决策入口。`ApprovalKind::Path` 变体已删,DB pending_approvals JSON 列 + /// 残留的 `kind:Path` 老行反序列化失败即整体跳过(切会话侧 warn 非阻断,等价挂起丢弃)。 pub pending_approvals: HashMap, /// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经 /// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。 @@ -1314,10 +1301,8 @@ impl Default for PerConvState { /// 待审批的工具调用 /// -/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk), -/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 + -/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir -/// 各只消费自己 kind,入口校验防误调)。 +/// `kind: ApprovalKind` 保留字段壳:原 Path/Risk 双 kind 设计随 path_auth 退役 +/// (2026-10-25)回退为单 Risk kind,字段保留作 serde 兼容与未来扩展。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingApproval { pub tool_call_id: String, @@ -1330,8 +1315,7 @@ pub struct PendingApproval { /// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。 #[serde(default)] pub created_at: Option, - /// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。 - /// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。 + /// 审批类型。path_auth 退役后仅剩 `ApprovalKind::Risk` 单变体(Path 变体已删)。 pub kind: ApprovalKind, /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。 /// @@ -1349,23 +1333,18 @@ pub struct PendingApproval { pub retry_count: u32, } -/// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。 -/// -/// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。 -/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。 -/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览; -/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。 +/// 审批类型枚举。path_auth 退役(2026-10-25)后仅剩 Risk 单变体, +/// 原 `Path(PathAuthRequest)` 变体已删(路径授权挂起链整体退役)。 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ApprovalKind { - /// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。 - Path(PathAuthRequest), - /// 普通 RiskLevel 审批(Med/High 风险工具)。 + /// 普通 RiskLevel 审批(Med/High 风险工具),由 `ai_approve` 消费(一次性 approve/reject)。 /// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。 - #[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·)·cargo Rust never-read 误报 Risk { diff: Option }, } -/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。 +/// 路径授权挂起请求载荷。【已退役】ApprovalKind::Path 变体已删,本结构仅作 +/// path_auth.rs 壳(恢复点)的 NeedsAuth 载荷类型保留,运行时无消费者。 +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PathAuthRequest { /// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。 diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index f2115e3..0cf088a 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -127,7 +127,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - {}\n\ - {}\n\ - {}\n\ + - {}\n\ - Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\ + - Never estimate or mention work-hours/duration/man-days — this is an AI-coding product where human + AI ship together, so traditional effort estimates are meaningless; express workload as change surface (files/lines/batches) instead.\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\ @@ -137,6 +139,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { duplicate_call_guidance(lang), file_reading_guidance(lang), verification_fast_check_guidance(lang), + history_search_guidance(lang), ), "\n## Current Projects\n", "\n## Current Tasks\n", @@ -161,7 +164,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - {}\n\ - {}\n\ - {}\n\ + - {}\n\ - 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\ + - 禁止预估或提及工时/耗时/人天——本产品是 AI Coding 场景,人与 AI 协作完成开发,传统工时估算无意义;需要表达工作量时用改动面(涉及文件数/代码行数/拆几批)描述。\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ @@ -171,6 +176,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { duplicate_call_guidance(lang), file_reading_guidance(lang), verification_fast_check_guidance(lang), + history_search_guidance(lang), ), "\n## 当前项目\n", "\n## 当前任务\n", @@ -197,6 +203,10 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { /// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与 /// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段 /// ([`sub_agent_tool_discipline_section`])只拼核心块。 +/// +/// **渐进披露(D1)后角色变更**:本函数 = 完整段,不再无条件常驻 system prompt—— +/// 常驻改拼路标短段([`doc_strategy_signpost_section`]),完整段仅当 +/// [`doc_strategy_needed`] 命中时按需附到易变侧(全文 ~12K 字符按需才进上下文)。 fn doc_exploration_strategy_section(lang: &str) -> String { format!( "{}{}", @@ -205,6 +215,44 @@ fn doc_exploration_strategy_section(lang: &str) -> String { ) } +/// 判定本轮 user 文本是否需要附带完整文档探索策略(渐进披露门,纯函数无 IO)。 +/// +/// 命中任一即附带:含 URL(http:// / https:// / www.)或文档关键词 +/// (「文档」「docs」「documentation」)。ASCII 一律不分大小写(抹平 Docs/DOCS/WWW. +/// 等书写差异);「官方文档」不必单查——含「官方文档」必含「文档」,单查子串即等价覆盖。 +/// 放近使用处(prompt.rs 组装链),不抽 trait 不过度抽象。 +pub(crate) fn doc_strategy_needed(user_text: &str) -> bool { + let lower = user_text.to_ascii_lowercase(); + lower.contains("http://") + || lower.contains("https://") + || lower.contains("www.") + || user_text.contains("文档") + || lower.contains("docs") + || lower.contains("documentation") +} + +/// 文档探索策略路标段(中/英,常驻 system prompt 的收敛短段,≤1200 字符)。 +/// +/// 渐进披露(D1):原完整段 ~12K 字符逐轮常驻烧上下文,而多数轮次并不涉及文档任务。 +/// 常驻只留路标——触发场景(URL/文档站/外部文档类问题)+ 三个工具名(fetch_url / +/// fetch_search / obscura)+「完整策略按需自动附带」一句引导,让 LLM 知道策略存在、 +/// 用对工具;完整段([`doc_exploration_strategy_section`])由组装链按 +/// [`doc_strategy_needed`] 命中附到易变侧。策略正文零改动,只动拼接结构。 +fn doc_strategy_signpost_section(lang: &str) -> &'static str { + match lang { + "en" => "\n\ + ## Documentation Exploration Strategy (signpost)\n\ + When the user message contains a URL, a docs site link, or an external-docs/integration question, you are in a documentation exploration scenario: pick the right URL tool first, never blind-fire requests at raw URLs.\n\ + Tool entries: `fetch_url` reads a URL into markdown (`render=true` goes through the obscura JS renderer); `fetch_search` queries a search engine for unknown URLs; obscura is the JS-render backend — when not installed, `fetch_url` silently falls back to static and returns an install `hint` field.\n\ + For documentation tasks, follow the full documentation exploration strategy (attached on demand): URL tool division rules, sniff → identify → extract → verify → apply flow, never fabricate URLs, switch tactics after 2 `render=true` failures.\n\n", + _ => "\n\ + ## 文档探索策略(路标)\n\ + 用户消息含 URL、文档站链接或外部文档/接入类问题时,即进入文档探索场景:先选对 URL 工具再动手,严禁对原始 URL 盲发请求。\n\ + 工具入口:`fetch_url` 读 URL 转 markdown(`render=true` 走 obscura JS 渲染);`fetch_search` 未知 URL 先搜索引擎拿候选;obscura 是 JS 渲染后端——未安装时 `fetch_url` 静态回退并返回安装引导 `hint` 字段。\n\ + 遇到文档类任务,遵循完整文档探索策略(按需自动附带):URL 三工具分工铁律、嗅探→识别→提取→验证→应用流程、严禁臆造 URL、`render=true` 失败 2 次必换方案。\n\n", + } +} + /// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段 /// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。 fn read_symbol_guidance(lang: &str) -> &'static str { @@ -242,9 +290,21 @@ fn verification_fast_check_guidance(lang: &str) -> &'static str { } } +/// 跨会话历史检索指引(中/英,不含行首 "- ")。P1-A(u-claw #6 等价):教 AI 跨会话 +/// 查「上次/之前怎么弄的」用 history_search,严禁 run_command 查 sqlite 代替(查配置 +/// 绕行的同款失败模式,先例 app_config_query_guidance_section 段式:正向指令+禁令+替代路径)。 +/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。 +fn history_search_guidance(lang: &str) -> &'static str { + match lang { + "en" => "**Cross-session history search**: when the user refers to a previous task, conclusion, or approach (\"last time\", \"earlier\", \"history\"), use history_search to search past conversations (auto keyword extraction; the first hop returns one snippet per conversation, pass conversation_id for a deep dive; omit query to list recent conversations). Never use run_command against the sqlite database file as a substitute; compressed early history of a hit conversation can be read back via read_file from the archive path (app data dir, context-archive/-archive.jsonl)", + _ => "**跨会话历史检索**:用户提到「上次/之前/历史」的任务、结论或做法时,用 history_search 检索历史会话(自动分词;首跳返回每会话一条命中片段,深读再传 conversation_id;query 省略时列最近会话概览)。严禁用 run_command 查 sqlite 数据库文件代替本工具;命中会话被压缩的早期原文可按存档路径(应用数据目录 context-archive/-archive.jsonl)用 read_file 回读", + } +} + /// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 —— /// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败 -/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具, +/// 2 次必换方案 / 查配置用 get_app_config / 跨会话查历史用 history_search。 +/// 子代理白名单缺省下发全量 Low 工具, /// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。 /// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与 /// 用户交互类行为准则(执行前说明意图/向用户提问)。 @@ -255,11 +315,13 @@ pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String { - {}\n\ - {}\n\ - {}\n\ + - {}\n\ {}{}", read_symbol_guidance(lang), duplicate_call_guidance(lang), file_reading_guidance(lang), verification_fast_check_guidance(lang), + history_search_guidance(lang), doc_strategy_core_section(lang), app_config_query_guidance_section(lang), ) @@ -452,11 +514,14 @@ async fn entity_fingerprint(db: &Arc, sql: &'static str) -> Option Option { let date = chrono::Local::now().format("%Y-%m-%d").to_string(); let custom = state @@ -489,6 +554,7 @@ async fn system_prompt_fingerprint( key.push_str("|t:"); key.push_str(id); } + key.push_str(if doc_full { "|d:1" } else { "|d:0" }); Some(key) } @@ -511,7 +577,8 @@ async fn system_prompt_fingerprint( /// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。 #[allow(dead_code)] pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { - build_system_prompt_with_excluded(state, lang, &[], &[]).await + // 无 user 文本上下文的预留 API:门判定退化为不命中(仅路标常驻,全文不附)。 + build_system_prompt_with_excluded(state, lang, &[], &[], "").await } /// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。 @@ -527,14 +594,21 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String /// /// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。 /// - 注入克制策略不变:仍各 take(20)。 +/// +/// **渐进披露(D1)**:`user_text` 为本轮用户文本,过 [`doc_strategy_needed`] 门—— +/// 常驻只拼路标短段,命中时完整段紧随其后(本函数 = 缓存关单串形态,无 stable/volatile +/// 拆分,全文随串整体逐轮重建,无缓存污染问题;BE3 会话内缓存指纹已纳入命中位)。 +/// 传空串(调用方无 user 上下文)等价于不命中:仅路标,全文不附。 pub(crate) async fn build_system_prompt_with_excluded( state: &AppState, lang: &str, excluded_project_ids: &[String], excluded_task_ids: &[String], + user_text: &str, ) -> String { + let doc_full = doc_strategy_needed(user_text); // BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。 - let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await; + let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids, doc_full).await; if let Some(key) = &fp_key { let cache = system_prompt_cache(); if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) { @@ -544,8 +618,11 @@ pub(crate) async fn build_system_prompt_with_excluded( let (prefix, _, _) = system_prompt_parts(lang); let mut prompt = env_profile_line(); prompt.push_str(&prefix); - // 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试) - prompt.push_str(&doc_exploration_strategy_section(lang)); + // 文档探索策略段(渐进披露 D1):路标常驻;命中门时完整段紧随(治盲目 http_request 瞎试) + prompt.push_str(doc_strategy_signpost_section(lang)); + if doc_full { + prompt.push_str(&doc_exploration_strategy_section(lang)); + } // 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) prompt.push_str(app_config_query_guidance_section(lang)); @@ -733,6 +810,46 @@ pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool { !(v == "false" || v == "0") } +// ============================================================ +// P0-B 压缩水位判据配置(KV 双键,对齐 audit/bg_run.rs 惯例:bool 开关+数值参数,默认关) +// ============================================================ + +/// 水位判据开关(`df-ai-compress-watermark`,默认关):关=现行 0.6 体积判据逐字节不变; +/// 开=替换为水位主判据(history+sys > ratio×budget),体积兜底=build_for_request 硬门。 +/// 判据消费点=context_lifecycle.rs::compress_should_trigger。 +const WATERMARK_SWITCH_KEY: &str = "df-ai-compress-watermark"; +/// 水位阈值(`df-ai-compress-watermark-ratio`,默认 "0.70")。 +const WATERMARK_RATIO_KEY: &str = "df-ai-compress-watermark-ratio"; + +/// 缺省水位阈值(u-claw 同款 70%)。 +const DEFAULT_WATERMARK_RATIO: f32 = 0.70; +/// ratio 钳位下限:再低会显著早于现行 0.6 判据触发(过度压缩,与治偏频目标反向)。 +pub(crate) const MIN_WATERMARK_RATIO: f32 = 0.3; +/// ratio 钳位上限:headroom 红线(分析 §4.3)——触发点+单轮增长须 < available, +/// 0.95 保 ≥5% budget 余量。 +pub(crate) const MAX_WATERMARK_RATIO: f32 = 0.95; + +/// 水位阈值解析:缺失/解析失败/非正数(含 0、负值、NaN/inf)归默认 0.70 +/// (对齐 bg_run.rs parse_grace「0 视为配置错误」);合法值钳位 [0.3, 0.95] 防误配。 +fn parse_watermark_ratio(raw: Option) -> f32 { + raw.and_then(|s| s.trim().parse::().ok()) + .filter(|r| r.is_finite() && *r > 0.0) + .map_or(DEFAULT_WATERMARK_RATIO, |r| { + r.clamp(MIN_WATERMARK_RATIO, MAX_WATERMARK_RATIO) + }) +} + +/// 读取压缩水位判据配置(KV 双键一次读齐):`(开关, 阈值)`。 +/// 默认 `(false, 0.70)`=现行判据逐字节不变(灰度:默认关,狗粮自开 KV 观察后再拍翻转)。 +pub(crate) async fn compress_watermark_cfg(state: &AppState) -> (bool, f32) { + let on = state + .settings + .get_bool_norm(WATERMARK_SWITCH_KEY, false) + .await; + let ratio = parse_watermark_ratio(state.settings.get_string_norm(WATERMARK_RATIO_KEY).await); + (on, ratio) +} + /// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。 /// /// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。 @@ -762,32 +879,64 @@ pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool { /// /// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」 /// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。 +/// +/// **渐进披露(D1)**:本签名保留给**拿不到本轮 user 文本**的调用方(如 agentic 续跑 +/// agentic/helpers.rs,本任务禁改该文件)——门判定退化为不命中:路标常驻、完整段不附。 +/// 有 user 文本的调用方(chat.rs send/regenerate/edit/force_send)改走 +/// [`build_system_prompt_parts_with_user`]。 pub(crate) async fn build_system_prompt_parts( state: &AppState, lang: &str, excluded_project_ids: &[String], excluded_task_ids: &[String], cache_enabled: bool, +) -> (String, String) { + build_system_prompt_parts_with_user(state, lang, excluded_project_ids, excluded_task_ids, "", cache_enabled).await +} + +/// [`build_system_prompt_parts`] 的 user 感知版(渐进披露 D1 主入口)。 +/// +/// 差异仅在文档策略完整段的归属:`doc_strategy_needed(user_text)` 命中时完整段 +/// **附到易变段**(不进 system 稳定段)——全文随本轮用户消息而变,留稳定段会逐轮失效 +/// system 缓存;进易变尾段与知识/augmentation 同理(拼消息流末尾,分段符 `\n\n---\n` +/// 对齐 chat.rs `append_volatile` 风格)。开关关单串路径委托 +/// [`build_system_prompt_with_excluded`](全文紧随路标,见其文档注释)。 +pub(crate) async fn build_system_prompt_parts_with_user( + state: &AppState, + lang: &str, + excluded_project_ids: &[String], + excluded_task_ids: &[String], + user_text: &str, + cache_enabled: bool, ) -> (String, String) { if !cache_enabled { - let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await; + let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids, user_text).await; return (full, String::new()); } - // 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义), - // 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。 + // 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索路标 + 查配置引导 + 自定义), + // 易变段(日期 + 项目/任务清单 + 按需的文档策略全文)挪到消息流末尾,不失效 system 缓存。 let (prefix, _, _) = system_prompt_parts(lang); let mut stable = env_profile_stable_body(); stable.push_str(&prefix); - stable.push_str(&doc_exploration_strategy_section(lang)); + stable.push_str(doc_strategy_signpost_section(lang)); stable.push_str(app_config_query_guidance_section(lang)); stable.push_str(&build_custom_section(state).await); let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await; - let volatile = if lists.is_empty() { + let mut volatile = if lists.is_empty() { env_profile_date_volatile() } else { format!("{}\n\n{}", env_profile_date_volatile(), lists) }; + // 文档策略全文按需附易变侧(D1):命中才带,非命中轮次省 ~12K 字符上下文。 + if doc_strategy_needed(user_text) { + let full = doc_exploration_strategy_section(lang); + volatile = if volatile.is_empty() { + full + } else { + format!("{}\n\n---\n{}", volatile, full) + }; + } (stable, volatile) } @@ -894,6 +1043,39 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str { mod tests { use super::*; + // 渐进披露(D1)门判定:URL 三形态 + 文档关键词命中;纯闲聊不命中;ASCII 不分大小写 + #[test] + fn doc_strategy_needed_gate() { + assert!(doc_strategy_needed("帮我接入 https://docs.example.com/v1/api")); + assert!(doc_strategy_needed("看下 http://example.com 这页写了啥")); + assert!(doc_strategy_needed("抓取 WWW.EXAMPLE.COM 首页")); // www. 大写命中 + assert!(doc_strategy_needed("按照官方文档配置 provider")); // 「文档」子串覆盖「官方文档」 + assert!(doc_strategy_needed("read the DOCS before coding")); // DOCS 大写命中 + assert!(doc_strategy_needed("check the Documentation page")); + assert!(!doc_strategy_needed("把任务标题改成部署清单")); + assert!(!doc_strategy_needed("fix the failing unit test")); + assert!(!doc_strategy_needed("")); // 空 user 文本(无上下文调用方)= 不命中 + } + + // 渐进披露(D1)路标段规格:≤1200 字符 + 三工具名 + 「按需附带」引导句(中/英) + #[test] + fn doc_strategy_signpost_section_shape() { + for lang in ["zh-CN", "en"] { + let s = doc_strategy_signpost_section(lang); + assert!( + s.chars().count() <= 1200, + "路标段超 1200 字符({}): {}", + lang, + s.chars().count() + ); + assert!(s.contains("fetch_url")); + assert!(s.contains("fetch_search")); + assert!(s.contains("obscura")); + } + assert!(doc_strategy_signpost_section("zh-CN").contains("按需自动附带")); + assert!(doc_strategy_signpost_section("en").contains("attached on demand")); + } + // 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段 #[test] fn system_prompt_zh_has_focus_section() { @@ -1118,6 +1300,35 @@ mod tests { assert!(en.contains("Do **NOT**")); } + // P1-A 跨会话历史检索指引:中/英双语在位(工具名/禁令/两跳用法/压缩原文回读路径), + // 且父会话 prefix 与子代理工具纪律段同源(工具下发与纪律下发不脱钩)。 + #[test] + fn history_search_guidance_present_zh_en_and_shared_with_sub_agent() { + let zh = history_search_guidance("zh-CN"); + assert!(zh.contains("history_search")); + assert!(zh.contains("跨会话历史检索")); + assert!(zh.contains("严禁用 run_command 查 sqlite"), "禁令必须在位: {zh}"); + assert!(zh.contains("conversation_id"), "两跳用法必须在位: {zh}"); + assert!(zh.contains("context-archive/-archive.jsonl"), "压缩原文回读路径必须在位: {zh}"); + + let en = history_search_guidance("en"); + assert!(en.contains("history_search")); + assert!(en.contains("Cross-session history search")); + assert!(en.contains("Never use run_command against the sqlite database file")); + assert!(en.contains("conversation_id")); + + // 父会话 prefix(中/英)与子代理工具纪律段同源拼接 + let (prefix_zh, _, _) = system_prompt_parts("zh-CN"); + assert!(prefix_zh.contains(history_search_guidance("zh-CN")), "父会话行为准则须含指引"); + let (prefix_en, _, _) = system_prompt_parts("en"); + assert!(prefix_en.contains(history_search_guidance("en")), "EN prefix must carry the guidance"); + let sub_zh = sub_agent_tool_discipline_section("zh-CN"); + assert!( + sub_zh.contains(history_search_guidance("zh-CN")), + "子代理工具纪律段须与父会话同源(工具下发了纪律必须同源下发)" + ); + } + // P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置), // 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。 #[test] @@ -1199,4 +1410,30 @@ mod tests { assert_eq!(got120.chars().count(), 121); assert!(got120.ends_with('…')); } + + // P0-B 水位阈值解析:缺失/非法/非正数归默认 0.70(对齐 parse_grace「0 视为配置错误」) + #[test] + fn watermark_ratio_defaults_on_missing_or_invalid() { + assert_eq!(parse_watermark_ratio(None), 0.70); + assert_eq!(parse_watermark_ratio(Some(String::new())), 0.70); + assert_eq!(parse_watermark_ratio(Some("abc".to_string())), 0.70); + assert_eq!(parse_watermark_ratio(Some("0".to_string())), 0.70, "0 视为配置错误归默认"); + assert_eq!(parse_watermark_ratio(Some("-0.5".to_string())), 0.70); + assert_eq!(parse_watermark_ratio(Some("NaN".to_string())), 0.70); + assert_eq!(parse_watermark_ratio(Some("inf".to_string())), 0.70); + } + + // P0-B 水位阈值钳位:合法值收进 [0.3, 0.95] 防误配,界内原样采纳 + #[test] + fn watermark_ratio_clamps_into_safe_range() { + assert_eq!(parse_watermark_ratio(Some("0.1".to_string())), 0.3, "过低钳到下限"); + assert_eq!(parse_watermark_ratio(Some("0.99".to_string())), 0.95, "过高钳到上限"); + assert_eq!(parse_watermark_ratio(Some("2".to_string())), 0.95); + assert_eq!(parse_watermark_ratio(Some("0.5".to_string())), 0.5); + assert_eq!(parse_watermark_ratio(Some("0.85".to_string())), 0.85); + assert_eq!(parse_watermark_ratio(Some(" 0.8 ".to_string())), 0.8, "容忍首尾空白"); + // 常量绑定:钳位边界即红线边界(防止常量被误改无人知) + assert_eq!(MIN_WATERMARK_RATIO, 0.3); + assert_eq!(MAX_WATERMARK_RATIO, 0.95); + } } diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index a20215c..928e6e5 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -18,7 +18,7 @@ //! | `stop` | `ai_chat_stop` | conversation_id? | //! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? | //! | `approve` | `ai_approve` | tool_call_id, approved | -//! | `authorize_dir` | `ai_authorize_dir` | tool_call_id, decision | +//! | `authorize_dir` | (已退役,path_auth 链下线) | — | //! | `continue_loop` | `ai_continue_loop` | conversation_id | //! | `stop_loop` | `ai_stop_loop` | conversation_id | //! | `switch_conversation` | (无,MVP 不处理) | — | @@ -63,7 +63,7 @@ use crate::state::AppState; // 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取), // 故 import 不含 Manager(避免 unused)。 use crate::commands::ai::{ - ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, + ai_approve, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event, record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval, @@ -240,22 +240,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State< } } - // ── authorize_dir:ai_authorize_dir(tool_call_id, decision) ── - "authorize_dir" => { - match ( - args_get_string(&command.args, "tool_call_id"), - args_get_string(&command.args, "decision"), - ) { - (Some(tool_call_id), Some(decision)) => { - let _ = ai_authorize_dir(app, state, tool_call_id, decision).await; - } - _ => { - tracing::warn!( - "[remote_bridge] authorize_dir 缺 tool_call_id 或 decision 参数,忽略" - ); - } - } - } + // ── authorize_dir:【已退役】path_auth 审批链下线(2026-10-25),命令静默忽略 ── // ── continue_loop:ai_continue_loop(conversation_id) ── "continue_loop" => { @@ -344,7 +329,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State< } // ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ── - // 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired, + // 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired, // 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令, // device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件, // miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。 @@ -523,7 +508,7 @@ async fn route_load_messages(state: &State<'_, AppState>, conversation_id: Strin /// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。 /// -/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件, +/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired 事件, /// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读 /// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent /// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。 @@ -551,6 +536,9 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String for p in approvals { let conv_id = Some(conversation_id.clone()); + // 到期时刻透传(前端倒计时单一真值=后端);created_at 缺失/禁用超时 → None 不显倒计时。 + let expires_at_millis = p.created_at.and_then(|c| super::audit::approval_expires_at_millis(state.inner(), c)); + // path_auth 退役(2026-10-25):单 Risk kind,直接重推 AiApprovalRequired。 let ev = match &p.kind { ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired { id: p.tool_call_id, @@ -559,17 +547,7 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String reason: "等待审批(重连恢复)".to_string(), diff: diff.clone(), conversation_id: conv_id, - }, - ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired { - id: p.tool_call_id, - tool: p.tool_name, - path: req.raw_paths.first().cloned().unwrap_or_default(), - dir: req - .dirs - .first() - .map(|d| d.to_string_lossy().to_string()) - .unwrap_or_default(), - conversation_id: conv_id, + expires_at_millis, }, }; let _ = state.ai_event_bus.publish_event(ev); @@ -1203,7 +1181,7 @@ fn args_get_parts(args: &Value, key: &str) -> Option> { // 覆盖: // 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败) // 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve / -// authorize_dir / continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略) +// (authorize_dir 已退役) continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略) // 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send) // // 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围, diff --git a/src-tauri/src/commands/ai/tool_failure_memory.rs b/src-tauri/src/commands/ai/tool_failure_memory.rs index c0314ee..112dfd6 100644 --- a/src-tauri/src/commands/ai/tool_failure_memory.rs +++ b/src-tauri/src/commands/ai/tool_failure_memory.rs @@ -141,12 +141,22 @@ mod tests { use super::*; use serde_json::json; + /// 进程级静态 memory() 被全部测试共享:并行运行时 A 测试的 record/clear 会清掉 + /// B 测试刚记录的计数(实测 precheck_blocks 偶发 unwrap None)。全 mod 测试统一 + /// 持本锁串行化,消除竞态。 + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn clear() { memory().lock().unwrap().clear(); } + fn serial() -> std::sync::MutexGuard<'static, ()> { + TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()) + } + #[test] fn precheck_blocks_after_two_failures() { + let _serial = serial(); clear(); let args = json!({"path": "/tmp/x.rs"}); assert!(precheck("read_file", &args).is_none()); @@ -166,6 +176,7 @@ mod tests { #[test] fn memory_key_normalizes_irrelevant_fields() { + let _serial = serial(); let a = json!({"path": "/a.rs", "limit": 10}); let b = json!({"path": "/a.rs", "limit": 999}); assert_eq!(memory_key("read_file", &a), memory_key("read_file", &b)); @@ -177,12 +188,14 @@ mod tests { #[test] fn run_command_key_is_command_itself() { + let _serial = serial(); let a = json!({"command": "cargo test", "working_dir": "E:/x"}); assert!(memory_key("run_command", &a).contains("cmd=cargo test")); } #[test] fn timeout_streak_aggregates_across_signatures_and_soft_warns() { + let _serial = serial(); clear(); timeout_streaks().lock().unwrap().clear(); // 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦; @@ -206,6 +219,7 @@ mod tests { #[test] fn non_timeout_failures_do_not_aggregate_to_timeout_streak() { + let _serial = serial(); clear(); timeout_streaks().lock().unwrap().clear(); for path in ["/a.rs", "/b.rs", "/c.rs"] { diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index e0801d6..aeab74b 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -158,8 +158,8 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> { anyhow::bail!("禁止访问敏感系统目录"); } // AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。 - // AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog - // → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist + // AppData 原走白名单流程(已随 path_auth 白名单机制退役,2026-10-25,现为默认放行)。 + // 真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist // 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治—— // 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。 Ok(()) @@ -559,9 +559,20 @@ pub fn build_ai_tool_registry( register_spawn_agent_tool(&mut registry, spawn_agent_ctx); register_uclaw_tool(&mut registry, db); super::lease::register_lease_tool(&mut registry); + register_history_tool(&mut registry, db); registry } +/// history_search AI 工具注册(1 个:跨会话历史检索,P1-A,u-claw #5/#6 等价)。 +/// +/// 工具职责:LLM 调 history_search(query, conversation_id?, limit?) 检索全部历史会话 +/// 消息正文(含已压缩原文,ai_messages=全量账本),两跳式瘦身(首跳每会话一条 120 字 +/// 片段,深查二跳传 conversation_id);query 空降级列最近 10 会话。风险 Low(纯只读)。 +/// 查询内核与 IPC search_messages 同源(commands/search.rs),声明式注册在 tools/history.rs。 +fn register_history_tool(registry: &mut AiToolRegistry, db: &Arc) { + super::tools::history::register(registry, db); +} + /// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。 /// /// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话), @@ -609,7 +620,7 @@ fn register_read_skill_tool(registry: &mut AiToolRegistry) { /// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原 /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count -/// 仍断言 48 总量 + 工具名集合稳定。 +/// 仍断言 81 总量 + 工具名集合稳定。 /// /// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给 /// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。 @@ -750,7 +761,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc) { /// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 + /// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更), /// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count -/// 仍断言 48 总量 + 工具名集合稳定。 +/// 仍断言 81 总量 + 工具名集合稳定。 fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::list_projects::register(registry, db); super::tools::project::register(registry, db); @@ -762,7 +773,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { /// /// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs), /// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 -/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 +/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。 fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::task::register(registry, db); } @@ -783,7 +794,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { /// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs), /// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 /// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试 -/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。 fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::task_graph::register(registry, db); } @@ -797,7 +808,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) /// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。 /// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 /// 本函数改 thin 委托 super::tools::git::register(...)。基线测试 -/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。 fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::git::register(registry, db); } @@ -808,7 +819,7 @@ fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc) { /// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原 /// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。 /// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试 -/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。 fn register_workflow_tools(registry: &mut AiToolRegistry) { super::tools::workflow::register(registry); } @@ -819,7 +830,7 @@ fn register_workflow_tools(registry: &mut AiToolRegistry) { /// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原 /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count -/// 仍断言 48 总量 + 工具名集合稳定。 +/// 仍断言 81 总量 + 工具名集合稳定。 fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::idea::register(registry, db); } @@ -845,7 +856,7 @@ fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc) { /// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原 /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count -/// 仍断言 48 总量 + 工具名集合稳定。 +/// 仍断言 81 总量 + 工具名集合稳定。 fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::trash::register(registry, db); } @@ -860,7 +871,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { /// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原 /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count -/// 仍断言 48 总量 + 工具名集合稳定。 +/// 仍断言 81 总量 + 工具名集合稳定。 fn register_file_tools( registry: &mut AiToolRegistry, allowed_dirs: &Arc>, @@ -1516,10 +1527,14 @@ mod tests { // W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面, // 注册在 lease.rs register_lease_tool)。 // 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。 + // history_search(2026-10-21): history 层 0→1(P1-A 跨会话历史检索,只读 Low, + // u-claw #5/#6 等价:关键词检索全部会话消息正文+无参列最近会话,两跳式瘦身, + // 查询内核与 IPC search_messages 同源,声明式注册 tools/history.rs)。 + // 81 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history。 assert_eq!( registry.len(), - 80, - "工具总数应为 80(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease),实际 {}", registry.len() + 81, + "工具总数应为 81(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 @@ -1595,6 +1610,9 @@ mod tests { "ask_uclaw", // ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs) "query_leases", + // ── history 层 (1) ──(P1-A 跨会话历史检索,只读 Low,u-claw #5/#6 等价, + // 注册在 tools/history.rs) + "history_search", ]; expected.sort_unstable(); diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 464496a..e9b7d61 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -1002,7 +1002,7 @@ pub fn register( registry, allowed_dirs: Arc>, "grep", - "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", + "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行", RiskLevel::Low, meta: ToolMeta { path_args: &["path"], @@ -1273,6 +1273,15 @@ pub fn register( let command = args["command"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))? .to_string(); + // 执行前最后闸(2026-10-25 走查补位):all 档/信任豁免下唯一保留硬线,与 approval/boundary 判定同源。 + // 审批链是前置闸(会话信任 check_trust_hits/boundary all 档都已各自拦截破坏性命令), + // 但兜底放行路径(如未来新增 auto 通道)若漏拦,此处是命令真正 spawn 前的最后防线。 + if crate::commands::ai::is_destructive_command(&command) { + return Err(anyhow::anyhow!( + "破坏性命令已拦截(执行层硬线):`{}` 属不可逆破坏类操作,拒绝执行。请改用安全方案", + command + )); + } // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { diff --git a/src-tauri/src/commands/ai/tools/history.rs b/src-tauri/src/commands/ai/tools/history.rs new file mode 100644 index 0000000..9001897 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/history.rs @@ -0,0 +1,363 @@ +//! history_search AI 工具声明式注册(跨会话历史检索,devflow 等价 u-claw #5/#6)。 +//! +//! 背景:P1-A 跨会话检索(分析:temp/分析-P1A-跨会话检索-2026-10-21.md)。ai_messages +//! 是全量账本(compressed 消息原文完整保留),检索面覆盖即全覆盖,零迁移零回填。 +//! 查询内核与 IPC search_messages 同源(commands/search.rs::keyword_search / +//! recent_conversation_briefs),本工具是 AI 通道的薄包装(DRY 无双轨)。 +//! +//! ## 两跳式瘦身(拍板②) +//! 首跳(不带 conversation_id)= 每会话一条 120 字片段 + 会话元信息的会话级概览; +//! 二跳传 conversation_id+query 深查该会话逐条命中(片段 300 字)。 +//! 预算纪律:工具结果整体对齐 2K 收敛(namespace_store.rs:32 NAMESPACE_BYTE_THRESHOLD= +//! 2048,超限由 namespace 引用化机制兜底转引用);首跳默认 5 会话 × 120 字天然在预算内, +//! 深跳若超预算走 namespace 引用化属机制设计路径,不做人为硬截。 +//! +//! ## 无参降级(拍板③) +//! query 空 = 列最近 10 会话概览(标题+摘要+时间,一工具两语义,u-claw #5 同行为); +//! 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字。 +//! +//! 风险:Low(纯只读),免审批,knowledge_search 同级。 + +use std::collections::HashSet; +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use df_storage::db::Database; + +use crate::commands::ai::audit::truncate_chars_ellipsis; +use crate::commands::ai::commands::search::{keyword_search, recent_conversation_briefs}; +use crate::commands::ai::knowledge_inject::extract_query_keywords; + +/// 首跳单会话片段长度(会话级概览,两跳式瘦身核心参数)。 +const FIRST_HOP_SNIPPET_CHARS: usize = 120; +/// 深跳逐条命中片段长度(单跳备选方案 limit5×300 字口径)。 +const DEEP_HOP_SNIPPET_CHARS: usize = 300; +/// 无参降级列最近会话条数(u-claw #5 口径)。 +const RECENT_CONVS_COUNT: usize = 10; +/// 工具 limit 默认与上限(与 knowledge_search 同手感)。 +const TOOL_DEFAULT_LIMIT: usize = 5; +const TOOL_MAX_LIMIT: usize = 10; +/// 首跳为凑满 limit 个会话多取的消息数(单会话多命中会挤占名额,6 倍起步封顶 60)。 +const FIRST_HOP_FETCH_CAP: usize = 60; + +/// 命中会话压缩原文回读指引(存档路径约定,与 archive.rs 召回句柄同款文案)。 +const ARCHIVE_RECALL_HINT: &str = + "被压缩的早期原文可按存档路径回读:应用数据目录 context-archive/-archive.jsonl(read_file)。"; + +/// 注册 history_search 工具。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + declare_tool!( + registry, + db: Arc, + "history_search", + "跨会话历史检索:在全部历史会话的消息正文(user/assistant/system,含已压缩原文)中按关键词检索。使用时机:用户提到「上次/之前/历史」的任务、结论或做法时调用,如「上次那个部署怎么弄的」「之前说过的 XX 约定」。两跳式:首跳不带 conversation_id,返回每会话一条命中片段(会话级概览,含 conversation_id);需要某会话更多上下文时二跳传 conversation_id 深查逐条命中。参数:query=关键词或自然语言(可省略,省略时降级列最近会话概览:标题+摘要+时间);conversation_id=限定会话深查(可选,须与 query 同传);limit=返回会话/消息条数(可选,默认 5,上限 10)", + RiskLevel::Low, + schema: object_schema(vec![ + ("query", "string", false), + ("conversation_id", "string", false), + ("limit", "integer", false), + ]), + args => { + let query = args["query"].as_str().unwrap_or("").trim().to_string(); + let conversation_id = args["conversation_id"] + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let limit = args["limit"] + .as_u64() + .unwrap_or(TOOL_DEFAULT_LIMIT as u64) + .clamp(1, TOOL_MAX_LIMIT as u64) as usize; + + // 无参降级(拍板③):query 空 → 列最近会话概览。conversation_id 与空 query + // 同传语义不明,显式报错防 LLM 猜。 + if query.is_empty() { + if conversation_id.is_some() { + anyhow::bail!("conversation_id 深查须与 query 同传;列最近会话请省略 query"); + } + let briefs = recent_conversation_briefs(&db, RECENT_CONVS_COUNT) + .await + .map_err(anyhow::Error::msg)?; + return Ok(serde_json::json!({ + "mode": "recent_conversations", + "conversations": briefs, + "hint": "以上为最近会话概览(标题+摘要+时间)。按关键词检索全部历史消息请传 query;深查某会话传 conversation_id+query。", + })); + } + + match conversation_id { + Some(conv_id) => { + // 二跳深查:会话内逐条命中(片段 300 字,超 2K 由 namespace 引用化兜底) + let hits = keyword_search(&db, &query, Some(&conv_id), limit, 0) + .await + .map_err(anyhow::Error::msg)?; + if hits.is_empty() { + return Ok(serde_json::json!({ + "mode": "messages", + "conversation_id": conv_id, + "results": [], + "hint": "该会话内无命中。可换关键词重试,或去掉 conversation_id 全局检索定位会话。", + })); + } + let title = hits[0].title.clone(); + let results: Vec = hits + .into_iter() + .map(|h| { + serde_json::json!({ + "seq": h.seq, + "role": h.role, + "timestamp": h.timestamp, + "snippet": truncate_chars_ellipsis(&h.snippet, DEEP_HOP_SNIPPET_CHARS), + }) + }) + .collect(); + Ok(serde_json::json!({ + "mode": "messages", + "conversation_id": conv_id, + "title": title, + "results": results, + "hint": ARCHIVE_RECALL_HINT, + })) + } + None => { + // 首跳:每会话一条首命中片段的会话级概览(拍板②两跳式瘦身)。 + // 命中已按 timestamp DESC,保序按会话去重即「每会话最新一条」。 + let fetch = (limit * 6).clamp(TOOL_DEFAULT_LIMIT * 6, FIRST_HOP_FETCH_CAP); + let hits = keyword_search(&db, &query, None, fetch, 0) + .await + .map_err(anyhow::Error::msg)?; + if hits.is_empty() { + // 分词后无有效关键词与「真无命中」分开给指引(前者换词,后者换法) + let hint = if extract_query_keywords(&query).is_empty() { + "query 分词后无有效关键词(全是停用词/标点),请换更具体的关键词(任务名/文件名/技术词)。" + } else { + "历史会话中无命中。可换关键词重试;或省略 query 列最近会话概览。" + }; + return Ok(serde_json::json!({ + "mode": "conversations", + "results": [], + "hint": hint, + })); + } + let mut seen: HashSet = HashSet::new(); + let results: Vec = hits + .into_iter() + .filter(|h| seen.insert(h.conversation_id.clone())) + .take(limit) + .map(|h| { + serde_json::json!({ + "conversation_id": h.conversation_id, + "title": h.title, + "updated_at": h.updated_at, + "origin": h.origin, + "snippet": truncate_chars_ellipsis(&h.snippet, FIRST_HOP_SNIPPET_CHARS), + }) + }) + .collect(); + Ok(serde_json::json!({ + "mode": "conversations", + "results": results, + "hint": format!("以上为每会话一条首命中片段(会话级概览)。需某会话更多上下文时传 conversation_id+query 深查逐条命中;{ARCHIVE_RECALL_HINT}"), + })) + } + } + } + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use df_storage::crud::{AiConversationRepo, AiMessageRepo}; + use df_storage::models::{AiConversationRecord, AiMessageRecord}; + use serde_json::json; + + async fn test_db() -> Arc { + Arc::new(Database::open_in_memory().await.unwrap()) + } + + async fn ins_conv(db: &Database, id: &str, title: &str, updated_at: &str) { + AiConversationRepo::new(db) + .insert(AiConversationRecord { + id: id.into(), + title: Some(title.into()), + messages: "[]".into(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: None, + completion_tokens: None, + pinned_goals: None, + pending_approvals: None, + created_at: "100".into(), + updated_at: updated_at.into(), + origin: None, + boundary_json: None, + loop_state: None, + status: "active".into(), + working_context_json: None, + }) + .await + .unwrap(); + } + + fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord { + AiMessageRecord { + id: format!("m_{conv}_{seq}"), + conversation_id: conv.into(), + seq, + role: role.into(), + content: content.into(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: "active".into(), + reasoning_content: None, + timestamp: Some(timestamp), + created_at: "100".into(), + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + is_estimated: None, + source: None, + } + } + + /// 首跳瘦身(拍板②):单会话多命中只出一条(会话级去重),片段截 120 字, + /// 带 conversation_id 供二跳深查;空结果给指引非裸空数组。 + #[tokio::test] + async fn first_hop_groups_per_conversation_with_slim_snippet() { + let db = test_db().await; + ins_conv(&db, "c1", "部署踩坑复盘", "200").await; + ins_conv(&db, "c2", "端口约定", "300").await; + AiMessageRepo::new(&db) + .insert_batch(vec![ + msg("c1", 0, "user", "部署第一次尝试失败", 100), + msg("c1", 1, "assistant", "部署第二次改用代理成功", 200), + msg("c2", 0, "user", "部署统一走 3307 端口", 300), + ]) + .await + .unwrap(); + + let mut registry = AiToolRegistry::new(); + register(&mut registry, &db); + let out = registry + .execute("history_search", json!({"query": "部署", "limit": 5})) + .await + .unwrap(); + + assert_eq!(out["mode"], "conversations"); + let results = out["results"].as_array().unwrap(); + assert_eq!(results.len(), 2, "两会话各一条,单会话多命中去重: {out}"); + let ids: Vec<&str> = results.iter().map(|r| r["conversation_id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["c2", "c1"], "timestamp DESC,c2 最新在前"); + for r in results { + let snippet = r["snippet"].as_str().unwrap(); + assert!( + snippet.chars().count() <= FIRST_HOP_SNIPPET_CHARS + 1, + "首跳片段应截 120 字(+省略号),实际 {}: {snippet}", + snippet.chars().count() + ); + } + let hint = out["hint"].as_str().unwrap(); + assert!(hint.contains("conversation_id"), "首跳应提示可二跳深查: {hint}"); + } + + /// 二跳深查:conversation_id+query 返回该会话逐条命中(mode=messages)。 + #[tokio::test] + async fn deep_hop_returns_message_level_hits() { + let db = test_db().await; + ins_conv(&db, "c1", "部署复盘", "200").await; + AiMessageRepo::new(&db) + .insert_batch(vec![ + msg("c1", 0, "user", "部署怎么弄", 100), + msg("c1", 1, "assistant", "部署分三步:构建/上传/重启", 200), + msg("c1", 2, "tool", "部署工具输出", 300), + ]) + .await + .unwrap(); + + let mut registry = AiToolRegistry::new(); + register(&mut registry, &db); + let out = registry + .execute( + "history_search", + json!({"query": "部署", "conversation_id": "c1"}), + ) + .await + .unwrap(); + + assert_eq!(out["mode"], "messages"); + assert_eq!(out["title"], "部署复盘"); + let results = out["results"].as_array().unwrap(); + assert_eq!(results.len(), 2, "深跳逐条命中但 tool role 仍排除: {out}"); + assert_eq!(results[0]["seq"], 1, "timestamp DESC,assistant 在前"); + } + + /// 无参降级(拍板③+⑤):query 空列最近会话,摘要走「system 压缩摘要 > 首条 user」 + /// 降级链;conversation_id 与空 query 同传显式报错。 + #[tokio::test] + async fn no_param_falls_back_to_recent_conversations_with_summary_chain() { + let db = test_db().await; + ins_conv(&db, "c_sum", "有摘要", "200").await; + ins_conv(&db, "c_user", "无摘要", "300").await; + AiMessageRepo::new(&db) + .insert_batch(vec![ + msg("c_sum", 0, "user", "c_sum 的用户消息不应入选", 1), + msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要优先", 2), + msg("c_user", 0, "user", "c_user 首条消息作摘要兜底", 3), + ]) + .await + .unwrap(); + + let mut registry = AiToolRegistry::new(); + register(&mut registry, &db); + let out = registry.execute("history_search", json!({})).await.unwrap(); + assert_eq!(out["mode"], "recent_conversations"); + let convs = out["conversations"].as_array().unwrap(); + assert_eq!(convs.len(), 2, "c_user(300)在前 c_sum(200)在后: {out}"); + let c_sum = convs.iter().find(|c| c["id"] == "c_sum").unwrap(); + assert!(c_sum["summary"].as_str().unwrap().contains("压缩摘要优先"), "摘要走 system 优先: {out}"); + let c_user = convs.iter().find(|c| c["id"] == "c_user").unwrap(); + assert!(c_user["summary"].as_str().unwrap().contains("首条消息"), "无 system 走首条 user 兜底: {out}"); + + // conversation_id 与空 query 同传 → 明确报错,不静默当列表用 + let err = registry + .execute("history_search", json!({"conversation_id": "c1"})) + .await + .unwrap_err(); + assert!(err.to_string().contains("query"), "报错应指引带 query: {err}"); + } + + /// 空命中给可执行指引(非裸空数组);分词无有效关键词与真无命中分开指引。 + #[tokio::test] + async fn empty_results_carry_actionable_hint() { + let db = test_db().await; + ins_conv(&db, "c1", "无关会话", "200").await; + AiMessageRepo::new(&db) + .insert_batch(vec![msg("c1", 0, "user", "完全无关的内容", 1)]) + .await + .unwrap(); + + let mut registry = AiToolRegistry::new(); + register(&mut registry, &db); + // 真无命中 + let out = registry + .execute("history_search", json!({"query": "量子纠缠实验"})) + .await + .unwrap(); + assert_eq!(out["results"].as_array().unwrap().len(), 0); + assert!(out["hint"].as_str().unwrap().contains("换关键词"), "真无命中指引: {out}"); + // 分词后无有效关键词(纯停用词) + let out2 = registry + .execute("history_search", json!({"query": "帮我看看一下这个"})) + .await + .unwrap(); + assert!(out2["hint"].as_str().unwrap().contains("关键词"), "分词空指引: {out2}"); + } +} diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 750a02a..44070f3 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -22,6 +22,9 @@ pub mod get_app_config; pub mod workflow; pub mod idea; pub mod knowledge; +// history_search 跨会话历史检索(P1-A,u-claw #5/#6 等价;查询内核与 IPC search_messages +// 同源在 commands/search.rs,本文件是 AI 通道薄包装)。 +pub mod history; pub mod trash; pub mod file; pub mod download_file; diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index f41b6df..cb1acee 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -224,6 +224,10 @@ pub fn start_if_enabled(app: tauri::AppHandle) { // 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻, // 与 run_loop 的按连接重建无关) tauri::async_runtime::spawn(run_approval_notifier(ctx.clone())); + // 换代通知(P1-B 方案甲,2026-10-21):停靠/压缩换代事件推绑定 chat,同款生命周期 + // 订阅任务。此前两事件已广播(emit_ai_chat_event 双写底座)但本文件零消费, + // 停靠/换代对飞书侧完全无感知 + tauri::async_runtime::spawn(run_generation_notifier(ctx.clone())); // 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死 // 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。 // 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻), @@ -1168,7 +1172,7 @@ async fn handle_message( false, // 不劫持用户活跃面板(编排节点同款语义) // #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源 Some(df_ai::provider::MessageSource { - channel: "feishu".into(), + channel: SOURCE_CHANNEL_FEISHU.into(), message_id: message_id.clone(), chat_id: Some(chat_id.clone()), sender: if open_id.is_empty() { None } else { Some(open_id.clone()) }, @@ -1294,6 +1298,18 @@ fn delivery_blocked(path: &str) -> Option<&'static str> { .map(|_| "敏感文件(凭据/密钥特征)") } +/// 入站消息渠道标识(source.channel 值,#958 溯源同参;spawn 链渠道段判定用) +pub(crate) const SOURCE_CHANNEL_FEISHU: &str = "feishu"; + +/// 交付引导(#958 生产端配套,2026-10-21 修「devflow 不会用飞书发文件」):出站回传消费端 +/// (extract_delivery_paths/reply_deliveries/delivery_blocked)早已在位,但全仓 prompt 层 +/// 零处教 AI 写 `[交付]` 标记行——AI 永远不会自发写标记,链路空转。本段经 spawn 链渠道 +/// 标识透传(source.channel==feishu)注入飞书渠道会话的 AI 语境(落点 chat.rs +/// spawn_agentic_session 稳定段之后,与 aug_seg 同构分流)。文案与解析器是同一契约, +/// 示例格式必须能被 extract_delivery_paths 原样提取(单测 delivery_guidance 锁死); +/// 敏感拦截只一句交底不重复造(拦截器本尊在 delivery_blocked)。 +pub(crate) const DELIVERY_GUIDANCE_PROMPT: &str = "(渠道提示)如需给用户发送本机文件:在报告正文写 `[交付] <文件的本机绝对路径>` 标记行,每个文件单独一行;渠道会自动把这些文件上传为飞书原生附件发给用户,无需其他操作。名字含凭据/密钥特征(如 .env、token、secret、password)的文件会被拦截拒发,不要尝试交付敏感文件。"; + /// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举) fn feishu_file_type(ext: &str) -> &'static str { match ext { @@ -1441,6 +1457,10 @@ async fn run_completion_watcher( if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await { if !t.trim().is_empty() { feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len())); + // #958 出站回传补口(2026-10-21):快任务兜底此前早退直投终版,漏过主循环 + // 统一出口的 reply_deliveries——秒级任务报告里的 [交付] 标记行全部落空。 + // 迟到终版(35min 降级 continue 后到达)走 rx.recv() 臂汇入主出口,无缺口 + reply_deliveries(&ctx.client, &chat_id, &t).await; let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await; let _ = stream_task.await; return; @@ -1609,6 +1629,148 @@ async fn run_approval_notifier(ctx: Arc) { } } +/// 换代通知聚合窗(P1-B 方案甲 §1.4 节流从严):首条换代事件开 30s 窗,窗尾同 chat 合并 +/// 一卡——多会话并行时同窗多代,逐条直推刷屏 +const COMPRESS_NOTIFY_WINDOW: std::time::Duration = std::time::Duration::from_secs(30); + +/// 停靠原因类别中文映射(stop_kind 词表 7 值,ai/mod.rs tests_task_parked 词表锁定; +/// 未知/缺省值走兜底,新增词表值须同步此处) +fn stop_kind_zh(kind: &str) -> &'static str { + match kind { + "approval_timeout" => "审批超时", + "cancelled" => "已取消", + "circuit_breaker" => "连续失败熔断", + "stall_breaker" => "进展停滞熔断", + "max_rounds" => "轮次预算用尽", + "fatal" => "致命错误", + "midstream" => "中途断流", + _ => "未知原因", + } +} + +/// 停靠通知卡文案(纯函数,单测锁死三要素:类别+原因+恢复指引)。恢复指引只交底真实 +/// 出路:桌面停靠卡「继续」或直接发新指令接力(飞书侧无 /continue 命令,文案不承诺) +fn park_notify_text(stop_kind: &str, reason: &str) -> String { + format!( + "⏸ 任务已停靠({}):{}\n桌面端会话可点「继续」恢复执行,也可直接在此发新指令接力。", + stop_kind_zh(stop_kind), + truncate(reason, 200), + ) +} + +/// 换代通知单行文案(纯函数):代号+摘要首行(压缩换代对用户的可感知面就一句; +/// 空摘要退化为纯换代告知,不出悬空冒号) +fn compress_notify_line(seq: u32, summary: &str) -> String { + let first = summary.lines().next().unwrap_or("").trim(); + if first.is_empty() { + format!("🔄 会话上下文已自动压缩换代(第 {seq} 代)。") + } else { + format!( + "🔄 会话上下文已自动压缩换代(第 {seq} 代):{}", + truncate(first, 60) + ) + } +} + +/// 聚合窗合并卡文案(纯函数):单会话单行直出;同 chat 多会话合并为清单 +fn merge_compress_notify(lines: &[String]) -> String { + match lines { + [one] => one.clone(), + many => format!( + "🔄 {} 个会话上下文已自动换代:\n{}", + many.len(), + many.iter().map(|l| format!("- {l}")).collect::>().join("\n") + ), + } +} + +/// 换代通知(分析 P1-B §1.4,2026-10-21):渠道生命周期订阅任务(与 run_approval_notifier +/// 同款骨架,跨重连常驻)。消费 ai_event_bus 的 AiTaskParked/AiAutoCompressed,反查 conv +/// 绑定飞书 chat 推文本卡;无绑定(纯桌面会话)不推,零打扰。此前两事件已广播(双写底座 +/// emit_ai_chat_event)但本文件零消费——停靠/换代对飞书侧完全无感知。 +/// AiTaskParked 同因去重(重放/多重终局汇聚不重推,恢复后再停靠因内容不同自然放行); +/// AiAutoCompressed 走 30s 聚合窗(同 chat 合并一卡);换代代号=进程内递增计数(方案甲 +/// 零 DDL,持久化代号建模留 P1 检索批),重启归 1。 +async fn run_generation_notifier(ctx: Arc) { + let state = ctx.app.state::(); + let mut rx = state.ai_event_bus.subscribe(); + // 停靠去重记忆:conv → 最近一次已推 (reason, stop_kind) + let mut pushed_parks: HashMap = HashMap::new(); + // 换代号:conv → 进程内压缩换代计数 + let mut gen_seq: HashMap = HashMap::new(); + // 聚合窗:chat → 待推行;flush_at=None=无窗,Some=窗尾时刻 + let mut pending_compress: HashMap> = HashMap::new(); + let mut flush_at: Option = None; + loop { + let item = if let Some(deadline) = flush_at { + match tokio::time::timeout_at(deadline, rx.recv()).await { + // 窗到:同 chat 合并推送后重开等事件(无窗期零 select 开销) + Err(_) => { + flush_at = None; + let chats = pending_compress.len(); + for (chat_id, lines) in pending_compress.drain() { + let body = merge_compress_notify(&lines); + let _ = send_markdown_card(&ctx.client, "", &chat_id, &body).await; + } + feishu_log(&format!("换代通知聚合窗推送 chats={chats}")); + continue; + } + Ok(item) => item, + } + } else { + rx.recv().await + }; + match item { + Ok(v) => match v.get("type").and_then(|t| t.as_str()) { + Some("AiTaskParked") => { + let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else { + continue; + }; + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string(); + let kind = v.get("stop_kind").and_then(|k| k.as_str()).unwrap_or("").to_string(); + if pushed_parks.get(cid) == Some(&(reason.clone(), kind.clone())) { + continue; + } + let Some(chat_id) = chat_of_conv(&ctx, cid).await else { + continue; + }; + // 去重记忆防无界(低频事件,超限全清只失去去重,无正确性影响) + if pushed_parks.len() > 256 { + pushed_parks.clear(); + } + pushed_parks.insert(cid.to_string(), (reason.clone(), kind.clone())); + let text = park_notify_text(&kind, &reason); + let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await; + feishu_log(&format!("停靠通知推送 chat={chat_id} conv={cid} kind={kind}")); + } + Some("AiAutoCompressed") => { + let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else { + continue; + }; + let Some(chat_id) = chat_of_conv(&ctx, cid).await else { + continue; + }; + let seq = gen_seq + .entry(cid.to_string()) + .and_modify(|n| *n += 1) + .or_insert(1); + let line = compress_notify_line( + *seq, + v.get("summary").and_then(|s| s.as_str()).unwrap_or(""), + ); + pending_compress.entry(chat_id).or_default().push(line); + if flush_at.is_none() { + flush_at = Some(tokio::time::Instant::now() + COMPRESS_NOTIFY_WINDOW); + } + } + _ => {} + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } +} + /// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved}, /// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先, /// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。 @@ -2679,11 +2841,13 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap Result { + let key = content_key(bytes, "application/octet-stream", Some(original_name)); + let dir = self.data_dir.join("attachments"); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("创建附件目录失败: {e}"))?; + let path = dir.join(&key); + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + if let Err(e) = tokio::fs::write(&path, bytes).await { + tokio::fs::remove_file(&path).await.ok(); + return Err(format!("附件写入失败: {e}")); + } + } + Ok(path) + } + /// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机 /// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用, /// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致 diff --git a/src/App.vue b/src/App.vue index 696df47..0ea0602 100644 --- a/src/App.vue +++ b/src/App.vue @@ -45,8 +45,7 @@ + 仅在主窗口与 ai-detached 窗口挂载(file-explorer 分离窗不挂)。 --> -
{{ $t('aiChat.pathAuthBypassBanner') }}
- - - @@ -114,7 +105,6 @@ import MessageList from './ai/MessageList.vue' import HelpRequiredCard from './ai/HelpRequiredCard.vue' import TaskParkedCard from './ai/TaskParkedCard.vue' import TaskBoundaryCard from './ai/TaskBoundaryCard.vue' -import DirAuthDialog from './ai/DirAuthDialog.vue' import { useConfirm } from '../composables/useConfirm' import { useToast } from '../composables/useToast' import { useProjectStore } from '../stores/project' @@ -137,13 +127,7 @@ const router = useRouter() // B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭) const { autoCompressedFlash } = useAiContext() -// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。 -// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。 -// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。 -const appSettings = useAppSettingsStore() -const unifiedApproval = appSettings.useSetting('df-ai-unified-approval', true) -// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅 -const pathAuthBypass = appSettings.useSetting('df-ai-path-auth-bypass', false) +// 「信任所有目录」总开关横幅已随授权目录机制退役移除(2026-10-25 默认全放行,横幅失去信息量) // ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内); // 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。── @@ -739,16 +723,6 @@ async function initContextEventListeners(): Promise { TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享, 原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */ -/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */ -.ai-auth-bypass-banner { - padding: 5px 14px; - background: var(--df-warning-bg); - color: var(--df-warning); - border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); - font-size: 12px; - user-select: none; -} - /* ═══ Input ═══ */ .ai-input-area { padding: 10px 14px; diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 63efe87..fdc7b61 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -84,41 +84,8 @@
{{ ln.text }}{{ '\n' }}
- -
- - - - -
-
+ +
- - -
{{ approveToast.msg }}
@@ -205,7 +164,6 @@ import { useTimerOwnership } from '@/composables/useTimerOwnership' import { callDurationText } from '@/composables/ai/useTurnTrace' import { formatDate, formatDuration } from '@/utils/time' import type { AiToolCallInfo } from '@/api/types' -import ConfirmDialog from './ConfirmDialog.vue' import ToolResultBody from './ToolResultBody.vue' import OffloadedResultBar from './OffloadedResultBar.vue' @@ -234,18 +192,14 @@ const emit = defineEmits<{ * 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。 */ 'install-obscura': [] - /** - * 审批按钮 → 父级转发 store.approveToolCall。 - * path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。 - * 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。 - */ + /** 审批按钮 → 父级转发 store.approveToolCall(一次性 approve/reject)。 */ approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }] }>() // 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。 const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc) -// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。 +// 审批状态机(按钮 loading/挂起时长/toast/approve+reject)抽离至 composable。 const { approving, approveToast, @@ -256,12 +210,6 @@ const { expireLevel, onApprove, onReject, - onAuthorizeOnce, - onAuthorizeSession, - onAuthorizeAlways, - onDeny, - confirmState, - answerConfirm, } = useToolApproval(toRef(props, 'tc'), emit) /** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */ @@ -597,16 +545,6 @@ const visibleArgs = computed(() => { } .ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); } .ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); } -/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */ -.ai-tool-btn--session { - background: var(--df-accent-bg); - color: var(--df-accent); - border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); -} -.ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); } -/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */ -.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); } -.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); } .ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); } .ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); } /* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */ diff --git a/src/components/ToolCardList.vue b/src/components/ToolCardList.vue index eed3502..76ea136 100644 --- a/src/components/ToolCardList.vue +++ b/src/components/ToolCardList.vue @@ -13,10 +13,6 @@ - - - {{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }} -
@@ -43,14 +39,6 @@ :class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }" /> - - - @@ -58,11 +46,8 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import ToolCard from './ToolCard.vue' -import ConfirmDialog from './ConfirmDialog.vue' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import type { AiToolCallInfo } from '@/api/types' -import { useConfirm } from '@/composables/useConfirm' -import { toolRiskLevel } from '@/composables/ai/useToolApproval' import { isToolFailure } from '@/composables/ai/useToolCard' // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) @@ -74,10 +59,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ - /** - * 审批按钮 → 父组件转发 store.approveToolCall。 - * path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。 - */ + /** 审批按钮 → 父组件转发 store.approveToolCall(一次性 approve/reject)。 */ approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }] /** 批量审批 → 父组件调用 approveAll/rejectAll */ 'batch-approve': [decision: 'approve' | 'reject'] @@ -154,27 +136,15 @@ const pendingCount = computed(() => props.toolCalls.filter(tc => tc.status === 'pending_approval').length ) -// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) -- -const { confirmState, confirmDialog, answerConfirm } = useConfirm() -/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */ -const highRiskPendingCount = computed(() => - props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length -) +// 批量批准 High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认(与单卡/浮窗同口径)。 /** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */ const batchProcessing = ref(false) /** - * 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义, - * 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。 + * 批量批准入口:直接向父级派发 batch-approve('approve')。 * 拒绝批量无破坏性,不拦。 */ async function onBatchApprove() { - if (highRiskPendingCount.value > 0) { - const ok = await confirmDialog( - t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }), - ) - if (!ok) return - } batchProcessing.value = true emit('batch-approve', 'approve') // emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位 @@ -366,7 +336,7 @@ function scrollToFirstPending(): void { // 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 → // path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden // (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 -// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。 +// 超时自动 approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。 // scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空), // 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。 let _unlistenPending: UnlistenFn | null = null diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index ceb52da..6698dfe 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -1,221 +1,20 @@