Compare commits

...
2 Commits
Author SHA1 Message Date
lxy cb23061088 新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
2026-10-11 22:27:20 +08:00
lxy 6f5c6b7532 优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退
- 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配
- BP3 断点机制+缓存命中率观测日志,调参有据
- 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费
- 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗
- namespace 引用跨重启内容哈希懒回填
2026-10-11 22:27:08 +08:00
34 changed files with 3242 additions and 323 deletions
+253 -18
View File
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
///
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
/// 之下(它发的也是 cache_control),两层正交。
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
pub fn set_bp3_enabled(enabled: bool) {
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
pub fn bp3_enabled() -> bool {
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
///
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
fn log_cache_usage(usage: &TokenUsage) {
let total_input = usage.prompt_cache_hit_tokens as u64
+ usage.prompt_cache_miss_tokens as u64
+ usage.prompt_tokens as u64;
if total_input == 0 {
return;
}
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
tracing::info!(
input_tokens = usage.prompt_tokens,
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
hit_rate = %hit_pct,
"Anthropic prompt cache 命中率观测"
);
}
// ============================================================
// Provider 实现
// ============================================================
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
// 构建非 system 消息(保留顺序,合并连续 tool_result)
let mut messages: Vec<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
let mut msgs_after_anchor: Option<usize> = None;
for m in req.messages.iter() {
for (src_idx, m) in req.messages.iter().enumerate() {
match m.role {
MessageRole::System => continue,
MessageRole::System => {
if src_idx > 0 {
msgs_after_anchor = Some(0);
}
continue;
}
MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
}
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
} else {
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
}
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
let mut content: Vec<serde_json::Value> = Vec::new();
if !m.content.is_empty() {
content.push(serde_json::json!({ "type": "text", "text": m.content }));
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
content.push(serde_json::json!({ "type": "text", "text": "" }));
}
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
// 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
// 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages);
let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
}
let tools = req.tools.map(|defs| {
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>,
msgs_after_anchor: &mut Option<usize>,
) {
if pending.is_empty() {
return;
}
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
if let Some(k) = msgs_after_anchor {
*k += 1;
}
}
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
/// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
/// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
/// 余 1 备用。
fn apply_message_cache_breakpoint(
messages: &mut [serde_json::Value],
after_anchor: Option<usize>,
) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
if let Some(k) = after_anchor {
if k > 0 && k <= n {
let bp3_idx = n - k;
if bp3_idx != bp2_idx {
Self::mark_cache_control(&mut messages[bp3_idx]);
}
}
}
Self::mark_cache_control(&mut messages[bp2_idx]);
}
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
/// array(blocks) 标末块;其余形态(异常)不动。
fn mark_cache_control(msg: &mut serde_json::Value) {
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
@@ -729,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0,
};
log_cache_usage(&usage);
AttemptOutcome::Ok(CompletionResponse {
text,
model: resp.model,
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
if chunk.finished {
if let Some(u) = &chunk.usage {
log_cache_usage(u);
}
}
chunks.push(Ok(chunk));
}
}
@@ -1258,7 +1351,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
@@ -1275,7 +1368,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
@@ -1289,7 +1382,7 @@ mod tests {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
@@ -1298,10 +1391,152 @@ mod tests {
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty());
}
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
#[test]
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
for idx in [0usize, 3] {
let blocks = messages[idx]["content"].as_array().unwrap();
assert_eq!(
blocks.last().unwrap()["cache_control"]["type"],
"ephemeral",
"idx{} 应带断点",
idx
);
}
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
}
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
#[test]
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
let blocks = messages[1]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
}
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
#[test]
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
// BP2(末条 user → n-2=0)照常标 q0
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
}
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
fn bp3_req(with_anchor: bool) -> CompletionRequest {
let mut messages = vec![
ChatMessage::system("sys-prompt"),
ChatMessage::user("q0"),
ChatMessage::assistant("a0"),
ChatMessage::user("q1"),
];
if with_anchor {
messages.insert(1, ChatMessage::system("## 压缩摘要"));
messages.insert(2, ChatMessage::system("## 话题标记"));
}
CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages,
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
}
}
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
#[test]
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
assert_eq!(q0_blocks[0]["text"], "q0");
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
// BP2 照常:末条 user 前相邻一条 a0
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
set_anthropic_cache_enabled(false);
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
}
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
#[test]
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(false);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"BP3 关,q0 保持字符串简写"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
#[test]
fn bp3_no_anchor_no_extra_breakpoint() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(false));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"无锚点应不加 BP3"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
+248 -5
View File
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token,实际 {}",
mm_tokens >= 1600,
"大 base64 应按图地板计 token,实际 {}",
mm_tokens
);
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
#[test]
fn over_budget_trims_old() {
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200));
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
let mut mgr = ContextManager::new(cfg(400));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 {
if i % 2 == 0 {
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
);
}
// ============================================================
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
// ============================================================
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
}
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
let est = TokenEstimator::default();
msgs.iter()
.filter(|m| !m.content.starts_with("[早期对话概要]"))
.map(|m| est.estimate_message(m) as u64)
.sum()
}
#[test]
fn block_trim_lands_below_target_water_level() {
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= target,
"裁后水位应落在目标 {} 以下, 实际 {}",
target,
remaining
);
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
let last_real = msgs
.iter()
.rev()
.find(|m| !m.content.starts_with("[早期对话概要]"))
.expect("视图非空");
assert!(
last_real.content.contains("第39条"),
"保护区最新消息被误裁, 实际末条: {}",
last_real.content
);
}
#[test]
fn block_trim_snaps_boundary_to_turn_start() {
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
let mut mgr = ContextManager::new(cfg(300));
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), filler);
}
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
for i in 0..6 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let first = msgs.first().expect("视图非空");
assert!(
matches!(first.role, MessageRole::User),
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
first.role
);
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
view_history_tokens(&msgs) <= target,
"吸附同时仍应满足目标水位"
);
assert_eq!(
has_head(&msgs),
has_tail(&msgs),
"轮吸附不得劈开工具三元组"
);
}
#[test]
fn block_trim_pathological_falls_back_to_hard_budget() {
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
let mut mgr = ContextManager::new(cfg(400));
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), &filler);
}
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
mgr.push(ChatMessage::system("[早期锚点]"));
for i in 0..5 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "病态回退仍应触发裁剪");
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= 400,
"硬预算安全底线:裁后不得超过 available, 实际 {}",
remaining
);
let first = msgs.first().expect("视图非空");
assert!(
first.content.contains("[早期锚点]"),
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
first.content
);
}
#[test]
fn cache_friendly_off_keeps_greedy_min_trim() {
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
// 不注入(字段默认 false)= 开关关
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let remaining = view_history_tokens(&msgs);
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
remaining <= 1000,
"贪心裁后应塞下硬预算, 实际 {}",
remaining
);
assert!(
remaining > target,
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
target,
remaining
);
}
#[test]
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(!msgs.is_empty());
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"开时视图不得残留 system: {:?}",
msgs.iter().map(role_tag).collect::<Vec<_>>()
);
assert!(
mgr.all_messages_clone()
.iter()
.any(|m| matches!(m.role, MessageRole::System)),
"存储态 system 锚点不应被改写(纯视图)"
);
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
final_view.extend(msgs);
assert!(
matches!(final_view[0].role, MessageRole::System),
"组装层首条 system_prompt 不转"
);
assert_eq!(
final_view
.iter()
.filter(|m| matches!(m.role, MessageRole::System))
.count(),
1,
"最终视图应只有组装层首条 system"
);
}
#[test]
fn system_anchor_head_insert_still_converted() {
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.insert_at(0, ChatMessage::system("[摘要]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
);
}
#[test]
fn system_anchor_stays_system_when_cache_friendly_off() {
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
"关时 system 锚点应原样进视图"
);
}
+139 -15
View File
@@ -20,9 +20,11 @@
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
pub mod archive;
use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
@@ -78,6 +80,16 @@ pub struct ContextManager {
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
}
impl ContextManager {
@@ -91,6 +103,9 @@ impl ContextManager {
pending_topic_marker: None,
persisted_msg_count: 0,
needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
}
}
@@ -153,6 +168,8 @@ impl ContextManager {
// 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0;
self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
}
/// 消息数量
@@ -196,10 +213,10 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
@@ -208,15 +225,12 @@ impl ContextManager {
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in &units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let (trim_end, removed) = if self.cache_friendly {
self.block_trim_target(&units, available)
} else {
self.greedy_trim_target(&units, available)
};
if trim_end == 0 {
tracing::warn!(
@@ -229,9 +243,9 @@ impl ContextManager {
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone());
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
@@ -264,14 +278,93 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
@@ -668,6 +761,11 @@ impl ContextManager {
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
@@ -694,10 +792,36 @@ impl ContextManager {
// newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() {
self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
}
newly_compressed
}
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
+104 -39
View File
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)]
pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32,
@@ -44,57 +44,83 @@ impl Default for TokenEstimator {
}
}
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
/// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
// base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
/// 不随 base64 字符串长度膨胀。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count();
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
if matches!(msg.role, crate::provider::MessageRole::Tool) {
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
char_count = n;
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
match crate::namespace_store::parse_ref_content_len(&msg.content) {
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
None => weighted_char_tokens(&msg.content, self.chars_ratio),
}
}
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
let mut url_image_floor_tokens: u32 = 0;
} else {
weighted_char_tokens(&msg.content, self.chars_ratio)
};
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Text { text } => {
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
if let Some(b) = base64 {
char_count += b.chars().count();
} else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
url_image_floor_tokens = url_image_floor_tokens
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
// url 为空(构造端二选一),不重复计
if base64.is_some() || url.is_some() {
image_floor_tokens =
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls {
@@ -113,9 +139,9 @@ impl TokenEstimator {
total
}
/// 估算纯文本字符串的 token 数(用于 system prompt)
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
weighted_char_tokens(text, self.chars_ratio)
}
}
@@ -656,6 +682,14 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。
///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
@@ -1755,8 +1789,8 @@ mod tests {
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
@@ -1764,10 +1798,41 @@ mod tests {
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
URL_IMAGE_TOKEN_FLOOR
IMAGE_TOKEN_FLOOR
);
}
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
}
+82
View File
@@ -74,6 +74,11 @@ pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
@@ -280,6 +285,40 @@ impl NamespaceStore {
))
}
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
@@ -563,4 +602,47 @@ mod tests {
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
}
@@ -114,6 +114,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
loop_state: row.get("loop_state").unwrap_or(None),
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
working_context_json: row.get("working_context_json").unwrap_or(None),
})
}
@@ -191,14 +193,14 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status, working_context_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json, rec.loop_state, rec.status
rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
],
)
},
@@ -207,13 +209,16 @@ impl_repo!(
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// 生命周期只经 set_loop_state / set_status 单点写。
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
rec.working_context_json, rec.id
],
)
}
@@ -1186,6 +1191,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.expect("insert");
@@ -1225,6 +1231,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.expect("insert");
@@ -1236,4 +1243,59 @@ mod tests {
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
}
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
#[tokio::test]
async fn working_context_json_roundtrip_and_legacy_null() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
let mut rec = AiConversationRecord {
id: "c1".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(wc_json.into()),
};
repo.insert(rec.clone()).await.expect("insert");
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
// update_full 全量写(save_conversation 路径)后列随行更新
rec.title = Some("t".into());
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
repo.update_full(&rec).await.expect("update");
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
VALUES ('legacy', '[]', '0', '0', 'active')",
[],
)
.expect("raw insert legacy row");
}
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
}
}
+74 -2
View File
@@ -65,7 +65,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -125,6 +127,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52),
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
(53, migrate_v53),
];
for (version, migrate_fn) in steps {
@@ -1532,7 +1536,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
/// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 52;
pub const LATEST_SCHEMA_VERSION: i32 = 53;
fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") {
@@ -1584,6 +1588,27 @@ fn migrate_v52(conn: &Connection) -> Result<()> {
Ok(())
}
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
///
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
/// 目标快照裁决语义另批,本列只做存储载体。
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
fn migrate_v53(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "working_context_json") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
[],
)?;
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
Ok(())
}
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。
///
@@ -2951,4 +2976,51 @@ mod tests {
"逐条相加,缺字段按 0"
);
}
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
#[test]
fn v53_working_context_json_idempotent() {
let conn = setup_legacy_db();
assert!(
!column_exists(&conn, "ai_conversations", "working_context_json"),
"迁移前应无 working_context_json 列"
);
migrate_v53(&conn).expect("v53 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 53, "应写入版本号 53");
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 53, "重跑后版本号恢复 53");
// 列可写可读;老会话行(不带该列插入)读回 NULL
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "working_context_json 应可写可读");
let legacy: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
.unwrap();
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
}
}
+5
View File
@@ -400,6 +400,11 @@ pub struct AiConversationRecord {
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String,
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
#[serde(default)]
pub working_context_json: Option<String>,
}
/// AI 工具执行审计记录
@@ -0,0 +1,238 @@
# 压缩裁剪缓存友好化 — 设计
> 2026-10-11 · 只读勘察产物
> 主题:上下文压缩与裁剪以破坏 provider 前缀缓存为代价 → 在保住预算安全的前提下最大化缓存命中
> 行号快照:2026-10-11 工作区(context/mod.rs 与 context_helpers.rs 有并行代理在改,实施前须对齐最新行号)
---
## 〇、文档定位与既有工作衔接
本文档是「上下文分层组装器-落地方案-2026-10-10.md」病灶④(裁剪后前缀缓存整体失配)的**机制细化**,并新增覆盖压缩链路的一次压尽问题。该方案已给出验收口径:「稳态下请求前缀只追加、不位移;裁剪以批量滞后发生」——本文档给出达成该口径的具体算法、参数推理与断点布点。两文档不冲突:分层组装器治①(真值 token)②(O(n²))③(预算随窗口)⑤(召回),本文档专治④+压缩,可与 P2.1/P2.2 并行实施(见§八协调注)。
另:看板 AC-EFF-C1-2「每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批)」与「警示消息打断前缀缓存」两登记项,均由本文档方案覆盖。
---
## 一、两个缺陷的准确定性与证据
### 缺陷1:滑动裁剪致前缀缓存整体失配
`build_for_request`(`crates/df-ai/src/context/mod.rs:185`)超预算时走贪心最小裁剪:`for unit in &units { if history_tokens - removed <= available { break } removed += unit.token_sum }`(`mod.rs:211-219`)——**每次只裁到刚好塞下为止**。对话每增长一条消息就重新越过 `available` 一点,裁剪边界 `trim_end` 随之前移一个单元,发送视图的**第一条消息每轮都变**。前缀缓存(anthropic 显式 cache_control 与 openai_compat 自动前缀缓存同理)要求从第 0 字节起逐字节相同,首条即变 → 整段历史永远 miss。
已缓和但不治本的既有修正:
- 淘汰补偿摘要从头部 insert(0) 改尾部 push(user 角色,`mod.rs:250-261`),注释明言治「命中率掉到个位数」;
- `push_system_anchor` 尾部追加不位移前缀(`agentic/helpers.rs:28-44`、`context_lifecycle.rs:309-313`);
- `tool_view_decisions` 冻结压缩决策防跨轮翻转(`agentic/mod.rs:2004-2005`),`compress_cache` 按内容哈希记忆化摘要结果(`stall_detector.rs:368-379`)→ tool_result 视图摘要跨轮字节稳定,已不构成缓存威胁。
**残余根因**:裁剪边界本身逐轮爬行。注意一个连带效应——淘汰摘要每轮即时重生成,其内容在两次裁剪之间是静态的(被淘汰集不变 → `extract_top_keywords` 排序确定性,`context_helpers.rs:284-288`);「摘要逐轮漂移」正是裁剪逐轮爬行的投影。块状化后漂移自动收敛到裁剪事件点。
### 缺陷2:一次压尽
压缩触发:`available*6/10 < history_tokens`(`agentic/context_lifecycle.rs:129-136`),后台 LLM 摘要成功后 `compress_old_messages(protect_start)` 把 `[0, len-6)` 的**全部 active 消息一次性标 compressed**(`context_lifecycle.rs:308`、兜底路径 `:442`;`compress_old_messages` 实现 `mod.rs:670-699`)。sanitize step0 按 `is_active` 白名单过滤(`sanitize.rs:40-43`;`is_active`= status None/Active,`df-ai-core/provider.rs:91-99`)→ 被压消息从发送视图**整体蒸发**,视图前缀从第 0 条起全变,一次压缩 = 历史段全量缓存重建。信息损失同样一次集中(用户体感「丢历史」)。
---
## 二、现状机制盘点(设计依赖的既有事实)
| 机制 | 位置 | 事实 |
|---|---|---|
| 预算公式 | `context_helpers.rs:154-178` | `budget = (max_tokens − output_reserve) × safety_ratio`;默认 128K/8K/0.85 → **budget ≈ 101,836**;`available = budget − sys_tokens`(sys 5–10K → available ≈ 92–97K) |
| 裁剪触发 | `mod.rs:198,214` | `history_tokens > available`(100% 硬门) |
| 保护区 | `context_helpers.rs:683` | `PROTECT_COUNT = 6`(≈最近 2 轮);**context_lifecycle.rs:53 另有一份本地副本 6,双源待统一** |
| 淘汰单元 | `mod.rs:612-642` | 三元组原子(头+尾+紧随独立 assistant),压缩定位/分段标记/裁剪共用 |
| 压缩触发 | `context_lifecycle.rs:129-136` | `0.6×available` + `has_compressible_messages` + 非压缩中 |
| 压缩边界回退 | `mod.rs:676-683` | `compress_old_messages` 内建单元边界回退(只少压不多压)——**分批压缩可直接复用** |
| 摘要补偿 | `context_helpers.rs:332-364` | 淘汰摘要 ≥100 字符才产,硬顶 1000 字符(`:692`);关键词提炼确定性 |
| system 断点 | `anthropic_compat.rs:127-145` | 开关开 → 顶层 system 包 blocks + cache_control |
| 消息级断点 | `anthropic_compat.rs:243-247,339-376` | 打在**末条 user 之前相邻一条**(末条 user 是漂移边界:易变尾段/本轮新问题,打它永远不命中);注释明言预算 4 个 cache_control,现用 2 个 |
| 缓存观测 | `anthropic_compat.rs:723-731` | usage 已映射 `cache_read/cache_creation`——**命中率观测零成本可取** |
| 开关范式 | `prompt.rs:657-695` | settings KV `df-ai-anthropic-cache`,`parse_cache_flag`:空/true/1=开,false/0/杂值=关;进程级 AtomicBool 同步;`cache_split_provider_type` 按 provider gate;关=逐字节旧行为 |
| 易变尾段 | `prompt.rs:709-736`、`agentic/mod.rs:1983-1990`、`stream_relay.rs:791-795` | 日期/清单/augmentation/知识在**每次 send 时构建一次**(`commands/chat.rs:417,996,2209,2476`),loop 内多轮稳定,以 user 角色追加消息流末尾 |
| system 折叠 | `anthropic_compat.rs:128-133,153` | **关键发现:存储态所有 role=System 消息(不论位置)被抽离折叠进顶层 system(sys.join)**。压缩摘要/关键词兜底/话题标记/催办全走 `push_system_anchor(ChatMessage::system(...))`(`context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557`)→ 每次此类事件都改写顶层 system → **BP1 连带全失配**。淘汰摘要已因同因改 user 角色(`mod.rs:254-256` 注释),压缩摘要等锚点未跟进 |
---
## 三、设计总览
三机制分层,各管一段:
```
历史水位轴(×available)
0 0.55 0.75 1.0(=available)
| | | |
|<-- 压缩目标水位 C_low | |
| |<-- 批次≈0.2A |
| | ↑ 压缩触发 C_high(深清,后台,保语义)
| | ↑ 裁剪触发 1.0(应急,view-only)
| | |←→| 裁剪块≈0.2A → 裁到 T=0.8
```
- **裁剪=应急**(build_for_request,view-only):只在压缩失灵(后台失败连续多轮/未及生效)时兜底,块状跳变;
- **压缩=深清**(后台 LLM,改存储 status):常态水位管理,分批渐进;
- 稳态下裁剪永不触发(0.75+批内增长 < 1.0),历史段前缀在两次压缩事件之间逐字节稳定。
---
## 四、方案一:块状裁剪
### 4.1 算法
替换 `mod.rs:211-219` 贪心循环,改「目标水位 + 轮边界吸附」:
1. 触发条件不变:`history_tokens > available`(硬安全门,`mod.rs:214` 同式)。
2. 目标水位 `T × available`(T 默认 0.8):累计淘汰单元 token,直到 `history_tokens − removed ≤ T×available` **且**当前边界吸附到轮边界。
3. **轮边界锚点**:单元边界 `unit.end` 处,若下一时刻消息(未淘汰首条)role=User,则该 end 是轮起点——整问整答同生同死,淘汰摘要按轮覆盖语义完整。
4. 若扫到保护区(`protect_start`)仍无同时满足两条件的边界(病态:单轮 token 均值 > 块大小),回退现行为:取满足硬预算 `available` 的最后单元边界(任何边界,保证不破安全底线)。
两次裁剪之间:`trim_end` 不动 → 被淘汰集不变 → 淘汰摘要内容不变(`extract_top_keywords` 确定性)→ 视图前缀逐字节稳定 → BP2 每轮命中、openai_compat 自动前缀缓存每轮命中。
### 4.2 块大小取值推理
块大小不设独立常量,由水位差导出:`块 = (1−T) × (budget − sys_tokens)`,随模型窗口自适应缩放(与 output_reserve/safety_ratio 的关系:二者先折算进 budget,块是其再切片,无双重调参)。
默认参数下 `1−T=0.2` → 块 ≈ 18–19K tokens。设单轮增长均值 Δ(本应用工具密集,2–10K 常态):
| T | 块 | 稳定窗口 ⌊块/Δ⌋ | 命中率≈窗口/(窗口+1) | 单次裁剪信息损失 |
|---|---|---|---|---|
| 0.9 | ≈9K | 1–4 轮 | 50–80% | 最小 |
| **0.8** | ≈19K | **2–9 轮** | **67–90%** | 中 |
| 0.7 | ≈29K | 3–14 轮 | 75–93% | 大 |
T=0.8 是拐点:再往上窗口塌缩(裁剪频度回升、每次裁剪的「全量重建税」频度变高),再往下单次丢历史变狠。对照今天:超预算后命中率 ≈0%(首条每轮变)。**估算误差 ±15%**(`context_helpers.rs:26` 自述)被 20% 水位差吸收,不会刚裁完就再触。
### 4.3 块边界锚点选型对比
| 候选 | 评价 |
|---|---|
| **轮边界(推荐)** | 语义完整(问+全工具链+答同生死);淘汰摘要按轮覆盖;边界是稳定地标,只在裁剪事件移动;三元组原子性由 `build_eviction_units` 已保证,轮吸附只是再加一层 |
| 消息数 | 块 token 分布不可控(30 条可 5K 也可 80K),裁剪频度与稳定窗口忽大忽小;仅配作病态回退 |
| 纯 token 水位 | 作为**停止条件**必要(§4.1 第 2 步),但单独作边界会劈轮;与轮吸附组合使用 |
---
## 五、方案二:分批压缩
### 5.1 双水位滞后 + 批次
- **触发 C_high = 0.75×available**(现 0.6):提高压缩间隔——压缩事件=全量缓存重建税,频度直接决定命中率;0.75 仍留 0.25A 给后台 LLM 压缩完成并下轮生效的提前量(后台化设计 `context_lifecycle.rs:30-31` 的既有前提)。
- **批次目标 C_low = 0.55×available**:每批压最旧一段,压到 `history − C_low×A` 的 token 量为止,即**每批 ≈ 0.2A ≈ 19K tokens**(与裁剪块同刻度,共用推理)。
- 批边界计算:从最旧起累计淘汰单元至批次 token 量,**轮边界吸附**(同§4.1 第 3 步);`batch_end ≤ len − PROTECT_COUNT`(保护区语义不变)。传入 `compress_old_messages(batch_end)`——其内部单元回退(`mod.rs:676-683`)与占位未闭合跳过(`compress.rs:65-66`)零改动复用。
- **滞后再武装**:压后 history=C_low < C_high,需再增长 0.2A 才触发下一批 → 天然防连发;`is_compressing` 重入标志、`head_id` 版本哨兵(`context_lifecycle.rs:70-77`)、L0-3 后台化全部不动。
- 关键词兜底路径(`context_lifecycle.rs:437-449`)同步改传 batch_end:失败降级的损失也从「一次压尽」收敛为「压一批」。
### 5.2 缓存语义:为什么分批仍然值得
压缩从视图头部移除消息,新前缀=[摘要锚点]+[剩余],**任何一次压缩都是历史段全量重建,分批不减小单次破坏面**。分批的收益在别处:
1. 单次信息损失有界(0.2A≈19K),摘要质量随输入变小而升,用户「丢历史」体感摊薄;
2. 摘要锚点改为**追加链**(见§六 BP3):第 k+1 次压缩把新摘要接在摘要链尾,链之前的 [system+链头] 前缀仍命中——一次压尽则每次都从零重建;
3. 两次压缩间隔拉长(0.75 触发+0.2A 滞后 vs 现 0.6 触发即压尽再爬坡),事件频度下降。
### 5.3 与块状裁剪的先后序(分工)
```
常态: 0.55A ←压缩批次— 0.75A 触发 … 增长 … 1.0A 裁剪(不该到)
失灵: 压缩连续失败(关键词兜底也失败/后台挂)→ 历史爬到 1.0A
→ 块状裁剪应急跳到 0.8A(丢真历史,无摘要)→ 给压缩再争取一批窗口
```
裁剪是唯一硬安全门,保持 100% 触发不动;压缩是语义保全的常态路径。二者水位关系(T=0.8 > C_high=0.75)保证:只要压缩机制活着,裁剪永不出场。
---
## 六、方案三:缓存断点布点
anthropic 预算 4 个 cache_control(`anthropic_compat.rs:349`),现用 2,建议用到 3:
| 断点 | 位置 | 什么变化会打断它 |
|---|---|---|
| **BP1 顶层 system**(现有) | system blocks 尾块(`anthropic_compat.rs:137-141`) | ① custom_prompt 变更(稳定段含自定义段,`prompt.rs:727`);② **任何 System 角色锚点事件**(压缩摘要/关键词兜底/话题标记/催办——折叠进顶层 system,见§二末行);③ 提示词文本随代码版本变更 |
| **BP2 消息级**(现有) | 末条 user 前相邻一条(`:350-357`);有淘汰摘要时落摘要上(摘要两裁剪间静态,反而更稳) | ① 裁剪边界移动(块状化前每轮/块状化后仅裁剪事件);② 压缩事件;③ namespace LRU 淘汰的 notice 替换改写历史中段(`agentic/mod.rs:1965-1982`,低频);④ 新近度豁免滚动——末 2 条 tool_result 豁免过期转压缩时一次性改写(`stall_detector.rs:321-330`,每次新结果进来推移一次);⑤ sanitize 自愈(确定性,输入不变输出不变,不构成威胁) |
| **BP3 摘要链尾**(新增,可选) | 摘要锚点链之后第一条消息 | 仅压缩事件失效;为 [system+摘要链] 小前缀保命中,配合§5.2 追加链语义 |
易变尾段(日期/清单/augmentation/知识)在 BP2 之后,不打断任何断点(既有设计正确);其每次 send 重建的变化被 BP2 挡住,只自伤尾段自身(本来也不可缓存)。
### 6.1 必修项:system 锚点的顶层折叠(打断 BP1 的根)
**发现**:`convert_request` 把存储态全部 role=System 消息抽离折叠进顶层 system(`anthropic_compat.rs:128-133`),消息流中 `System => continue` 剔除(`:153`)。因此每次压缩摘要/话题标记/催办事件:顶层 system 内容变(BP1 失配)+ 消息数组少一条(BP2 位置迁移)→ 双断点同时重置。
**修法(view 层角色转换,存储不动)**:`build_for_request` 出口(sanitize 之后)把**下标>0 的 role=System 消息**映射为 role=User 发送。收益:
- BP1 内容回到纯 system_prompt 稳定段,锚点事件不再打断顶层缓存;
- 存储态仍是 System:`has_compressible_messages` 的 System 排除(`mod.rs:656`)、前端展示、压缩摘要定位语义全部不变——纯视图变更,与淘汰摘要先例(`mod.rs:254-256` role 必须 user)同构收口;
- openai_compat 侧同视图生效(system 留中原位,user 也合法,中性)。
细节:转换须在 sanitize step 之后(user 是最普通角色,不触发三元组/首条规则);首条 system_prompt(`agentic/mod.rs:1983`,组装层加入,不在 storage)不转换。
---
## 七、风险与回退
### 7.1 行为变更逐条
| # | 变更 | 风险 | 缓解 |
|---|---|---|---|
| 1 | 块状裁剪:单次多丢(裁到 T 水位而非刚好塞下) | 裁剪时刻上下文损失变大(有界:≤0.2A) | 换取两裁剪间全量命中;T 可调(拍板点1);病态回退保硬预算 |
| 2 | 分批压缩:C_high 0.6→0.75、批≈0.2A | 后台压缩生效前留给裁剪的余量 0.4A→0.25A;压缩事件频度上升(批小了) | is_compressing/版本哨兵/单元回退全复用;批失败下轮重试(滞后再武装);拍板点2 可保守维持 0.6 |
| 3 | system 锚点 view 层转 user | LLM 把摘要当作用户话语而非系统指令的语义差;多 provider 兼容面 | 纯 view-only 可瞬时回退;淘汰摘要已用同形态跑了数月无异常(`mod.rs:259-261`);拍板点3 |
| 4 | BP3 新增 | 网关对 cache_control 数量/位置的兼容 | 默认关,随开关;预算 4 内 |
### 7.2 开关设计(照 `df-ai-anthropic-cache` 范式)
- 新 settings KV **`df-ai-cache-friendly-context`**,`parse_cache_flag` 同语义(空/true/1=开;false/0/杂值=关),前端设置页一枚开关;
- 总开关 gate 三机制:块状裁剪 + 分批压缩 + system 锚点 view 转换;BP3 挂在同一开关(anthropic 缓存总开关 `df-ai-anthropic-cache` 仍独立管 cache_control 形态,两层正交);
- 开关关 = 三处全部旁路,行为与今日逐字节等价(与 `cache_split_provider_type` provider gate 叠加,非缓存受益 provider 不受 view 转换影响——不,view 转换与 provider 无关,应 gate 在新开关下全员生效或全员旁路,不由 provider 类型分叉);
- 裁剪/转换是 view-only(逐请求),回滚瞬时;压缩是存储 status 单向(已 compressed 不回滚,与今天回滚语义一致,可接受并在开关文档注明)。
### 7.3 回退路径
开关关 → 全量回旧行为。已落库的 compressed 状态保留(软删可追溯,历史数据默认保留);水位常量均在常量层(T/C_high/C_low),极端情况改常量即再调参,无需动逻辑。
---
## 八、分期
### 快赢(下一小批可入,改动小、收益确定)
| 项 | 内容 | 落点 | 量级 |
|---|---|---|---|
| W1 | **块状裁剪**(T 水位+轮吸附+病态回退) | `context/mod.rs:211-219` 替换 + 单测(水位/轮边界/病态三case) | S |
| W2 | **system 锚点 view 层转 user** | `build_for_request` 出口 + 单测(首条不转/转换后 sanitize 不破) | S |
| W3 | **命中率观测日志**:`cache_read/cache_creation` 已在 usage(`anthropic_compat.rs:728-730`),每轮 info 输出命中率 | stream 收尾处 | XS |
| W4 | BP3 摘要链尾断点(随 anthropic 开关) | `apply_message_cache_breakpoint` 加一档 | XS |
W3 最先:先有数据,后面所有调参有据。**协调注**:W1/W2 落在 context/mod.rs(并行代理在改),实施前 rebase;若分层组装器 P2.2(组装器结构化)先行合入,块状裁剪改为在组装器出口实施,算法不变。
### 结构性(需更多验证)
| 项 | 内容 | 依赖 |
|---|---|---|
| S1 | **分批压缩**(C_high/C_low 双水位+批边界+兜底同批)+ PROTECT_COUNT 双源统一(`context_helpers.rs:683` vs `context_lifecycle.rs:53`) | W3 数据验证压缩事件频度;狗粮长会话 |
| S2 | 水位再校准:T/C_high 按 W3 实测命中率-损失曲线微调 | S1 上线后一轮狗粮 |
| S3 | namespace LRU 淘汰 notice 的前缀断治理(notice 文案确定化已天然成立,残余为位置断,收益小后置) | 低优先 |
---
## 九、拍板点(4 项)
1. **裁剪目标水位 T**:推荐 **0.8**(块≈19K、稳定窗口 2–9 轮、命中 67–90%);备选 0.85(保守,少丢但裁剪频)——§4.2 表。
2. **压缩双水位**:推荐 **C_high=0.75 / C_low=0.55**(批≈0.2A,滞后 0.2A);备选维持现状 0.6 单水位(仅做分批不做提频调整)——§5.1。
3. **system 锚点 view 层转 user**:推荐**做**(治 BP1 折叠根因);备选不做(则压缩/话题标记事件继续双断点全失配,BP1 收益打折)——§6.1。
4. **总开关默认值**:推荐**默认开**(对齐 `df-ai-anthropic-cache` 默认开先例,看板 939⑧;view-only 为主可瞬时回退);备选默认关观察一轮——§7.2。
---
## 附:证据坐标索引
| 论断 | 坐标 |
|---|---|
| 贪心最小裁剪逐轮爬行 | `crates/df-ai/src/context/mod.rs:211-219`(触发 `:198,214`,切片 `:239-246`) |
| 淘汰摘要尾追+缓存注释 | `mod.rs:250-261`;确定性提炼 `context_helpers.rs:284-288,332-364` |
| 一次压尽 | `agentic/context_lifecycle.rs:129-136`(0.6 触发)、`:308,442`(compress_old_messages(protect_start))、`mod.rs:670-699` |
| is_active 白名单过滤 | `context/sanitize.rs:40-43`;`df-ai-core/provider.rs:91-99` |
| system 全量折叠(打断 BP1) | `anthropic_compat.rs:128-133,153`;锚点 system 入库 `context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557` |
| 消息级断点(末条 user 前) | `anthropic_compat.rs:243-247,339-376`;4 个预算 `:349`;usage 映射 `:723-731` |
| 开关范式 | `commands/ai/prompt.rs:657-695`;易变尾段拆分 `:709-736`、`stream_relay.rs:791-795`、每 send 构建一次 `commands/chat.rs:417,996,2209,2476` |
| tool_result 摘要已稳定(不威胁) | 冻结 `agentic/mod.rs:2004-2005`、`stall_detector.rs:352-363`;记忆化 `:368-379`;新近度豁免滚动 `:321-330` |
| 预算公式与保护区 | `context_helpers.rs:154-178,683,692`;双源 PROTECT_COUNT `context_lifecycle.rs:53` |
| 请求组装序 | `agentic/mod.rs:1143`(sys_tokens)、`:1957`(build_for_request)、`:1965-1982`(namespace 展开)、`:1983-1990`(system+history+尾段) |
| 上位目标与验收口径 | `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(病灶④/「裁剪以批量滞后发生」) |
@@ -0,0 +1,203 @@
# 跨会话召回四层架构 — 设计
> 日期: 2026-10-11 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 决策背景: 用户拍板决策单 C1 走 B 重量路线(对齐 u-claw 分身养成层四层架构), 推翻原「先 A 后 B」建议(见 aichat上下文管理-派工与决策单-2026-09-29.md C1)。
> 前序文档: 跨会话记忆四层架构-设计-2026-09-29.md(首版四层稿, W1-W4 批次均未实施)与跨会话召回最小闭环-设计-2026-10-04.md(已拍板并落地, 构成本设计的存量底座)。本文档是 09-29 首版按 2026-10-11 现状的重勘与收编, 实施以本文档为准。
> 铁律(继承 u-claw, 09-29 首版已立): **跨会话召回内容严禁进 system prompt 与常驻组装段, 只走工具按需查**。工具描述可进 system(静态文本, 属稳定缓存前缀), 召回内容不进。u-claw 原案教训: 频繁变更的注入毁 provider 前缀缓存。
> 勘察基线: 2026-10-11 工作区。crates/df-ai 的 context/mod.rs 与 context_helpers.rs 有并行代理在改, 引用行号实施前须复核。
---
## 一、四层口径与 u-claw 的对齐
u-claw 分身养成层原案按数据流阶段分层(捕获/精炼/召回/验证, 见 memory project-uclaw-avatar-layers); devflow 侧 09-29 首版起按**记忆的时间半径**分层(轮内/跨轮/跨会话/跨重启), 本设计沿用时间半径口径, 理由是它与 devflow 现有基建一一对应, 逐层可独立验收。两套口径的语义交点在第三层: u-claw 的「召回严禁进 system prompt」铁律原样继承, 并在 devflow 语境下收紧(u-claw 原案保留了低频 CLAUDE.md 摘要注入通道, rollover 级更新保缓存; devflow 侧跨会话召回不做任何形式的自动注入, 连低频摘要注入也不要, 只走工具)。
| u-claw 阶段口径 | devflow 时间半径口径 | 对应关系 |
|---|---|---|
| L1 捕获(hooks 零延迟) | L1 轮内 | 轮内上下文采集与组装 |
| L2 精炼(信号过滤+小模型) | L2 跨轮 | 压缩精炼+WorkingContext 回填 |
| L3 召回(严禁进 system prompt) | L3 跨会话 | 本设计主体 |
| L4 验证(观察项) | L4 跨重启 | 目标快照+恢复语义(验证语义落在恢复续接正确性) |
---
## 二、四层在 devflow 的映射(存储/写入/读取/生命周期/现状)
### 2.1 L1 轮内(现状已建, 本设计不动)
- 存储载体: per_conv 内存(`ContextManager.messages`, `crates/df-ai/src/context/mod.rs`)+ `ai_conversations.messages` 落库。
- 写入时机: 每轮 push, 大工具结果引用化入账(`src-tauri/src/commands/ai/audit/mod.rs:1184-1191`)。
- 读取通道: 每轮组装, 滑窗 `build_for_request`(`context/mod.rs:185`)+ system 段 `build_runtime_prompt`(`agentic/mod.rs:3042`)。
- 生命周期: 会话级, 随会话存亡。
- 现状标注: 分层组装器方案(上下文分层组装器-落地方案-2026-10-10.md)管辖此层与组装结构, 本设计零改动。
### 2.2 L2 跨轮(现状已建, 2026-10-11 新增压缩存档, 本设计不动)
- 存储载体: 内存视图(compressed 状态标记/淘汰摘要)+ 压缩摘要锚点(system anchor 尾部追加, `agentic/context_lifecycle.rs:335`)+ **压缩原文存档**(`<data_dir>/context-archive/<conv_id>-archive.jsonl` 追加写, `crates/df-ai/src/context/archive.rs:35-39`)。
- 写入时机: 压缩水位 `available*6/10 < history_tokens` 触发(`context_lifecycle.rs:133-135`), 后台 LLM 产 nl_summary+json_card; WorkingContext 写入链 `begin_turn`(`commands/chat.rs:950`、`:2444`)与 `sync_after_compress`(`context_lifecycle.rs:337`); 存档追加写(`archive.rs:44`), 开关 `df-ai-compress-recall` 默认开(`commands/ai/prompt.rs:709-718`)。
- 读取通道: 摘要常驻锚点(会话内每轮可见); 原文经摘要尾部召回句柄行 read_file 按需回读(`archive.rs:72-74`)。
- 生命周期: 会话级; 存档文件跨重启仍在, 会话删除级联清理是已记录缺口(`archive.rs:7` 注释自证「留待后续挂点」)。
- 现状标注: 压缩可召回 2026-10-11 刚落地(工作区未提交); 记忆卡四类卡(C-m1, 上下文三层记忆-设计-2026-10-04.md)设计已立未实施, 落地后是 L2 的结构化产物。
### 2.3 L3 跨会话(本设计主体)
**存量底座(A 路线已落地部分, 2026-10-11 实测)**:
- 写入: 自动提炼 `maybe_spawn_extraction`(`commands/ai/knowledge_inject.rs:660`)挂在正常收敛点(`agentic/mod.rs:1636`, save 之后), 水位制防重(`EXTRACT_WATERMARKS` :70, 新增 10 条用户消息起提 :52, 提炼窗口 40 条 :48), 产 candidate 带 source_project 弱推断归属(:1180、:1212)与 source_ref 会话级溯源(:1216-1219); `knowledge_insert` 工具(`commands/ai/tools/knowledge.rs:95`, 固定 candidate, 但 handler 无会话上下文拿不到 conv_id, source_ref 记 "ai-chat" 渠道兜底, :13-16 注释自证)。
- 读取: 注入侧 `auto_inject` top-3 带项目软排序(`knowledge_inject.rs:520-521`, 检索走 `search_keywords_scoped`, `crates/df-storage/src/crud/idea_repo.rs:834`, 同项目第一档/全局第二档/异项目第三档); 工具侧 `knowledge_search`(`tools/knowledge.rs:51`, 仅 published, 分词检索, 单条 content 截 500 字 :41, 空结果带可执行指引 :72-77)。
- 把关: `knowledge_promote` 显式转正工具(`tools/knowledge.rs:165`), 复用达标自动晋升(默认关, `knowledge_inject.rs:549-565`), 生命线留痕(KnowledgeTimeline)。
**缺口(本设计补齐)**: 会话终态提炼(删除/停靠时点无提炼挂点)、工具写入的归属补全(缺会话上下文注入机制)、工具读取的项目维度(knowledge_search 未接 scoped 检索)、用户侧脉络卡通道。详见第三节。
### 2.4 L4 跨重启(持久化面盘点)
**已有**:
- 消息与上下文重建: messages 落库 + `restore_from_messages`(`context/mod.rs:310` 附近)。
- 目标钉扎持久化(G1 已落地): pinned_goals 每轮 save 落库(`commands/ai/conversation.rs:466-468`), 会话载入恢复(`commands/conversation.rs:492-495`)。
- 任务边界: boundary_json(V45, `crates/df-storage/src/migrations.rs:1366`)。
- 断点续跑(V49): loop_state 列(`migrations.rs:1446`), 启动扫描 running 改 parked(`src/state.rs:455-460`), 恢复链 `ai_list_recoverable`/`ai_recover_parked`/`recover_parked_inner`(`commands/conversation.rs:292-337`), 前端 G5 可恢复分组(`src/components/ai/ConversationSidebar.vue:208`), B5 无人值守引擎自动恢复优先于领单(`agentic/unattended.rs:1017-1018`)。
- 收敛快照事件: AiCompleted 透传 pinned_goals_snapshot(`agentic/mod.rs:1663`)。
**缺口(本设计补齐, 详见第四节)**: WorkingContext 不持久化(df-storage 全仓零引用, 重启后 goals/decisions/unresolved 全丢, 压缩重锚定与目标钉扎跨重启断裂); namespace_store 重启清空(`agentic/mod.rs:1975` 注释自证「重启后 namespace 清空」, 懒重建未做); 目标快照槽语义缺失(看板 D4 挂账); 压缩存档文件无删除级联。
---
## 三、L3 主体设计
### 3.1 写入侧: 三通道加一挂点
| 通道 | 时机 | 状态 |
|---|---|---|
| 自动提炼 | 每轮正常收敛后, 水位制(10 条新用户消息起提) | 已落地 |
| 工具沉淀 | 用户说「记住/记一下」时 agent 调 knowledge_insert | 已落地, 归属待补 |
| 显式转正 | 用户认可时 knowledge_promote | 已落地 |
| **会话终态提炼(新增)** | 会话删除前 + parked 停靠时 | 本设计新增 |
**终态提炼设计**:
- 触发点两处: ① `ai_conversation_delete`(`commands/conversation.rs:854`)删除动作前, 后台 fire-and-forget 执行, 提炼失败或未完成不阻塞删除(删除是用户显式意图, 延迟它属于体验倒退; 未及落库的条目随会话消失是可接受损失, 与「历史数据默认保留」不冲突, 因候选未及产生即无产物); ② `emit_task_parked` 停靠三件套处(`agentic/mod.rs:1464-1477`), 停靠意味着任务未完结, 此刻的目标/决策/未解项最有提炼价值。手动触发已有(`trigger_extraction_now`, `knowledge_inject.rs:819`), 不重复建。
- 强制清水位: 复用 `trigger_extraction_now` 的清哨兵语义(:827-828), 终态提炼不受「10 条新用户消息」水位限制, 短会话有实质产出也应提炼一次; 防重仍走 InFlight 水位, 与可能在飞的常规提炼互斥。
- 提炼范围: 常规提炼只看尾部 40 条窗口(:48); 终态提炼取全量 active 消息, 并把 WorkingContext 的 decisions/unresolved 一并喂给提炼提示词(此刻 working_context 在内存中是最新态), 让「本会话定了什么、卡在哪」成为提炼输入而非仅靠消息流重放。
- **提炼什么与不提炼什么的边界**: 提炼产出限「跨会话可复用的经验」: 决策与依据、事实发现(环境约束/结构/坑)、偏好与约定、踩坑。**未完成事项不进知识表**: 它是会话态不是知识, 归 L4 目标快照与停靠说明承接(与根治设计分工矩阵一致: knowledge 是高蒸馏沉淀, pinned_goals/任务态是实时意图, `docs/02-架构设计/上下文根治设计-20261006.md` 第十节)。此边界修正 09-29 首版「未完成事项提炼进知识库」的含糊表述。
- 工具写入归属补全: knowledge_insert 拿不到 conv_id 的根因是工具 handler 只有 args 入参(`tools/knowledge.rs:13-16`)。补法: 工具注册或执行侧传入当前活跃会话 id(与 R1 批的会话上下文注入机制同一件基建, 见 3.3), insert 前 `resolve_source_project`(`knowledge_inject.rs:948`)算归属, source_ref 记 `conv:{id}`, 与自动提炼产物同格式。
### 3.2 存储选型: 复用 knowledge 表, 零 schema 变更
| 选项 | 评价 |
|---|---|
| **A 复用 knowledges 表(推荐)** | 检索/复用计数/晋升/生命线/归属/溯源全套机制已在; kind 七种语义(踩坑/架构模式/诊断/评审规则等, `migrations.rs:1828-1830`)覆盖决策与事实类产出; 零迁移零新面 |
| B knowledges 补 scope 层级字段 | 知识库模型重审(2026-10-05)UC6 提案(会话/项目/全局); 触发制后置, 单项目桌面场景 source_project 已够, 宁少勿预留 |
| C 新建 session_memories 表 | 与 knowledges 的检索/晋升/代谢/留痕重复建设, 双轨违背 DRY; 09-29 首版未选, 本版明确否决 |
| D 复用压缩存档 JSONL 当跨会话记忆 | 存档是原文不是蒸馏产物, 无结构化元数据, 检索需全文件扫描; 根治设计第八节定位明确: 知识条目是晋升终点, 原文页池是全程。存档维持现状(会话内 read_file 召回, 已落地), 不升级为 L3 存储 |
**推荐 A**。与根治设计晋升单向门一致: 会话内页(压缩存档, 原始)到记忆卡(会话内, 中蒸馏, C-m1)到 knowledge 条目(跨会话, 高蒸馏)。L3 只收蒸馏产物, 原料留在 L2 侧。
### 3.3 读取侧: 只走工具按需查
**铁律的两层精确表述**(消解与组装器方案的表面冲突):
- 跨会话层(本设计): 召回内容一律走工具, 无任何自动注入通道。prompt 总 token 不随历史会话数增长(09-29 首版 W3 验收口径沿用)。
- 会话内层(组装器管辖): 分层组装器 P2.3 的 recall 段(会话内窗口外页/记忆卡)落发送视图尾部 user 角色、带配额闸, 不进 system 段, 与铁律字面一致; 其 v1 半径拍板已锁「仅当前会话」(组装器方案拍板点 4 选 A), 跨会话被明确延后到本设计, 且本设计选择工具通道而非注入通道, 比组装器的会话内置尾注入更保守。两文档无冲突: 组装器治会话内, 本设计治跨会话, 共用 `knowledge_inject` 检索面。
**工具语义(不新造 recall_memory, 强化学员 knowledge_search)**:
09-29 首版提案的 recall_memory 专用工具与已落地的 knowledge_search 语义重合(查「之前怎么解决的/有没有记过」, `tools/knowledge.rs:55` 描述已含使用时机引导), 再造一个违背 DRY。本设计对 knowledge_search 做两点强化:
1. 项目软排序接入: 检索改走 `search_keywords_scoped`, project_id 由调用时上下文解析(见下条)。同项目经验排前, 异项目与全局知识仍可召回不淹没(三档排序已在 df-storage 侧就绪, `idea_repo.rs:834-880`, 零新存储逻辑)。
2. 会话上下文注入机制: 工具 handler 现拿不到 conv_id(`tools/knowledge.rs:13-16` 自证), 需要在工具执行侧补一个「当前会话 id」的注入通道(注册时捕获或执行时从 conv_states 解析)。这是本设计唯一的新基建件, 同时服务 3.1 的写入归属与 3.3 的读取排序, 一件基建两处收益。
**引导注入方式**: 靠工具描述(静态, 随 system 进稳定缓存前缀, 现有描述已含使用时机)与空结果指引(:72-77 已有), **不新增每轮注入文字**。易变尾段是每轮变化区, 放常量引导既占 token 又无增量价值。
**注入侧与工具侧分工**: 注入侧(`auto_inject`, top-3, 项目软排序已接)是「高蒸馏 published 知识的被动注入」, 服务「agent 不知道自己不知道」的场景; 工具侧是「agent 主动按需查」, 服务明确的历史问题。两者互补且都已存在, 本设计不扩大注入剂量(top-3 配额不动)。
**脉络卡(用户侧辅助通道)**: 新会话或会话列表项上, 按 source_project 聚合展示同项目近期会话的决策/结论摘要(数据源: knowledge 表按 source_ref 反查会话 + ai_conversations 元数据, 纯前端读侧)。用户点「带入上下文」才转为显式 @提及注入。非 LLM 注入, 不触碰铁律; 把「要不要带历史」的决策权交给用户, 与 bot 决策接口哲学(用户只做决策)一致。
---
## 四、L4 与目标快照: 重启恢复还缺什么
现有 parked 续跑链(V49 扫描、ai_recover_parked、B5 引擎自动恢复)解决的是「loop 死了能接着跑」; 缺的是「接着跑时脑子里还带着之前的记忆与最终意图」。三个缺口:
**缺口 1: WorkingContext 持久化(最重)**。重启后 goals/decisions/unresolved 全丢, 压缩重锚定(sync_after_compress 回填的决策)与目标钉扎跨重启断裂, 恢复的会话等于半失忆续跑。设计:
- 载体: `ai_conversations` 加 `working_context_json` 列(V50, ALTER TABLE 可逆, 与 boundary_json V45/loop_state V49 同款模式)。
- 写入时机: `save_conversation` 每轮落库点, 与 pinned_goals 同段序列化 `conv.working_context`(`commands/ai/conversation.rs:466` 附近)。
- 恢复时机: 会话载入(commands/conversation.rs:492 恢复 pinned_goals 的同段)与 `recover_parked_inner` 反序列化回 per_conv。
- 不选 settings KV 单键: 全会话映射挤一个键, 清理随会话删除无法级联, 单点膨胀。
**缺口 2: 目标快照槽(对齐 u-claw goal.go L1/L2/L3 语义, 看板 D4 并入本设计)**。现状恢复语义是注入恢复引导消息重走 spawn 正典链, 没有「按最终有效意图续, 不重放历史目标流」的快照裁决。设计:
- 快照内容: Active 目标(WorkingContext.goals)+ pinned_goals + unresolved + updated_at + source。**不另立存储**, 就是 working_context_json 列的语义化读取(单一事实源, 宁少勿预留), pinned_goals 已有独立列继续沿用。
- 恢复语义: 恢复/续接时按快照对齐当前有效意图, 中途被调整作废的目标不复活; 停靠卡与恢复引导消息由快照生成, 防止「按旧目标继续跑偏」(D4 原始动机)。
- 验收沿用 09-29 首版 W4 口径: 会话 A 调整目标后重启, 续接时只认最终目标。
**缺口 3: namespace 懒重建**。重启后 namespace 引用只读得到淘汰占位(`agentic/mod.rs:1975`), 而 DB messages 存的是展开后原文(namespace_store 侧既有约定)。设计: per_conv 重建时从 messages 原文回填 store(懒: 首次展开命中该引用时回填, 避免载入即全量扫描)。独立小批, 量级 S。
**顺带挂点**: 压缩存档的会话删除级联清理(archive.rs:7 记录的缺口)挂在 R3 批的删除提炼同一条链路上(提炼完成后清理存档文件, 顺序: 先提炼后删文件), 避免单独再开删除挂点。
---
## 五、与既有机制的关系
**知识库读写闭环(看板「观察进化系统只写不读」验收, docs/todo.md:414-415)**: 本设计 L3 写入侧(自动提炼+工具沉淀+终态提炼三通道)与读取侧(注入面+工具面+脉络卡三面)就是该验收的实现载体。质量闸引用知识库模型重审(2026-10-05)的实施序: 准入分级(candidate 不注入, published 且 verified 或 reuse 达标才可注入)与置信重估(自动提取默认 medium, 治 87% high 通胀)是该设计已立项项, 本设计不重复立项, 仅保证终态提炼产物走同一套把关。
**与分层组装器方案(2026-10-10)**: 见 3.3 两层表述。补充一点衔接: 组装器的 MemoryCardSource(C-m1 记忆卡)属会话内 L2 产物; 记忆卡中被验证可复用的条目, 未来经终态提炼通道晋升进 knowledge 表(会话内中蒸馏到跨会话高蒸馏的单向门), 这是两套设计的汇合点, 实施上互不阻塞。
**与 u-claw 分身的关系: 各自实现, 语义对齐, 不共享组件**。理由: ①语言栈不同(Go 网关 vs Rust/Tauri 桌面应用), 抽公共 crate 的跨语言成本远大于收益; ②数据载体不同(u-claw 账本 JSONL+CLAUDE.md 通道 vs devflow SQLite knowledge 表+工具注册表), 硬对齐载体必有一方扭曲; ③真正需要一致的是四层语义、召回铁律、目标快照续接语义, 这些走设计档互引即可(本文档第一节即为对齐记录)。与「devflow集成u-claw-可行性研究-2026-09-28.md」的通道集成(ask_uclaw 工具形态)正交: 那是能力互调, 这是各自体内记忆架构, 不冲突。
---
## 六、分期(每批独立验收)
| 批 | 内容 | 依赖 | 量级 | 验收 |
|---|---|---|---|---|
| R1 | L3 读取强化: 会话上下文注入机制 + knowledge_search 接项目软排序 | 无(scoped 检索 df-storage 侧已就绪) | S-M | 新会话问「这个项目之前定了什么」调工具命中且同项目排前; prompt token 不随历史会话数增长 |
| R2 | L4 重启不失忆: working_context_json 列 + 保存/恢复接线 | 无 | M | 重启应用后压缩重锚定与目标钉扎仍生效 |
| R3 | L3 写入收口: 会话终态提炼(删除前+parked 停靠) + knowledge_insert 归属补全 + 存档级联清理 | R1(共用会话上下文注入机制) | M | 关会话/停靠后知识库新增归属正确条目; 删除会话后存档文件消失 |
| R4 | 目标快照槽+恢复续接语义(D4 并入) | R2(载体) | M | 调整目标后重启续接只认最终目标, 作废目标不复活 |
| R5 | namespace 懒重建 | 无 | S | 重启后旧引用可读原文, 淘汰占位消失 |
| R6 | 脉络卡(前端读侧) | R3(数据在) | S-M | 新会话可见同项目近期决策摘要, 「带入上下文」为显式动作 |
顺序: R1(读取先行见效, 依赖最少)到 R2 到 R3 到 R4, R5/R6 可随批插空。后置触发制(不在本设计实施): scope 层级字段、代谢任务、语义检索(知识库模型重审已立项, 触发制)。
---
## 七、拍板点(6 项)
1. **终态提炼触发点集合**: 建议三触发(删除前后台非阻断+parked 停靠+手动已有); 备选仅停靠+手动(删除走静默, 防删除动作被后台提炼占用资源, 代价是删除会话的经验流失)。见 3.1。
2. **knowledge_search 工具级项目软排序与会话上下文注入机制**: 建议做(跨会话工具查询才有项目维度, 且一件基建服务读写两处); 备选不做(工具保持全局检索, 项目维度仅注入侧, 量级降为 S)。见 3.3。
3. **WorkingContext 持久化载体**: 建议加列 working_context_json(V50 模式, 可迁移可回退, 随会话级联); 备选 settings KV 单键(免迁移, 但清理难、单点膨胀)。见四节缺口 1。
4. **目标快照与 D4 合并**: 建议并入本设计 R4 批(D4 原始诉求与快照槽同一件事, 拆开必双实现); 备选留 D4 单独走(则本设计 R4 改为只做存储载体)。见四节缺口 2。
5. **脉络卡是否本期做**: 建议做(用户可见收益最大, 纯读侧零注入风险); 备选后置(先跑工具通道看真实使用率)。见 3.3。
6. **未完成事项是否进知识表**: 建议不进(归 L4 快照与停靠说明, 知识表只收可复用经验, 防任务态污染知识面); 备选兼容收(kind 用 workflow_optimization+tags 标注 todo, 有「过期待办变死知识」风险, 依赖代谢任务兜底)。见 3.1 边界。
---
## 八、风险
**存储膨胀**: 终态提炼加自动提炼的 candidate 增长(基线 1073 条, 知识库模型重审 2026-10-05 快照)。治理: 提炼查重(title 相似命中即跳过或 reuse+1, 最小闭环设计第四节已列)、代谢任务(重审 UC4, 触发制后置)、存档级联清理(R3 子项)、注入与检索配额上限不动(top-3/top-10)。万条量级 LIKE+索引可承受(重审 E2 前提)。
**提炼质量与幻觉**: LLM 提炼可能编造事实或置信通胀(87% high 已实证)。治理: candidate 不进检索面(误写代价低, 人工审核可弃)、自动提取置信默认 medium(high 需证据, 重审 E4)、reasoning 字段留判断依据(`knowledge_inject.rs:1197-1198` 已回填)、promote 显式转正为唯一人为把关口、蒸馏来源质量权重(重审 E5, 后置)。终态提炼喂入 WorkingContext 决策属结构化输入, 幻觉面小于全文重放, 但仍按同一候选通道把关不豁免。
**隐私边界**: 全链本地 SQLite+本地 JSONL, 与「本地优先, 数据不出门」pivot 一致。三个注意点: embedding 管道维持默认关(C2 已拍板, 本设计不触碰); 终态提炼调用 LLM 发送会话内容, 与普通对话同一 provider 同一外发面, 无增量风险; 压缩存档明文落 data_dir, 敏感度与 DB messages 同级, 确保不进任何同步/上传/日志路径(现状无此路径, 保持即可)。
**缓存与注入纪律回归**: 最大长期风险是未来实现把召回内容「顺手」拼进 system 或常驻段(毁前缀缓存, u-claw 原案教训)。结构防线: 工具通道在组装结构上就不进组装、脉络卡纯前端、组装器召回段落尾 user 角色; 验收红线: 「prompt token 不随历史会话数增长」进每批回归口径, R1 验收即含此项。
---
## 附录: 证据坐标索引
| 论断 | 坐标 |
|---|---|
| 自动提炼挂正常收敛点 | `src-tauri/src/commands/ai/agentic/mod.rs:1636`; 水位制 `knowledge_inject.rs:660-720`(:52 阈值/:48 窗口/:70 水位) |
| 提炼产 candidate 带 source_project/溯源 | `knowledge_inject.rs:1180`(:1212)/(:1216-1219); 归属弱推断 `:948` |
| 注入侧项目软排序已接 | `knowledge_inject.rs:520-521`; `crates/df-storage/src/crud/idea_repo.rs:834-880`(三档 CASE :862-867) |
| 三知识工具与归属缺口自证 | `src-tauri/src/commands/ai/tools/knowledge.rs:49`(register)/(:51 search)/(:95 insert)/(:165 promote); 拿不到 conv `:13-16` |
| 压缩链与 WorkingContext 写入 | `context_lifecycle.rs:133-135`(水位)/(:335 锚点)/(:337 sync); `chat.rs:950`(:2444) begin_turn; `context_helpers.rs:886-996`(结构与方法) |
| 压缩存档(今日落地, 未提交) | `crates/df-ai/src/context/archive.rs:17`(:35 路径/:44 追加/:72 句柄); 开关 `prompt.rs:709-718`; 清理缺口 `archive.rs:7` |
| knowledge 表 schema | `crates/df-storage/src/migrations.rs:1835-1858`(V7, source_project :1846, kind 注释 :1828-1830) |
| L4 已有持久化面 | `conversation.rs:466-468`(pinned 落库)/`commands/conversation.rs:492-495`(恢复); V45 `migrations.rs:1366`; V49 `:1446`; 启动扫描 `state.rs:455-460`; 恢复链 `commands/conversation.rs:292-337`; 前端 `ConversationSidebar.vue:208` |
| L4 缺口自证 | df-storage 全仓无 working_context 引用(grep 实测); namespace 重启清空 `agentic/mod.rs:1975`; AiCompleted 快照透传 `agentic/mod.rs:1663` |
| 删除与停靠挂点 | `commands/conversation.rs:854`(ai_conversation_delete); `agentic/mod.rs:1464-1477`(emit_task_parked 三件套) |
| 上位设计与衔接 | 上下文根治设计-20261006.md(第七/八/九/十节); 上下文分层组装器-落地方案-2026-10-10.md(3.3/P2.3/拍板点4); 上下文三层记忆-设计-2026-10-04.md(C-m1); 知识库模型重审-精细化设计-2026-10-05.md(UC6/E4/E5/实施序) |
+5 -5
View File
@@ -590,7 +590,7 @@
### 待办(按优先级)
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%)
2. [x] **C-r0 验证命令后台化**(原验收:>90s 同步等待占比 <15%,现状 45%)——**【10-11 已实施并推送 c7d0ed9,代码就绪;实测口径待开关开启后取数】**:实现见下方 941 条;开关 KV `df-ai-run-command-background` **默认关**(=同步路径逐字节不变,故生效前占比不变属预期),需开开关后跑一段时间再核 <15% 目标。
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
@@ -705,13 +705,13 @@
- **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
- **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
- **HIGH ④(压缩)** 压缩**单向不可逆**:被压原文对 LLM 永久不可见(`context/mod.rs:664-699` 标 Compressed + `sanitize.rs:42` is_active 过滤出局),设计 C-r1「压缩不丢/可召回」未落地 → 方向:复用 P1 句柄模式,压缩写 pointer 可回读。
- **④ 设计要点(2026-10-11 登记,待实施)**:①落盘时机=compress_old_messages 标记时(原文在手,compress_old_messages 返回值即 newly_compressed 克隆);②句柄复用 tool_offload(ctx- 前缀同目录,或独立 archive- 前缀区分语义);③召回入口=压缩摘要尾部附一行「早期历史全文已存档: <path>(read_file 可召回)」;④开关 df-ai-compress-recall 默认开(行为变更可回退);⑤磁盘治理:存档随会话删除级联(复用 959 后的清理挂点)或按「只增不减」纪律暂不清理;⑥估算零影响(存档不在上下文);⑦与 907 邻接防线的交互:摘要+句柄行是 system 消息内容,不楔三元组(插入点已在 system 锚点避让保护内)。工作量:中(一个落盘调用+摘要尾部拼行+开关),可与小批实施。
- MED ⑤ `chars_ratio=0.35` 中文低估 2-3 倍、base64 高估(`context_helpers.rs:29-44/73-74`);⑥ 保护区固定 6 条 + 补偿仅 300 字(`:648/:655`);⑦ 分层组装 P2 未落地(窗口外历史无检索召回);⑧ **★** system 可变段(日期+项目/任务清单)内嵌稳定段致**前缀缓存失效**(`prompt.rs:611-635` 缓存拆分开关默认关;清单描述不截断 `:565/:584`);⑨ 滑动裁剪致前缀缓存整体失配(`context/mod.rs:239-246`);⑩ 一次压尽(windows 式分批未做);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- **HIGH ④(压缩)** ✅ 已实施(2026-10-11):压缩原文 JSONL 存档(`data_dir/context-archive/<conv>-archive.jsonl` 追加写)+摘要尾部召回句柄行(经 push_system_anchor 锚点内,sanitize 安全)+开关 `df-ai-compress-recall` 默认开(src-tauri 读 KV 传参,df-ai 不直读 settings);存档失败降级无句柄不影响压缩;4 新测试,585+845 双绿。原设计 C-r1「压缩不丢/可召回」落地。
- **④ 设计要点(2026-10-11 登记)→ ✅ 已按要点实施,偏离两处**:②句柄改 df-ai 内独立轻量存档(archive.rs,settings 在 src-tauri 侧 SQLite 跨 crate 不可直读,tool_offload 同因不复用);⑤磁盘治理按「只增不减」,级联清理留挂点注释。其余要点逐条落地。
- MED ⑤ ✅ 已修(2026-10-11):CJK 分档 1.2 token/字+base64 改图地板(与 url 同口径,估算对象从字符流修正为实际消耗),`weighted_char_tokens` 单点;⑥ ✅ 已修(补偿上限 300→1000,fda991c);⑦ 分层组装 P2 → **✅ 已解锁待实施**(2026-10-11 C1 拍板走 B:跨会话召回四层架构,[设计文档](./02-架构设计/专项设计/跨会话召回四层架构-设计-2026-10-11.md)——L1/L2 基建已齐,L3 只补四缺口(终态提炼/工具归属与项目排序/脉络卡/读取强化),L4 三缺口(WorkingContext 持久化/namespace 懒重建/目标快照),6 批分期 R1-R6 拍板 6 项;窗口外历史召回走 L3 工具通道铁律不进 system prompt)→ **✅ R1-R6 全部落地**(2026-10-11:R1 读取强化/R2 WorkingContext 持久化 V53/R5 namespace 懒重建先行;R3 终态提炼删除+停靠双挂点/R4 目标快照恢复续接防旧目标复活/R6 脉络卡纯读侧+带入上下文经输入框显式注入);⑧ ✅ 已修核验(2026-10-11):缓存拆分默认开(cd77c1d)+清单描述 120 字截断(truncate_desc)+消费链四调用点实通(chat.rs×3/helpers.rs×1),开关关=逐字等价旧路径;⑨ 滑动裁剪致前缀缓存整体失配+⑩ 一次压尽 → **✅ 快赢 W1-W4 已实施**(2026-10-11 拍板按推荐值,[设计文档](./02-架构设计/专项设计/压缩裁剪缓存友好化-设计-2026-10-11.md)):W1 块状裁剪(0.8×水位+轮边界吸附+病态回退,贪心路径保留为开关关回退)/W2 锚点 view 转 user(治 BP1 折叠根因)/W3 命中率观测日志(每轮 info 供调参)/W4 BP3 断点(机制就位,W2 开时自然旁路);总开关 `df-ai-cache-friendly-context` 默认开。**剩 S1 分批压缩**(C_high 0.75/C_low 0.55,待 W3 观测数据验证压缩事件频度后实施)+S2 水位再校准+S3 namespace 淘汰断(后置);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
941. [ ] **C-r0 验证命令后台化:方案已定,落地需拍板(行为变更)**(2026-10-10 Wave A 5a 勘察,代理按纪律拒改并交方案)
941. [x] **C-r0 验证命令后台化(2026-10-11 已实施并推送 c7d0ed9)**(原 2026-10-10 Wave A 5a 勘察)——**【10-11 实施完成】**:audit/bg_run.rs 新模块(~530 行)——开关 KV `df-ai-run-command-background`(**默认关=同步路径逐字节不变**)+`df-ai-run-command-grace-secs`(默认 90,解析失败/0 按默认);**仅主会话生效**,子代理独立循环不转后台;grace 三路竞速(Done/Expired/Aborted)+占用闸 BG_HOLDS+HoldGuard RAII+detached 自持 30s 心跳+会话删除自中止+失败记忆+完成通知;挂接 audit/mod.rs(签名升 &Arc,同步路径逐字保留仅抽 abort_when_invalid 工厂)+guard.rs(+9 行 transition 单点拦 Idle,终局放行)。自查修复真缺陷:peek_loop_exited 先读后 release(先释放吞标记致永久占用)。验证:cargo test -p devflow --lib 845 passed(含本项新 10 测)。**注**:loop 主体 agentic/mod.rs 零改动,占用语义收敛在 guard/conv_state 单点表达(比原预估更小)。
- 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。
- 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。
@@ -30,9 +30,10 @@
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::path::Path;
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use df_ai::context::ContextManager;
@@ -40,7 +41,7 @@ use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency;
use crate::state::{AppState, LlmConcurrency};
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm;
@@ -207,6 +208,17 @@ pub(super) async fn maybe_auto_compress(
return false;
}
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时把原文
// 存档 + 摘要尾部拼召回句柄。spawn 前读值拷贝,后台 task 不持 AppState 引用
//(State 借用非 'static,不可移入 task)。
let (recall_on, data_dir) = {
let st = app_handle.state::<AppState>();
(
super::super::prompt::compress_recall_enabled(&st).await,
st.data_dir.clone(),
)
};
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
//
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
@@ -266,6 +278,7 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens,
"压缩 provider 重建失败",
&head_id,
recall_on.then_some(data_dir.as_path()),
)
.await;
return;
@@ -305,12 +318,21 @@ pub(super) async fn maybe_auto_compress(
);
return;
}
if recall_on {
conv.messages.set_compress_recall(&data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start);
// 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
// 回读;句柄行只进 system 锚点,前端事件仍用原 nl_summary 不外发。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary.nl_summary, &p),
None => summary.nl_summary.clone(),
};
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary));
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card);
}
@@ -370,6 +392,7 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens,
"压缩失败",
&head_id,
recall_on.then_some(data_dir.as_path()),
)
.await;
}
@@ -409,6 +432,9 @@ pub(super) async fn maybe_auto_compress(
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
///
/// `recall`:压缩可召回开关(Some = 开,携带存档 data_dir)。开则压缩标记时原文落盘存档,
/// 关键词摘要尾部拼召回句柄行;None 零行为变更(不落盘、摘要无句柄)。
async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -417,6 +443,7 @@ async fn keyword_fallback_commit(
pre_tokens: u32,
log_head: &str,
head_id: &Option<String>,
recall: Option<&Path>,
) {
let inserted = {
let mut session = session_arc.lock().await;
@@ -439,14 +466,23 @@ async fn keyword_fallback_commit(
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false
} else {
// 压缩可召回:开(Some)则压缩标记时原文落盘存档,关键词摘要尾部拼召回句柄行。
if let Some(dir) = recall {
conv.messages.set_compress_recall(dir, conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() {
false
} else {
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进 system 锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(keyword_fallback, &p),
None => keyword_fallback.to_string(),
};
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback));
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
true
}
}
@@ -487,7 +523,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在");
@@ -510,7 +546,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在");
@@ -527,7 +563,7 @@ mod tests {
async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await;
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
let session = arc.lock().await;
assert!(
+153 -23
View File
@@ -1714,6 +1714,61 @@ async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, S
}
}
/// 展开请求视图里的 namespace 引用:store 命中直取原文;未命中记入返回列表
/// (下标 + 引用路径)交懒回填,条目本身维持引用原样待替换。
fn collect_namespace_misses(history: &mut [ChatMessage], store: &df_ai::namespace_store::NamespaceStore) -> Vec<(usize, String)> {
let mut misses = Vec::new();
for (idx, m) in history.iter_mut().enumerate() {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
match store.read_only(&path) {
Some(original) => m.content = original.to_string(),
None => misses.push((idx, path)),
}
}
}
misses
}
/// 回填应用:找回的引用替换为原文;无原文(真淘汰:LRU 淘汰且消息里无原文)维持
/// 既有富提示/EVICTED_PLACEHOLDER 语义,不误填。
fn apply_namespace_views(
history: &mut [ChatMessage],
misses: &[(usize, String)],
store: &df_ai::namespace_store::NamespaceStore,
recovered: &HashMap<String, String>,
conv_id: &str,
) {
for (idx, path) in misses {
history[*idx].content = match recovered.get(path) {
Some(original) => original.clone(),
None => {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
store
.eviction_rich_notice(path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
}
};
}
}
/// namespace 懒回填的原文源:该会话 DB messages 原文(落库约定存展开后原文)。
/// 仅回填触发时调用(常态零 DB 开销);读失败返回空列表,回填退化为占位语义不阻塞本轮。
async fn load_conv_message_contents(app_handle: &AppHandle, conv_id: &str) -> Vec<String> {
let state = app_handle.state::<AppState>();
match state.ai_messages.list_by_conversation(conv_id).await {
Ok(records) => records.into_iter().map(|r| r.content).collect(),
Err(e) => {
tracing::warn!(conv_id = %conv_id, error = %e, "[namespace] 回填原文拉取失败,维持占位");
Vec::new()
}
}
}
// ============================================================
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
@@ -1945,8 +2000,20 @@ async fn prepare_round_inputs(
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
// build_for_request 读 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。
let messages = {
let session = session_arc.lock().await;
// 缓存友好上下文总开关(KV `df-ai-cache-friendly-context`,默认开):开则 build_for_request
// 走块状裁剪 + system 锚点 view 层转 user;关则两者旁路(与旧路径逐字节等价)。
// 锁外读值拷贝(下方 build 锁段无 await,对齐 context_lifecycle 的开关读取范式)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
super::prompt::cache_friendly_context_enabled(&st).await
};
let (mut history_msgs, namespace_misses) = {
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
}
let conv = match session.conv_read(conv_id) {
Some(c) => c,
None => {
@@ -1960,25 +2027,21 @@ async fn prepare_round_inputs(
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。
for m in &mut history_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = session
.namespace_store
.eviction_rich_notice(&path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
}
}
// read_only None(条目被 LRU 淘汰 / 重启后 store 清空)→ 记入未命中,下方懒回填;
// 仍无原文才落占位文案(见 apply_namespace_views)。
let namespace_misses = collect_namespace_misses(&mut history_msgs, &session.namespace_store);
(history_msgs, namespace_misses)
};
// namespace 懒回填(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时才触发——从该会话 DB messages 原文按内容哈希找回并重建 store 条目,
// 避免载入即全量扫描;消息里无原文的真淘汰维持既有富提示/占位。
// DB 拉取在会话锁外(锁内禁长 I/O),回填与视图替换短暂重入锁。
if !namespace_misses.is_empty() {
let contents = load_conv_message_contents(app_handle, conv_id).await;
let mut session = session_arc.lock().await;
let refs: Vec<String> = namespace_misses.iter().map(|(_, p)| p.clone()).collect();
let recovered = session.namespace_store.backfill_from_messages(&refs, &contents);
apply_namespace_views(&mut history_msgs, &namespace_misses, &session.namespace_store, &recovered, conv_id);
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
@@ -1987,8 +2050,7 @@ async fn prepare_round_inputs(
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
};
let messages = msgs;
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
//
@@ -3813,3 +3875,71 @@ mod empty_round_guard_tests {
assert!(empty_round_should_park(4, 3));
}
}
// namespace 懒回填契约(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时从 DB messages 原文按内容哈希回填;二次展开走 store 不再触发回填;
// 消息里无原文的真淘汰维持占位不误填。全链 = collect/apply 两纯函数 + store 回填 API。
#[cfg(test)]
mod namespace_backfill_tests {
use super::{apply_namespace_views, collect_namespace_misses};
use df_ai::namespace_store::{NamespaceStore, EVICTED_PLACEHOLDER};
use df_ai::provider::ChatMessage;
// 引用路径取自热 store(模拟重启前生成),与生产 store() 同源
fn ref_path_of(content: &str) -> String {
NamespaceStore::new(1 << 20).store("read_file", content)
}
fn history_with_ref(path: &str) -> Vec<ChatMessage> {
vec![ChatMessage::tool_result("tc1", path)]
}
#[test]
fn miss_backfills_from_message_original_and_expands() {
let original = "文件A的内容行\n".repeat(300);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default(); // 重启后空 store
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
assert_eq!(misses.len(), 1, "store 空应记未命中");
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, original, "展开应得到原文而非占位");
assert_eq!(
store.read_only(&path).map(str::to_owned),
Some(original),
"store 应已回填该条目",
);
}
#[test]
fn second_expansion_hits_store_without_rebackfill() {
let original = "x".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
// 下一轮重建视图:store 已命中,未命中为空 → 回填阶段(含 DB 拉取)不再触发
let mut history2 = history_with_ref(&path);
let misses2 = collect_namespace_misses(&mut history2, &store);
assert!(misses2.is_empty(), "二次展开应走 store,无未命中");
assert_eq!(history2[0].content, original);
}
#[test]
fn real_eviction_keeps_placeholder_without_wrong_fill() {
let original = "y".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
// 消息里无对应原文(仅无关内容)→ 回填为空,维持占位,store 不误填
let recovered = store.backfill_from_messages(&[path.clone()], &["无关消息".to_string()]);
assert!(recovered.is_empty(), "无对应原文不应产生回填结果");
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, EVICTED_PLACEHOLDER);
assert_eq!(store.read_only(&path), None, "真淘汰不得误填 store");
}
}
+15 -1
View File
@@ -48,7 +48,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
@@ -892,9 +892,23 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
// 缓存友好上下文总开关(与主 loop 同款:KV `df-ai-cache-friendly-context`,默认开;
// 锁外读值拷贝,下方锁段无 await)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
crate::commands::ai::prompt::cache_friendly_context_enabled(&st).await
};
let (messages, estimated_prompt) = {
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(&child_conv_id).is_some() {
session
.conv(&child_conv_id)
.messages
.set_cache_friendly(cache_friendly);
}
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
@@ -633,6 +633,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".to_string(),
working_context_json: None,
})
.await
.expect("测试会话插入应成功");
+21 -2
View File
@@ -1968,6 +1968,9 @@ pub async fn ai_chat_compress_context(
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时原文落盘存档
// + 摘要尾部拼召回句柄;关零行为变更。
let recall_on = super::super::prompt::compress_recall_enabled(&state).await;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2083,9 +2086,17 @@ pub async fn ai_chat_compress_context(
let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&keyword_fallback, &p),
None => keyword_fallback.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&anchor_text));
}
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
@@ -2110,8 +2121,16 @@ pub async fn ai_chat_compress_context(
let __lock_t1312 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary));
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点;前端事件仍用原摘要。
let summary_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary, &p),
None => summary.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&summary_text));
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
}
@@ -358,12 +358,23 @@ pub(crate) async fn recover_parked_inner(
if !state.conv_states.can_accept_request(conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string());
}
let message = PARKED_RECOVERY_MESSAGE.to_string();
// 目标快照:按最终有效意图生成恢复引导消息,不重放历史目标流。
// WorkingContext 与 pinned_goals 两列语义化读取;全空回退 PARKED_RECOVERY_MESSAGE。
let working_context = working_context_from_snapshot(record.working_context_json.as_deref());
let pinned = parse_pinned_goals(record.pinned_goals.as_deref());
let message = build_recovery_message(&working_context, &pinned);
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
}
// WorkingContext 随行恢复(V53):重启后 per_conv 若无内存态(引擎自动恢复未先 switch),
// 惰性建 per_conv 并从 DB 快照带回压缩重锚定的决策/未解项;已有内存态(switch 已还原)
// 重解同一快照幂等无害。
{
let mut session = state.ai_session.lock().await;
session.conv(conversation_id).working_context = working_context;
}
spawn_agentic_session(
app,
state,
@@ -380,6 +391,105 @@ pub(crate) async fn recover_parked_inner(
.await
}
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。
/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext,
pinned_goals: &[crate::commands::ai::GoalEntry],
) -> String {
use df_ai::context_helpers::GoalStatus;
let mut seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active) && seen.insert(g.text.clone()) {
active.push(&g.text);
}
}
for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active) && seen.insert(g.text.clone()) {
active.push(&g.text);
}
}
if active.is_empty() && working_context.unresolved.is_empty() {
return PARKED_RECOVERY_MESSAGE.to_string();
}
let mut msg = String::from(
"[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务(中途已作废或已完成的目标不重放):\n",
);
if !active.is_empty() {
msg.push_str("\n有效目标:\n");
for t in &active {
msg.push_str("- ");
msg.push_str(t);
msg.push('\n');
}
}
if !working_context.unresolved.is_empty() {
msg.push_str("\n未解事项:\n");
for u in &working_context.unresolved {
msg.push_str("- ");
msg.push_str(u);
msg.push('\n');
}
}
msg.push_str("\n若快照不足以判断进度,先向用户确认已完成步骤再继续。");
msg
}
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| {
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj
.get("status")
.and_then(|s| s.as_str())
.map(|s| {
if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}
})
.unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
})
.collect(),
_ => vec![],
}
}
/// WorkingContext 落库快照反解(V53 working_context_json,恢复侧单一入口)。
/// None/空串(老会话列 NULL=无 WorkingContext)与解析失败一律返回空 WorkingContext:
/// 行为同今日的半失忆续跑,坏数据降级不炸恢复链路。与落库侧 working_context_snapshot 成对。
pub(crate) fn working_context_from_snapshot(
json: Option<&str>,
) -> df_ai::context_helpers::WorkingContext {
json.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str(s) {
Ok(wc) => Some(wc),
Err(e) => {
tracing::warn!("working_context_json 反解失败(按空上下文降级): {e}");
None
}
})
.unwrap_or_else(df_ai::context_helpers::WorkingContext::new)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(
@@ -490,35 +600,13 @@ pub async fn ai_conversation_switch(
if !mem_has_unsaved {
conv.messages.restore_from_messages(messages);
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
let raw: Option<serde_json::Value> = record.pinned_goals
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
match raw {
Some(serde_json::Value::Array(arr)) => {
arr.iter().filter_map(|v| {
// 新格式: {"text":"...","status":"active"}
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj.get("status").and_then(|s| s.as_str())
.map(|s| if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
// 旧格式: "text" → 转 active
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
}).collect()
}
_ => vec![],
}
};
conv.pinned_goals = pinned_goals;
// 新旧格式兼容解析收敛到 parse_pinned_goals(与 parked 恢复共享,单一事实源)。
conv.pinned_goals = parse_pinned_goals(record.pinned_goals.as_deref());
// WorkingContext 随行读载(V53 working_context_json):DB 快照还原内存真相源,
// 压缩重锚定的决策/未解项跨重启不断裂。与 pinned_goals 同守卫(内存有未落库
// 新消息时不覆盖,DB 快照只回填干净内存)。
conv.working_context =
working_context_from_snapshot(record.working_context_json.as_deref());
}
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
@@ -880,6 +968,16 @@ pub async fn ai_conversation_delete(
}
};
// 跨会话召回(终态提炼删除挂点):删除动作前后台提炼一次,失败/未完成不阻塞删除
// (删除是用户显式意图,延迟属体验倒退)。挂点内同步取走内存快照(全量 active 消息
// + WorkingContext 决策),LLM 提炼在后台;收尾顺带级联清理压缩存档(先提炼后删文件)。
crate::commands::ai::knowledge_inject::spawn_terminal_extraction(
&state,
&conversation_id,
Some(state.data_dir.clone()),
)
.await;
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)。
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
@@ -1594,6 +1692,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".to_string(),
working_context_json: None,
})
.await
.expect("测试会话插入应成功");
@@ -1625,4 +1724,69 @@ mod tests {
// 无子代理的父返空
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
}
// ── 目标快照恢复语义 ──
fn wc_with_goals(texts: &[(&str, df_ai::context_helpers::GoalStatus)]) -> df_ai::context_helpers::WorkingContext {
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.goals = texts
.iter()
.map(|(t, s)| df_ai::context_helpers::GoalItem {
id: format!("g-{}", t),
text: t.to_string(),
status: s.clone(),
})
.collect();
wc
}
#[test]
fn recovery_message_uses_snapshot_and_skips_dead_goals() {
use df_ai::context_helpers::GoalStatus;
// 调整后重启只认最终集:作废(Cancelled)与已完成(Completed)不出现,Active 与钉扎目标全保留
let wc = wc_with_goals(&[
("旧目标已作废", GoalStatus::Cancelled),
("交付 v2", GoalStatus::Active),
("旧目标已完成", GoalStatus::Completed),
]);
let pinned = vec![crate::commands::ai::GoalEntry::new("钉扎:每天跑测试".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert!(msg.contains("交付 v2"), "Active 目标须在快照: {msg}");
assert!(msg.contains("钉扎:每天跑测试"), "钉扎目标须在快照: {msg}");
assert!(!msg.contains("旧目标已作废"), "作废目标不复活: {msg}");
assert!(!msg.contains("旧目标已完成"), "已完成目标不重放: {msg}");
assert!(msg.contains("未解事项") == false || !wc.unresolved.is_empty());
}
#[test]
fn recovery_message_unresolved_included_and_dedup_works() {
use df_ai::context_helpers::GoalStatus;
let mut wc = wc_with_goals(&[("同一目标", GoalStatus::Active)]);
wc.unresolved.push("端口冲突未确认".into());
let pinned = vec![crate::commands::ai::GoalEntry::new("同一目标".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert_eq!(msg.matches("同一目标").count(), 1, "去重后只出现一次: {msg}");
assert!(msg.contains("端口冲突未确认"), "未解事项须进快照: {msg}");
}
#[test]
fn recovery_message_empty_snapshot_falls_back_to_constant() {
let wc = df_ai::context_helpers::WorkingContext::new();
let msg = build_recovery_message(&wc, &[]);
assert_eq!(msg, PARKED_RECOVERY_MESSAGE, "快照全空回退原常量,行为同今日");
}
#[test]
fn parse_pinned_goals_new_and_legacy_formats() {
// 新格式保 status;旧格式转 Active;非数组/坏 JSON 空降级
let new_fmt = parse_pinned_goals(Some(r#"[{"text":"a","status":"completed"},{"text":"b"}]"#));
assert_eq!(new_fmt.len(), 2);
assert!(matches!(new_fmt[0].status, crate::commands::ai::GoalStatus::Completed));
assert!(matches!(new_fmt[1].status, crate::commands::ai::GoalStatus::Active));
let legacy = parse_pinned_goals(Some(r#"["旧文本"]"#));
assert_eq!(legacy.len(), 1);
assert!(matches!(legacy[0].status, crate::commands::ai::GoalStatus::Active));
assert!(parse_pinned_goals(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).is_empty());
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
//!
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
use tauri::State;
/// 单条脉络摘要(字段前端契约,snake_case)
#[derive(serde::Serialize, Clone, Debug)]
pub struct ContextBrief {
pub title: String,
pub summary: String,
pub source_ref: Option<String>,
pub source_project: Option<String>,
pub reuse_count: i32,
}
/// 项目脉络分组
#[derive(serde::Serialize, Clone, Debug)]
pub struct ProjectBriefs {
pub source_project: String,
pub briefs: Vec<ContextBrief>,
}
/// 单条摘要截断上限(字符):脉络卡是扫一眼的导航,不是全文阅读,超长压到一行半
const BRIEF_SUMMARY_MAX_CHARS: usize = 120;
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
/// 客观信号),组间按组内最高复用数降序(活跃项目排前)。
fn aggregate_briefs(
records: Vec<df_storage::models::KnowledgeRecord>,
per_project: usize,
) -> Vec<ProjectBriefs> {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<ContextBrief>> = HashMap::new();
for r in records {
let Some(proj) = r.source_project.clone() else {
continue;
};
let brief = ContextBrief {
title: truncate_chars(&r.title, 60),
summary: truncate_chars(&r.content, BRIEF_SUMMARY_MAX_CHARS),
source_ref: r.source_ref,
source_project: Some(proj.clone()),
reuse_count: r.reuse_count,
};
groups.entry(proj).or_default().push(brief);
}
let mut out: Vec<ProjectBriefs> = groups
.into_iter()
.map(|(source_project, mut briefs)| {
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
briefs.truncate(per_project);
ProjectBriefs { source_project, briefs }
})
.collect();
out.sort_by(|a, b| {
let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0);
top(b).cmp(&top(a))
});
out
}
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
#[tauri::command]
pub async fn ai_context_briefs(
state: State<'_, crate::AppState>,
per_project: Option<usize>,
) -> Result<Vec<ProjectBriefs>, String> {
let records = state
.knowledge
.list_by_status("published")
.await
.map_err(|e| e.to_string())?;
Ok(aggregate_briefs(records, per_project.unwrap_or(5)))
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(title: &str, content: &str, project: Option<&str>, reuse: i32) -> df_storage::models::KnowledgeRecord {
df_storage::models::KnowledgeRecord {
id: format!("k-{title}"),
kind: "pitfall".into(),
title: title.into(),
content: content.into(),
tags: None,
status: "published".into(),
confidence: Some("high".into()),
reuse_count: reuse,
verified: true,
source_project: project.map(Into::into),
source_ref: Some("conv:test".into()),
reasoning: None,
embedding_status: None,
created_at: "1700000000".into(),
updated_at: "1700000000".into(),
}
}
#[test]
fn briefs_group_by_project_and_sort_by_reuse() {
let recs = vec![
rec("甲项目低复用", "c1", Some("proj-a"), 1),
rec("甲项目高复用", "c2", Some("proj-a"), 9),
rec("乙项目", "c3", Some("proj-b"), 3),
rec("无归属不入卡", "c4", None, 99),
];
let out = aggregate_briefs(recs, 5);
assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}");
assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序");
assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序");
assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some())));
}
#[test]
fn briefs_truncate_per_project_and_summary() {
let recs: Vec<_> = (0..8)
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
.collect();
let out = aggregate_briefs(recs, 3);
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
}
}
+118 -1
View File
@@ -267,6 +267,24 @@ async fn save_conversation_serialized(
}
/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
/// WorkingContext 落库快照序列化(V53 working_context_json,save 每轮随行写)。
/// 四段全空(无目标/决策/未解项/步骤)返回 None——NULL=无上下文,列语义干净,
/// 老会话恢复行为同旧;序列化失败 warn 降级为 None(仅丢本轮快照,下轮 save 重写)。
fn working_context_snapshot(wc: &df_ai::context_helpers::WorkingContext) -> Option<String> {
if wc.goals.is_empty() && wc.decisions.is_empty() && wc.unresolved.is_empty()
&& wc.current_step.is_none()
{
return None;
}
match serde_json::to_string(wc) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("WorkingContext 序列化失败(本轮快照降级为 NULL): {e}");
None
}
}
}
async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
@@ -295,7 +313,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = {
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary, conv_working_ctx) = {
let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -335,6 +353,9 @@ async fn save_conversation_inner(
let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone();
// WorkingContext 随行落库快照(V53 working_context_json,跨会话召回 L4):
// 压缩重锚定的决策/未解项跨重启不断裂,恢复侧经 working_context_from_snapshot 反解
let conv_working_ctx = working_context_snapshot(&conv.working_context);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
(
provider_id,
@@ -346,6 +367,7 @@ async fn save_conversation_inner(
cloned,
conv_origin,
conv_boundary,
conv_working_ctx,
)
};
let __lock_total = __lock_start.elapsed();
@@ -471,6 +493,8 @@ async fn save_conversation_inner(
rec.boundary_json = conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty());
// WorkingContext 随行持久化(V53):None(全空/序列化失败)保持 NULL
rec.working_context_json = conv_working_ctx;
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -548,6 +572,7 @@ async fn save_conversation_inner(
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
working_context_json: conv_working_ctx,
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -850,4 +875,96 @@ mod tests {
truncate_decision(&mut m);
assert_eq!(m.content, short);
}
// ---------- WorkingContext 持久化(V53,跨会话召回 L4) ----------
/// 保存→重载 WorkingContext 字段一致:goals/decisions/unresolved/current_step/version
/// 全回(save 侧 working_context_snapshot 正典序列化 + 恢复侧 working_context_from_snapshot
/// 正典反解 + repo 落库往返,即 save_conversation/会话载入/parked 恢复三条链共用的两个端点)。
#[tokio::test]
async fn working_context_snapshot_roundtrip_via_repo() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.version.current_turn = 7;
wc.goals.push(df_ai::context_helpers::GoalItem {
id: "t7".into(),
text: "重构存储层".into(),
status: df_ai::context_helpers::GoalStatus::Active,
});
wc.decisions.push(df_ai::context_helpers::DecisionItem {
question: "迁移号?".into(),
decision: "顺延 V53".into(),
reason: "V50-V52 已占用".into(),
});
wc.unresolved.push("端口未定".into());
wc.current_step = Some(df_ai::context_helpers::StepInfo {
step_name: "迁移".into(),
status: df_ai::context_helpers::StepStatus::InProgress,
});
let snap = working_context_snapshot(&wc).expect("非空上下文应产出快照");
repo.insert(df_storage::models::AiConversationRecord {
id: "c-wc".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(snap),
})
.await
.expect("insert");
let loaded = repo.get_by_id("c-wc").await.expect("get").expect("row");
let restored = crate::commands::ai::commands::conversation::working_context_from_snapshot(
loaded.working_context_json.as_deref(),
);
assert_eq!(restored.goals.len(), 1, "goals 应回");
assert_eq!(restored.goals[0].text, "重构存储层");
assert_eq!(restored.goals[0].status, df_ai::context_helpers::GoalStatus::Active);
assert_eq!(restored.decisions.len(), 1, "decisions 应回");
assert_eq!(restored.decisions[0].decision, "顺延 V53");
assert_eq!(restored.decisions[0].reason, "V50-V52 已占用");
assert_eq!(restored.unresolved, vec!["端口未定".to_string()], "unresolved 应回");
let step = restored.current_step.expect("current_step 应回");
assert_eq!(step.step_name, "迁移");
assert_eq!(step.status, df_ai::context_helpers::StepStatus::InProgress);
assert_eq!(restored.version.current_turn, 7, "脏标记版本应回");
}
/// 无持久化恢复=空 WorkingContext 不报错:全空上下文 save 侧写 NULL(不产脏 JSON);
/// 旧会话列 NULL/空串/坏 JSON 恢复一律空上下文(行为同今日),坏数据降级不 panic。
#[test]
fn working_context_empty_snapshot_none_and_null_restore_empty() {
let empty = df_ai::context_helpers::WorkingContext::new();
assert!(
working_context_snapshot(&empty).is_none(),
"全空上下文快照应为 None(落 NULL,列语义干净)"
);
use crate::commands::ai::commands::conversation::working_context_from_snapshot;
let restored = working_context_from_snapshot(None);
assert!(restored.goals.is_empty() && restored.decisions.is_empty());
assert!(restored.unresolved.is_empty() && restored.current_step.is_none());
let blank = working_context_from_snapshot(Some(""));
assert!(blank.goals.is_empty(), "空串按无上下文");
let bad = working_context_from_snapshot(Some("not-json"));
assert!(bad.decisions.is_empty(), "坏 JSON 降级为空上下文不 panic");
}
}
+433 -62
View File
@@ -27,7 +27,7 @@ use super::boundary::TaskBoundary;
use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str;
use super::{AiSession};
use super::{AiSession, PerConvState};
// ============================================================
// 知识注入/提炼参数(集中收口,不散魔法数)
@@ -902,6 +902,250 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
fn terminal_extraction_system_prompt() -> String {
format!(
"{}\n6. 本次提炼发生在会话终态(删除前或停靠),只提炼跨会话可复用的经验:决策与依据、\
事实发现(环境约束/结构/坑)、偏好与约定、踩坑;未完成事项、待办、下一步计划是会话\
任务态不是知识,一律不要提炼",
EXTRACTION_SYSTEM_PROMPT
)
}
// ============================================================
// 会话终态提炼(跨会话召回写入收口):删除前 + parked 停靠两挂点
// ============================================================
/// 终态提炼内存快照(挂点时刻):全量 active 消息 + WorkingContext 决策/未解项。
/// 快照即输入——删除链路调用方随即删 per_conv 与 DB 行,后台提炼只认快照不回读。
struct TerminalExtractSnapshot {
/// 全量 active user/assistant 消息(时间序,不受常规提炼尾部窗口限制)
messages: Vec<ChatMessage>,
/// WorkingContext 已确认决策(此刻内存最新态,决策 + 依据拼合)
decisions: Vec<String>,
/// 未解项(仅供提炼理解上下文,提示词已约定不作为知识产出)
unresolved: Vec<String>,
}
impl TerminalExtractSnapshot {
/// 有实质产出才提:active 消息达常规提炼的门槛量,或 WorkingContext 已有沉淀
/// (压缩后 active 消息少但决策/未解项仍在的会话,这些结构化输入本身值得提一次)。
fn has_extractable_input(&self) -> bool {
self.messages.len() >= 4 || !self.decisions.is_empty() || !self.unresolved.is_empty()
}
}
/// 决策条目单行文本:决策 + 依据(依据空则只留决策)。
fn decision_line(d: &df_ai::context_helpers::DecisionItem) -> String {
if d.reason.trim().is_empty() {
d.decision.clone()
} else {
format!("{}(依据: {})", d.decision, d.reason)
}
}
/// 从 per_conv 取终态提炼快照(须在挂点锁内同步调用:删除链路随后即删 per_conv)。
fn take_terminal_snapshot(conv: &PerConvState) -> TerminalExtractSnapshot {
TerminalExtractSnapshot {
messages: conv
.messages
.iter()
.filter(|m| {
matches!(m.role, MessageRole::User | MessageRole::Assistant) && m.is_active()
})
.cloned()
.collect(),
decisions: conv
.working_context
.decisions
.iter()
.map(decision_line)
.collect(),
unresolved: conv.working_context.unresolved.clone(),
}
}
/// 压缩存档级联清理(删除链路):会话删除后其存档文件再无召回方,一并移除。
/// 路径规则只读引用 df-ai 压缩召回存档约定(<data_dir>/context-archive/<conv_id>-archive.jsonl)。
fn remove_conv_archive(data_dir: &std::path::Path, conv_id: &str) {
let path = data_dir
.join("context-archive")
.join(format!("{}-archive.jsonl", conv_id));
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!(conv_id, path = %path.display(), "[knowledge] 会话压缩存档已级联清理"),
// 无存档(该会话未触发过压缩召回)是常态,静默
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(conv_id, error = %e, path = %path.display(), "[knowledge] 会话压缩存档清理失败(非阻断)"),
}
}
/// 会话终态提炼入口(写入收口):删除前 + parked 停靠两挂点共用。
///
/// 与常规提炼的差异:强制清水位(清 Done 节奏水位与哨兵,不受「10 条新用户消息」水位
/// 限制,复用手动提炼的清哨兵语义);提炼范围取全量 active 消息并喂入 WorkingContext
/// 决策/未解项;产出边界限跨会话可复用经验,未完成事项不进知识表。防重走 InFlight
/// 水位,与可能在飞的常规提炼互斥。
///
/// 挂点同步完成的只有快照与水位占位(微秒级),LLM 提炼后台 fire-and-forget,
/// 调用方(删除动作/停靠事件)不被阻塞。`cleanup_archive_dir`: Some = 删除链路,
/// 提炼收尾后级联清理压缩存档(先提炼后删文件);提炼未发生的三类出口
/// (无内存态/占位互斥/provider 不可用)存档照清,防孤儿文件。
pub(crate) async fn spawn_terminal_extraction(
state: &AppState,
conv_id: &str,
cleanup_archive_dir: Option<std::path::PathBuf>,
) {
// 会话标题(生命线溯源展示用):此刻行还在(删除链路调用方在挂点返回后才删行),
// 后台任务不再回读 DB,取不到降级占位。
let conv_title = conv_title_or_placeholder(
AiConversationRepo::new(&state.db)
.get_by_id(conv_id)
.await
.ok()
.flatten()
.and_then(|c| c.title),
);
// 快照 + 强制清水位同一锁临界区(TOCTOU 对齐 maybe_spawn_extraction):快照取此刻
// 内存最新态(含未落库轮次),清哨兵语义与手动提炼一致——只清 Done 节奏水位,
// InFlight 占位保留作互斥(下方占位检查拦截)。
let snapshot = {
let mut session = state.ai_session.lock().await;
let snap = session.conv_read(conv_id).map(take_terminal_snapshot);
if snap.is_some() {
session.conv(conv_id).knowledge_extracted = false;
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::Done(_))) {
wms.remove(conv_id);
}
}
snap
};
// 出口 1:无内存态——会话从未在本进程打开(消息只在 DB),无 WorkingContext 可喂,
// 删除链路即将删行,不做 DB 抢读竞态(设计:未及提炼的随会话消失是可接受损失)。
let Some(snapshot) = snapshot else {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
};
if !snapshot.has_extractable_input() {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
// provider 先于 InFlight 占位解析:解析失败的出口不再占位(占位先置会因无任务
// 收口而永久卡死该会话的后续提炼)。
let provider_config = match super::prompt::get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(conv_id, error = %e, "[knowledge] 终态提炼跳过:无可用 provider");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
};
// 出口 2:InFlight 占位互斥——在飞常规提炼已在覆盖近期内容,不叠加刷 candidate。
{
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::InFlight)) {
tracing::info!(conv_id, "[knowledge] 终态提炼跳过:已有提炼任务进行中");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
wms.insert(conv_id.to_string(), ExtractWatermark::InFlight);
}
let db = state.db.clone();
let session_arc = state.ai_session.clone();
let llm_concurrency = state.llm_concurrency.clone();
let conv_id = conv_id.to_string();
spawn_with_inflight_guard(conv_id.clone(), async move {
let result =
extract_terminal_knowledge(&db, &conv_id, &snapshot, &provider_config, &llm_concurrency, &conv_title)
.await;
match &result {
Ok(inserted) => tracing::info!(conv_id = %conv_id, inserted, "[knowledge] 终态提炼完成"),
Err(e) => tracing::warn!(conv_id = %conv_id, error = %e, "[knowledge] 终态提炼失败(非阻断)"),
}
// 水位收口:任意出口记 Done 保节奏(与手动/自动路径同语义);哨兵只在会话仍在
// 内存时置位(get_mut 不惰性重建——删除链路的 per_conv 已随删除消失)。
{
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
if matches!(result, Ok(n) if n > 0) {
if let Some(conv) = session.per_conv.get_mut(&conv_id) {
conv.knowledge_extracted = true;
}
}
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
}
// 删除链路:提炼收尾后级联清理压缩存档(先提炼后删文件)。
if let Some(dir) = cleanup_archive_dir {
remove_conv_archive(&dir, &conv_id);
}
});
}
/// 终态提炼:全量 active 消息 + WorkingContext 决策/未解项喂提炼,产出限跨会话可复用
/// 经验(未完成事项由提示词边界拦截),落库走与常规提炼同一核心。
async fn extract_terminal_knowledge(
db: &Arc<Database>,
conv_id: &str,
snapshot: &TerminalExtractSnapshot,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
) -> anyhow::Result<usize> {
let mut conv_text = String::new();
// WorkingContext 此刻内存最新态:让「本会话定了什么、卡在哪」成为提炼输入而非仅靠
// 消息流重放;未解项标注仅供理解上下文,配合提示词边界不产出为知识。
if !snapshot.decisions.is_empty() {
conv_text.push_str("[本会话已确认的决策]\n");
for d in &snapshot.decisions {
conv_text.push_str(d);
conv_text.push('\n');
}
}
if !snapshot.unresolved.is_empty() {
conv_text.push_str("[本会话未解决的事项(仅供理解上下文,不是知识)]\n");
for u in &snapshot.unresolved {
conv_text.push_str(u);
conv_text.push('\n');
}
}
conv_text.push_str("\n[对话内容]\n");
for m in &snapshot.messages {
let role = match m.role {
MessageRole::User => "用户",
MessageRole::Assistant => "助手",
_ => continue,
};
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
conv_title,
&snapshot.messages,
&terminal_extraction_system_prompt(),
&conv_text,
)
.await
}
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
@@ -945,7 +1189,9 @@ fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
///
/// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。
/// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。
async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
/// 工具侧(knowledge_search)以空消息复用本函数:注入侧与工具侧同一归属口径,
/// 级 b 依赖消息扫描,工具 handler 无消息入参,空切片下仅级 a boundary 生效。
pub(crate) async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
let project_repo = ProjectRepo::new(db);
let conv_repo = AiConversationRepo::new(db);
@@ -1003,10 +1249,10 @@ fn normalize_dir_for_match(dir: &str) -> String {
s.to_lowercase()
}
/// 从对话中提炼知识,产出 candidate 写入知识库
/// 从对话中提炼知识,产出 candidate 写入知识库(常规自动/手动提炼入口)
///
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
/// LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate
/// 组装提炼输入 → [`run_extraction_core`] 完成提炼与落库
///
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
@@ -1021,12 +1267,7 @@ async fn extract_knowledge_from_conversation(
.get_by_id(conv_id)
.await?
.ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?;
// 对话标题(生命线溯源用,空标题降级为占位,避免字节切片风险)
let conv_title = conv
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string());
let conv_title = conv_title_or_placeholder(conv.title.clone());
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
@@ -1080,17 +1321,8 @@ async fn extract_knowledge_from_conversation(
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
}
// F-260619-04 P1 消息级溯源:取末条 assistant 消息 id(本轮 AI 产出知识的载体)。
// 反向扫描全量 messages(不限 recent 6 条,确保取到对话最新 assistant,即便它不在
// 提炼窗口内也属于本轮 AI 产出)。老数据消息无 id → None,source_ref 降级 None,
// 展示侧兼容 conv: 旧格式 + 无 source_ref。
let last_assistant_msg_id: Option<&str> = messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
// 构造提炼消息: system 指令 + 对话内容(user 角色)
// 构造提炼输入:常规提炼只看尾部窗口(终态提炼走 extract_terminal_knowledge 另入口,
// 全量 active + WorkingContext 上下文一并喂提炼)
let mut conv_text = String::new();
for m in recent.iter().rev() {
let role = match m.role {
@@ -1101,8 +1333,92 @@ async fn extract_knowledge_from_conversation(
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
&conv_title,
&messages,
EXTRACTION_SYSTEM_PROMPT,
&conv_text,
)
.await
}
/// 会话标题降级(提炼生命线溯源展示用):空白标题降级占位。
fn conv_title_or_placeholder(title: Option<String>) -> String {
title
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string())
}
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
fn candidate_record_from_item(
item: &serde_json::Value,
source_project: Option<&str>,
source_ref: Option<String>,
) -> Option<KnowledgeRecord> {
let kind = item.get("kind").and_then(|v| v.as_str())?.to_string();
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
// 空白标题/正文与 kind 缺失同判:残缺条目跳过(与 knowledge_insert 工具入参校验同口径)
if title.trim().is_empty() || content.trim().is_empty() {
return None;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
Some(KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.map(|p| p.to_string()),
source_ref,
reasoning,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
}
/// 提炼核心(常规尾部窗口/终态全量共用):LLM 提炼 → 解析截断打捞 → 归属推断 →
/// candidate 批量落库。提炼输入由调用方组装;归属与消息级溯源口径统一
/// (source_ref 记 conv_msg:{assistant_msg_id},老数据无 id 降级 conv:{conv_id})。
async fn run_extraction_core(
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
attribution_messages: &[ChatMessage],
system_prompt: &str,
conv_text: &str,
) -> anyhow::Result<usize> {
// 消息级溯源:末条 assistant 消息 id 是本轮 AI 产出知识的载体(常规路径反向扫全量
// messages,压缩消息也在内,确保取到对话最新 assistant;老数据无 id → 降级对话级)。
let last_assistant_msg_id: Option<&str> = attribution_messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
let source_ref = Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
});
let extract_messages = vec![
ChatMessage::system(EXTRACTION_SYSTEM_PROMPT),
ChatMessage::system(system_prompt),
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
];
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
@@ -1177,50 +1493,14 @@ async fn extract_knowledge_from_conversation(
let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
// 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。
let source_project = resolve_source_project(db, conv_id, &messages).await;
let source_project = resolve_source_project(db, conv_id, attribution_messages).await;
if let Some(pid) = &source_project {
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
}
let mut inserted = 0;
for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) {
Some(k) => k.to_string(),
None => continue,
};
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
if title.is_empty() || content.is_empty() {
let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
continue;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
let record = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 跨会话召回(D1):归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.clone(),
// F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。
// last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级,
// 展示侧双格式解析兼容。
source_ref: Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
}),
reasoning: reasoning.clone(),
embedding_status: None,
created_at: now.clone(),
updated_at: now,
};
match knowledge_repo.insert(record.clone()).await {
Ok(_) => {
@@ -1235,8 +1515,8 @@ async fn extract_knowledge_from_conversation(
.record_extracted(
&record.id,
conv_id,
&conv_title,
reasoning.as_deref().unwrap_or(""),
conv_title,
record.reasoning.as_deref().unwrap_or(""),
last_assistant_msg_id,
)
.await;
@@ -1521,6 +1801,96 @@ mod tests {
assert!(out.contains(&"编译".to_string()));
assert!(out.contains(&"失败".to_string()));
}
// ---------- 会话终态提炼(跨会话召回写入收口) ----------
/// LLM 产出单条 → candidate 记录统一口径:溯源 source_ref 由调用方给定(消息级
/// conv_msg:{id}/对话级 conv:{id}),状态恒 candidate 不自动发布,归属项目透传。
#[test]
fn candidate_record_carries_source_ref_and_candidate_status() {
let item = serde_json::json!({
"kind": "pitfall",
"title": "跨端比对乱名",
"content": "传输层截断假象,两端各算 md5 数值裁决",
"tags": ["proxy"],
"confidence": "medium",
"reasoning": "可复用的排障口径"
});
let rec = candidate_record_from_item(&item, Some("p1"), Some("conv_msg:m-1".into()))
.expect("完整条目应产出记录");
assert_eq!(rec.status, "candidate", "LLM 产出恒为候选,不自动发布");
assert_eq!(rec.source_ref.as_deref(), Some("conv_msg:m-1"));
assert_eq!(rec.source_project.as_deref(), Some("p1"));
assert_eq!(rec.reasoning.as_deref(), Some("可复用的排障口径"));
}
/// kind 缺失或标题/正文空白的条目跳过,不产出残缺记录刷 candidate。
#[test]
fn candidate_record_skips_incomplete_items() {
let no_kind = serde_json::json!({"title": "t", "content": "c"});
assert!(candidate_record_from_item(&no_kind, None, None).is_none());
let blank_title = serde_json::json!({"kind": "pitfall", "title": " ", "content": "c"});
assert!(candidate_record_from_item(&blank_title, None, None).is_none());
}
/// 终态快照:取全量 active user/assistant 消息(压缩/软删消息排除)+ WorkingContext
/// 决策(决策+依据拼合)与未解项。
#[test]
fn terminal_snapshot_takes_active_messages_and_working_context() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("早期问题"));
let mut compressed = ChatMessage::assistant("已压缩回复");
compressed.status = Some(df_ai::provider::MessageStatus::Compressed);
conv.messages.push(compressed);
conv.messages.push(ChatMessage::assistant("最新回复"));
conv.working_context.decisions.push(df_ai::context_helpers::DecisionItem {
question: "用哪个端口".into(),
decision: "统一走 3307 代理".into(),
reason: "防直连风控".into(),
});
conv.working_context.unresolved.push("证书续期未验证".into());
let snap = take_terminal_snapshot(&conv);
assert_eq!(snap.messages.len(), 2, "非 active 消息不进终态提炼");
assert_eq!(snap.decisions, vec!["统一走 3307 代理(依据: 防直连风控)".to_string()]);
assert_eq!(snap.unresolved, vec!["证书续期未验证".to_string()]);
assert!(snap.has_extractable_input());
}
/// 空闲寒暄会话(消息过少且 WorkingContext 无沉淀)不提,省一次无效 LLM 调用。
#[test]
fn terminal_snapshot_chitchat_not_extractable() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("在吗"));
conv.messages.push(ChatMessage::assistant("在"));
assert!(!take_terminal_snapshot(&conv).has_extractable_input());
}
/// 存档级联清理:对应会话存档文件消失,他会话不受波及;重复清理幂等(NotFound 静默)。
#[test]
fn remove_conv_archive_deletes_only_target() {
let base = std::env::temp_dir().join(format!("d2a-arch-{}", std::process::id()));
let dir = base.join("context-archive");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("conv-x-archive.jsonl");
std::fs::write(&target, "{\"role\":\"user\",\"content\":\"历史\"}\n").unwrap();
let other = dir.join("conv-y-archive.jsonl");
std::fs::write(&other, "keep").unwrap();
remove_conv_archive(&base, "conv-x");
assert!(!target.exists(), "对应会话存档应级联清理");
assert!(other.exists(), "他会话存档不受波及");
remove_conv_archive(&base, "conv-x"); // 幂等:文件已不在不报错
let _ = std::fs::remove_dir_all(&base);
}
/// 空白标题降级占位(生命线溯源展示不为空)。
#[test]
fn conv_title_blank_falls_back_to_placeholder() {
assert_eq!(conv_title_or_placeholder(None), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some(" ".into())), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some("标题".into())), "标题");
}
}
#[cfg(test)]
@@ -1616,6 +1986,7 @@ mod salvage_tests {
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
+14
View File
@@ -74,6 +74,8 @@ pub mod unattended;
pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake;
// 脉络卡数据面:published 知识按项目聚合,纯读侧喂前端「带入上下文」决策
pub mod context_briefs;
use agentic::conv_state::{ConvState, ConvStateStore};
@@ -1496,4 +1498,16 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
"任务已停靠",
reason,
);
// ④ 跨会话召回(终态提炼停靠挂点):停靠意味着任务未完结,此刻的目标/决策/未解项
// 最有提炼价值。fire-and-forget 不阻塞事件/通知链路,提炼失败仅内部留痕;
// 挂点同步快照在后台任务首段完成(本函数同步返回,不持 session 锁)。
{
use tauri::Manager;
let app = app.clone();
let conv_id = conv_id.to_string();
tauri::async_runtime::spawn(async move {
let state = app.state::<crate::state::AppState>();
knowledge_inject::spawn_terminal_extraction(&state, &conv_id, None).await;
});
}
}
+46
View File
@@ -681,6 +681,10 @@ fn parse_cache_flag(v: &str) -> bool {
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
///
/// 同处顺带读缓存友好上下文总开关 `df-ai-cache-friendly-context`(默认开,值语义同
/// [`parse_cache_flag`])同步 BP3 摘要链尾断点开关(anthropic_compat 不读本侧 KV,
/// 照 set_anthropic_cache_enabled 进程级同步先例)。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
@@ -691,9 +695,51 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
.unwrap_or_default();
let enabled = parse_cache_flag(&v);
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
let friendly_v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
df_ai::anthropic_compat::set_bp3_enabled(parse_cache_flag(&friendly_v));
enabled
}
/// 读取压缩可召回开关(AppState settings KV `df-ai-compress-recall`,默认开)。
///
/// 值语义:显式 "false"/"0"(设置页开关关)→ 关;缺省/空(从未设置)/"true"/"1"/其余杂值
/// → 开(按规格仅显式关闭生效)。开时调用方在压缩标记前把 data_dir + conv_id 注入
/// ContextManager(`set_compress_recall`):被压原文落盘存档,压缩摘要尾部拼召回句柄行
/// (LLM 经 read_file 回读);关时零行为变更(不落盘、摘要无句柄行)。
pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-compress-recall")
.await
.ok()
.flatten()
.unwrap_or_default();
!(v == "false" || v == "0")
}
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
///
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
/// 开时调用方在 `build_for_request` 前把值注入 ContextManager(`set_cache_friendly`):
/// 超预算裁剪走块状(目标水位+轮边界吸附)+ 存储态 system 锚点 view 层转 user;
/// 关时两者旁路,裁剪与视图与旧路径逐字节等价。
pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
parse_cache_flag(&v)
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
@@ -1,14 +1,13 @@
//! D2a 工具结果过期外置 + D2d 90 天归档:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//! D2a 工具结果过期外置:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//!
//! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」):
//! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt,
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。
//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留
//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。
//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。
//! - 归档清理已彻底移除(2026-10-11):原 90 天归档设计(2026-10-06 提出,2026-10-09 停用)
//! 现整体删除,外置文件只增不减、永不清理。read_offloaded 读回入口已接线
//! (IPC ai_read_offloaded_result),句柄永远可读回。
//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。
use std::path::PathBuf;
@@ -35,11 +34,6 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。
/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。
#[allow(dead_code)]
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
/// 句柄 JSON 字段名(读取降级判据)。
const HANDLE_MARK: &str = "__offloaded__";
@@ -65,7 +59,7 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str)
return result.to_string();
}
let head: String = result.chars().take(200).collect();
// meta 旁路文件:cleanup_expired 的认领判据(防误删同目录非本模块产物)
// meta 旁路文件:标记本模块自产物(带 offloaded 标记),供溯源识别
let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name });
let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string());
let handle = serde_json::json!({
@@ -87,57 +81,6 @@ pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}"))
}
/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
///
/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。
/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。
#[allow(dead_code)]
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
let root = base.join("tool-results");
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(ARCHIVE_AFTER_DAYS * 86400);
let mut removed = 0usize;
let mut freed = 0u64;
for month in entries.flatten() {
let Ok(files) = std::fs::read_dir(month.path()) else { continue };
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
// marker 头校验:句柄 JSON 写入文件首行注释不行(txt 是原文)——改判据:
// 同名 .meta.json 存在且 offloaded=true 才认领。写入侧同步产出 meta。
let meta = p.with_extension("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta) else { continue };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta_str) else { continue };
if !v.get(HANDLE_MARK).and_then(|b| b.as_bool()).unwrap_or(false) {
continue;
}
let Ok(md) = f.metadata() else { continue };
let Ok(modified) = md.modified() else { continue };
let mtime = modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if mtime < cutoff {
let size = md.len();
if std::fs::remove_file(&p).is_ok() {
let _ = std::fs::remove_file(&meta);
removed += 1;
freed += size;
}
}
}
}
(removed, freed)
}
#[cfg(test)]
mod tests {
use super::*;
+231 -9
View File
@@ -11,9 +11,12 @@
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
//!
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id,
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待
//! 上下文注入机制具备后补全)。
//! 会话上下文通道:handler 仍只有 args 入参,会话 id 由执行链注入——所有工具执行
//! (process_tool_calls 与审批/授权窗口)统一经 execute_with_heartbeat 包
//! spawn_agent::scope_caller_conv,handler 内 caller_conv() 读取。search 消费:
//! 解析当前会话归属项目接三档软排序,无 scope(直调)或归属推断失败降级全局检索;
//! insert 消费:source_ref 记 conv:{id} 与自动提炼产物同格式,归属项目同口径推断,
//! 无 scope 降级渠道标记 "ai-chat" 兜底溯源。
//!
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
@@ -31,7 +34,8 @@ use df_storage::models::KnowledgeRecord;
use df_types::types::new_id;
use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
use crate::commands::ai::knowledge_inject::{extract_query_keywords, resolve_source_project, spawn_embedding_for_record};
use crate::commands::ai::tools::spawn_agent::caller_conv;
use crate::commands::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis;
use crate::state::load_knowledge_config;
@@ -52,7 +56,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"knowledge_search",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,当前会话关联项目的知识排前,其后按标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
RiskLevel::Low,
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
args => {
@@ -66,7 +70,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
let keywords = extract_query_keywords(query);
let hits = repo.search_keywords(&keywords, false, limit).await?;
// 会话上下文通道(caller_conv)解析归属项目接三档软排序:同项目排前,全局与
// 异项目仍可召回不淹没。软排序只改顺序不改命中面;无 scope(直调)或归属
// 推断失败降级 project_id=None,与全局检索行为逐字一致,不报错。
let current_project = match caller_conv() {
Some(conv_id) => resolve_source_project(&db, &conv_id, &[]).await,
None => None,
};
let hits = repo
.search_keywords_scoped(&keywords, false, limit, current_project.as_deref())
.await?;
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
if hits.is_empty() {
@@ -123,6 +136,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.collect();
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
});
// 写入归属(跨会话召回写入收口):会话上下文通道解析当前会话,source_ref 记
// conv:{id} 与自动提炼产物同格式,归属项目同口径推断(boundary 级 a);
// 无 scope(直调)降级渠道标记兜底溯源,与 search 无 scope 降级同风格。
let (source_project, source_ref) = match caller_conv() {
Some(conv_id) => (
resolve_source_project(&db, &conv_id, &[]).await,
Some(format!("conv:{}", conv_id)),
),
None => (None, Some("ai-chat".to_string())),
};
let now = now_millis();
let record = KnowledgeRecord {
id: new_id(),
@@ -135,9 +158,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
confidence: None,
reuse_count: 0,
verified: false,
source_project: None,
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源
source_ref: Some("ai-chat".to_string()),
source_project,
source_ref,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
@@ -204,3 +226,203 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ai::boundary::TaskBoundary;
use crate::commands::ai::tools::spawn_agent::scope_caller_conv;
use serde_json::json;
async fn test_db() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.unwrap())
}
async fn ins_project(db: &Arc<Database>, id: &str, path: &str) {
df_storage::crud::ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: format!("项目-{id}"),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
async fn ins_conv_with_boundary(db: &Arc<Database>, id: &str, dirs: &[&str]) {
let boundary = TaskBoundary {
risk_ceiling: RiskLevel::Medium,
dir_allowlist: dirs.iter().map(|d| d.to_string()).collect(),
forbid_destructive: true,
budget: None,
};
df_storage::crud::AiConversationRepo::new(db)
.insert(df_storage::models::AiConversationRecord {
id: id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn ins_knowledge(db: &Arc<Database>, title: &str, project: Option<&str>, reuse: i32) {
let now = now_millis();
df_storage::crud::KnowledgeRepo::new(db)
.insert(KnowledgeRecord {
id: new_id(),
kind: "pitfall".into(),
title: title.into(),
content: format!("{title} 正文"),
tags: None,
status: "published".into(),
confidence: None,
reuse_count: reuse,
verified: false,
source_project: project.map(|p| p.into()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
}
fn search_titles(out: &serde_json::Value) -> Vec<&str> {
out["results"]
.as_array()
.unwrap()
.iter()
.map(|r| r["title"].as_str().unwrap())
.collect()
}
/// 注入通道端到端 + 项目软排序:scope 内执行 search,按会话 boundary 解析归属项目,
/// 同项目条目排前(异项目即便复用次数更高,项目档位是第一排序键也压不过)。
#[tokio::test]
async fn search_scoped_ranks_same_project_first() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_project(&db, "p2", "e:/wk-lab/proj-b").await;
ins_conv_with_boundary(&db, "conv-1", &["e:/wk-lab/proj-a/"]).await;
// 两标题对查询「部署踩坑」的 2-gram 命中分相同,全局序下复用 9 的 p2 在前;
// scoped 序下 p1 靠项目档位反超。
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-1".to_string(), async {
registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
})
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑实录", "部署踩坑指南"],
"同项目知识应排前(项目档位为第一排序键)"
);
}
/// 无会话上下文(未 scope 直调)降级全局检索:不报错,排序回落标题命中+复用降序,
/// 高复用异项目条目回首位,证明走的是全局序而非 scoped 序。
#[tokio::test]
async fn search_without_scope_falls_back_to_global() {
let db = test_db().await;
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑指南", "部署踩坑实录"],
"无项目上下文应回落全局序(复用降序)且不报错"
);
}
/// 注入通道在位性:scope 内 caller_conv 解析出注入的会话 id(search 与 insert 沉淀
/// 通道共用同一读取点,后者写入收口批接入);scope 外读不到(None,直调防御)。
#[tokio::test]
async fn caller_conv_channel_resolves_inside_scope() {
assert!(caller_conv().is_none(), "scope 外应读不到会话 id");
let seen = scope_caller_conv("conv-chan".to_string(), async { caller_conv() }).await;
assert_eq!(seen.as_deref(), Some("conv-chan"), "scope 内应解析出注入的会话 id");
}
/// 写入归属补全(写入收口):scope 内 insert,source_ref 记 conv:{id} 与自动提炼
/// 产物同格式,归属项目按会话 boundary 同口径解析(级 a)。
#[tokio::test]
async fn insert_scoped_records_conv_source_ref() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_conv_with_boundary(&db, "conv-9", &["e:/wk-lab/proj-a/"]).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-9".to_string(), async {
registry
.execute("knowledge_insert", json!({"title": "端口约定", "content": "数据库统一走 3307 代理"}))
.await
})
.await
.unwrap();
assert_eq!(out["status"], "candidate");
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("conv:conv-9"), "溯源与自动提炼产物同格式");
assert_eq!(rec.source_project.as_deref(), Some("p1"), "归属项目按 boundary 解析");
}
/// 无 scope(直调)降级渠道标记兜底溯源,归属项目留空,不报错。
#[tokio::test]
async fn insert_without_scope_falls_back_to_channel_ref() {
let db = test_db().await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_insert", json!({"title": "要点", "content": "内容"}))
.await
.unwrap();
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("ai-chat"));
assert_eq!(rec.source_project, None);
}
}
+15 -29
View File
@@ -1044,34 +1044,8 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) {
}
// 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志
run_self_monitor(app, cfg).await;
// 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。
// 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测
// 保留,未来若需恢复只改此处。
run_daily_offload_cleanup(app).await;
}
/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。
///
/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体
/// 与单测均未删。当前为空转,不触碰任何外置文件。
async fn run_daily_offload_cleanup(_app: &AppHandle) {
// 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。
// 用户决策改为全部保留,故摘除调用点,函数保留为空转。
//
// use std::sync::atomic::AtomicU64;
// static LAST_DAY: AtomicU64 = AtomicU64::new(0);
// let day = std::time::SystemTime::now()
// .duration_since(std::time::UNIX_EPOCH)
// .map(|d| d.as_secs() / 86400)
// .unwrap_or(0);
// if LAST_DAY.swap(day, Ordering::SeqCst) == day {
// return; // 今日已跑
// }
// let data_dir = _app.path().app_data_dir().unwrap_or_default();
// let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
// if removed > 0 {
// tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
// }
// 归档清理已彻底移除(2026-10-11):90 天归档设计整体删除,外置文件永不清理,
// 详见 tool_result_offload.rs 头注释。
}
// ============================================================
@@ -1289,10 +1263,22 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
if !state.conv_states.can_accept_request(&conv_id) {
return Err(format!("会话 {} 正在生成中,无需恢复", conv_id));
}
// 恢复引导消息与 IPC 正典同源:按目标快照生成,快照全空回退常量。
// 引擎路径同步拿快照语义,防「引擎自动恢复按旧目标跑偏」。
let working_context = crate::commands::ai::commands::conversation::working_context_from_snapshot(
record.working_context_json.as_deref(),
);
let pinned = crate::commands::ai::commands::conversation::parse_pinned_goals(
record.pinned_goals.as_deref(),
);
let message = crate::commands::ai::commands::conversation::build_recovery_message(
&working_context,
&pinned,
);
spawn_agentic_session(
app,
&state,
crate::commands::ai::commands::conversation::PARKED_RECOVERY_MESSAGE.to_string(),
message,
None,
None,
None,
+1
View File
@@ -523,6 +523,7 @@ pub fn run() {
commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list,
commands::ai::context_briefs::ai_context_briefs,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
+8
View File
@@ -418,4 +418,12 @@ export const aiApi = {
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId })
},
/**
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
*/
listContextBriefs(perProject?: number): Promise<{ source_project: string; briefs: { title: string; summary: string; source_ref: string | null; source_project: string | null; reuse_count: number }[] }[]> {
return invoke('ai_context_briefs', { perProject: perProject ?? null })
},
}
+18 -1
View File
@@ -153,7 +153,7 @@ import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeConvDraftAppend, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog'
@@ -1207,6 +1207,23 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
})
})
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
watch(
() => convDraftAppends.has(store.state.activeConversationId ?? ''),
(has) => {
if (!has) return
const convId = store.state.activeConversationId
const chunk = takeConvDraftAppend(convId)
if (!chunk) return
inputText.value = inputText.value ? `${inputText.value}\n${chunk}` : chunk
nextTick(() => {
autoResize()
focus()
})
},
)
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
+152
View File
@@ -0,0 +1,152 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
:title="t('aiChat.contextBriefsToggle')"
@click="toggle"
>
<svg
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
><polyline points="9 18 15 12 9 6"/></svg>
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
</button>
<div v-if="expanded" class="ctx-briefs-body">
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">{{ g.source_project }}</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-title">{{ b.title }}</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
:title="t('aiChat.contextBriefsBringIn')"
@click="bringIn(g, b)"
>{{ t('aiChat.contextBriefsBringIn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_project: string | null
reuse_count: number
}
interface ProjectBriefs {
source_project: string
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const expanded = ref(false)
const loading = ref(false)
const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) {
loading.value = true
try {
groups.value = await aiApi.listContextBriefs()
} catch {
groups.value = []
} finally {
loading.value = false
}
}
}
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId
if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
}
</script>
<style scoped>
.ctx-briefs {
border-bottom: 1px solid var(--border, #e0e0e0);
padding: 4px 10px;
font-size: 12px;
}
.ctx-briefs-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 2px;
border: none;
background: none;
color: var(--text-secondary, #666);
cursor: pointer;
font-size: 12px;
}
.ctx-briefs-count {
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
color: var(--accent, #4080ff);
border-radius: 8px;
padding: 0 6px;
font-size: 11px;
}
.ctx-briefs-body {
max-height: 220px;
overflow-y: auto;
padding-bottom: 4px;
}
.ctx-briefs-empty {
color: var(--text-tertiary, #999);
padding: 6px 2px;
}
.ctx-briefs-project {
font-weight: 600;
color: var(--text-primary, #333);
padding: 5px 2px 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 1px dashed var(--border, #eee);
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--text-primary, #333);
}
.ctx-briefs-item-summary {
color: var(--text-secondary, #666);
margin: 2px 0;
line-height: 1.4;
}
.ctx-briefs-bring {
border: 1px solid var(--border, #ddd);
background: none;
color: var(--accent, #4080ff);
border-radius: 4px;
padding: 1px 8px;
font-size: 11px;
cursor: pointer;
}
.ctx-briefs-bring:hover {
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
}
</style>
@@ -32,6 +32,8 @@
/>
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
</div>
<!-- 脉络卡:同项目近期决策摘要,纯读侧,「带入上下文」追加进当前会话输入框 -->
<ContextBriefsCard />
<div class="ai-conv-list">
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults">
+19
View File
@@ -627,6 +627,25 @@ export function setConvDraft(convId: string | null | undefined, text: string): v
}
}
// ── 脉络卡「带入上下文」投递信箱 ──
// 背景:convDrafts 只在切会话时被 ChatInput 读写,键入过程不落 Map——外部组件直接写
// convDrafts 会基于陈旧值合并,丢用户当前键入内容。故走一次性投递:外部组件 push,
// ChatInput watch 到后以「当前输入框实时内容」为合并基底追加,消费即清,不重放。
export const convDraftAppends = reactive(new Map<string, string>())
/** 向某会话输入框投递一段追加文本(脉络卡带入上下文等外部来源) */
export function pushConvDraftAppend(convId: string, text: string): void {
convDraftAppends.set(convId, text)
}
/** 取走并清除投递(一次性消费,无投递返回 undefined) */
export function takeConvDraftAppend(convId: string | null | undefined): string | undefined {
if (!convId) return undefined
const v = convDraftAppends.get(convId)
if (v !== undefined) convDraftAppends.delete(convId)
return v
}
// ── I5 发送失败错误分类 ──
//
// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、
+5
View File
@@ -363,5 +363,10 @@ export default {
outboxActionFail: 'Action failed, please retry',
outboxDuePast: 'Scheduled time must be in the future',
outboxNeedConv: 'No target conversation; create one first',
contextBriefsToggle: 'Expand or collapse project briefs',
contextBriefsTitle: 'Project briefs',
contextBriefsLoading: 'Loading…',
contextBriefsEmpty: 'No published knowledge yet',
contextBriefsBringIn: 'Bring into context',
},
}
+5
View File
@@ -364,5 +364,10 @@ export default {
outboxActionFail: '操作失败,请重试',
outboxDuePast: '定时时间必须晚于当前时刻',
outboxNeedConv: '无目标会话,请先创建会话',
contextBriefsToggle: '展开或收起项目脉络',
contextBriefsTitle: '项目脉络',
contextBriefsLoading: '加载中…',
contextBriefsEmpty: '暂无项目沉淀知识',
contextBriefsBringIn: '带入上下文',
},
}