From 7eb2e25ed5a48dfd8c1a3dc5793b84c30ed1f9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 9 Aug 2026 21:35:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20aichat=E6=95=88=E7=8E=87?= =?UTF-8?q?=E6=89=B9(save=E6=97=A0=E5=8F=98=E5=8C=96=E6=8D=B7=E5=BE=84/?= =?UTF-8?q?=E6=96=AD=E8=B7=AF=E5=99=A8=E5=B0=BE=E7=AA=97/system=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=8C=87=E7=BA=B9/=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=88=B7=E6=96=B0=E6=94=B6=E6=95=9B)=20+=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20LLM=20prompt=20caching(cache=5Fcontrol=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E9=BB=98=E8=AE=A4=E5=85=B3/system=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=AE=B5=E6=98=93=E5=8F=98=E6=AE=B5=E5=88=86=E7=A6=BB=E6=B2=BB?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E7=8E=87)=20+=20=E9=94=80?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/anthropic_compat.rs | 90 ++++++- crates/df-ai/src/anthropic_helpers.rs | 16 +- crates/df-ai/src/context/mod.rs | 11 + docs/todo.md | 22 +- src-tauri/src/commands/ai/agentic/helpers.rs | 31 ++- src-tauri/src/commands/ai/agentic/mod.rs | 45 +++- src-tauri/src/commands/ai/commands/chat.rs | 119 ++++++-- src-tauri/src/commands/ai/conversation.rs | 99 ++++--- src-tauri/src/commands/ai/knowledge_inject.rs | 58 ++-- src-tauri/src/commands/ai/prompt.rs | 253 ++++++++++++++++-- src/composables/ai/useAiConversations.ts | 117 ++++---- src/composables/ai/useAiEvents.ts | 11 +- src/composables/ai/useAiLifecycleEvents.ts | 6 +- 13 files changed, 698 insertions(+), 180 deletions(-) diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 2f0e3ce..c088c8c 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -29,10 +29,38 @@ use crate::retry::{ // 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers, // 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。 use crate::anthropic_helpers::{ - apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, + apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, SystemBlock, ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS, }; +// ============================================================ +// Anthropic prompt caching 开关(进程级全局,受 AppState settings 同步) +// ============================================================ + +/// Anthropic prompt caching 总开关(默认关)。 +/// +/// 该分支同时服务 Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages +/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。 +/// +/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}` +/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。 +/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。 +/// +/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点 +/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。 +static ANTHROPIC_CACHE_ENABLED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// 设置 anthropic prompt caching 开关(上层读取 AppState settings 后同步)。 +pub fn set_anthropic_cache_enabled(enabled: bool) { + ANTHROPIC_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst); +} + +/// 读取 anthropic prompt caching 开关(`convert_request` 组装 system 形态用)。 +pub fn anthropic_cache_enabled() -> bool { + ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst) +} + // ============================================================ // Provider 实现 // ============================================================ @@ -92,8 +120,10 @@ impl AnthropicCompatProvider { req.model }; - // 抽离 system 消息 - let system: Option = { + // 抽离 system 消息。 + // 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在 + // 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。 + let system: Option = { let sys: Vec = req .messages .iter() @@ -102,8 +132,14 @@ impl AnthropicCompatProvider { .collect(); if sys.is_empty() { None + } else if anthropic_cache_enabled() { + Some(SystemBlock::Cached(vec![serde_json::json!({ + "type": "text", + "text": sys.join("\n\n"), + "cache_control": { "type": "ephemeral" }, + })])) } else { - Some(sys.join("\n\n")) + Some(SystemBlock::Plain(sys.join("\n\n"))) } }; @@ -1126,4 +1162,50 @@ mod tests { "补占位后 precheck 应通过" ); } + + // ---------- prompt caching:开关驱动 system 形态 ---------- + + /// 开关控制 system 形态:开 → blocks 数组含 cache_control:ephemeral;关 → 纯字符串。 + /// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。) + #[test] + fn anthropic_cache_switch_controls_system_shape() { + set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例 + + let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet"); + let mk = || CompletionRequest { + model: "claude-3-5-sonnet".into(), + messages: vec![ + ChatMessage::system("a"), + ChatMessage::system("b"), + ChatMessage::user("hi"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + + // 开:数组形态 + cache_control(Anthropic prompt caching) + set_anthropic_cache_enabled(true); + let body = provider.convert_request(mk()); + match body.system.expect("开关开时应有 system") { + SystemBlock::Cached(blocks) => { + assert_eq!(blocks.len(), 1, "开关开应包成单 blocks 数组"); + assert_eq!(blocks[0]["type"], "text"); + assert_eq!(blocks[0]["text"], "a\n\nb", "多条 system 应以 \\n\\n join 进单个 text 块"); + assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral"); + } + SystemBlock::Plain(s) => panic!("开关开应为数组形态,实际 Plain: {}", s), + } + + // 关:纯字符串(兼容非官方网关,零行为变更) + set_anthropic_cache_enabled(false); + let body = provider.convert_request(mk()); + match body.system { + Some(SystemBlock::Plain(s)) => assert_eq!(s, "a\n\nb", "开关关多条 system 应以 \\n\\n join 纯字符串"), + other => panic!("开关关应为纯字符串,实际: {:?}", other), + } + } } diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index 9ce7955..2fdf544 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -18,6 +18,20 @@ use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCal // Anthropic API 请求/响应结构体 // ============================================================ +/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制): +/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API +/// 代理——部分网关会拒收 cache_control 字段)。 +/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用 +/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。 +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub(crate) enum SystemBlock { + /// 纯字符串形态(兼容网关,开关关) + Plain(String), + /// blocks 数组形态(含 cache_control,开关开) + Cached(Vec), +} + /// Anthropic 请求体 #[derive(Debug, Clone, Serialize)] pub(crate) struct AnthropicRequest { @@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest { pub messages: Vec, pub max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, + pub system: Option, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, pub stream: bool, diff --git a/crates/df-ai/src/context/mod.rs b/crates/df-ai/src/context/mod.rs index 548609b..3cb65bd 100644 --- a/crates/df-ai/src/context/mod.rs +++ b/crates/df-ai/src/context/mod.rs @@ -275,6 +275,17 @@ impl ContextManager { self.messages.iter().map(|t| t.message.clone()).collect() } + /// 只读最近 N 条消息(尾部切片 clone)。BE2(AC-EFF-R2-1):断路器/探索熔断检查等只关心 + /// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。 + /// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。 + pub fn recent_messages(&self, n: usize) -> Vec { + let skip = self.messages.len().saturating_sub(n); + self.messages[skip..] + .iter() + .map(|t| t.message.clone()) + .collect() + } + /// 从 Vec 恢复(兼容从 DB 加载) pub fn restore_from_messages(&mut self, messages: Vec) { // clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载", diff --git a/docs/todo.md b/docs/todo.md index 4cb1681..4a9d44e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -615,21 +615,21 @@ graph TD > 用户反馈「aichat 运行的效率很低」+「成本走查,比如有的 LLM 要求的缓存命中率规则」。详单见 [aichat效率走查-2026-08-09.md](./05-代码审查/aichat效率走查-2026-08-09.md)。共 **74 项**(单轮13/工具9/流式12/前端12/会话16/LLM成本12)。守 session-role-diagnose-only:仅走查登记,未实施代码。 **LLM 成本/缓存命中(用户重点 · P0×2)**: -- [ ] **AC-EFF-C0-1** — 全链路无 cache_control 注解,Anthropic 提示缓存未启用(system 纯 String 需改 text-block 数组;openai/DeepSeek 依赖自动前缀缓存) -- [ ] **AC-EFF-C0-2** — system prompt 每轮实时 DB 重建(prompt.rs:295/315 list_active 20+20),前缀不稳定 → **多轮 100% 缓存不命中每轮全价**。**修复优先级最高**:C0-1+C0-2 一起修才有效 -- [ ] **AC-EFF-C1-1~3** — 日期在 system 位置0跨天失效 / 每轮全量重发 O(n²) 无缓存兜底 / augmentation+知识注入拼 system 添失效源 +- [x] **AC-EFF-C0-1** — ✅ 已修(2026-08-09 d09d136):AnthropicRequest.system 改 untagged SystemBlock 枚举(Plain/Cached),cache_control:{type:ephemeral},开关 ANTHROPIC_CACHE_ENABLED 默认关 +- [x] **AC-EFF-C0-2** — ✅ 已修(d09d136):system 稳定段/易变段分离——日期+项目/任务清单+augmentation+知识注入挪消息流末尾 merge_volatile_tail(治跨天失效+前缀稳定),开关关逐字等价现状;df-ai 492 测试通过 +- [~] **AC-EFF-C1-1** ✅ 日期挪易变段已修 / **C1-2** 🟡 每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批) / **C1-3** ✅ augmentation+知识注入挪消息流已修 **后端效率(单轮成本 + 会话 + 工具 · P0×4)**: -- [ ] **AC-EFF-R1-1** 每轮全量历史重放无增量(O(n²) 成本大头) / **AC-EFF-R2-1** 每轮 ≥4 次全历史深克隆(2 次持 session 锁) -- [ ] **AC-EFF-L0-1** save_conversation 每轮 O(N) 克隆+截断+记录构建(单线程 runtime,无变化捷径在 records 后判定) -- [ ] **AC-EFF-L0-2** system prompt 每次操作 6-8 次串行 DB 查询全在首 token 关键路径 -- [ ] **AC-EFF-L0-3** 自动压缩在 loop 内同步阻塞整轮(非流式 LLM 最长 60s)+ 必全量重写 -- [ ] **AC-EFF-T1-1** 审计每工具 ≥1 次串行 INSERT(全局 DB 锁瓶颈,需批量事务) / **AC-EFF-T1-2** 只读缓存"假缓存"(每工具全量扫+DB 往返,需 O(1) 索引) / **AC-EFF-T1-3** git 工具不进缓存 +- [x] **AC-EFF-R1-1** 🟡 全量历史重放(cache_control 命中后 O(n²) 实际降为 cache_read,增量机制待后续) / **AC-EFF-R2-1** ✅ 已修(0300764):count_recent_failures+check_stall_breaker 改 recent_messages 尾窗口 40 条 +- [x] **AC-EFF-L0-1** — ✅ 已修(0300764):save_conversation 无变化捷径提前判定(无变化不 clone/truncate/构建 records) +- [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接) +- [ ] **AC-EFF-L0-3** — 🟡 自动压缩同步阻塞整轮(留设计,后台化+防重入) +- [ ] **AC-EFF-T1-1** 审计串行 INSERT 需批量事务 / **T1-2** 只读缓存 O(1) 索引 / **T1-3** git 工具不进缓存 —— 🟡 待后续批(DB 层改造) **前端效率(流式 + 交互 · P1×4)**: -- [ ] **AC-EFF-S1-1** 单块回复每帧全量 lexer+parse(块级 memo 退化,长文卡顿主因) / **AC-EFF-S1-2** 长尾代码块每帧重跑 hljs -- [ ] **AC-EFF-F1-1~3** 回合收尾双拉列表 / notify 同窗口自触发 / loadConversations 无防抖(合并一个 debounced scheduleConversationsRefresh) -- [ ] **AC-EFF-S2-1** 每次 flush 两次整组件重渲染 / **AC-EFF-S2-5** AiCommandOutput 逐行未合批事件风暴 +- [ ] **AC-EFF-S1-1** 单块回复每帧全量渲染 / **S1-2** 长尾代码块每帧 hljs —— 🟡 待后续批(流式增量,渲染重构) +- [x] **AC-EFF-F1-1~3** — ✅ 已修(0300764):scheduleConversationsRefresh 250ms trailing debounce 收敛回合双拉+notify 自触发+无防抖 +- [ ] **AC-EFF-S2-1** 每次 flush 两次渲染 / **S2-5** AiCommandOutput 事件风暴 —— 🟡 待后续批 **P2/P3 其余 50+ 项**: 见详单文档(R2/R3/T2/T3/S2/S3/F2/F3/L1/L2/L3/C2/C3 全量) diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index efa2703..4ae29c5 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -4,8 +4,8 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::state::AppState; use super::approval_timeout; -use crate::commands::ai::prompt::{build_system_prompt, get_active_provider}; -use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt; +use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider}; +use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; use super::conv_state::ConvState; @@ -94,7 +94,12 @@ pub async fn try_continue_agent_loop( }; let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let conv_id_owned = conv_id.to_string(); - let system_prompt = build_system_prompt(state, &lang).await; + // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分, + // OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。 + let cache_on = cache_enabled(state).await + && matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat"); + let (system_prompt, base_volatile) = + build_system_prompt_parts(state, &lang, &[], &[], cache_on).await; let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); @@ -103,7 +108,23 @@ pub async fn try_continue_agent_loop( let knowledge_config = state.knowledge_config.lock().await.clone(); let llm_concurrency = state.llm_concurrency.clone(); - let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await; + // 知识注入:缓存模式拼到易变段末尾(追加消息流,不失效 system 缓存),否则拼 system + // (与旧路径逐字等价)。 + let volatile_tail = if cache_on { + let knowledge = build_knowledge_context_for_conv(state, conv_id, &knowledge_config).await; + let mut tail = base_volatile; + if !knowledge.is_empty() { + tail = if tail.is_empty() { knowledge } else { format!("{}\n\n---\n{}", tail, knowledge) }; + } + if tail.is_empty() { None } else { Some(tail) } + } else { + None + }; + let system_prompt = if cache_on { + system_prompt + } else { + inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await + }; let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst); let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst); @@ -141,7 +162,7 @@ pub async fn try_continue_agent_loop( }; tauri::async_runtime::spawn(async move { - super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; + super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; }); } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index b35e883..8b866fe 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -263,6 +263,15 @@ pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3; /// 单轮并行失败爆发,又足够长以容忍偶发抖动。 pub const CIRCUIT_BREAKER_WINDOW: usize = 20; +/// BE2(AC-EFF-R2-1):断路器/探索熔断读消息的尾部窗口大小。 +/// +/// count_recent_failures / check_stall_breaker 只关心最近若干条消息(末尾工具结果 + 其前驱 +/// assistant tool_calls / 最近几轮工具签名),用 ContextManager::recent_messages 只 clone 尾部 +/// 切片,避免 all_messages_clone 每轮全量深克隆(长对话每轮 O(n) clone 浪费)。 +/// 40:同轮工具回填通常 1~5 条,富余覆盖其定义调用与最近多轮;超窗工具映射退化为 +/// unknown_tool(仅失败分组更粗)/ 签名样本截断(仍达 STALL_BREAKER_SAMPLE_SIZE=12),不误熔断。 +pub const BREAKER_TAIL_N: usize = 40; + /// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations /// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。 pub const CIRCUIT_BREAKER_ENABLED: bool = true; @@ -598,6 +607,25 @@ async fn emit_retry_attempt( tokio::time::sleep(delay).await; } +/// 把 Anthropic prompt caching 易变尾段追加到请求消息流**末尾**(一条 user 消息)。 +/// +/// 多场景命中率设计要点: +/// - **放消息流末尾而非 system**:任何位于 system 之后的消息级缓存断点(含未来「多轮对话缓存」 +/// 在末条 user 上打断点)都在易变段之前——易变内容(日期/清单/augmentation/知识)每日/每请求 +/// 变化时,只影响其自身及之后(无),不失效已缓存的 system/历史前缀。若放首条 user,跨天日期 +/// 变化会失效其后全部历史缓存。 +/// - **用 user 角色而非 system**:anthropic_compat::convert_request 会把 System-role 消息抽到 +/// 顶层 system(受 cache_control 缓存),易变段若为 system 会随请求变化失效整段 system 缓存。 +/// 必须 user 承载,convert_request 对连续 user 做 merge_consecutive_users,追加安全。 +/// - **不持久化**:仅注入本轮请求的 messages clone,per_conv.messages 不含易变段 → 每轮重建 +/// 恰好一条,无重复。 +fn merge_volatile_tail(messages: &mut Vec, tail: &str) { + if tail.is_empty() { + return; + } + messages.push(ChatMessage::user(tail)); +} + /// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环 /// /// 退出条件: @@ -611,6 +639,9 @@ pub(crate) async fn run_agentic_loop( app_handle: AppHandle, provider_config: AiProviderRecord, system_prompt: String, + // Anthropic prompt caching 易变尾段(缓存模式:日期/清单/augmentation/知识,追加到消息流末尾; + // None = 开关关,保持现状行为)。 + volatile_tail: Option, conv_id: String, knowledge_config: crate::state::KnowledgeConfig, llm_concurrency: LlmConcurrency, @@ -1523,6 +1554,11 @@ pub(crate) async fn run_agentic_loop( } let mut msgs = vec![ChatMessage::system(&system_prompt)]; msgs.extend(history_msgs); + // Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。 + // 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。 + if let Some(tail) = &volatile_tail { + merge_volatile_tail(&mut msgs, tail); + } msgs }; @@ -2188,10 +2224,13 @@ async fn count_recent_failures( conv_id: &str, fail_window: &mut std::collections::VecDeque, ) -> (u32, String) { + // BE2(AC-EFF-R2-1):只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆 + // (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls + // (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。 let messages = { let session = session_arc.lock().await; match session.conv_read(conv_id) { - Some(conv) => conv.messages.all_messages_clone(), + Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), None => Vec::new(), } }; @@ -2424,8 +2463,10 @@ async fn check_stall_breaker( // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 let signatures: Vec = { let session = session_arc.lock().await; + // BE2(AC-EFF-R2-1):只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用, + // 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。 let messages = match session.conv_read(conv_id) { - Some(conv) => conv.messages.all_messages_clone(), + Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), None => Vec::new(), }; let mut sigs: Vec = Vec::new(); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 2a116c0..e01637f 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -37,8 +37,8 @@ use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::sanitize; use super::super::conversation::save_conversation; -use super::super::knowledge_inject::inject_knowledge_into_prompt; -use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded}; +use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; +use super::super::prompt::{build_system_prompt_parts, cache_enabled}; use super::super::{AiChatEvent, ApprovalKind, SessionState}; @@ -240,6 +240,20 @@ fn excluded_ids_from_mentions(spans: &Option>) -> (Vec bool { + matches!(provider_type, "anthropic" | "anthropic_compat") +} + +/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。 +fn append_volatile(volatile: String, seg: &str) -> String { + if volatile.is_empty() { + seg.to_string() + } else { + format!("{}\n\n---\n{}", volatile, seg) + } +} + // ============================================================ // 发送 / 审批 / 控制 // ============================================================ @@ -301,24 +315,38 @@ pub async fn ai_regenerate( let _tool_defs = state.ai_tools.tool_definitions(); let lang = language.unwrap_or_else(|| "zh-CN".to_string()); - let system_prompt = build_system_prompt(&state, &lang).await; - // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 let conv_id = conversation_id.clone(); + // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 + let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); + let (mut system_prompt, mut volatile) = + build_system_prompt_parts(&state, &lang, &[], &[], 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, // 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。 - let mut system_prompt = { + { let config = state.knowledge_config.lock().await.clone(); - inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await - }; + if cache_on { + let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await; + if !knowledge.is_empty() { + volatile = append_volatile(volatile, &knowledge); + } + } else { + system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + } + } // Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + if cache_on { + volatile = append_volatile(volatile, &aug_seg); + } else { + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + } } + let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None }; // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; @@ -352,7 +380,7 @@ pub async fn ai_regenerate( }; tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; }); Ok("ok".to_string()) @@ -633,12 +661,19 @@ pub async fn ai_chat_send( // (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。 // mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。 let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); - let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; + // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 + let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); + let (mut system_prompt, mut volatile) = + build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + if cache_on { + volatile = append_volatile(volatile, &aug_seg); + } else { + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + } } // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。 @@ -648,8 +683,16 @@ pub async fn ai_chat_send( // 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 { let config = state.knowledge_config.lock().await.clone(); - system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + if cache_on { + let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await; + if !knowledge.is_empty() { + volatile = append_volatile(volatile, &knowledge); + } + } else { + system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + } } + let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None }; // 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。 // ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键: @@ -679,7 +722,7 @@ pub async fn ai_chat_send( }; tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; }); Ok("ok".to_string()) @@ -1718,23 +1761,37 @@ pub async fn ai_chat_edit( let _tool_defs = state.ai_tools.tool_definitions(); let lang = language.unwrap_or_else(|| "zh-CN".to_string()); - let system_prompt = build_system_prompt(&state, &lang).await; - // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 let conv_id = conversation_id.clone(); + // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。 + let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); + let (mut system_prompt, mut volatile) = + build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await; // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。 // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user, // helper 读到的末条 active user 即编辑后的新文本,语义等价。 - let mut system_prompt = { + { let config = state.knowledge_config.lock().await.clone(); - inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await - }; + if cache_on { + let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await; + if !knowledge.is_empty() { + volatile = append_volatile(volatile, &knowledge); + } + } else { + system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + } + } // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + if cache_on { + volatile = append_volatile(volatile, &aug_seg); + } else { + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + } } + let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None }; // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; @@ -1761,6 +1818,7 @@ pub async fn ai_chat_edit( app_handle, provider_config, system_prompt, + volatile_tail, conv_id, knowledge_config, llm_concurrency, @@ -1960,19 +2018,34 @@ pub async fn ai_chat_force_send( let lang = language.unwrap_or_else(|| "zh-CN".to_string()); // 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。 let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); - let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; + // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。 + let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type); + let (mut system_prompt, mut volatile) = + build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await; // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + if cache_on { + volatile = append_volatile(volatile, &aug_seg); + } else { + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); + } } // conv_id 已在上方状态占用块得出。 // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 { let config = state.knowledge_config.lock().await.clone(); - system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + if cache_on { + let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await; + if !knowledge.is_empty() { + volatile = append_volatile(volatile, &knowledge); + } + } else { + system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; + } } + let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None }; // 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。 // force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。 @@ -1992,7 +2065,7 @@ pub async fn ai_chat_force_send( let max_retries = state.agent_max_retries.load(Ordering::SeqCst); tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; }); Ok("ok".to_string()) diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index e433a25..5ef3192 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -251,7 +251,9 @@ async fn save_conversation_inner( // 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。 // 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow, // 避免同时持有 session 的 mut 和 immut 借用(E0502)。 - let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = { + // BE1(AC-EFF-L0-1):无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, + // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 + let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = { let mut session = session_arc.lock().await; // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 @@ -264,50 +266,66 @@ async fn save_conversation_inner( return; } let __wait = __lock_start.elapsed(); + // 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow, + // 避免同时持有 session 的 mut 和 immut 借用(E0502)。 let provider_id = session.active_provider_id.clone(); let created_at = session.active_conv_created_at.clone(); let conv = session.conv(conv_id); - let cloned = ( - conv.messages.all_messages_clone(), + let msg_len = conv.messages.len(); + let persisted_count = conv.messages.persisted_msg_count(); + let needs_full_rewrite = conv.messages.needs_full_rewrite(); + // 无变化捷径提前判定:消息数未增且非 dirty → DB ai_messages 与内存完全一致, + // 免 clone(下方直接走元数据 update_full,消息写入整段跳过)。 + let cloned = if !needs_full_rewrite && msg_len == persisted_count { + Vec::new() + } else { + conv.messages.all_messages_clone() + }; + let pinned_goals = conv.pinned_goals.clone(); + let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) + ( provider_id, created_at, - conv.pinned_goals.clone(), - conv.messages.persisted_msg_count(), - conv.messages.needs_full_rewrite(), - ); - let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) - cloned + pinned_goals, + msg_len, + persisted_count, + needs_full_rewrite, + cloned, + ) }; let __lock_total = __lock_start.elapsed(); if __lock_total > std::time::Duration::from_millis(50) { - eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total); + eprintln!("[LOCK-DIAG] save 快照段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total); } - // truncate 在锁外(clone 副本上操作,不影响 session 真相源) - // T2: 展开 namespace 引用为原文再落库 - { - let session = session_arc.lock().await; - for m in &mut msgs { - if df_ai::namespace_store::is_namespace_ref(&m.content) { - if let Some(original) = session.namespace_store.read_only(&m.content) { - m.content = original.to_string(); + // BE1:仅当消息确有可能变化(非无变化捷径)才对 clone 副本做 namespace 展开 + truncate。 + let no_message_change = !needs_full_rewrite && msg_len == persisted_count; + if !no_message_change { + // truncate 在锁外(clone 副本上操作,不影响 session 真相源) + // T2: 展开 namespace 引用为原文再落库 + { + let session = session_arc.lock().await; + for m in &mut persist_msgs { + if df_ai::namespace_store::is_namespace_ref(&m.content) { + if let Some(original) = session.namespace_store.read_only(&m.content) { + m.content = original.to_string(); + } } } } - } - for m in &mut msgs { - // P0-2:tool result 是结构化 JSON(provider 读工具返回原样), - // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。 - // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 - if !matches!(m.role, df_ai::provider::MessageRole::Tool) { - m.content = truncate_for_persist(&m.content); - } - // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片), - // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。 - if let Some(parts) = m.parts.as_ref() { - m.parts = truncate_parts_for_persist(parts); + for m in &mut persist_msgs { + // P0-2:tool result 是结构化 JSON(provider 读工具返回原样), + // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。 + // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 + if !matches!(m.role, df_ai::provider::MessageRole::Tool) { + m.content = truncate_for_persist(&m.content); + } + // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片), + // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。 + if let Some(parts) = m.parts.as_ref() { + m.parts = truncate_parts_for_persist(parts); + } } } - let persist_msgs = msgs; // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目, @@ -335,12 +353,19 @@ async fn save_conversation_inner( // 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。 let now = now_millis(); let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone()); - let total_len = persist_msgs.len(); - let records: Vec = persist_msgs - .iter() - .enumerate() - .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at)) - .collect(); + // BE1:no_message_change 时 persist_msgs 为空,records 也空(下方无变化捷径直接跳过消息写入, + // 仅元数据 update_full);非无变化时 records 由全量 persist_msgs 构建(append 切片仍按 persisted_count)。 + let records: Vec = if no_message_change { + Vec::new() + } else { + persist_msgs + .iter() + .enumerate() + .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at)) + .collect() + }; + // BE1:total_len 用 clone 后权威条数(no_message_change 时用 lock 读的 msg_len,=persisted_count)。 + let total_len = if no_message_change { msg_len } else { persist_msgs.len() }; let conv_repo = AiConversationRepo::new(db); let msg_repo = df_storage::crud::AiMessageRepo::new(db); diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 746f136..55ea199 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -351,6 +351,39 @@ pub(crate) async fn build_knowledge_context( out } +/// 取末条 active user 消息文本 + id,构建知识库上下文段(DRY:注入 [`inject_knowledge_into_prompt`] +/// 与 Anthropic prompt caching 易变段共用)。 +/// +/// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤 +/// 导致两值取自不同消息。auto_inject 关 / 无命中返回空串。 +pub(crate) async fn build_knowledge_context_for_conv( + state: &AppState, + conv_id: &str, + config: &crate::state::KnowledgeConfig, +) -> String { + let (last_user_text, user_message_id) = { + let __lock_t = std::time::Instant::now(); + let session = state.ai_session.lock().await; + let msgs = session + .conv_read(conv_id) + .map(|c| c.messages.all_messages_clone()) + .unwrap_or_default(); + let found = msgs + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::User) && m.is_active()); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold); + } + match found { + Some(m) => (m.content.clone(), m.id.clone()), + None => (String::new(), None), + } + }; + build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await +} + /// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。 /// /// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` / @@ -378,30 +411,7 @@ pub(crate) async fn inject_knowledge_into_prompt( system_prompt: String, config: &crate::state::KnowledgeConfig, ) -> String { - // 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤 - // 导致两值取自不同消息。 - let (last_user_text, user_message_id) = { - let __lock_t = std::time::Instant::now(); - let session = state.ai_session.lock().await; - let msgs = session - .conv_read(conv_id) - .map(|c| c.messages.all_messages_clone()) - .unwrap_or_default(); - let found = msgs - .iter() - .rev() - .find(|m| matches!(m.role, MessageRole::User) && m.is_active()); - let __hold = __lock_t.elapsed(); - if __hold > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold); - } - match found { - Some(m) => (m.content.clone(), m.id.clone()), - None => (String::new(), None), - } - }; - let knowledge_context = - build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await; + let knowledge_context = build_knowledge_context_for_conv(state, conv_id, config).await; if knowledge_context.is_empty() { system_prompt } else { diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index e549db6..ebe21da 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -1,5 +1,9 @@ //! 系统提示词构建 + 活跃提供商获取 +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use df_storage::db::Database; use df_storage::models::AiProviderRecord; use crate::commands::err_str; @@ -43,7 +47,24 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result String { let today = chrono::Local::now().format("%Y-%m-%d"); - let (os, shell, interp) = if cfg!(target_os = "windows") { + format!("当前日期: {today} | {}", env_profile_stable_body()) +} + +/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。 +/// +/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息), +/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。 +fn env_profile_stable_body() -> String { + let (os, shell, interp) = env_os_shell_interp(); + format!( + "运行环境: {os} | shell: {shell}\n\ + 执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n" + ) +} + +/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。 +fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) { + if cfg!(target_os = "windows") { ("Windows", "PowerShell", "python(无 python3)、node") } else if cfg!(target_os = "macos") { ("macOS", "bash/zsh", "python3、node") @@ -51,11 +72,13 @@ fn env_profile_line() -> String { ("Linux", "bash", "python3、node") } else { ("Unknown", "sh", "python3、node") - }; - format!( - "当前日期: {today} | 运行环境: {os} | shell: {shell}\n\ - 执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n" - ) + } +} + +/// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。 +fn env_profile_date_volatile() -> String { + let today = chrono::Local::now().format("%Y-%m-%d"); + format!("今天是 {today}") } /// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) @@ -247,6 +270,98 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str { } } +// ── BE3(AC-EFF-L0-2): system prompt 会话内缓存 ── +// +// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 + +// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。 +// +// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃 +// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接 +// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。 +// +// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串, +// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增 +// AppState 字段,进程内单例,懒初始化。 +// +// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」, +// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。 +static SYSTEM_PROMPT_CACHE: OnceLock>> = OnceLock::new(); + +/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。 +const SYSTEM_PROMPT_CACHE_MAX: usize = 32; + +fn system_prompt_cache() -> &'static Mutex> { + SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。 +/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。 +async fn entity_fingerprint(db: &Arc, sql: &'static str) -> Option { + let conn = db.conn().clone(); + let result = tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard.prepare(sql).ok()?; + let rows = stmt + .query_map([], |row| { + let id: String = row.get(0)?; + let updated: String = row.get(1)?; + Ok(format!("{}@{};", id, updated)) + }) + .ok()?; + let mut out = String::new(); + for r in rows { + out.push_str(&r.ok()?); + } + Some(out) + }) + .await; + result.ok().flatten() +} + +/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。 +/// +/// 指纹覆盖全部依赖输入: +/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。 +/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。 +/// - custom_prompt:与 build 内读的同一设置,变更即失效。 +/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。 +/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。 +async fn system_prompt_fingerprint( + state: &AppState, + lang: &str, + excl_proj: &[String], + excl_task: &[String], +) -> Option { + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let custom = state + .settings + .get("custom_prompt") + .await + .ok() + .flatten() + .unwrap_or_default(); + let proj_fp = entity_fingerprint( + &state.db, + "SELECT id, updated_at FROM projects WHERE deleted_at IS NULL", + ) + .await?; + let task_fp = entity_fingerprint( + &state.db, + "SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL", + ) + .await?; + let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}"); + for id in excl_proj { + key.push_str("|p:"); + key.push_str(id); + } + for id in excl_task { + key.push_str("|t:"); + key.push_str(id); + } + Some(key) +} + /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// /// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path), @@ -260,6 +375,11 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str { /// /// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给 /// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。 +/// +/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走 +/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API +/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。 +#[allow(dead_code)] pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { build_system_prompt_with_excluded(state, lang, &[], &[]).await } @@ -283,7 +403,15 @@ pub(crate) async fn build_system_prompt_with_excluded( excluded_project_ids: &[String], excluded_task_ids: &[String], ) -> String { - let (prefix, proj_label, task_label) = system_prompt_parts(lang); + // BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。 + let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).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) { + return cached.clone(); + } + } + let (prefix, _, _) = system_prompt_parts(lang); let mut prompt = env_profile_line(); prompt.push_str(prefix); // 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试) @@ -291,55 +419,142 @@ pub(crate) async fn build_system_prompt_with_excluded( // 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) prompt.push_str(app_config_query_guidance_section(lang)); + // 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。 + prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await); + prompt.push_str(&build_custom_section(state).await); + + // BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。 + if let Some(key) = fp_key { + let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner()); + if cache.len() >= SYSTEM_PROMPT_CACHE_MAX { + cache.clear(); + } + cache.insert(key, prompt.clone()); + } + prompt +} + +/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段 +/// [`build_system_prompt_parts`] 共用)。 +/// +/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。 +/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。 +async fn build_context_lists( + state: &AppState, + excluded_project_ids: &[String], + excluded_task_ids: &[String], + lang: &str, +) -> String { + let (_, proj_label, task_label) = system_prompt_parts(lang); + let mut out = String::new(); // 附加当前数据上下文 if let Ok(projects) = state.projects.list_active().await { if !projects.is_empty() { - prompt.push_str(proj_label); + out.push_str(proj_label); // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) for p in projects.iter().take(20) { if excluded_project_ids.iter().any(|id| id == &p.id) { continue; } - prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); + out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); if let Some(ref dir) = p.path { - prompt.push_str(&format!(" 目录: {}\n", dir)); + out.push_str(&format!(" 目录: {}\n", dir)); } } // 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用 - prompt.push_str(&projects_listed_note(lang, 20)); + out.push_str(&projects_listed_note(lang, 20)); } } // 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层) // 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。 if let Ok(tasks) = state.tasks.list_active().await { if !tasks.is_empty() { - prompt.push_str(task_label); + out.push_str(task_label); // 去重:被 @ 的任务跳过(同项目去重机理) for tk in tasks.iter().take(20) { if excluded_task_ids.iter().any(|id| id == &tk.id) { continue; } - prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); + out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); } // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks - prompt.push_str(&tasks_listed_note(lang)); + out.push_str(&tasks_listed_note(lang)); } } + out +} - // 自定义提示词(设置中配置,追加到系统 prompt 末尾) +/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。 +async fn build_custom_section(state: &AppState) -> String { if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { if !custom.is_empty() { let clean = custom.trim().trim_matches('"'); if !clean.is_empty() { - prompt.push_str("\n## 自定义指令\n"); - prompt.push_str(clean); - prompt.push('\n'); + return format!("\n## 自定义指令\n{}\n", clean); } } } + String::new() +} - prompt +/// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。 +/// +/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。 +/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态 +/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。 +pub(crate) async fn cache_enabled(state: &AppState) -> bool { + let v = state + .settings + .get("df-ai-anthropic-cache") + .await + .ok() + .flatten() + .unwrap_or_default(); + let enabled = v == "true" || v == "1"; + df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled); + enabled +} + +/// 构建 system prompt(Anthropic prompt caching 模式)。 +/// +/// 返回 `(system 稳定段, 易变尾段)`: +/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`] +/// 逐字等价(零行为变更,兼容非官方网关)。 +/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段 +/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非 +/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容 +/// 不会失效已缓存前缀(跨天缓存仍命中)。 +/// +/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`, +/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。 +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) { + if !cache_enabled { + let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await; + return (full, String::new()); + } + // 开关开:稳定段留 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(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() { + env_profile_date_volatile() + } else { + format!("{}\n\n{}", env_profile_date_volatile(), lists) + }; + (stable, volatile) } /// 项目清单尾部注明语(中/英)。 diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 2f22357..0812b03 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -62,6 +62,20 @@ async function loadConversations() { } } +// FE1(AC-EFF-F1-1/2/3):会话列表刷新收敛——所有「操作后/事件后」的列表刷新统一走本调度器。 +// 约 250ms trailing debounce:合并同一窗口内多次触发(回合收尾的 notify 自触发 + 显式 +// loadConversations 曾双拉,switch/new/delete 的本地刷新 + notify)为一次 IPC 列表拉取。 +// 跨窗口同步仍靠 notifyConversationChanged 的 emit——分离窗口 listener 也收敛到本调度器, +// 各自窗口只拉一次。初始加载(AiChat.vue 挂载)仍直调 loadConversations,不受防抖延迟。 +let _refreshTimer: ReturnType | null = null +function scheduleConversationsRefresh(): void { + if (_refreshTimer) clearTimeout(_refreshTimer) + _refreshTimer = setTimeout(() => { + _refreshTimer = null + void loadConversations() + }, 250) +} + // 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return, // 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。 let _newConvLock = false @@ -103,7 +117,8 @@ async function newConversation() { // queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain) state.agentRound = 0 state.searchQuery = '' - await loadConversations() + // FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。 + scheduleConversationsRefresh() notifyConversationChanged() } finally { // 防抖释放:300ms 后允许再次新建 @@ -286,15 +301,15 @@ export async function switchConversation(id: string, force = false) { loadMoreCursor.loading = false state.messages = [] notifyConversationChanged() - void loadConversations() + scheduleConversationsRefresh() return } // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 - // 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。 + // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。 console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) if (mySwitchId !== _latestSwitchId) return pushConvOpFail('switchConvFail') - void loadConversations() + scheduleConversationsRefresh() return } finally { // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 @@ -393,53 +408,60 @@ export async function switchConversation(id: string, force = false) { // 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批, // 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。 // 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。 - try { - const pending = await aiApi.pendingToolCalls(id) - // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals - if (mySwitchId !== _latestSwitchId) return - if (pending.length) { - // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 - const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) - const pendingIds = new Set(pending.map(p => p.tool_call_id)) - const restored: AiToolCallInfo[] = [] - for (const m of state.messages) { - for (const tc of (m.toolCalls || [])) { - if (pendingIds.has(tc.id)) { - tc.status = 'pending_approval' - const kind = pendingKindMap.get(tc.id) ?? 'risk' - tc.kind = kind - // path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path; - // 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。 - if (kind === 'path') { - const p = (tc.args as { path?: string } | null)?.path - tc.path = p - tc.dir = p ?? undefined - tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' }) + // F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息 + // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。 + const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0) + if (!targetGen && !hasToolCalls) { + state.pendingApprovals = [] + } else { + try { + const pending = await aiApi.pendingToolCalls(id) + // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals + if (mySwitchId !== _latestSwitchId) return + if (pending.length) { + // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 + const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) + const pendingIds = new Set(pending.map(p => p.tool_call_id)) + const restored: AiToolCallInfo[] = [] + for (const m of state.messages) { + for (const tc of (m.toolCalls || [])) { + if (pendingIds.has(tc.id)) { + tc.status = 'pending_approval' + const kind = pendingKindMap.get(tc.id) ?? 'risk' + tc.kind = kind + // path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path; + // 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。 + if (kind === 'path') { + const p = (tc.args as { path?: string } | null)?.path + tc.path = p + tc.dir = p ?? undefined + tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' }) + } + restored.push({ + id: tc.id, + name: tc.name, + args: tc.args, + status: 'pending_approval', + kind, + path: tc.path, + dir: tc.dir, + reason: tc.reason, + // 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。 + conversationId: id, + }) + // 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过; + // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) + startApprovalTimer(tc.id, tc.name, kind) } - restored.push({ - id: tc.id, - name: tc.name, - args: tc.args, - status: 'pending_approval', - kind, - path: tc.path, - dir: tc.dir, - reason: tc.reason, - // 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。 - conversationId: id, - }) - // 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过; - // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) - startApprovalTimer(tc.id, tc.name, kind) } } + state.pendingApprovals = restored + } else { + state.pendingApprovals = [] } - state.pendingApprovals = restored - } else { + } catch { state.pendingApprovals = [] } - } catch { - state.pendingApprovals = [] } } @@ -493,7 +515,7 @@ async function deleteConversation(id: string) { loadMoreCursor.convId = null loadMoreCursor.loading = false } - await loadConversations() + scheduleConversationsRefresh() // 删的是活跃会话:回落相邻会话作为新活跃视图 if (neighborId) await switchConversation(neighborId) notifyConversationChanged({ deletedConvId: id }) @@ -604,6 +626,7 @@ function toggleSidebar() { export function useAiConversations() { return { loadConversations, + scheduleConversationsRefresh, newConversation, switchConversation, deleteConversation, @@ -617,4 +640,4 @@ export function useAiConversations() { } } -export { loadConversations, loadMoreHistory, getLoadMoreState } +export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState } diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 666f08e..d4208ee 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -18,7 +18,7 @@ import { state } from '@/stores/ai' import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared' import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { setStreaming } from './streamingGuard' -import { loadConversations } from './useAiConversations' +import { scheduleConversationsRefresh } from './useAiConversations' import { handleStreamingEvent } from './useAiStreamingEvents' import { handleToolEvent } from './useAiToolEvents' import { handleLifecycleEvent } from './useAiLifecycleEvents' @@ -61,7 +61,8 @@ function handleUserMessageEvent(event: AiChatEvent): boolean { // 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表 const uc = event.conversation_id ?? null if (uc && state.activeConversationId && uc !== state.activeConversationId) { - void loadConversations() + // FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。 + scheduleConversationsRefresh() return true } const last = state.messages[state.messages.length - 1] @@ -96,7 +97,8 @@ export function handleEvent(event: AiChatEvent) { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { // 非当前会话终止:收敛会话状态(删 Map 项回不在生成) convStates.delete(convId || '') - void loadConversations() + // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 + scheduleConversationsRefresh() // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) if (event.type === 'AiCompleted') { emit('ai-drain-queue', { conversationId: event.conversation_id }) @@ -142,7 +144,8 @@ export async function startListener() { try { _unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenConvChanged = await listen('ai-conversation-changed', (e) => { - void loadConversations() + // FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。 + scheduleConversationsRefresh() // 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。 const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId if (deleted && state.activeConversationId === deleted) { diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index b8ea9e8..f1044d6 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -13,7 +13,6 @@ import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllTo import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' import { clearStreamWatchdog } from './useAiStream' import { setStreaming } from './streamingGuard' -import { loadConversations } from './useAiConversations' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' const appSettings = useAppSettingsStore() @@ -116,7 +115,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { const conv = state.conversations.find(c => c.id === state.activeConversationId) if (conv) conv.pinned_goals = event.pinned_goals } - void loadConversations() + // FE1(AC-EFF-F1-1/2/3):去掉显式 loadConversations(曾与下方 notify 自触发双拉), + // 列表刷新统一收敛到 ai-conversation-changed listener 的防抖调度,一次收尾只拉一次。 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 if (isShowTokenUsage()) { state.lastTokenUsage = { @@ -191,7 +191,7 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { options: event.options, conversationId: event.conversation_id || state.activeConversationId || null, } - void loadConversations() + // FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。 notifyConversationChanged() return true }