优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账
This commit is contained in:
@@ -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<String> = {
|
||||
// 抽离 system 消息。
|
||||
// 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在
|
||||
// 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。
|
||||
let system: Option<SystemBlock> = {
|
||||
let sys: Vec<String> = 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<serde_json::Value>),
|
||||
}
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
|
||||
pub messages: Vec<serde_json::Value>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
pub system: Option<SystemBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
pub stream: bool,
|
||||
|
||||
@@ -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<ChatMessage> {
|
||||
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<ChatMessage>) {
|
||||
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
|
||||
|
||||
+11
-11
@@ -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 全量)
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatMessage>, 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<String>,
|
||||
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<String>,
|
||||
) -> (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<String> = {
|
||||
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<String> = Vec::new();
|
||||
|
||||
@@ -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<MentionSpanDto>>) -> (Vec<Strin
|
||||
(proj, task)
|
||||
}
|
||||
|
||||
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
|
||||
fn is_anthropic_type(provider_type: &str) -> 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())
|
||||
|
||||
@@ -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<df_storage::models::AiMessageRecord> = 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<df_storage::models::AiMessageRecord> = 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AiProviderRe
|
||||
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
||||
fn env_profile_line() -> 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<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
|
||||
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
|
||||
|
||||
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
|
||||
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
|
||||
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
|
||||
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 项目清单尾部注明语(中/英)。
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user