新增: namespace淘汰治理与可恢复会话分组
- namespace 淘汰治理: pin 机制(关键产出 LRU 永不淘汰, 超池 50% 最旧降级) + EvictionLog 留档富提示(原工具名+内容摘要+重调指引) + owner 软隔离(无主最旧先淘); 子代理报告出口 pin 收编 - 错误路径广播 AiAgentFinished(status=error): 错误任务有终态, watcher/任务卡可收敛 - 桌面侧栏可恢复分组: parked 会话独立分组+点击恢复, 断点续跑读侧闭环 - 验收: cargo check 零 error + vue-tsc EXIT 0
This commit is contained in:
@@ -5,6 +5,15 @@
|
||||
//!
|
||||
//! 存储格式: "namespace://tool_name/args_hash"
|
||||
//! 例: namespace://read_file/a1b2c3d4
|
||||
//!
|
||||
//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2)
|
||||
//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超
|
||||
//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。
|
||||
//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示
|
||||
//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。
|
||||
//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→
|
||||
//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。
|
||||
//! - **零 schema 变更**:纯内存态增强。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
@@ -16,6 +25,7 @@ pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
///
|
||||
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
||||
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
||||
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
|
||||
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
||||
|
||||
/// namespace 字节阈值(> 2048 bytes 触发)
|
||||
@@ -27,6 +37,12 @@ pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||||
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
||||
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
|
||||
|
||||
/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。
|
||||
pub const EVICTION_LOG_CAP: usize = 50;
|
||||
|
||||
/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。
|
||||
pub const PIN_RATIO_CAP: f64 = 0.5;
|
||||
|
||||
/// 判定工具结果是否应进入 namespace
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
||||
@@ -55,14 +71,33 @@ pub struct NamespaceEntry {
|
||||
pub content: String,
|
||||
pub created_at: Instant,
|
||||
pub access_count: u64,
|
||||
/// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1)
|
||||
pub pinned: bool,
|
||||
/// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主
|
||||
pub owner: Option<String>,
|
||||
/// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用
|
||||
pub evict_hint: String,
|
||||
}
|
||||
|
||||
/// namespace 存储(内存 HashMap + LRU 淘汰)
|
||||
/// 淘汰留档记录(EvictionLog 元素)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvictionRecord {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
/// 内容前 80 字符(hint),供富提示识别原文
|
||||
pub hint: String,
|
||||
pub evicted_at: Instant,
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceStore {
|
||||
entries: HashMap<String, NamespaceEntry>,
|
||||
max_bytes: usize,
|
||||
current_bytes: usize,
|
||||
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
|
||||
eviction_log: std::collections::VecDeque<EvictionRecord>,
|
||||
}
|
||||
|
||||
impl NamespaceStore {
|
||||
@@ -72,6 +107,7 @@ impl NamespaceStore {
|
||||
entries: HashMap::new(),
|
||||
max_bytes,
|
||||
current_bytes: 0,
|
||||
eviction_log: std::collections::VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,19 +116,37 @@ impl NamespaceStore {
|
||||
Self::new(10 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// 存入 namespace。返回引用路径字符串。
|
||||
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
|
||||
///
|
||||
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
|
||||
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
|
||||
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
|
||||
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
|
||||
self.store_with_owner(tool_name, content, None)
|
||||
}
|
||||
|
||||
/// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。
|
||||
pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, false)
|
||||
}
|
||||
|
||||
/// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰,
|
||||
/// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。
|
||||
pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, true)
|
||||
}
|
||||
|
||||
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
|
||||
let key = simple_hash(content);
|
||||
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
||||
|
||||
// 已存在 → 刷新 access_count,不重复占用空间
|
||||
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
|
||||
if self.entries.contains_key(&key) {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.access_count += 1;
|
||||
if pinned && !entry.pinned {
|
||||
entry.pinned = true;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -105,15 +159,33 @@ impl NamespaceStore {
|
||||
return path;
|
||||
}
|
||||
|
||||
// pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内
|
||||
// (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控
|
||||
if pinned {
|
||||
let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize;
|
||||
while self.pinned_bytes() + new_bytes > cap {
|
||||
if !self.demote_oldest_pin() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查容量,超限淘汰
|
||||
self.evict_if_needed(new_bytes);
|
||||
|
||||
// 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位),
|
||||
// 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin
|
||||
if self.current_bytes + new_bytes > self.max_bytes && !pinned {
|
||||
return path;
|
||||
}
|
||||
|
||||
let entry = NamespaceEntry {
|
||||
key: key.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: Instant::now(),
|
||||
access_count: 1,
|
||||
pinned,
|
||||
owner: owner.map(str::to_string),
|
||||
evict_hint: content.chars().take(80).collect(),
|
||||
};
|
||||
|
||||
self.current_bytes += new_bytes;
|
||||
@@ -121,6 +193,51 @@ impl NamespaceStore {
|
||||
path
|
||||
}
|
||||
|
||||
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
|
||||
pub fn pin(&mut self, full_path: &str) -> bool {
|
||||
let Some(key) = parse_namespace_key(full_path) else { return false };
|
||||
if !self.entries.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0)
|
||||
> (self.max_bytes as f64 * PIN_RATIO_CAP) as usize
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn unpin(&mut self, full_path: &str) {
|
||||
if let Some(key) = parse_namespace_key(full_path) {
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// pin 条目总字节
|
||||
pub fn pinned_bytes(&self) -> usize {
|
||||
self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum()
|
||||
}
|
||||
|
||||
/// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。
|
||||
fn demote_oldest_pin(&mut self) -> bool {
|
||||
let oldest_pin = self.entries.iter()
|
||||
.filter(|(_, e)| e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone());
|
||||
if let Some(key) = oldest_pin {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.pinned = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 读取完整内容
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
@@ -135,6 +252,17 @@ impl NamespaceStore {
|
||||
self.entries.get(key).map(|e| e.content.as_str())
|
||||
}
|
||||
|
||||
/// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」,
|
||||
/// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。
|
||||
pub fn eviction_rich_notice(&self, full_path: &str) -> Option<String> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let rec = self.eviction_log.iter().find(|r| r.key == key)?;
|
||||
Some(format!(
|
||||
"[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。",
|
||||
rec.tool_name, rec.hint, rec.tool_name,
|
||||
))
|
||||
}
|
||||
|
||||
/// 检查 key 是否存在
|
||||
pub fn contains(&self, full_path: &str) -> bool {
|
||||
parse_namespace_key(full_path)
|
||||
@@ -151,26 +279,68 @@ impl NamespaceStore {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// LRU 淘汰:删除最旧条目直到腾出所需空间
|
||||
fn evict_if_needed(&mut self, needed: usize) {
|
||||
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
|
||||
/// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。
|
||||
/// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前
|
||||
/// 存入方归属(淘汰保护对象,None=无主视角)。
|
||||
fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) {
|
||||
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
|
||||
// 找最旧的条目
|
||||
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
|
||||
let oldest_key = self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned && e.owner.is_none())
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone());
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| {
|
||||
!e.pinned && match (&e.owner, cur_owner) {
|
||||
(Some(o), Some(c)) => o != c,
|
||||
(Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主"
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
});
|
||||
|
||||
if let Some(key) = oldest_key {
|
||||
if let Some(entry) = self.entries.remove(&key) {
|
||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
||||
match oldest_key {
|
||||
Some(key) => {
|
||||
if let Some(entry) = self.entries.remove(&key) {
|
||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
||||
// EvictionLog 留档(50 条上限,超出弹最旧)
|
||||
self.eviction_log.push_back(EvictionRecord {
|
||||
key: entry.key,
|
||||
tool_name: entry.tool_name,
|
||||
hint: entry.evict_hint,
|
||||
evicted_at: Instant::now(),
|
||||
owner: entry.owner,
|
||||
});
|
||||
while self.eviction_log.len() > EVICTION_LOG_CAP {
|
||||
self.eviction_log.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容入口(无主视角)
|
||||
fn evict_if_needed(&mut self, needed: usize) {
|
||||
self.evict_if_needed_with_owner(needed, None);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.current_bytes = 0;
|
||||
self.eviction_log.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,4 +475,61 @@ mod tests {
|
||||
assert!(ns.is_empty());
|
||||
assert_eq!(ns.current_bytes, 0);
|
||||
}
|
||||
|
||||
// ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ──
|
||||
|
||||
#[test]
|
||||
fn pin_protects_from_eviction() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a"));
|
||||
assert!(ns.contains(&pinned_path));
|
||||
// 塞入未 pin 大内容逼淘汰:pin 条目须存活
|
||||
let _ = ns.store("t2", &"x".repeat(80));
|
||||
assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰");
|
||||
assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_log_rich_notice() {
|
||||
let mut ns = NamespaceStore::new(50);
|
||||
let victim = ns.store("read_file", &"v".repeat(40));
|
||||
let victim_path = victim.clone();
|
||||
// 挤出 victim
|
||||
let _ = ns.store("t2", &"y".repeat(45));
|
||||
assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰");
|
||||
// 富提示:含工具名+内容前 80 字+重新调用指引
|
||||
let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中");
|
||||
assert!(notice.contains("read_file"), "富提示应含工具名: {notice}");
|
||||
assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}");
|
||||
// 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER
|
||||
assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_soft_isolation_evicts_other_owner_first() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
// conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b)
|
||||
let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a"));
|
||||
let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b"));
|
||||
// conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己
|
||||
let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b"));
|
||||
assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助");
|
||||
assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")),
|
||||
"他主(conv-a)条目应先被淘汰");
|
||||
assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_ratio_cap_demotes_oldest() {
|
||||
// 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后
|
||||
// 下一次 pin 腾位路径应降级它)
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let p_old = ns.store_pinned("old", &"o".repeat(60), None);
|
||||
// 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old
|
||||
let _ = ns.store_pinned("new", &"n".repeat(30), None);
|
||||
// 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活
|
||||
let _ = ns.store("t3", &"z".repeat(80));
|
||||
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
|
||||
"降级机制应使 pin 总量可被压缩");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
path = %path,
|
||||
"[namespace] 引用已淘汰,替换为提示文案"
|
||||
);
|
||||
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
|
||||
m.content = session
|
||||
.namespace_store
|
||||
.eviction_rich_notice(&path)
|
||||
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,7 +1835,10 @@ async fn prepare_round_inputs(
|
||||
path = %path,
|
||||
"[namespace] 引用已淘汰,替换为提示文案"
|
||||
);
|
||||
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
|
||||
m.content = session
|
||||
.namespace_store
|
||||
.eviction_rich_notice(&path)
|
||||
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2704,6 +2707,21 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
|
||||
error_type: Some(error_type),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// #23 修复:错误路径也广播 AiAgentFinished(status=error)——原只发 AiError,飞书
|
||||
// completion_watcher 只消费 AiAgentFinished → 错误任务永不收敛(watcher 空等到二次
|
||||
// 超时弃守,且无失败终态)。单一事实源在引擎层,订阅方(飞书 watcher/桌面任务卡)各自消费。
|
||||
app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||
AiChatEvent::AiAgentFinished {
|
||||
agent_id: conv_id.to_string(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
summary: format!("任务失败: {}", error.chars().take(300).collect::<String>()),
|
||||
status: "error".to_string(),
|
||||
rounds: 0,
|
||||
tokens: 0,
|
||||
rejected_tools: None,
|
||||
stop_kind: Some("error".to_string()),
|
||||
},
|
||||
);
|
||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||
let brief: String = error.chars().take(160).collect();
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
|
||||
@@ -532,7 +532,7 @@ pub(crate) async fn run_sub_agent_loop(
|
||||
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
|
||||
emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentFinished {
|
||||
agent_id: child_conv_id.clone(),
|
||||
conversation_id: Some(parent_conv_id),
|
||||
conversation_id: Some(parent_conv_id.clone()),
|
||||
summary: outcome.summary.clone(),
|
||||
status: outcome.status.as_str().to_string(),
|
||||
rounds: outcome.rounds,
|
||||
@@ -566,8 +566,16 @@ pub(crate) async fn run_sub_agent_loop(
|
||||
}
|
||||
|
||||
// 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收)
|
||||
// + namespace 治理:报告若经工具结果入库(namespace 引用路径),统一出口 pin 保护
|
||||
// (store hash 去重:已入库条目原地升级 pinned;未入库则 store_pinned 收编,
|
||||
// 超大报告也不因父会话 LRU 淘汰丢报告)。owner=父会话,软隔离归当前主。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
if df_ai::namespace_store::should_use_namespace(&outcome.summary, "sub_agent_report") {
|
||||
session.namespace_store.store_pinned(
|
||||
"sub_agent_report", &outcome.summary, Some(&parent_conv_id),
|
||||
);
|
||||
}
|
||||
session.per_conv.remove(&child_conv_id);
|
||||
session.save_locks.remove(&child_conv_id);
|
||||
session.conv_origins.remove(&child_conv_id);
|
||||
@@ -744,7 +752,12 @@ async fn run_sub_agent_loop_inner(
|
||||
match session.namespace_store.read_only(&path) {
|
||||
Some(original) => m.content = original.to_string(),
|
||||
None => {
|
||||
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
|
||||
// 淘汰治理富提示:Log 命中给原工具名+内容 hint+重调指引,
|
||||
// 未命中(跨会话残留/未知 key)落回既有占位
|
||||
m.content = session
|
||||
.namespace_store
|
||||
.eviction_rich_notice(&path)
|
||||
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,11 @@ async fn save_conversation_inner(
|
||||
let session = session_arc.lock().await;
|
||||
for m in &mut persist_msgs {
|
||||
if df_ai::namespace_store::is_namespace_ref(&m.content) {
|
||||
if let Some(original) = session.namespace_store.read_only(&m.content) {
|
||||
// 淘汰治理富提示:Log 命中给原工具名+hint,未命中原样保留引用
|
||||
// (落库持久化路径,不落占位文案——占位只在请求组装视图层替换)
|
||||
if let Some(notice) = session.namespace_store.eviction_rich_notice(&m.content) {
|
||||
m.content = notice;
|
||||
} else if let Some(original) = session.namespace_store.read_only(&m.content) {
|
||||
m.content = original.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,32 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- G5 可恢复分组(loop_state='parked',断点续跑闭环读侧;置于活跃分组后、归档前) -->
|
||||
<template v-if="recoverable.length">
|
||||
<div class="ai-conv-group-title">
|
||||
<span class="ai-conv-recover-dot" aria-hidden="true"></span>
|
||||
{{ t('aiChat.recoverableCount', { n: recoverable.length }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in recoverable"
|
||||
:key="'r-' + conv.id"
|
||||
class="ai-conv-item ai-conv-item--recoverable"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||
tabindex="0"
|
||||
:title="t('aiChat.recoverableHint')"
|
||||
@click="onRecover(conv.id)"
|
||||
@keydown.enter.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span class="ai-conv-recover-badge">⏸</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 归档分组(可折叠) -->
|
||||
<template v-if="archivedConvs.length">
|
||||
<div class="ai-conv-group-title ai-conv-group-title--fold" @click="store.toggleArchivedFold()">
|
||||
@@ -289,6 +315,7 @@
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { aiApi } from '../../api/ai'
|
||||
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
|
||||
import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
|
||||
@@ -579,6 +606,36 @@ const archivedConvs = computed(() =>
|
||||
|| Number(b.updated_at) - Number(a.updated_at)),
|
||||
)
|
||||
|
||||
// ── G5 可恢复分组(V49 断点续跑读侧:list_parked 桌面消费闭环最后一块) ──
|
||||
// 数据源 ai_list_recoverable(loop_state='parked' 完备真相,不受侧栏 limit 截断);
|
||||
// 点击走 ai_recover_parked(注入恢复引导消息,重走 spawn 正典链路)后自然切换会话。
|
||||
const recoverable = ref<{ id: string; title: string | null; model: string | null; updated_at: string }[]>([])
|
||||
const recoveringId = ref<string | null>(null)
|
||||
async function loadRecoverable() {
|
||||
try {
|
||||
recoverable.value = await aiApi.listRecoverable()
|
||||
} catch (e) {
|
||||
console.error('[G5] loadRecoverable failed', e)
|
||||
}
|
||||
}
|
||||
async function onRecover(id: string) {
|
||||
if (recoveringId.value) return
|
||||
recoveringId.value = id
|
||||
try {
|
||||
await aiApi.recoverParked(id)
|
||||
await store.switchConversation(id)
|
||||
showConvToast(t('aiChat.recoverableDone'))
|
||||
await loadRecoverable() // 恢复后 loop_state 已迁 running,刷新清单
|
||||
} catch (e) {
|
||||
console.error('[G5] recover failed', e)
|
||||
showConvToast(t('aiChat.recoverableFail', { msg: String(e) }))
|
||||
} finally {
|
||||
recoveringId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const searchResults = computed<AiConversationSummary[] | null>(
|
||||
() => store.filteredConversations.value,
|
||||
)
|
||||
@@ -603,6 +660,8 @@ function onResizeStart(e: MouseEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// G5 可恢复清单初载(parked 会话列表,断点续跑读侧)
|
||||
void loadRecoverable()
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
|
||||
@@ -862,9 +921,29 @@ body.ai-sidebar-resizing * {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge {
|
||||
/* G5 可恢复分组(parked 会话,amber 系提示「需注意/可继续」语义;⏸ 徽标+分组标题小圆点) */
|
||||
.ai-conv-recover-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.ai-conv-item--recoverable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-conv-recover-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--df-warning, #f0c75e);
|
||||
cursor: help;
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge { flex-shrink: 0;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
|
||||
@@ -21,6 +21,10 @@ export default {
|
||||
moreActions: 'More actions',
|
||||
archived: 'Archived',
|
||||
archivedCount: 'Archived ({n})',
|
||||
recoverableCount: 'Recoverable ({n})',
|
||||
recoverableHint: 'Task parked by restart interruption; click to resume',
|
||||
recoverableDone: 'Task resumed, running in background',
|
||||
recoverableFail: 'Recover failed: {msg}',
|
||||
emptyConversation: 'No conversations',
|
||||
// UX-06 §3.1: conversation search (live title filter, Ctrl+K to focus)
|
||||
searchPlaceholder: 'Search chats…',
|
||||
|
||||
@@ -21,6 +21,10 @@ export default {
|
||||
moreActions: '更多操作',
|
||||
archived: '已归档',
|
||||
archivedCount: '已归档 ({n})',
|
||||
recoverableCount: '可恢复 ({n})',
|
||||
recoverableHint: '任务因重启中断停靠,点击恢复继续执行',
|
||||
recoverableDone: '任务已恢复,继续后台执行',
|
||||
recoverableFail: '恢复失败: {msg}',
|
||||
emptyConversation: '暂无对话',
|
||||
// UX-06 §3.1:对话搜索(实时过滤标题,Ctrl+K 聚焦)
|
||||
searchPlaceholder: '搜索对话…',
|
||||
|
||||
Reference in New Issue
Block a user