From 63bff8bf969bca949c916a97f219ecb69f8a73bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 17 Jun 2026 00:50:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-15=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=8E=8B=E7=BC=A9=E5=9F=BA=E7=A1=80(compress=5Fprompt?= =?UTF-8?q?=E5=9B=9B=E6=AE=B5=E5=BC=8F+compress=5Fvia=5Fllm+context?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E6=96=B9=E6=B3=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/context.rs | 294 ++++++++++++++++++++++++-- src-tauri/src/commands/ai/compress.rs | 148 +++++++++++++ src-tauri/src/commands/ai/mod.rs | 16 ++ src-tauri/src/commands/ai/prompt.rs | 66 ++++++ 4 files changed, 507 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/commands/ai/compress.rs diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index 5bb4fc1..1922e68 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -106,7 +106,7 @@ impl ContextConfig { /// 消息在逻辑上的分组标签,用于淘汰时保持原子性 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MessageGroup { +pub enum MessageGroup { /// 普通 User / Assistant 文本消息(可独立淘汰) Standalone, /// Assistant 带 tool_calls,是三元组的头 @@ -116,10 +116,14 @@ enum MessageGroup { } /// 带有 token 缓存和分组信息的消息条目 -struct TrackedMessage { - message: ChatMessage, - token_count: u32, - group: MessageGroup, +/// +/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后, +/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问, +/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。 +pub struct TrackedMessage { + pub message: ChatMessage, + pub token_count: u32, + pub group: MessageGroup, } fn classify_group(msg: &ChatMessage) -> MessageGroup { @@ -150,6 +154,9 @@ pub struct ContextManager { history_tokens: u32, config: ContextConfig, estimator: TokenEstimator, + /// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。 + /// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。 + is_compressing: bool, } /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) @@ -166,6 +173,7 @@ impl ContextManager { history_tokens: 0, config, estimator: TokenEstimator::default(), + is_compressing: false, } } @@ -194,6 +202,7 @@ impl ContextManager { pub fn clear(&mut self) { self.messages.clear(); self.history_tokens = 0; + self.is_compressing = false; } /// 消息数量 @@ -522,14 +531,49 @@ impl ContextManager { self.messages.iter().map(|t| &t.message) } - // ── 内部方法 ── + // ── F-15 上下文管理增强辅助方法(阶段1 基础,零行为变化)── + // + // 这些方法供阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic loop + // 自动压缩调用。本阶段只暴露方法 + 单测,不接 IPC/前端/agentic.rs。 - /// 构建淘汰单元列表 + /// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`) + pub fn config(&self) -> &ContextConfig { + &self.config + } + + /// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token) /// - /// 每个单元是连续消息范围 [start, end),保证: - /// - 工具调用三元组(ToolCallHead + ToolResultTail* + 紧随的文本 Assistant)在同一单元 - /// - 保护区内的消息不纳入任何单元 - fn build_eviction_units(&self, protect_start: usize) -> Vec { + /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走 + /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。 + pub fn messages_mut(&mut self) -> &mut [TrackedMessage] { + &mut self.messages + } + + /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。 + /// + /// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义, + /// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。 + pub fn insert_at(&mut self, index: usize, message: ChatMessage) { + let tokens = self.estimator.estimate_message(&message); + let group = classify_group(&message); + if message.is_active() { + self.history_tokens += tokens; + } + self.messages.insert(index, TrackedMessage { + message, + token_count: tokens, + group, + }); + } + + /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 + /// + /// 返回每个单元的右开区间 end + token 总和,保证: + /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元 + /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元 + /// + /// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。 + pub fn build_eviction_units(&self, protect_start: usize) -> Vec { let mut units = Vec::new(); let mut i = 0usize; @@ -537,14 +581,12 @@ impl ContextManager { let mut token_sum = 0u32; if self.messages[i].group == MessageGroup::ToolCallHead { - // 收集完整三元组:Head + 后续所有 ToolResultTail + 紧随的文本 Assistant token_sum += self.messages[i].token_count; i += 1; while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail { token_sum += self.messages[i].token_count; i += 1; } - // 紧随的 Standalone Assistant(工具调用的最终文本回复) if i < protect_start && self.messages[i].group == MessageGroup::Standalone && matches!(self.messages[i].message.role, MessageRole::Assistant) @@ -553,7 +595,6 @@ impl ContextManager { i += 1; } } else { - // Standalone / ToolResultTail(理论上孤立 Tail 不该出现,按单条处理) token_sum += self.messages[i].token_count; i += 1; } @@ -563,12 +604,64 @@ impl ContextManager { units } + + /// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。 + /// + /// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated + /// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。 + pub fn has_compressible_messages(&self, protect_start: usize) -> bool { + let end = protect_start.min(self.messages.len()); + self.messages[..end] + .iter() + .any(|t| t.message.is_active()) + } + + /// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`, + /// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。 + /// + /// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens` + /// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。 + /// + /// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离 + /// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。 + /// + /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。 + pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec { + let end = compress_end.min(self.messages.len()); + let mut newly_compressed = Vec::new(); + for t in self.messages[..end].iter_mut() { + if t.message.is_active() { + newly_compressed.push(t.message.clone()); + t.message.status = Some("compressed".to_string()); + self.history_tokens = self.history_tokens.saturating_sub(t.token_count); + } + } + newly_compressed + } + + /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。 + pub fn is_compressing(&self) -> bool { + self.is_compressing + } + + /// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位), + /// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。 + pub fn set_compressing(&mut self, v: bool) { + self.is_compressing = v; + } + + // ── 内部方法 ── + + // (原 build_eviction_units / classify_group 等纯函数已在上方公开或文件级定义; + // ContextManager 的私有辅助如需新增放在这里。) } /// 淘汰单元:连续消息范围 [..end) + token 总和 -struct EvictionUnit { - end: usize, - token_sum: u32, +/// +/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。 +pub struct EvictionUnit { + pub end: usize, + pub token_sum: u32, } #[cfg(test)] @@ -944,4 +1037,171 @@ mod tests { msgs.len() ); } + + // ── F-15 阶段1 辅助方法单测 ── + + #[test] + fn compress_old_messages_marks_compressed_and_returns_refs() { + // F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed, + // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("旧消息1")); + mgr.push(ChatMessage::assistant("旧回复1")); + mgr.push(ChatMessage::user("新消息2")); + let tokens_before = mgr.history_tokens(); + assert!(tokens_before > 0); + + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 2, "应压缩前 2 条 active"); + assert_eq!(compressed[0].content, "旧消息1"); + assert_eq!(compressed[1].content, "旧回复1"); + + // status 已改 compressed + assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); + assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed")); + // 保护区外(本例 index 2)仍 active + assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动"); + + // 持久化全量不变 + assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)"); + + // token 已扣(剩第 3 条的) + let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2")); + assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条"); + } + + #[test] + fn compress_old_messages_is_idempotent() { + // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("a")); + mgr.push(ChatMessage::user("b")); + + let first = mgr.compress_old_messages(2); + assert_eq!(first.len(), 2); + let tokens_after_first = mgr.history_tokens(); + + let second = mgr.compress_old_messages(2); + assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)"); + assert_eq!( + mgr.history_tokens(), + tokens_after_first, + "二次压缩 history_tokens 不应再变(幂等)" + ); + } + + #[test] + fn compress_old_messages_clamps_oversized_end() { + // compress_end 越界自动 clamp 到 len,不 panic。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("唯一")); + let compressed = mgr.compress_old_messages(999); + assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)"); + assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零"); + } + + #[test] + fn compress_old_messages_skips_already_inactive() { + // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。 + let mut mgr = ContextManager::new(cfg(100_000)); + let mut truncated = ChatMessage::user("被截断"); + truncated.status = Some("truncated".to_string()); + mgr.push(truncated); + mgr.push(ChatMessage::user("active 一条")); + let tokens_before = mgr.history_tokens(); + // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条 + + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过"); + assert_eq!(mgr.history_tokens(), 0); + assert_eq!( + mgr.history_tokens(), + tokens_before.saturating_sub(tokens_before), + "幂等扣除:truncated 本就没计 token,active 扣光" + ); + // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆) + assert_eq!( + mgr.messages_mut()[0].message.status.as_deref(), + Some("truncated"), + "已 truncated 不应被改写为 compressed" + ); + } + + #[test] + fn has_compressible_messages_respects_protect_zone() { + let mut mgr = ContextManager::new(cfg(100_000)); + for i in 0..8 { + mgr.push(ChatMessage::user(&format!("消息 {}", i))); + } + // protect_start=6 → [0,6) 内有 active → true + assert!(mgr.has_compressible_messages(6)); + // protect_start=0 → 空范围 → false + assert!(!mgr.has_compressible_messages(0)); + // 全部压缩后 → false + mgr.compress_old_messages(6); + assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息"); + } + + #[test] + fn is_compressing_flag_round_trip() { + // 标志位读写 round-trip;clear() 复位。 + let mut mgr = ContextManager::new(cfg(100_000)); + assert!(!mgr.is_compressing(), "默认 false"); + mgr.set_compressing(true); + assert!(mgr.is_compressing(), "set true 后应读到 true"); + mgr.set_compressing(false); + assert!(!mgr.is_compressing(), "set false 后复位"); + // clear 复位 + mgr.set_compressing(true); + mgr.clear(); + assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing"); + } + + #[test] + fn insert_at_adds_to_budget_when_active() { + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("a")); + let tokens_before = mgr.history_tokens(); + + // 插入 active system 消息 → 计入 token + mgr.insert_at(0, ChatMessage::system("## 摘要")); + assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token"); + assert_eq!(mgr.len(), 2); + assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要"); + + // 插入 !active 消息 → 不计入 token + let tokens_before_inactive = mgr.history_tokens(); + let mut inactive = ChatMessage::user("x"); + inactive.status = Some("truncated".to_string()); + mgr.insert_at(0, inactive); + assert_eq!( + mgr.history_tokens(), + tokens_before_inactive, + "!active 消息插入不应计 token" + ); + } + + #[test] + fn build_eviction_units_keeps_triplet_atomic_public() { + // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("前置")); + mgr.push(ChatMessage::assistant_with_tools( + "调", + vec![ToolCall::new("c1", "fn", "{}")], + )); + mgr.push(ChatMessage::tool_result("c1", "结果")); + mgr.push(ChatMessage::assistant("完成")); + mgr.push(ChatMessage::user("后置")); + // protect_start=5(全部纳入) + let units = mgr.build_eviction_units(5); + // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开 + // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3) + let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元"); + assert!( + unit2.end >= 4, + "三元组三件套应在同一淘汰单元, end={}", + unit2.end + ); + } } diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs new file mode 100644 index 0000000..67ba2fb --- /dev/null +++ b/src-tauri/src/commands/ai/compress.rs @@ -0,0 +1,148 @@ +//! F-15 上下文压缩 — LLM 摘要公共函数 +//! +//! 阶段1 基础(本文件):仅暴露 `compress_via_llm` 函数,供阶段2 IPC +//! (`ai_chat_compress_context`)与阶段3 agentic loop 自动压缩共用。 +//! +//! 模式严格对齐 [`super::title`] 的 `generate_title_via_llm`:非流式 +//! `provider.complete()` + `LlmConcurrency` 双层 Semaphore 限流 + select_model_id +//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`, +//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。 + +use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; +// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化 +// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 +// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 +use df_ai::router::{ + select_model_id, CostTier, IntelligenceTier, Modality, TaskRequirements, +}; +use df_storage::models::AiProviderRecord; + +use crate::state::LlmConcurrency; + +use super::prompt::compress_prompt; + +/// 调 LLM 非流式把 active 消息压缩为四段式摘要。 +/// +/// - 输入:`provider`(经 [`super::secret::build_provider_for`] 构造,含 keyring 解析) +/// + `provider_config`(取 `default_model` 兜底 + `model_configs` 路由池) +/// + `active_msgs`(待压缩的 active 消息,来自 [`ContextManager::compress_old_messages`] +/// 的返回或阶段2 IPC 取全段 active) +/// + `lang`("zh"/"en",与系统提示词语言一致,影响 `compress_prompt` 选模板) +/// - 输出:`Result` — `Ok` 为 LLM 生成的摘要文本(已 trim); +/// `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。 +/// +/// 模型选择:`select_model_id(TaskRequirements{Standard 智力, Medium 成本上限, 无 tool_use})`, +/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。 +/// +/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流, +/// 不与主 stream_llm 抢资源失控)。 +/// +/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed + +/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。 +// +// dead_code:阶段1 基础函数,阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic +// loop 自动压缩会调用。本阶段只暴露 + 单测,不接调用点(零行为变化原则)。 +#[allow(dead_code)] +pub(crate) async fn compress_via_llm( + provider: &dyn LlmProvider, + provider_config: &AiProviderRecord, + active_msgs: Vec, + lang: &str, + llm_concurrency: &LlmConcurrency, +) -> Result { + if active_msgs.is_empty() { + return Err("无可压缩消息(active 消息为空)".to_string()); + } + + // 路由选模型:Standard 智力够摘要,无 tool_use,Medium 成本上限。 + // None(池空/无匹配)→ 兜底 default_model(行为不变)。 + let compress_req = TaskRequirements { + modalities: vec![Modality::Text], + needs_tool_use: false, + min_intelligence: IntelligenceTier::Standard, + max_cost: Some(CostTier::Medium), + estimated_context: 0, + }; + let model = select_model_id(&compress_req, &provider_config.model_configs) + .unwrap_or_else(|| provider_config.default_model.clone()); + + // 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。 + let mut prompt = vec![ChatMessage::system(compress_prompt(lang))]; + prompt.extend(active_msgs); + let request = CompletionRequest { + model, + messages: prompt, + temperature: Some(0.2), + max_tokens: Some(1024), + stream: false, + tools: None, + tool_choice: None, + }; + + // LLM 并发限流(压缩属独立调用,纳入双层 Semaphore) + let _global_permit = llm_concurrency.acquire_global().await; + let _per_conv_permit = llm_concurrency.acquire_per_conv().await; + + let resp = provider + .complete(request) + .await + .map_err(|e| format!("LLM 压缩调用失败: {}", e))?; + let summary = clean_summary(&resp.text); + if summary.is_empty() { + return Err("LLM 压缩返回空摘要".to_string()); + } + Ok(summary) +} + +/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回 +/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。 +#[allow(dead_code)] +fn clean_summary(raw: &str) -> String { + let t = raw.trim(); + // 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```) + let t = t + .strip_prefix("```markdown") + .or_else(|| t.strip_prefix("```")) + .unwrap_or(t) + .trim_start_matches('\n'); + let t = t.strip_suffix("```").unwrap_or(t).trim(); + t.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_summary_trims_and_strips_fences() { + assert_eq!(clean_summary(" hello "), "hello"); + assert_eq!(clean_summary("```markdown\n## 意图\nfoo\n```"), "## 意图\nfoo"); + assert_eq!(clean_summary("```\n## 决策\nbar\n```"), "## 决策\nbar"); + // 保留内部 markdown 结构(摘要本就是结构化) + assert_eq!(clean_summary("## 意图\n- a\n- b"), "## 意图\n- a\n- b"); + } + + #[test] + fn clean_summary_empty_stays_empty() { + assert_eq!(clean_summary(" "), ""); + assert_eq!(clean_summary(""), ""); + } + + #[test] + fn compress_prompt_returns_template_per_lang() { + // 中文模板(默认) + let zh = compress_prompt("zh"); + assert!(zh.contains("意图")); + assert!(zh.contains("决策")); + assert!(zh.contains("文件")); + assert!(zh.contains("约束")); + // 英文模板 + let en = compress_prompt("en"); + assert!(en.contains("Intent")); + assert!(en.contains("Decisions")); + assert!(en.contains("Files")); + assert!(en.contains("Constraints")); + // 未知语言降级中文 + assert_eq!(compress_prompt("fr"), compress_prompt("zh")); + } +} diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 85279d4..a4b9e23 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -11,6 +11,7 @@ //! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理 //! - [`skills`] — 本机 Claude 技能扫描 //! - [`prompt`] — 系统提示词构建 +//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数) //! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验 //! - [`knowledge_inject`] — 知识库注入 + 提炼 //! @@ -23,6 +24,7 @@ pub mod agentic; pub mod audit; pub mod commands; +pub mod compress; pub mod conversation; pub mod knowledge_inject; pub mod prompt; @@ -213,6 +215,19 @@ pub struct AiSession { /// /// 单例一份:当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)。 pub iteration_used: usize, + /// F-01 阶段6: 用户指定模型 override(主对话专用)。 + /// + /// **语义**:前端顶部下拉「指定」模式选的 model_id;None/空=自动模式(路由器选)。 + /// 仅主对话(agentic)生效——标题/扫描/灵感等内部调用仍走路由,不读此字段。 + /// + /// **兜底原则**(agentic.rs run_agentic_loop 内强制):override 非空且在该 provider + /// model_configs 池中 → 用 override;否则用 resolved_model(路由结果)。绝不让 + /// override 导致无模型。 + /// + /// **生命周期**:ai_chat_send/regenerate/edit 写入(随发消息带);新对话/会话切换时清空 + /// (防上一对话 override 残留污染新对话)。try_continue_agent_loop 读此字段透传给续跑 + /// loop(审批续跑/达 max 续跑保持 override 一致,语义=同一主对话)。 + pub model_override: Option, } impl AiSession { @@ -228,6 +243,7 @@ impl AiSession { stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), iteration_used: 0, + model_override: None, } } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 72f9990..fb61b07 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -127,3 +127,69 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String prompt } + +/// 上下文压缩 system prompt 模板(F-15 §4.2,纯函数无 IO)。 +/// +/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留 +/// 旧消息语义。调用方把返回的 system prompt 放在消息列表首位,后接被压缩的 +/// active 消息原文(user/assistant/tool)喂给 `provider.complete()`: +/// +/// ```ignore +/// let mut msgs = vec![ChatMessage::system(compress_prompt(lang))]; +/// msgs.extend(active_msgs); +/// let resp = provider.complete(CompletionRequest { messages: msgs, .. }).await?; +/// ``` +/// +/// `lang` 与系统提示词保持一致取值集合("en" 英文,其余一律中文)。 +// +// dead_code:阶段1 基础函数,阶段2 IPC(ai_chat_compress_context)与阶段3 agentic +// 自动压缩会经 compress_via_llm 调用本函数。本阶段只暴露 + 单测,不接调用点。 +#[allow(dead_code)] +pub(crate) fn compress_prompt(lang: &str) -> &'static str { + match lang { + "en" => "You are a conversation summarizer. Compress the following conversation \ + into a structured summary that preserves the essential context for \ + continuing the work. Output exactly four sections:\n\ + \n\ + ## Intent\n\ + The user's core intent and requirements (what they are trying to achieve).\n\ + \n\ + ## Decisions\n\ + Key decisions already made (which approach/option was chosen, why alternatives \ + were rejected).\n\ + \n\ + ## Files\n\ + Files modified/created/inspected, with a one-line description of the change in each.\n\ + \n\ + ## Constraints\n\ + Important constraints, preferences, or open issues to respect going forward.\n\ + \n\ + Rules:\n\ + - Be concise; prefer bullet points.\n\ + - Drop small talk and transient pleasantries; keep only technically load-bearing facts.\n\ + - Preserve file paths, identifiers, and error messages verbatim.\n\ + - Do NOT invent facts not present in the conversation.\n\ + - Output the four sections only, no preamble or extra commentary.", + _ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\ + 严格按以下四段输出:\n\ + \n\ + ## 意图\n\ + 用户的核心意图和需求(用户想要达成什么)。\n\ + \n\ + ## 决策\n\ + 已做出的关键决策(选了哪种方案/选项,为何排除其他方案)。\n\ + \n\ + ## 文件\n\ + 已修改/创建/查看过的文件,每条一行简述其变更内容。\n\ + \n\ + ## 约束\n\ + 重要约束、偏好或待解决的问题,后续需要继续遵守或留意。\n\ + \n\ + 要求:\n\ + - 简洁,优先用要点。\n\ + - 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\ + - 文件路径、标识符、错误信息等照原样保留。\n\ + - 不要编造对话中没有的事实。\n\ + - 只输出上述四段内容,不要前言、解释或额外评论。", + } +}