新增: 跨会话召回四层架构与脉络卡

- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
This commit is contained in:
lxy
2026-10-11 22:27:20 +08:00
parent 6f5c6b7532
commit cb23061088
28 changed files with 2236 additions and 246 deletions
@@ -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!(
+160 -30
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,35 +2027,30 @@ 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());
}
}
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
// 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);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
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,