diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 7b10a00..6a9ea44 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -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, + /// 最近一次命中命中的工具名(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::(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, String> { + let conn = db.conn(); + let rows: Vec<(String, String, Option, String)> = + tokio::task::spawn_blocking(move || -> Result, 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>(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 = 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, 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, + 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 时其不计入, diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 267ea67..034a64c 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -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 { + 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 = 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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a63f2ab..e33987c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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();