新增: 子代理轨迹查看与任务关联会话

- 会话只读窥视命令,不切换当前状态
- 任务关联会话反查,审计表结果精确匹配
- 托盘气泡键名修正,首关说明链路修通
This commit is contained in:
lxy
2026-10-03 17:57:33 +08:00
parent 8e09112d59
commit 890ceef0ec
3 changed files with 273 additions and 4 deletions
+229 -2
View File
@@ -10,11 +10,12 @@
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use tauri::State;
use df_ai::ai_tools::RiskLevel;
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
use df_storage::crud::{AiConversationRepo, AiToolExecutionRepo, AuditQuery};
use df_storage::db::Database;
use df_storage::models::AiToolExecutionRecord;
use df_types::types::new_id;
@@ -250,6 +251,128 @@ pub async fn list_tool_executions(
})
}
// ============================================================
// S4 TaskDrawer 关联会话反查(按 task_id 推导发起会话,只读)
// ============================================================
/// 任务工具精确名单(与 tools/task.rs 声明的三个任务工具一一对应)。
///
/// 审计表无"task_id → conversation"映射列,主链路靠本名单行 + result JSON 反推;
/// SQL 与本名单必须同步维护(见下方 SELECT 注释)。
const TASK_CONV_TOOLS: [&str; 3] = ["create_task", "update_task", "advance_task"];
/// 关联会话跳转条目 DTO(传给前端 TaskDrawer「关联会话」info-item)。
#[derive(Debug, Clone, Serialize)]
pub struct TaskConversationRef {
pub conversation_id: String,
/// 会话标题(ai_conversations.title,懒创建未命名时 None,前端回退显 id)
pub title: Option<String>,
/// 最近一次命中命中的工具名(create_task/update_task/advance_task)
pub tool_name: String,
/// 最近一次命中时间(毫秒字符串,同库 requested_at 口径)
pub requested_at: String,
}
/// 单行审计记录命中判定(纯函数):工具在名单内 且 result JSON 顶层 id 与 task_id 精确判等。
///
/// 禁 LIKE:任务 id 做子串匹配会误命中(如 "t1" 命中 "t12"),必须解析 JSON 取 id 字段判等;
/// result 非 JSON / 缺 id 字段 / id 不等 / 工具不在名单 → 一律不命中(failed 行的
/// `{"error":...}` 与 rejected 行的人话文本由此自然被排除)。
fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) -> bool {
if !TASK_CONV_TOOLS.contains(&tool_name) {
return false;
}
result
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok())
.and_then(|v| v.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
.map(|id| id == task_id)
.unwrap_or(false)
}
/// 按 task_id 反查发起过任务工具的会话(供 IPC 与单测共用,不依赖 AppState)。
///
/// 数据源 ai_tool_executions 审计表(V9):每笔任务工具执行都记 conversation_id + result。
/// repo 现有查询只支持 status/risk/tool_keyword(LIKE)维度,表达不了 `tool_name IN 精确名单`,
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
///
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
/// 更早的关联对"跳回最近上下文"场景无意义。
pub(crate) async fn find_task_conversations(
db: &Database,
task_id: &str,
) -> Result<Vec<TaskConversationRef>, String> {
let conn = db.conn();
let rows: Vec<(String, String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
// 工具名单硬拷贝自 TASK_CONV_TOOLS(三个名字),改动须两处同步
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
WHERE tool_name IN ('create_task','update_task','advance_task') \
AND conversation_id IS NOT NULL \
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for r in mapped {
out.push(r.map_err(|e| e.to_string())?);
}
Ok(out)
})
.await
.map_err(|e| format!("find_task_conversations join 失败: {}", e))??;
let conv_repo = AiConversationRepo::new(db);
let mut visited: HashSet<String> = HashSet::new();
let mut out = Vec::new();
for (conv_id, tool_name, result, requested_at) in rows {
if !match_task_audit_row(&tool_name, result.as_deref(), task_id) {
continue;
}
// 去重 + 防同会话重复查 origin(首个可见行即该会话最近一次命中)
if !visited.insert(conv_id.clone()) {
continue;
}
if let Ok(Some(rec)) = conv_repo.get_by_id(&conv_id).await {
// origin=None(主会话)才列;查无记录的会话同样不列(见函数注释③)
if rec.origin.is_none() {
out.push(TaskConversationRef {
conversation_id: conv_id,
title: rec.title,
tool_name,
requested_at,
});
}
}
}
Ok(out)
}
/// TaskDrawer「关联会话」查询 IPC:按 task_id 反查发起过 create/update/advance_task 的主会话,
/// 按 最近命中 倒序去重返回。只读,无副作用。
#[tauri::command]
pub async fn list_task_conversations(
state: State<'_, AppState>,
task_id: String,
) -> Result<Vec<TaskConversationRef>, String> {
find_task_conversations(&state.db, &task_id).await
}
// ============================================================
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
// ============================================================
@@ -421,6 +544,110 @@ mod tests {
assert_eq!(out.stats[0].tool_name, "read_file");
}
/// S4 关联会话反查:插入一条指定 conv/tool/result/requested_at 的审计行(测试构造数据用)。
async fn insert_conv_audit(
repo: &AiToolExecutionRepo,
conv: &str,
tool: &str,
result: Option<String>,
t: i64,
) {
repo.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv.to_string()),
message_id: None,
tool_call_id: new_id().into(),
tool_name: tool.to_string(),
arguments: "{}".to_string(),
result,
status: "completed".to_string(),
risk_level: "low".to_string(),
requested_at: t.to_string(),
executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()),
})
.await
.expect("测试数据插入应成功");
}
/// S4 关联会话反查:插入一条指定 origin 的会话记录(V44 origin 列,测试过滤子会话用)。
async fn insert_conversation(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>) {
use df_storage::models::AiConversationRecord;
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: id.to_string(),
title: title.map(|s| s.to_string()),
messages: "[]".to_string(),
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: "1".to_string(),
updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()),
})
.await
.expect("测试会话插入应成功");
}
/// S4 关联会话反查三件事:① result JSON 顶层 id 精确判等(禁 LIKE,前缀 id 不误命中);
/// ② 同会话多行命中按最近一次去重 + 行按 requested_at 倒序;③ origin='sub_agent' 子会话
/// 与已不在库的会话 id 剔除,只列主会话。SQL 三个工具名与 TASK_CONV_TOOLS 同步覆盖。
#[tokio::test]
async fn find_task_conversations_exact_id_dedup_origin_filter() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t = 2_000_000_000_000i64;
insert_conversation(&db, "conv-main", Some("主会话"), None).await;
insert_conversation(&db, "conv-sub", Some("子代理"), Some("sub_agent")).await;
// conv-main:create(t-30) + update(t-10) 两次命中 → 去重后取 update(最近)
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t1","title":"A","status":"todo"}"#.into()), t - 30).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t1","field":"title","updated":true}"#.into()), t - 10).await;
// conv-sub:advance 命中但 origin='sub_agent' → 过滤
insert_conv_audit(&repo, "conv-sub", "advance_task",
Some(r#"{"id":"t1","status":"in_progress"}"#.into()), t - 20).await;
// 干扰项:非名单工具 / id 前缀(t11 ≠ t1)/ 缺 id / 非法 JSON / 其他任务
insert_conv_audit(&repo, "conv-main", "list_tasks",
Some(r#"{"id":"t1"}"#.into()), t - 5).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t12","field":"title","updated":true}"#.into()), t - 6).await;
insert_conv_audit(&repo, "conv-main", "advance_task",
Some(r#"{"error":"boom"}"#.into()), t - 7).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some("审批超时,自动取消".into()), t - 8).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t2","title":"B"}"#.into()), t - 9).await;
insert_conv_audit(&repo, "conv-ghost", "create_task",
Some(r#"{"id":"t1","title":"C"}"#.into()), t - 4).await; // 会话已不在库
let out = find_task_conversations(&db, "t1").await.unwrap();
assert_eq!(out.len(), 1, "只应命中主会话 conv-main(去重后单条)");
assert_eq!(out[0].conversation_id, "conv-main");
assert_eq!(out[0].title.as_deref(), Some("主会话"));
assert_eq!(out[0].tool_name, "update_task", "同会话两行命中取最近一次(update)");
assert_eq!(out[0].requested_at, (t - 10).to_string());
}
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
#[test]
fn match_task_audit_row_truth_table() {
assert!(match_task_audit_row("create_task", Some(r#"{"id":"t1"}"#), "t1"));
assert!(!match_task_audit_row("create_task", Some(r#"{"id":"t12"}"#), "t1"), "禁 LIKE:前缀不误命中");
assert!(!match_task_audit_row("list_tasks", Some(r#"{"id":"t1"}"#), "t1"), "名单外工具不命中");
assert!(!match_task_audit_row("create_task", Some("人话文本"), "t1"), "非 JSON 不命中");
assert!(!match_task_audit_row("create_task", Some(r#"{"error":"x"}"#), "t1"), "缺 id 不命中");
assert!(!match_task_audit_row("create_task", None, "t1"), "result 空不命中");
}
/// from 时间过滤:仅统计 requested_at >= from 的记录。
///
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,
@@ -604,6 +604,42 @@ pub async fn ai_conversation_load_more(
}))
}
/// 只读窥视子代理轨迹(S3 子代理轨迹查看)
///
/// 复用 ai_conversation_switch 的读路径(ai_messages.list_by_conversation → record_to_message,
/// 含空表回退旧 messages JSON 列的兼容分支),但**只读不切状态**:不写 activeConversationId/
/// per_conv/pending_approvals/标题重生成。子会话(ai_conversations.origin='sub_agent')不进侧栏、
/// 不能走 switch(切走会把父会话 active 顶掉并触发一整套会话态副作用),前端完成卡浮层
/// 查看轨迹的唯一合理通道就是本只读命令。
///
/// 返回全量消息不分页:子代理单任务轨迹体量有限,分页属过度设计(设计宁少勿预留)。
#[tauri::command]
pub async fn ai_conversation_peek(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
// 读路径与 switch 同构:优先 ai_messages 表,空表回退旧 messages JSON 列(双向兼容)
let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
.map_err(err_str)?;
let messages: Vec<ChatMessage> = if !all_records.is_empty() {
all_records.iter().map(record_to_message).collect()
} else if record.messages != "[]" && !record.messages.is_empty() {
serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?
} else {
Vec::new()
};
let messages_json = serde_json::to_string(&messages)
.map_err(|e| format!("序列化消息失败: {}", e))?;
Ok(serde_json::json!({
"title": record.title,
"messages": messages_json,
}))
}
/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
+8 -2
View File
@@ -458,6 +458,8 @@ pub fn run() {
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
@@ -480,6 +482,8 @@ pub fn run() {
commands::ai::ai_authorize_dir,
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions,
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
commands::ai::audit::record::list_task_conversations,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats,
// 知识库
@@ -561,9 +565,11 @@ pub fn run() {
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
return;
}
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡。
// 键名必须为 running:App.vue 监听端读 e.payload.running,曾发 "active" 致
// 端上恒取 undefined→按 0 处理,气泡整条链路静默失效(UX-P3 批次修正)。
if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "active": active }));
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "running": active }));
}
// 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染)
let win = window.clone();