新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("测试会话插入应成功");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user