diff --git a/.gitignore b/.gitignore index 9507047..2754a8c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ coverage/ # 损坏备份(FR-S7 write_file 事故残留)+临时文件 *.broken.bak tmp/ + +# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码) +workflows/ diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 2e2bcd9..ae397ca 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -529,8 +529,13 @@ impl LlmProvider for AnthropicCompatProvider { .map(move |event| match event { Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)), Err(e) => { + // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, + // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): + // Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。 + // 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。 + let ctx = format!("Anthropic SSE 错误: {}", e); error!(error = %e, "Anthropic SSE 事件流错误"); - Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e)) + Err(anyhow::Error::from(e).context(ctx)) } }); diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index cbb3853..cf0fb74 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -287,6 +287,14 @@ impl ContextManager { fn sanitize_messages(messages: Vec) -> Vec { use std::collections::HashSet; + // step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。 + // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, + // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 + let messages: Vec = messages + .into_iter() + .filter(|m| m.is_active()) + .collect(); + // step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id) let resolved_ids: HashSet<&str> = messages .iter() @@ -432,6 +440,77 @@ impl ContextManager { true } + /// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result) + /// + /// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results + /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。 + /// + /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早 + /// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。 + /// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。 + pub fn pop_last_assistant_round(&mut self) -> bool { + if self.messages.is_empty() { + return false; + } + let mut popped_any = false; + // 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空 + while let Some(last) = self.messages.last() { + if matches!(last.message.role, MessageRole::User) { + break; + } + let removed = self.messages.pop().expect("just checked non-empty"); + self.history_tokens = self.history_tokens.saturating_sub(removed.token_count); + if matches!(removed.message.role, MessageRole::Assistant) { + popped_any = true; + } + } + popped_any + } + + /// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。 + /// + /// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文, + /// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。 + /// + /// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同, + /// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。 + /// 找不到返回 Err(()),调用方据此报错。 + pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result { + // 反向找末条 active user 消息且 content 匹配 + let pos = self.messages.iter().rposition(|t| { + matches!(t.message.role, MessageRole::User) + && t.message.content == target_content + && t.message.is_active() + }); + let Some(i) = pos else { return Err(()) }; + // i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的) + let mut count = 0usize; + for t in self.messages[i + 1..].iter_mut() { + if t.message.is_active() { + t.message.status = Some("truncated".to_string()); + count += 1; + } + } + Ok(count) + } + + /// 替换末条 active user 消息的 content(UX-09 编辑重生成)。 + /// + /// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。 + /// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。 + pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> { + let pos = self.messages.iter().rposition(|t| { + matches!(t.message.role, MessageRole::User) && t.message.is_active() + }); + let Some(i) = pos else { return Err(()) }; + let old_tokens = self.messages[i].token_count; + self.messages[i].message.content = new_content.to_string(); + let new_tokens = self.estimator.estimate_message(&self.messages[i].message); + self.messages[i].token_count = new_tokens; + self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens); + Ok(()) + } + /// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等) pub fn iter(&self) -> impl Iterator { self.messages.iter().map(|t| &t.message) diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 1cddf74..e238162 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -505,8 +505,13 @@ impl LlmProvider for OpenAICompatProvider { .map(move |event| match event { Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)), Err(e) => { - error!("SSE 流错误: {}", e); - Err(anyhow::anyhow!("SSE 流错误: {}", e)) + // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, + // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): + // Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。 + // 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。 + let ctx = format!("SSE 流错误: {}", e); + error!("{}", ctx); + Err(anyhow::Error::from(e).context(ctx)) } }); diff --git a/crates/df-ai/src/provider.rs b/crates/df-ai/src/provider.rs index ce36f1c..0e86252 100644 --- a/crates/df-ai/src/provider.rs +++ b/crates/df-ai/src/provider.rs @@ -48,23 +48,33 @@ pub struct ChatMessage { /// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯) #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// 消息状态(UX-09 编辑重生成):None/"active" 正常可见; + /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) + /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, } impl ChatMessage { pub fn system(content: impl Into) -> Self { - Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None } + Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } } pub fn user(content: impl Into) -> Self { - Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None } + Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } } pub fn assistant(content: impl Into) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None } + Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } } pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None } + Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None } } pub fn tool_result(call_id: impl Into, content: impl Into) -> Self { - Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None } + Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None } + } + + /// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。 + pub fn is_active(&self) -> bool { + !matches!(self.status.as_deref(), Some("truncated")) } } diff --git a/crates/df-core/src/events.rs b/crates/df-core/src/events.rs index 4920e82..d2adbf7 100644 --- a/crates/df-core/src/events.rs +++ b/crates/df-core/src/events.rs @@ -61,6 +61,12 @@ pub enum WorkflowEvent { node_id: NodeId, error: String, }, + /// 节点执行被取消(如 HumanNode 人工审批取消:set_cancelled 后节点返回 Err, + /// 状态保 Cancelled 终态)。语义有别于 NodeFailed(失败):取消是用户主动行为, + /// 前端按取消归「取消」展示,不应归类「失败」。 + NodeCancelled { + node_id: NodeId, + }, /// 工作流暂停(等待外部输入) WorkflowPaused { reason: String, diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index 949d743..77cb8ac 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -78,6 +78,12 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { } }; + // CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。 + // 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程, + // 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。 + // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 + cmd.kill_on_drop(true); + if let Some(dir) = &request.working_dir { cmd.current_dir(dir); } diff --git a/crates/df-nodes/src/human_node.rs b/crates/df-nodes/src/human_node.rs index 1b1f855..0e1b7f8 100644 --- a/crates/df-nodes/src/human_node.rs +++ b/crates/df-nodes/src/human_node.rs @@ -6,6 +6,32 @@ use tokio::sync::broadcast; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use df_core::events::{SelectType, WorkflowEvent}; +/// F-260616-06 阶段2: 拒绝语义化关键字。 +/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。 +/// +/// 识别范围(避免误伤): +/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信; +/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝, +/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。 +/// +/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 + +/// 「reject/decline/deny/no/block」英文,覆盖默认 options `["同意","拒绝"]`。 +const REJECT_KEYWORDS: &[&str] = &[ + "拒绝", "驳回", "退回", "否决", + "reject", "decline", "declined", "deny", "denied", "no", "block", +]; + +/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS)。 +fn is_reject_decision(d: &str) -> bool { + let normalized = d.trim().to_lowercase(); + REJECT_KEYWORDS.iter().any(|k| normalized == *k) +} + +/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。 +fn contains_reject(picked: &[String]) -> bool { + picked.iter().any(|d| is_reject_decision(d)) +} + /// 人工审批节点(阻塞节点) pub struct HumanNode; @@ -84,6 +110,26 @@ impl Node for HumanNode { let each_valid = picked.iter().all(|d| !d.is_empty()) && (options.is_empty() || picked.iter().all(|d| options.contains(d))); if count_ok && each_valid { + // F-260616-06 阶段2: 拒绝语义化。 + // 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"), + // 下游无法据 failed 触发退回/重做。 + // 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err + // "人工审批被拒绝(用户选择: )",executor Err 分支 set_failed + // → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。 + // 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。 + if contains_reject(&picked) { + let primary = picked.first().cloned().unwrap_or_default(); + let comment_str = comment.unwrap_or_default(); + return Err(anyhow::anyhow!( + "人工审批被拒绝(用户选择: {}){}", + primary, + if comment_str.is_empty() { + String::new() + } else { + format!(";意见: {}", comment_str) + } + )); + } // 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者) let primary = picked.first().cloned().unwrap_or_default(); return Ok(NodeOutput::from_value(serde_json::json!({ @@ -636,4 +682,169 @@ mod tests { assert_eq!(out.data["decision"], json!("同意")); assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值"); } + + // ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) ===== + + /// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。 + /// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。 + #[tokio::test] + async fn reject_decision_returns_error() { + let bus = EventBus::new(); + let ctx = make_ctx( + &bus, + "exec-reject", + "node-h", + json!({ "options": ["同意", "拒绝"] }), + ); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response(&bus_clone, "exec-reject", "node-h", "拒绝", Some("方案不行")).await; + + let err = handle.await.unwrap().unwrap_err().to_string(); + assert!( + err.contains("拒绝") && err.contains("人工审批"), + "拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}", + err + ); + assert!( + err.contains("方案不行"), + "拒绝 Err 应携带 comment, 实际: {}", + err + ); + } + + /// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。 + #[tokio::test] + async fn approve_decision_still_ok() { + let bus = EventBus::new(); + let ctx = make_ctx( + &bus, + "exec-approve", + "node-h", + json!({ "options": ["同意", "拒绝"] }), + ); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response(&bus_clone, "exec-approve", "node-h", "同意", None).await; + + let out = handle.await.unwrap().unwrap(); + assert_eq!(out.data["decision"], json!("同意")); + } + + /// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。 + /// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。 + #[tokio::test] + async fn english_reject_keyword_returns_error() { + let bus = EventBus::new(); + let ctx = make_ctx( + &bus, + "exec-en", + "node-h", + json!({ "options": ["approve", "reject"], "timeout_secs": 5 }), + ); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response(&bus_clone, "exec-en", "node-h", "reject", None).await; + + let err = handle.await.unwrap().unwrap_err().to_string(); + assert!( + err.contains("拒绝"), + "英文 reject 关键字应返 Err, 实际: {}", + err + ); + } + + /// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err + /// (选了「驳回」即驳回,即便同时选了「同意」)。 + #[tokio::test] + async fn multiple_select_with_one_reject_returns_error() { + let bus = EventBus::new(); + let ctx = make_ctx( + &bus, + "exec-multi-reject", + "node-h", + json!({ + "options": ["同意", "驳回", "备注"], + "select_type": "multiple" + }), + ); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response_multi( + &bus_clone, + "exec-multi-reject", + "node-h", + &["同意", "驳回"], + Some("驳回其中一项"), + ) + .await; + + let err = handle.await.unwrap().unwrap_err().to_string(); + assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err); + } + + /// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err, + /// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。 + #[tokio::test] + async fn empty_options_free_text_reject_keyword_still_errors() { + let bus = EventBus::new(); + let ctx = make_ctx(&bus, "exec-free-reject", "node-h", json!({ "options": [] })); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response(&bus_clone, "exec-free-reject", "node-h", "拒绝", None).await; + + let err = handle.await.unwrap().unwrap_err().to_string(); + assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err); + } + + /// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。 + /// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。) + #[tokio::test] + async fn empty_options_non_reject_free_text_still_ok() { + let bus = EventBus::new(); + let ctx = make_ctx(&bus, "exec-free-ok", "node-h", json!({ "options": [] })); + let bus_clone = bus.clone(); + + let handle = tokio::spawn(async move { HumanNode.execute(ctx).await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + send_response(&bus_clone, "exec-free-ok", "node-h", "再讨论一下", None).await; + + let out = handle.await.unwrap().unwrap(); + assert_eq!(out.data["decision"], json!("再讨论一下")); + } + + /// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。 + #[test] + fn reject_keyword_detection_normalized() { + assert!(is_reject_decision("拒绝")); + assert!(is_reject_decision(" 拒绝 ")); + assert!(is_reject_decision("Reject")); + assert!(is_reject_decision(" decline ")); + assert!(is_reject_decision("驳回")); + assert!(is_reject_decision("退回")); + assert!(!is_reject_decision("同意")); + assert!(!is_reject_decision("approve")); + assert!(!is_reject_decision("再讨论")); + assert!(!is_reject_decision("")); + // 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判 + assert!(!is_reject_decision("拒绝啦")); + assert!(contains_reject(&["同意".into()]) == false); + assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true); + } } diff --git a/crates/df-nodes/src/lib.rs b/crates/df-nodes/src/lib.rs index bc024a8..b0d43a9 100644 --- a/crates/df-nodes/src/lib.rs +++ b/crates/df-nodes/src/lib.rs @@ -5,3 +5,4 @@ pub mod human_node; pub mod script_node; pub mod task_advance_node; pub mod task_state_machine; +pub mod task_workflow_templates; diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 921c9a5..2b4532d 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -30,9 +30,12 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A /// - `target_status`:目标状态(7 态之一,snake_case) /// /// 流程: -/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示) +/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示) /// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链) -/// 3. 状态机:can_transition(from, to) 不合法 → InvalidState 错误(含 from/to 上下文) +/// 3. 状态机(三类拒绝,错误区分供前端分辨): +/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作) +/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文) +/// - can_transition 闸门矩阵判否即此分支 /// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true /// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功) /// @@ -57,17 +60,18 @@ pub async fn advance_task_atomic( .await? .ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?; - // 3. 状态机校验(同态拒绝:todo→todo 等,can_transition 返回 false)。 + // 3. 状态机校验(三类拒绝,错误类型区分供前端分辨): + // - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例) + // - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」 let from = current.status.as_str(); if from == target_status { - return Err(df_core::error::Error::InvalidState { - current: from.to_string(), - expected: target_status.to_string(), - }); + return Err(df_core::error::Error::Validation(format!( + "相同状态 {from:?},无需推进" + ))); } if !can_transition(from, target_status) { return Err(df_core::error::Error::InvalidState { - current: from.to_string(), + current: format!("{from}→{target_status}(非法状态转换)"), expected: target_status.to_string(), }); } @@ -258,26 +262,48 @@ mod tests { #[tokio::test] async fn illegal_skip_rejected() { + // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 let repo = setup().await; repo.insert(rec("t1", "todo")).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); - assert!(matches!(err, df_core::error::Error::InvalidState { .. })); + match err { + df_core::error::Error::InvalidState { current, expected } => { + assert!(current.contains("todo→done"), "非法转换消息应含 from→to,实际: {current}"); + assert!(current.contains("非法状态转换"), "消息应含「非法状态转换」,实际: {current}"); + assert_eq!(expected, "done"); + } + other => panic!("非法转换应是 InvalidState,实际: {other:?}"), + } } #[tokio::test] async fn terminal_done_no_successor() { + // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 let repo = setup().await; repo.insert(rec("t1", "done")).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); - assert!(matches!(err, df_core::error::Error::InvalidState { .. })); + match err { + df_core::error::Error::InvalidState { current, .. } => { + assert!(current.contains("done→todo"), "终态后继消息应含 from→to,实际: {current}"); + } + other => panic!("终态后继应是 InvalidState,实际: {other:?}"), + } } #[tokio::test] async fn same_status_rejected() { + // CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。 + // 同态属空操作,归 Validation「相同状态,无需推进」; + // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 let repo = setup().await; repo.insert(rec("t1", "in_progress")).await.unwrap(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); - assert!(matches!(err, df_core::error::Error::InvalidState { .. })); + match err { + df_core::error::Error::Validation(msg) => { + assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}"); + } + other => panic!("同态拒绝应是 Validation,实际: {other:?}"), + } } #[tokio::test] @@ -339,4 +365,191 @@ mod tests { assert_eq!(r.status, "in_progress"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); } + + // ============================================================ + // CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖 + // + // workflow.rs 的 spawn 闭包回调依赖三段纯计算: + // 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖) + // 2. regression_target(target) 推失败退回态(workflow.rs 私有,此处镜像锁定契约) + // 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to) + // + // workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate + // 无此基础设施 → 降级为纯函数级 + 状态机集成级(零 mock,稳)。回调三段计算全覆盖, + // 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。 + // ============================================================ + + /// 镜像 workflow.rs::regression_target(workflow.rs L44-51 私有函数)的失败退回映射。 + /// + /// 此处是契约镜像(非导入):若 workflow.rs 改映射而忘同步,这里会红,提醒两处一致。 + /// 映射:testing→in_review / in_review→in_progress / in_progress→todo / 其他→None。 + /// + /// 为什么不 pub regression_target 复用? 它是 workflow.rs(app crate)的私有函数, + /// df-nodes 不依赖 app crate(单向依赖:app → df-nodes)。把退回映射当 df-nodes 的 + /// 业务契约之一镜像锁定,符合「推进链业务逻辑落 df-nodes」(D-260616-03)定位。 + fn regression_target(target: &str) -> Option<&'static str> { + match target { + "testing" => Some("in_review"), + "in_review" => Some("in_progress"), + "in_progress" => Some("todo"), + _ => None, + } + } + + /// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。 + /// 抽出纯函数便于直接断言,与 workflow.rs L267-273 的 match 逻辑一一对应。 + /// + /// 返回 Option(非 &str):completed 分支回传入的 target_status(非 'static), + /// failed 分支回 regression_target 的常量。统一 String 避免生命周期冲突。 + fn callback_advance_target(status: &str, target_status: &str) -> Option { + match status { + "completed" => Some(target_status.to_string()), + "failed" => regression_target(target_status).map(String::from), + _ => None, + } + } + + #[test] + fn regression_target_mapping_matches_contract() { + // 锁定 workflow.rs ②-4 失败退回映射表(逐项)。 + assert_eq!(regression_target("testing"), Some("in_review")); + assert_eq!(regression_target("in_review"), Some("in_progress")); + assert_eq!(regression_target("in_progress"), Some("todo")); + // done 是终态前向目标,失败无可退态(workflow.rs 注释:done→None) + assert_eq!(regression_target("done"), None); + // todo 是起点态无可退;blocked/cancelled 非推进链目标 → None + assert_eq!(regression_target("todo"), None); + assert_eq!(regression_target("blocked"), None); + assert_eq!(regression_target("cancelled"), None); + // 未知态防御性 → None + assert_eq!(regression_target("merged"), None); + assert_eq!(regression_target(""), None); + } + + #[test] + fn callback_completed_advances_to_target() { + // ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个) + assert_eq!( + callback_advance_target("completed", "in_progress").as_deref(), + Some("in_progress") + ); + assert_eq!( + callback_advance_target("completed", "testing").as_deref(), + Some("testing") + ); + assert_eq!( + callback_advance_target("completed", "done").as_deref(), + Some("done") + ); + } + + #[test] + fn callback_failed_advances_to_regression_target() { + // ②-4:工作流 failed → 推进到 regression_target(target) + assert_eq!( + callback_advance_target("failed", "testing").as_deref(), + Some("in_review") + ); + assert_eq!( + callback_advance_target("failed", "in_review").as_deref(), + Some("in_progress") + ); + assert_eq!( + callback_advance_target("failed", "in_progress").as_deref(), + Some("todo") + ); + // failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志) + assert_eq!(callback_advance_target("failed", "done"), None); + } + + #[test] + fn callback_non_terminal_status_skips() { + // 工作流非终态(running/cancelled 等)不应触发回调 — workflow.rs match 的 _ 分支。 + // (cancelled 在 workflow.rs 实际归入 failed 路径因 status 字段取值,这里锁定 + // 唯一终态语义:仅 "completed"/"failed" 两态触发推进。) + for non_terminal in ["running", "pending", ""] { + assert_eq!( + callback_advance_target(non_terminal, "in_progress"), + None, + "非终态 {non_terminal:?} 不应触发回调推进" + ); + } + } + + /// ②-3 回调落库验证:completed → advance_task_atomic(target) 成功推进 + 不累加 rounds。 + /// 覆盖三个 target 的前向推进(in_progress/testing/done)从各自合法 from 态触发。 + #[tokio::test] + async fn callback_completed_advance_lands_in_db() { + // in_progress: todo → in_progress(②-3 in_progress 模板完成) + let repo = setup().await; + repo.insert(rec("c1", "todo")).await.unwrap(); + let to = callback_advance_target("completed", "in_progress").unwrap(); + let r = advance_task_atomic(&repo, "c1", &to).await.unwrap(); + assert_eq!(r.status, "in_progress"); + assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds"); + + // testing: in_review → testing(②-3 testing 模板自审+核对通过) + repo.insert(rec("c2", "in_review")).await.unwrap(); + let to = callback_advance_target("completed", "testing").unwrap(); + let r = advance_task_atomic(&repo, "c2", &to).await.unwrap(); + assert_eq!(r.status, "testing"); + assert_eq!(r.review_rounds, 0); + + // done: testing → done(②-3 done 模板最终核对通过) + repo.insert(rec("c3", "testing")).await.unwrap(); + let to = callback_advance_target("completed", "done").unwrap(); + let r = advance_task_atomic(&repo, "c3", &to).await.unwrap(); + assert_eq!(r.status, "done"); + assert_eq!(r.review_rounds, 0); + } + + /// ②-4 回调落库验证:failed → advance_task_atomic(regression_target) 退回 + 累加 rounds。 + /// 关键差异:②-4 退回转换是 regression,review_rounds += 1(与 ②-3 前向推进 0 增量对比)。 + #[tokio::test] + async fn callback_failed_regression_lands_in_db_with_rounds_bump() { + // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) + let repo = setup().await; + repo.insert(rec("f1", "testing")).await.unwrap(); + let to = callback_advance_target("failed", "testing").unwrap(); + assert_eq!(to, "in_review", "testing 失败应退回 in_review"); + let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); + assert_eq!(r.status, "in_review"); + assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)"); + + // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): + // in_review → in_progress。此处验证 regression_target 对 in_review 的映射, + // 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 + repo.insert(rec("f2", "in_review")).await.unwrap(); + let to = callback_advance_target("failed", "in_review").unwrap(); + assert_eq!(to, "in_progress"); + let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); + assert_eq!(r.status, "in_progress"); + assert_eq!(r.review_rounds, 1); + + // in_progress 模板失败:任务当前 in_progress → 退回 todo(rounds 不累加, + // 因 todo→in_progress 是前向,in_progress→todo 是非法转换 —— 见状态机) + // 关键:regression_target("in_progress")=todo,但状态机不允许 in_progress→todo, + // 故回调 advance_task_atomic 会报 InvalidState。验证这一防御性行为: + repo.insert(rec("f3", "in_progress")).await.unwrap(); + let to = callback_advance_target("failed", "in_progress").unwrap(); + assert_eq!(to, "todo"); + let err = advance_task_atomic(&repo, "f3", &to).await.unwrap_err(); + assert!( + matches!(err, df_core::error::Error::InvalidState { .. }), + "in_progress→todo 是非法转换,回调应被状态机拦截(InvalidState),实际: {err:?}" + ); + // 任务状态未被改动(仍是 in_progress) + let still = repo.get_by_id("f3").await.unwrap().unwrap(); + assert_eq!(still.status, "in_progress", "非法退回不应改动 status"); + } + + /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 + /// workflow.rs 此分支仅 tracing::warn! 跳过,不调 advance_task_atomic。 + /// 锁定:done 失败不会推进任务(终态前向目标失败,无可退态)。 + #[tokio::test] + async fn callback_failed_done_skips_advance() { + let to = callback_advance_target("failed", "done"); + assert_eq!(to, None, "done 失败无退回映射 → 回调跳过(None)"); + // 不调 advance_task_atomic(workflow.rs L294-302 的 else 分支) + } } diff --git a/crates/df-nodes/src/task_workflow_templates.rs b/crates/df-nodes/src/task_workflow_templates.rs new file mode 100644 index 0000000..dd0ab3c --- /dev/null +++ b/crates/df-nodes/src/task_workflow_templates.rs @@ -0,0 +1,211 @@ +//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03) +//! +//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边, +//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。 +//! +//! 三模板对应推进链三个前向转换点: +//! - "in_progress": todo→in_progress,单 AiNode 执行任务 +//! - "testing": in_review→testing,AiNode 自审 → HumanNode 核对(human reject 走 Err +//! 触发工作流 failed → ②-4 回调退回 in_review) +//! - "done": testing→done,单 HumanNode 最终核对 +//! +//! 不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。 +//! +//! config 设计:节点级 config 不硬编码 task_id / provider 等运行时参数 —— 全部留空。 +//! 运行时 run_workflow(task_id, target_status) 调用方注入全局 config(task_id / +//! target_status / provider 配置),由 ④-1 deep_merge 与节点级 config 合并 +//! (节点级覆盖全局)。模板只描述拓扑,不绑定具体任务。 + +use df_workflow::dag_def::DagDef; + +/// 按目标状态返回推进链工作流模板。 +/// +/// `target_status` 取推进链前向目标状态(in_progress / testing / done)。 +/// 未匹配返回 None —— 调用方降级(如直接 IPC 推进,不启工作流),不 panic。 +/// +/// 返回的 DagDef 节点级 config 为空对象或仅含拓扑级元信息(label 等), +/// 运行时参数由调用方全局 config 注入 + deep_merge。 +pub fn template_for(target_status: &str) -> Option { + match target_status { + "in_progress" => Some(in_progress_template()), + "testing" => Some(testing_template()), + "done" => Some(done_template()), + _ => None, + } +} + +/// todo → in_progress:单 AiNode 执行任务。 +/// +/// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。 +fn in_progress_template() -> DagDef { + let mut dag = DagDef::new(); + // config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入 + // (④-1 deep_merge 合并),模板只描述拓扑。 + dag.add_node( + "ai_execute", + "ai", + serde_json::json!({}), + ); + dag +} + +/// in_review → testing:AiNode 自审 → HumanNode 核对。 +/// +/// 拓扑:ai → human 串行。HumanNode reject 走 Err(②-5 已做)→ 工作流 failed → +/// ②-4 失败回调退回 in_review(review_rounds+=1)。 +/// 通过则完成回调(②-3)推进 status 到 testing。 +fn testing_template() -> DagDef { + let mut dag = DagDef::new(); + dag.add_node("ai_self_review", "ai", serde_json::json!({})); + dag.add_node( + "human_review", + "human", + serde_json::json!({ + // 拓扑级元信息(非运行时参数):审批卡片默认文案。options 含「拒绝」 + // 触发 ②-5 reject → Err → 工作流 failed。 + "title": "核对 AI 自审结果", + "options": ["同意", "拒绝"], + }), + ); + dag.add_edge("ai_self_review", "human_review"); + dag +} + +/// testing → done:单 HumanNode 最终核对。 +/// +/// 拓扑:1 个 human 节点。通过则完成回调(②-3)推进 status 到 done。 +/// reject 同理走 Err → failed → ②-4 退回。 +fn done_template() -> DagDef { + let mut dag = DagDef::new(); + dag.add_node( + "human_final_review", + "human", + serde_json::json!({ + "title": "最终核对", + "options": ["同意", "拒绝"], + }), + ); + dag +} + +// ============================================================ +// 单元测试 — 模板拓扑断言 +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_status_returns_none() { + assert!(template_for("unknown").is_none()); + assert!(template_for("").is_none()); + assert!(template_for("merged").is_none()); + } + + #[test] + fn in_progress_template_single_ai_node() { + let dag = template_for("in_progress").expect("in_progress 模板存在"); + assert_eq!(dag.nodes.len(), 1, "in_progress: 单节点"); + assert!(dag.edges.is_empty(), "in_progress: 无边"); + let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在"); + assert_eq!(node.node_type, "ai"); + // config 留空(运行时注入) + assert!( + node.config.as_object().map(|o| o.is_empty()).unwrap_or(true), + "ai_execute config 应为空对象" + ); + } + + #[test] + fn testing_template_ai_to_human_serial() { + let dag = template_for("testing").expect("testing 模板存在"); + assert_eq!(dag.nodes.len(), 2, "testing: ai + human 两节点"); + assert_eq!(dag.edges.len(), 1, "testing: 单串行边"); + // 节点类型 + let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在"); + assert_eq!(ai.node_type, "ai"); + let human = dag.nodes.get("human_review").expect("human_review 存在"); + assert_eq!(human.node_type, "human"); + // 边方向:ai → human + let edge = &dag.edges[0]; + assert_eq!(edge.source, "ai_self_review"); + assert_eq!(edge.target, "human_review"); + // human options 含「拒绝」(触发 ②-5 reject) + let options = human + .config + .get("options") + .and_then(|v| v.as_array()) + .expect("human_review options 存在"); + let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect(); + assert!(opts.contains(&"拒绝"), "options 应含「拒绝」触发 reject"); + } + + #[test] + fn done_template_single_human_node() { + let dag = template_for("done").expect("done 模板存在"); + assert_eq!(dag.nodes.len(), 1, "done: 单节点"); + assert!(dag.edges.is_empty(), "done: 无边"); + let node = dag.nodes.get("human_final_review").expect("human_final_review 存在"); + assert_eq!(node.node_type, "human"); + } + + // ---------- ②-3 / ②-4 回调语义对齐(CR-06 观察项 ② 测试补强) ---------- + // + // 工作流完成(②-3)/失败(②-4)回调在 src-tauri/src/commands/workflow.rs spawn 闭包里, + // 回调的核心计算是:template_for 选模板 + regression_target(target) 推退回态 + + // advance_task_atomic 落库。这里锁定 template_for 三模板对回调语义的约束, + // 保证回调侧(workflow.rs)的 target_status 取值与模板存在性一致 —— + // 即回调只会对 template_for 返回 Some 的三个 target 触发,其余 target 模板不存在、 + // 工作流压根不会启动,回调自然不触发。 + + /// 三模板对应的 target_status 集合(workflow.rs ②-3/②-4 回调仅对这些 target 触发) + const CALLBACK_TARGETS: &[&str] = &["in_progress", "testing", "done"]; + + #[test] + fn callback_targets_all_have_templates() { + // ②-3/②-4 回调依赖 template_for(target) 非空 —— 凡是能进回调的 target 必有模板。 + // 若新增一个回调 target 但忘了加模板,这里会先红。 + for target in CALLBACK_TARGETS { + assert!( + template_for(target).is_some(), + "回调 target {target:?} 必须有对应模板(template_for 非 None)" + ); + } + } + + #[test] + fn non_callback_targets_have_no_template() { + // 反向:非推进链 target(todo/in_review/blocked/cancelled/未知) 无模板, + // 工作流不启动,②-3/②-4 回调不触发 —— 锁定回调边界。 + for non_target in ["todo", "in_review", "blocked", "cancelled", "merged", ""] { + assert!( + template_for(non_target).is_none(), + "非推进链 target {non_target:?} 不应有模板(否则回调边界被扩大)" + ); + } + } + + #[test] + fn testing_and_done_templates_have_reject_path() { + // ②-4 失败回调依赖模板内 human reject 走 Err → 工作流 failed → regression_target。 + // testing(done 同理)模板的 human 节点 options 必须含「拒绝」,否则失败回调不可达。 + // (in_progress 模板只有 ai 节点,失败是 ai 异常,无 reject 路径 —— 单独验证。) + for target in ["testing", "done"] { + let dag = template_for(target).unwrap_or_else(|| panic!("{target} 模板应存在")); + let human = dag.nodes.values().find(|n| n.node_type == "human").unwrap_or_else(|| { + panic!("{target} 模板应含 human 节点(承载 reject → failed → ②-4 回调)") + }); + let options = human + .config + .get("options") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("{target} human 节点 config 应有 options 数组")); + let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect(); + assert!( + opts.contains(&"拒绝"), + "{target} human options 应含「拒绝」(否则 ②-4 失败回调路径不可达)" + ); + } + } +} diff --git a/crates/df-storage/src/crud.rs b/crates/df-storage/src/crud.rs index e5aa0b7..4083e13 100644 --- a/crates/df-storage/src/crud.rs +++ b/crates/df-storage/src/crud.rs @@ -1047,6 +1047,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result("archived")? != 0, + pinned: row.get::<_, i32>("pinned")? != 0, prompt_tokens: row.get("prompt_tokens")?, completion_tokens: row.get("completion_tokens")?, created_at: row.get("created_at")?, @@ -1137,10 +1138,11 @@ impl_repo!( from_row => |row| ai_conversation_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, prompt_tokens, completion_tokens, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, + if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, rec.created_at, rec.updated_at ], @@ -1148,9 +1150,10 @@ impl_repo!( }, update => |conn, rec| { conn.execute( - "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, prompt_tokens = ?7, completion_tokens = ?8, updated_at = ?9 WHERE id = ?10", + "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11", params![ rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, + if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, rec.updated_at, rec.id ], @@ -1237,6 +1240,42 @@ impl AiToolExecutionRepo { .await .map_err(storage_err)? } + + /// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。 + /// + /// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码 + /// ORDER BY created_at,本表无该列)。limit/offset 上限钳制(limit ≤ 200), + /// 防前端恶意/失误传超大值。 + pub async fn list_recent( + &self, + limit: u32, + offset: u32, + ) -> Result> { + let conn = self.conn.clone(); + // 钳制 limit 防滥用(默认 50,最大 200) + let safe_limit = limit.min(200) as i64; + let safe_offset = offset as i64; + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare( + "SELECT * FROM ai_tool_executions ORDER BY requested_at DESC LIMIT ?1 OFFSET ?2", + ) + .map_err(storage_err)?; + let rows = stmt + .query_map(params![safe_limit, safe_offset], |row| { + ai_tool_execution_from_row(row) + }) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } } impl_repo!( @@ -1611,6 +1650,26 @@ impl AiConversationRepo { .await .map_err(storage_err)? } + + /// 设置置顶标记(仅改 pinned,不动 updated_at) — UX-17 + /// + /// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。 + pub async fn set_pinned(&self, id: &str, pinned: bool) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET pinned = ?1 WHERE id = ?2", + params![if pinned { 1 } else { 0 }, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } } // ============================================================ diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 4310f43..e5474c6 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> { // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 - let steps: [(i32, fn(&Connection) -> Result<()>); 15] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 16] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -50,6 +50,7 @@ pub fn run(conn: &Connection) -> Result<()> { (13, migrate_v13), (14, migrate_v14), (15, migrate_v15), + (16, migrate_v16), ]; for (version, migrate_fn) in steps { @@ -276,6 +277,24 @@ fn migrate_v15(conn: &Connection) -> Result<()> { Ok(()) } +/// V16: 幂等补 ai_conversations.pinned 列(对话置顶,UX-17) +/// +/// 侧栏置顶分组排序信号:前端按 pinned DESC, updated_at DESC 排,置顶在前。 +/// 纯元数据标记(同 archived),NOT NULL DEFAULT 0 保证老库行非 NULL,AiConversationRecord.pinned 为 bool。 +/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15 模式),对新库/老库均安全。 +fn migrate_v16(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ai_conversations", "pinned") { + conn.execute( + "ALTER TABLE ai_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", + [], + )?; + tracing::info!("v16: 补建 ai_conversations.pinned 列(对话置顶)"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [16])?; + tracing::info!("迁移 v16 完成"); + Ok(()) +} + /// V1 建表 SQL const V1_SQL: &str = " -- 想法表 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 94f0baa..61bad48 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -160,6 +160,7 @@ pub struct AiConversationRecord { pub model: Option, pub models: Option, // 用过的所有 model(JSON 数组字符串,去重) pub archived: bool, // 是否归档(侧栏折叠展示) + pub pinned: bool, // 是否置顶(排序置前;UX-17) pub prompt_tokens: Option, // 输入 token 累计(流式 usage 落库) pub completion_tokens: Option, // 输出 token 累计(流式 usage 落库) pub created_at: String, diff --git a/crates/df-workflow/src/dag.rs b/crates/df-workflow/src/dag.rs index c96d24f..5b0d8a8 100644 --- a/crates/df-workflow/src/dag.rs +++ b/crates/df-workflow/src/dag.rs @@ -22,6 +22,12 @@ pub struct Dag { pub nodes: HashMap>, /// 边集合 pub edges: Vec, + /// 节点级配置(DagDef.nodes[id].config 提取自 build_dag,run 时与全局 config deep_merge) + /// + /// 语义:节点级覆盖全局级。DagExecutor 构造 NodeContext 时执行 + /// `deep_merge(initial_config, node_configs[id])`,节点定义里写的同名 key 优先于 + /// run_workflow 传入的全局 config。 + pub node_configs: HashMap, } impl Dag { @@ -30,6 +36,7 @@ impl Dag { Self { nodes: HashMap::new(), edges: Vec::new(), + node_configs: HashMap::new(), } } @@ -38,6 +45,19 @@ impl Dag { self.nodes.insert(id, node); } + /// 添加节点(附带节点级配置) + /// + /// 节点级配置在 DagExecutor 构造 NodeContext 时覆盖全局 config(deep_merge)。 + pub fn add_node_with_config( + &mut self, + id: NodeId, + node: Box, + config: serde_json::Value, + ) { + self.nodes.insert(id.clone(), node); + self.node_configs.insert(id, config); + } + /// 添加边 pub fn add_edge(&mut self, source: NodeId, target: NodeId) { self.edges.push(Edge { @@ -135,3 +155,85 @@ impl Default for Dag { Self::new() } } + +/// 深度合并两个 JSON 值:节点级 `node` 覆盖全局级 `global`。 +/// +/// 规则: +/// - 两端均为 Object:递归合并(同 key 时节点级覆盖全局级,新增 key 各自保留)。 +/// - 非两端 Object:节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)。 +/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null); +/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局)。 +/// +/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`, +/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。 +pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde_json::Value { + use serde_json::Value; + match (global, node) { + (Value::Object(g), Value::Object(n)) => { + let mut merged = g.clone(); + for (k, nv) in n { + // 同 key 递归(支持嵌套对象局部覆盖);不同类型时递归退化为「节点级直接覆盖」 + let gv = merged.get(k).cloned(); + let merged_v = match gv { + Some(gv) => deep_merge(&gv, nv), + None => nv.clone(), + }; + merged.insert(k.clone(), merged_v); + } + Value::Object(merged) + } + // 非两端 Object:节点级覆盖全局级(含 Null、标量、数组) + _ => node.clone(), + } +} + +#[cfg(test)] +mod deep_merge_tests { + use super::deep_merge; + use serde_json::json; + + #[test] + fn node_overrides_global_scalar() { + assert_eq!(deep_merge(&json!({"a": 1}), &json!({"a": 2})), json!({"a": 2})); + } + + #[test] + fn node_adds_new_key() { + assert_eq!( + deep_merge(&json!({"a": 1}), &json!({"b": 2})), + json!({"a": 1, "b": 2}) + ); + } + + #[test] + fn nested_object_recursive_merge() { + assert_eq!( + deep_merge(&json!({"o": {"x": 1, "y": 2}}), &json!({"o": {"y": 9}})), + json!({"o": {"x": 1, "y": 9}}) + ); + } + + #[test] + fn empty_node_config_is_noop() { + // 核心保现有调用方行为:节点 config 为空对象 → 结果等同全局(零行为破坏) + let global = json!({"title": "确认", "options": ["同意"]}); + assert_eq!(deep_merge(&global, &json!({})), global); + } + + #[test] + fn missing_node_config_treated_as_noop() { + // DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照: + // 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。 + let global = json!({"a": 1}); + assert_eq!(deep_merge(&global, &json!(null)), json!(null)); + } + + #[test] + fn array_replaces_not_concat() { + // 非两端 Object(数组)→ 节点级直接覆盖,不拼接 + assert_eq!( + deep_merge(&json!({"arr": [1, 2]}), &json!({"arr": [3]})), + json!({"arr": [3]}) + ); + } +} diff --git a/crates/df-workflow/src/executor.rs b/crates/df-workflow/src/executor.rs index 60d7ecb..f9ef3b3 100644 --- a/crates/df-workflow/src/executor.rs +++ b/crates/df-workflow/src/executor.rs @@ -99,7 +99,13 @@ impl DagExecutor { let ctx = NodeContext { node_id: node_id.clone(), inputs, - config: initial_config.clone(), + // 节点级覆盖全局级:deep_merge(initial_config, node_configs[id])。 + // 节点 NodeDef.config 未定义(node_configs 缺该 key)→ 直接用全局 initial_config, + // 与旧行为一致(零行为破坏现有 HumanNode/AiNode 等读全局 config 的调用方)。 + config: match dag.node_configs.get(node_id) { + Some(node_cfg) => crate::dag::deep_merge(&initial_config, node_cfg), + None => initial_config.clone(), + }, execution_id: self.execution_id.clone(), event_bus: self.event_bus.clone(), // 共享执行器状态机,使节点(如 HumanNode)能读取真实状态而非空状态机 @@ -142,16 +148,24 @@ impl DagExecutor { let error_msg = e.to_string(); // 已取消的节点(如 HumanNode 审批取消)跳过 set_failed: // Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(B-03b-R1) - if !self.state_machine.is_cancelled(&node_id) { + if self.state_machine.is_cancelled(&node_id) { + // emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」 + self.event_bus + .send(WorkflowEvent::NodeCancelled { + node_id: node_id.clone(), + }) + .await; + } else { self.state_machine.set_failed(node_id.clone())?; + self.event_bus + .send(WorkflowEvent::NodeFailed { + node_id: node_id.clone(), + error: error_msg, + }) + .await; } - self.event_bus - .send(WorkflowEvent::NodeFailed { - node_id: node_id.clone(), - error: error_msg, - }) - .await; - // 同层多个失败时只报告第一个 + // 取消与失败一致:节点返回 Err 即中止后续层(run 返回 Err); + // 仅事件类型区分,工作流结果语义保持不变(零行为破坏)。 if first_err.is_none() { first_err = Some(e.context(format!("节点 {} 执行失败", node_id))); } @@ -210,6 +224,35 @@ mod tests { /// 测试节点:直接返回错误 struct FailNode; + /// 测试节点:把 ctx.config 的指定 key 字符串原样回显到 output(验证节点级 config 下沉) + struct EchoConfigNode { + key: String, + } + + #[async_trait] + impl Node for EchoConfigNode { + async fn execute(&self, ctx: NodeContext) -> NodeResult { + let v = ctx + .config + .get(&self.key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(NodeOutput::from_value(serde_json::json!({ "echo": v }))) + } + + fn schema(&self) -> NodeSchema { + NodeSchema { + params: serde_json::Value::Null, + output: serde_json::Value::Null, + } + } + + fn node_type(&self) -> &str { + "echo_config" + } + } + #[async_trait] impl Node for FailNode { async fn execute(&self, _ctx: NodeContext) -> NodeResult { @@ -228,6 +271,47 @@ mod tests { } } + /// ④-1:节点级 config 下沉验证。 + /// DagDef 节点 config={foo:"bar"} + 全局 config={foo:"GLOBAL"} → NodeContext.config.foo=="bar" + /// (节点级覆盖全局级)。同时验证缺节点 config 的节点回退全局 config(零行为破坏现有调用方)。 + #[tokio::test] + async fn node_config_overrides_global_in_node_context() { + use crate::registry::NodeRegistry; + + // 构造 DagDef:n1 节点写 config={foo:"bar"};n2 节点 config 空(验证回退全局) + let mut def = crate::dag_def::DagDef::new(); + def.add_node("n1".to_string(), "echo".to_string(), serde_json::json!({ "foo": "bar" })); + def.add_node("n2".to_string(), "echo".to_string(), serde_json::json!({})); + + // 注册 echo 工厂 → EchoConfigNode(读 config.foo) + let mut registry = NodeRegistry::new(); + registry.register("echo", |_cfg| { + Box::new(EchoConfigNode { key: "foo".to_string() }) + }); + + let dag = registry.build_dag(&def).expect("build_dag"); + + let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string()); + // 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局) + let outputs = executor + .run(&dag, serde_json::json!({ "foo": "GLOBAL" })) + .await + .expect("run"); + + // n1:节点级 foo="bar" 覆盖全局 "GLOBAL" + assert_eq!( + outputs["n1"].data["echo"], + serde_json::json!("bar"), + "节点级 config 应覆盖全局" + ); + // n2:节点 config 空 → 回退全局 foo="GLOBAL"(零行为破坏现有调用方) + assert_eq!( + outputs["n2"].data["echo"], + serde_json::json!("GLOBAL"), + "空节点 config 应回退全局" + ); + } + #[tokio::test] async fn test_same_layer_runs_in_parallel() { // 两个无依赖节点位于同一层,各 sleep 100ms @@ -369,4 +453,46 @@ mod tests { NodeStatus::Cancelled ); } + + /// 复核-新③:取消节点(Err 路径)emit 的应是 NodeCancelled(非 NodeFailed), + /// 前端按 type 区分「取消」与「失败」。订阅事件总线收集所有事件断言。 + #[tokio::test] + async fn test_cancelled_node_emits_node_cancelled_event() { + use df_core::events::WorkflowEvent; + use df_core::types::NodeStatus; + + let mut dag = Dag::new(); + dag.add_node("c".to_string(), Box::new(CancelSelfNode)); + + let bus = EventBus::new(); + let mut rx = bus.subscribe(); + let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string()); + let _ = executor.run(&dag, serde_json::Value::Null).await; + + // 收集所有事件(run 已结束,事件总线无新事件) + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + + // 必含 NodeCancelled { node_id: "c" } + let cancelled = events.iter().any(|e| matches!( + e, + WorkflowEvent::NodeCancelled { node_id } if node_id == "c" + )); + assert!(cancelled, "取消节点应 emit NodeCancelled, 实际事件: {:?}", events); + + // 不应含 node "c" 的 NodeFailed(失败事件)—— 语义双标修复的核心验证 + let failed = events.iter().any(|e| matches!( + e, + WorkflowEvent::NodeFailed { node_id, .. } if node_id == "c" + )); + assert!(!failed, "取消节点不应 emit NodeFailed, 实际事件: {:?}", events); + + // 状态保持 Cancelled + assert_eq!( + executor.state_machine.get(&"c".to_string()), + NodeStatus::Cancelled + ); + } } diff --git a/crates/df-workflow/src/registry.rs b/crates/df-workflow/src/registry.rs index 790b942..d7087ed 100644 --- a/crates/df-workflow/src/registry.rs +++ b/crates/df-workflow/src/registry.rs @@ -46,7 +46,10 @@ impl NodeRegistry { let mut node_ids: std::collections::HashSet<&String> = std::collections::HashSet::new(); for (id, node_def) in &def.nodes { let node = self.create(&node_def.node_type, &node_def.config)?; - dag.add_node(id.clone(), node); + // 节点级 config 下沉到 Dag.node_configs:DagExecutor.run 构造 NodeContext 时 + // deep_merge(initial_config, node_configs[id])(节点级覆盖全局级),解决此前 + // initial_config.clone() 直接当 ctx.config、忽略 NodeDef.config 的问题。 + dag.add_node_with_config(id.clone(), node, node_def.config.clone()); node_ids.insert(id); } diff --git a/docs/02-架构设计/功能决策记录-2026-06-14.md b/docs/02-架构设计/功能决策记录-2026-06-14.md index e100cdc..e50edbe 100644 --- a/docs/02-架构设计/功能决策记录-2026-06-14.md +++ b/docs/02-架构设计/功能决策记录-2026-06-14.md @@ -449,6 +449,30 @@ - **边界**:取消分支在 B-07 + `set_cancelled` 补齐前恒 false(等价无取消,功能不残);跨工作流并发 HumanNode 在 B-06 修前有 Response 错配风险。 - **状态**:📐 设计完成(2026-06-14),未实施。详见 [B-03-人工审批响应机制-2026-06-14.md](./B-03-人工审批响应机制-2026-06-14.md)。 +## 任务推进链(7 态状态机 + 工作流联动) + +> tasks 表从 todo→done 的状态推进链路。阶段1(7 态状态机 + advance_task CAS 原子写 + 软删除)已落地;阶段2(工作流联动:task_id + 完成回调 advance_task + DAG 模板)进行中。详细实施路径见 [任务推进链实施路径-2026-06-16.md](./任务推进链实施路径-2026-06-16.md) / [推进链阶段2实施路径-2026-06-16.md](./推进链阶段2实施路径-2026-06-16.md)。关联决策 D-260616-01~04(前端7态对齐 / 软删除 / Node trait 归属 / 阶段1先行)。 + +### advance_task / 状态机走 df-nodes Node trait,不复活 df-task(D-260616-03) +- **决策**:任务推进业务逻辑(`can_transition_to` 状态机 / `advance_task` 原子写 / 闸门节点)落在 **df-nodes crate 的 Node trait 扩展**(`task_state_machine.rs` / `task_advance_node.rs`),IPC 层 thin 入口。不复活 2026-06-12 刚因零引用删除(cf017f8)的 df-task crate,不塞 commands/task.rs。 +- **原因/取舍**:① 对齐 D3「业务逻辑在 df-nodes 实现(Node trait 纯接口,df-workflow/src/node.rs:67)」原则;② 复活一个零引用刚删的 crate 是制造新死码,df-nodes 补 `df-storage` 依赖读 TaskRecord 即可(核实无循环依赖);③ IPC 层保持 thin(task.rs 仅 3 行转发)守住 D3 不让业务逻辑下沉 IPC。前端对齐后端 7 态(D-260616-01,types.rs:131,激活 InReview/Testing/Blocked 三闸门态)。 +- **状态**:✅ 阶段1 落地(commit d2cb38c:7态状态机 + advance_task CAS + 软删除,25 测试);🚧 阶段2 进行中(batch32:注册 TaskAdvanceNode + config 下沉 + HumanNode reject)。 + +### DagExecutor config 下沉:节点级覆盖全局级 deep_merge(④-1) +- **决策**:`DagExecutor.run` 构造 `NodeContext.config` 从「`initial_config.clone()` 覆盖一切」改为「`deep_merge(node_def_config, initial_config)`」——节点级配置覆盖全局级(节点定义优先)。Dag 加 `node_configs: HashMap`,build_dag 填入 NodeDef.config,run 合并下沉。 +- **原因/取舍**:原实现(executor.rs:99-107)用全局 initial_config 覆盖 NodeDef.config,**节点级配置被完全忽略**——TaskAdvanceNode.execute 读 `ctx.config.task_id` 拿到全局 config 而非节点定义写的 task_id,节点参数化失效。这是阶段2 的架构前置阻塞点:不修则 TaskAdvanceNode 无法从 DAG 接收 task_id。选「节点级覆盖全局级」(节点定义优先)而非全局覆盖节点级,因节点是更具体的配置源。deep_merge 对 Object 递归合并,非 Object 节点级直接覆盖。现有 HumanNode/AiNode 也受益(它们当前读 ctx.config 拿全局 config,但无人通过 NodeDef.config 定义节点参数故未暴露)。 +- **状态**:🚧 实施中(batch32 ④-1 agent:dag.rs + executor.rs + registry.rs + workflow.rs,配 deep_merge / node_config_overrides 测试)。 + +### DAG 模板硬编码,不建 workflow_defs 表(②-6) +- **决策**:任务推进的工作流 DAG 模板(todo→in_progress / in_review→testing / testing→done 三条推进边 + 退回)**硬编码**在 `df-nodes/task_workflow_templates.rs`(导出 `template_for(target_status) -> DagDef`),**不建 workflow_defs 表**。`tasks.workflow_def_id` 字段留 None。 +- **原因/取舍**:模板数量少且稳定(3 条推进边 + 退回),建表需 CRUD UI + 版本管理 + 关联维护,过度工程。[业务系统设计-2026-06-12.md](./业务系统设计-2026-06-12.md) 确认 workflow_defs 从未建表(工作流定义 dag_json 内嵌 workflow_executions),延续此约定。硬编码模板随代码版本管理,零运行时配置开销。 +- **状态**:📐 设计定稿待实施(②-6,batch33+)。 + +### 工作流回调语义:成功与任务推进解耦,失败按 target 退回(②-3/②-4/②-5) +- **决策**:工作流完成后回调 advance_task 的语义——**成功**(executor Ok):task_id + target_status 都 Some 时调 `advance_task_atomic`,回调失败只 warn 不回滚工作流(工作流已完成是事实,任务推进失败前端提示手动处理);**失败**(executor Err):按 target_status 推算退回态(testing→in_review / in_review→in_progress)调 advance,或加 `failure_target_status` 参数。HumanNode reject(②-5)从 Ok 改返 Err,使审查拒绝走 failed 触发退回。 +- **原因/取舍**:工作流成功与任务推进是两个独立事实,解耦避免「工作流成功但任务推进失败时回滚已完成工作流」的复杂性;失败退回让审查拒绝能回流上一态(review_rounds+1)。CAS 已防回调与手动 advance 并发撞(advance_task_atomic 捕获 InvalidState 降级)。跨表事务缺失(阶段2 回调失败降级,阶段3/4 补 Database.transaction())。 +- **状态**:📐 设计定稿待实施(batch32 做 ②-5 HumanNode reject 语义化,batch33 做 ②-3/②-4 回调)。 + ## 需求与待办 > 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。 diff --git a/docs/02-架构设计/推进链阶段2实施路径-2026-06-16.md b/docs/02-架构设计/推进链阶段2实施路径-2026-06-16.md new file mode 100644 index 0000000..fee5eb6 --- /dev/null +++ b/docs/02-架构设计/推进链阶段2实施路径-2026-06-16.md @@ -0,0 +1,93 @@ +# 推进链阶段2 实施路径(F-260616-06) + +> 2026-06-16 Plan agent 评估产出。阶段1(7态状态机+advance_task CAS+软删除,commit d2cb38c)已落地。阶段2 目标:工作流联动任务推进(task_id + 完成回调 advance_task + DAG 模板)。 +> +> 决策依据:D-260616-03(advance_task/节点走 df-nodes Node trait,不复活 df-task)。 + +## 一、现状基线(代码事实) + +| 维度 | 现状 | 评估 | +|---|---|---| +| 推进链状态机 | `task_state_machine.rs` 7态 + can_transition/is_regression,完整单测 | ✅ 阶段2 复用零改动 | +| advance_task 原子写 | `advance_task_atomic(repo,id,target)` IPC 直驱已注册,CAS+软删+退回 bump_rounds | ✅ 复用 | +| TaskAdvanceNode (Node trait) | `task_advance_node.rs:101-166` 已实现,持 Arc,execute 读 ctx.config | ✅ 节点就绪,仅未注册 | +| build_registry 未注册 | `state.rs:232` `fn build_registry()` 无参,无法构造持 db 的 TaskAdvanceNode | 🔴 阻塞点 | +| run_workflow task_id | `workflow.rs:36` 签名无 task_id;但 WorkflowRecord.task_id 字段(models/migrations/crud)全链路就绪(写库硬编码 None) | 🟡 IPC 扩展,持久层零改动 | +| DagExecutor.run | `executor.rs:49` initial_config.clone() 下沉 NodeContext.config,**忽略 NodeDef.config** | 🔴 DAG 设计缺口(④-1) | +| WorkflowCompleted 事件 | 仅 total_duration_ms,无 execution_id/task_id | 🟡 回调需 IPC 闭包捕获 task_id | + +## 二、实施步骤 + +### ④ 类 — 架构前置(阻塞) + +**④-1 DagExecutor config 下沉语义修复** +- 问题:`executor.rs:99-107` NodeContext.config = initial_config.clone() 覆盖 NodeDef.config,TaskAdvanceNode 读不到节点配置。 +- 修法(方案 A):`NodeContext.config = deep_merge(node_def_config, initial_config)` 节点级覆盖全局级。Dag 加 `node_configs: HashMap`,build_dag 填入,run 合并下沉。 +- 文件锁:`crates/df-workflow/src/{dag.rs,executor.rs,registry.rs}` + `src-tauri/src/commands/workflow.rs` + +### ② 类 — 需设计/行为变更(依赖④) + +- **②-1 build_registry 注入 db + 注册 TaskAdvanceNode** — 改签名 `build_registry(db: Arc)` + move 闭包 `register("task_advance", move |_| Box::new(TaskAdvanceNode::new(db.clone())))` + init 调用传 db.clone()。不改 Node trait(构造时注入)。文件锁:`src-tauri/src/state.rs` +- **②-2 run_workflow 加 task_id + target_status 参数** — IPC 签名扩展(Option 可选兼容)+ WorkflowRecord.task_id 填入 + spawn move 捕获。文件锁:`workflow.rs` + `api/workflow.ts` + `stores/project/workflow.ts` +- **②-3 完成回调 WorkflowCompleted→advance_task** — spawn 闭包内 executor.run Ok 后,task_id+target_status 都 Some 时调 advance_task_atomic。失败 warn 不回滚(工作流成功语义与任务推进解耦)。文件锁:`workflow.rs` +- **②-4 失败回调退回语义** — failed 时按 target_status 推算退回态(testing→in_review,in_review→in_progress)调 advance,或加 failure_target_status 参数。文件锁:`workflow.rs` +- **②-5 HumanNode reject 语义化** — options 含 reject/block 时返 Err(非 Ok),使工作流 failed 触发退回。文件锁:`crates/df-nodes/src/human_node.rs` +- **②-6 DAG 模板** — `df-nodes/task_workflow_templates.rs`(新)导出 `template_for(target_status)->DagDef`,5 前向边+退回。不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。文件锁:`crates/df-nodes/src/task_workflow_templates.rs` + +### ① 类 — 可并行(无依赖) + +- **①-1 前端 TaskDetail 工作流推进按钮** — 与手动 advance 并存,调 workflowApi.run+监听进度。文件锁:`TaskDetail.vue` + `api/workflow.ts` + `stores/project/workflow.ts` +- **①-3 i18n 文案** — `locales/{zh-CN,en}/tasks.ts` + +## 三、风险 + 依赖 + +| 风险 | 缓解 | +|---|---| +| 状态机联动一致性(工作流回调 vs 手动 advance 并发撞 CAS) | advance_task_atomic 已 CAS,回调层捕获 InvalidState 降级 | +| advance 失败回滚(工作流 Ok 但回调 advance 失败) | 不回滚工作流(已完成事实),回调失败 warn+前端提示手动处理 | +| 工作流 failed 退回语义 | ②-4 按 target 推算退回态或 failure_target_status 参数 | +| DAG 循环 | DagExecutor 拓扑排序已检测环,充分 | +| task_id 缺失降级 | task_id/target_status 都 None 时不触发回调,向后兼容 | +| DagExecutor config 下沉(④-1) | 必须先修,否则节点参数化失效 | +| HumanNode reject 语义化(②-5) | 阶段2 必做,否则审查拒绝无法退回 | +| 跨表事务缺失 | 阶段2 回调失败降级,阶段3/4 补 Database.transaction() | + +## 四、实施顺序(串行关键路径) + +``` +④-1 (config 下沉) + → ②-1 (注册 TaskAdvanceNode) ← 单 task_advance 节点 DAG 可端到端 + → ②-5 (HumanNode reject 语义化) ← 审查拒绝能走 failed + → ②-2 (run_workflow task_id) + → ②-3 (完成回调) ← AiNode+HumanNode 工作流推进任务可跑 + → ②-4 (失败回调退回) + → ②-6 (DAG 模板) ← 前端可一键触发 + → ①-1 / ①-3 (前端接入 + i18n) ← 并行收尾 +``` + +**最小可验证里程碑**(④-1+②-1+②-2+②-3):`run_workflow(name, dag, {}, taskId="t1", targetStatus="in_progress")`,DAG=单 task_advance 节点,验证任务 todo→in_progress。无 AiNode/HumanNode 依赖,纯推进链联动验证。 + +## 五、TaskAdvanceNode 注册路径 + +```rust +fn build_registry(db: Arc) -> NodeRegistry { + let mut registry = NodeRegistry::new(); + registry.register("human", |_| Box::new(df_nodes::human_node::HumanNode)); + registry.register("ai", |_| Box::new(df_nodes::ai_node::AiNode)); + registry.register("task_advance", move |_| { + Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone())) + }); + registry +} +``` + +工厂闭包 move 捕获 db(构造时注入),每次 create 调用 clone 构造新实例。不改 Node trait。 + +## 六、DAG 模板形态(阶段2) + +工作流级 target_status(一个工作流对应一次推进): +- `todo→in_progress`:单 AiNode 执行 +- `in_review→testing`:AiNode 自审 + HumanNode 核对(reject→②-5 Err→failed→②-4 退回 in_progress,review_rounds+1) +- `testing→done`:HumanNode 最终核对 + +节点级不携带 target_status(避免节点间状态不一致),工作流级统一。 diff --git a/docs/02-架构设计/经验记录-2026-06-14.md b/docs/02-架构设计/经验记录-2026-06-14.md index eb717fc..2449099 100644 --- a/docs/02-架构设计/经验记录-2026-06-14.md +++ b/docs/02-架构设计/经验记录-2026-06-14.md @@ -75,7 +75,7 @@ ### 知识删除语义:knowledge_archive 软删除(命名统一)[2026-06-13] -- **决策**:知识删除 command 命名 `knowledge_archive`(执行 `UPDATE status='archived'`),**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例;主列表 `knowledge_list(status=None)` 默认 `AND status!='archived'` 过滤。 +- **决策**:知识删除 command 命名 `knowledge_archive`(执行 `UPDATE status='archived'`),**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例;主列表 `knowledge_list(status=None)` 默认仅返回 published(library 纯已发布,[2026-06-16] F-260616-02 决策 a 收窄:pending_review 归 `knowledge_list_candidates` 收件箱,不再混杂 library)。 - **原因/取舍**:idea/task/project 的 `delete_xxx` 都是硬删(DELETE FROM),若 knowledge 也叫 delete 却做归档,API 语义混淆(调用方期望数据消失,实际还在 DB)。conversation 模块已有正确先例(archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。 - **状态**:✅ 已实施(Tier 1) diff --git a/docs/03-模块文档/df-knowledge-知识库-2026-06-14.md b/docs/03-模块文档/df-knowledge-知识库-2026-06-14.md index e6ea967..d01808d 100644 --- a/docs/03-模块文档/df-knowledge-知识库-2026-06-14.md +++ b/docs/03-模块文档/df-knowledge-知识库-2026-06-14.md @@ -49,13 +49,13 @@ candidate ──→ pending_review ──→ published ──→ archived | Command | 说明 | |---------|------| -| `knowledge_list(status?)` | 全量列表,默认排除 archived | +| `knowledge_list(status?)` | 列表,默认仅 published(library 纯已发布,F-260616-02 决策 a);显式传 status 按该状态过滤(含 archived) | | `knowledge_get(id)` | 单条查询 | | `knowledge_search(query, kind?, limit?)` | LIKE 检索,top-N≤3 | | `knowledge_create(input)` | 创建(status=candidate)| | `knowledge_update_status(id, status)` | 状态转换(含合法矩阵校验)| | `knowledge_record_reuse(id)` | reuse_count +1 | -| `knowledge_list_candidates()` | 审核收件箱(按 confidence 排序)| +| `knowledge_list_candidates()` | 待处理收件箱(candidate+pending_review,按 confidence 排序)| | `knowledge_archive(id)` | 软删除 | | `knowledge_get_config()` | 读取 KnowledgeConfig | | `knowledge_save_config(config)` | 保存 KnowledgeConfig | @@ -133,3 +133,82 @@ Tier 1+ 目标:MCP Shell 封装(`mcp-server` 转发 IPC),外部工具使 - [df-storage 存储层](./df-storage-存储层-2026-06-12.md) — knowledges 表结构 + 向量工具函数 - [df-ai AI 集成模块](./df-ai-AI集成模块-2026-06-12.md) — hybrid_search / generate_embedding / extract - [功能决策记录](../02-架构设计/功能决策记录-2026-06-14.md) — 检索方案演进决策 + + +--- + +## 已知问题(2026-06-16 审查) + +> 来源:全量源码逐行审查(knowledge.rs / knowledge_inject.rs / knowledge_timeline.rs / stores/knowledge.ts / Knowledge.vue / agentic.rs / commands.rs / state.rs) + +### 🔴 P0 严重 + +#### KP-1: KnowledgeConfig 不持久化 — 重启丢失全部配置 + +- **位置:** `state.rs:208` 初始化 + `knowledge.rs:knowledge_save_config` +- **现象:** `knowledge_save_config` 仅写入内存 `Arc>`,未落库。应用重启后 `AppState::init` 永远用 `KnowledgeConfig::default()` +- **影响:** 用户配置的向量检索 provider、提炼参数等关闭应用后全部丢失。系统已有 `app_settings` KV 表和 `SettingsRepo` 可用,但未接入 +- **修复方向:** `knowledge_save_config` / `knowledge_get_config` 读写 `SettingsRepo`(key=`knowledge_config`,value=JSON) + +#### KP-2: OnIdle 触发模式完全未实现 — 配置项为死代码 + +- **位置:** `state.rs` 定义 `ExtractTrigger::OnIdle` + `idle_timeout_ms`;`knowledge_inject.rs:maybe_spawn_extraction` +- **现象:** `maybe_spawn_extraction` 仅检查 `trigger_mode == OnComplete`,`OnIdle` 与 `ManualOnly` 都直接 `return Ok(())`。`idle_timeout_ms` 在整个 `src-tauri` 中零引用 +- **影响:** 用户选择 `OnIdle` 模式后,知识提炼永远不会触发(与 ManualOnly 行为相同但无任何提示) +- **修复方向:** 要么实现 idle 定时器(tokio::time::interval 轮询 AiSession 最后活动时间),要么移除 OnIdle 变体并在前端隐藏该选项 + +#### KP-3: 审批恢复循环路径缺失知识注入 + +- **位置:** `agentic.rs:try_continue_agent_loop` vs `commands.rs:ai_chat` +- **现象:** `ai_chat` 首次消息时调用 `build_knowledge_context` 注入 system prompt;`try_continue_agent_loop`(审批通过后恢复循环)仅调用 `build_system_prompt`,**未调用** `build_knowledge_context` +- **影响:** 对话经历工具审批流程后恢复时,system prompt 中不包含任何知识库上下文,AI 在多轮工具调用场景下丢失经验注入 + +--- + +### 🟡 P1 中等 + +#### KP-4: 前端搜索只能搜到 published 知识 + +- **位置:** `knowledge.rs:knowledge_search` → `KnowledgeRepo::search`(底层固定 `WHERE status='published'`) +- **现象:** 前端搜索栏调用链 `store.search()` → `knowledgeApi.search()` → IPC → `repo.search()`,底层 SQL 硬编码 `status='published'` +- **影响:** 知识库 Tab 列表加载的是 `list_non_archived()`(含 candidate/pending_review/published),但搜索只能命中 published,导致列表显示条目数 > 可搜索条目数,体验矛盾 +- **修复方向:** 前端搜索走独立的 `knowledge_list` + 前端过滤,或后端新增 `search_all_status()` 方法供前端使用 + +#### KP-5: 编辑操作缺失生命线事件 + +- **位置:** `knowledge.rs:knowledge_update` +- **现象:** `knowledge_create` 有 `record_created`,`knowledge_update_status` 有 `record_status_change`,但 `knowledge_update`(编辑 title/content/tags 等)**无任何 Timeline 调用** +- **影响:** 知识被编辑修改后,生命周期时间线中不显示编辑记录,审计追踪存在断档 +- **修复方向:** 新增 `EVENT_UPDATED` 事件类型 + `KnowledgeTimeline::record_updated()` 方法 + +#### KP-6: 嵌入生成失败无重试/标记 — 可能永久无向量索引 + +- **位置:** `knowledge_inject.rs:spawn_embedding_for_knowledge` +- **现象:** 发布时 fire-and-forget 生成嵌入,失败只 `warn log`,无重试机制、无失败标记 +- **影响:** provider 临时不可用(网络抖动/API 限流)时该知识永远不会获得向量嵌入,即使后续 provider 恢复。混合检索中该条目向量信号永久缺失 +- **修复方向:** 可选方案:(a) 定期补偿任务扫描 `published AND embedding IS NULL`;(b) 前端详情页显示嵌入状态并提供"重新生成"按钮 + +#### KP-7: 前端 updateStatus 后仅本地移除 — 不刷新列表 + +- **位置:** `stores/knowledge.ts:updateStatus` +- **现象:** 发布/拒绝/归档操作后仅 `filter` 移除本地数组中的条目,不重新 `loadList()` / `loadCandidates()` +- **影响:** 状态变化后需手动刷新才能看到最新列表。若其他路径(如 AI 提炼)并发写入候选列表,本地状态会进一步偏离 + +--- + +### 🟢 P2 低优先级 + +#### KP-8: 搜索后分类筛选状态丢失 + +- **位置:** `Knowledge.vue:onSearchInput` +- **现象:** 搜索框清空时触发 `store.loadList()`,但不恢复之前的 `activeKind` 分类筛选状态 + +#### KP-9: knowledge_search limit 硬限 3 条 + +- **位置:** `knowledge.rs:knowledge_search` → `.min(3)` +- **现象:** 即使传入 `limit=10` 也只返回最多 3 条。Tier 1 内部合理,但 Tier 2 MCP 对外开放后可能受限 + +#### KP-10: 矛盾知识无消歧机制 + +- **位置:** 模块设计层面(文档已记录此决策) +- **现象:** 两条矛盾知识可能同时返回,完全依赖 LLM 上下文理解取舍。当前 top-3 限制降低了冲突概率,但随知识积累会逐渐显现 diff --git a/docs/todo.md b/docs/todo.md index 887c532..ed11088 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -60,7 +60,15 @@ > 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。 -- [ ] F-260616-01 [P2] — **aichat Agentic 最大循环轮次支持配置(Settings)**。现状:`MAX_AGENT_ITERATIONS=10` 硬编码常量(`agentic.rs:33`),复杂 agentic 任务未收敛即被截断,用户无法调。**黄金参考:`llm_concurrency`**——项目唯一成熟的「前端数字配置→AppState 字段→热改 command→透传 loop 形参」范式,本需求 1:1 复刻。改动 8 处:①`state.rs` AppState 加 `agent_max_iterations: Arc`(默认 10) ②`agentic.rs` 常量改名 `DEFAULT_MAX_AGENT_ITERATIONS` + `run_agentic_loop` 增形参 `max_iterations` + 循环(L156)/超限消息(L321-332)用形参 + `try_continue_agent_loop`(L478) 补传 ③`commands.rs` `ai_chat_send`(L114) 补传 + 新 command `ai_set_agent_max_iterations`(clamp 1-50 store) ④`lib.rs` invoke_handler(L125 区)注册 ⑤`api/ai.ts` 新 `setAgentMaxIterations`(仿 L73 `setConcurrencyConfig`) ⑥`Settings.vue` reactive(L615 区)+UI 数字 input(min1 max50,紧挨并发控制 L268)+watch 持久化+`syncAgentMaxIterations`+onMounted 推后端 ⑦i18n 补 zh/en 键。**生效时机**:loop 入口 `load` 取快照传值,当前 loop 锁定边界,热改下次发消息生效(防边界飘移;与 `llm_concurrency` 传 Arc 实时反映的区别)。key `df-ai-agent-max-iterations`,范围 1-50 双 clamp。`agentic.rs:27-32` 注释已预留此扩展点。**低风险,复刻现成范式无新机制**。— 详案见本地 plan 文件 `quizzical-prancing-hennessy.md`。 +- [x] ✅(batch38·2026-06-16·workflow wx5vd8ppk) F-260616-01 [P2] — **aichat Agentic 最大循环轮次支持配置(Settings)**。已落地(1:1 复刻 llm_concurrency,8 处改动全闭环)。现状:`MAX_AGENT_ITERATIONS=10` 硬编码常量(`agentic.rs:33`),复杂 agentic 任务未收敛即被截断,用户无法调。**黄金参考:`llm_concurrency`**——项目唯一成熟的「前端数字配置→AppState 字段→热改 command→透传 loop 形参」范式,本需求 1:1 复刻。改动 8 处:①`state.rs` AppState 加 `agent_max_iterations: Arc`(默认 10) ②`agentic.rs` 常量改名 `DEFAULT_MAX_AGENT_ITERATIONS` + `run_agentic_loop` 增形参 `max_iterations` + 循环(L156)/超限消息(L321-332)用形参 + `try_continue_agent_loop`(L478) 补传 ③`commands.rs` `ai_chat_send`(L114) 补传 + 新 command `ai_set_agent_max_iterations`(clamp 1-50 store) ④`lib.rs` invoke_handler(L125 区)注册 ⑤`api/ai.ts` 新 `setAgentMaxIterations`(仿 L73 `setConcurrencyConfig`) ⑥`Settings.vue` reactive(L615 区)+UI 数字 input(min1 max50,紧挨并发控制 L268)+watch 持久化+`syncAgentMaxIterations`+onMounted 推后端 ⑦i18n 补 zh/en 键。**生效时机**:loop 入口 `load` 取快照传值,当前 loop 锁定边界,热改下次发消息生效(防边界飘移;与 `llm_concurrency` 传 Arc 实时反映的区别)。key `df-ai-agent-max-iterations`,范围 1-50 双 clamp。`agentic.rs:27-32` 注释已预留此扩展点。**低风险,复刻现成范式无新机制**。— 详案见本地 plan 文件 `quizzical-prancing-hennessy.md`。 + +- [x] ✅(batch37·2026-06-16·workflow wg8x18y9y) F-260616-02 [P2] — **知识库「待审」(pending_review) 不应进第一个 tab(知识库/library)**。决策 a 落地:inbox 收 candidate+pending_review,library 纯 published。现状:`Knowledge.vue` 顶层两 tab——`library`(知识库,L19) 用 `store.items`、`inbox`(审核收件箱,L22) 用 `store.candidates`。根因在数据源:①后端 `knowledge_list`(knowledge.rs:71-81) `status=None` 默认返回「**非 archived 全部**」= candidate + **pending_review** + published;②前端 store `loadList`(knowledge.ts:60) 无 status 参数调它 → `state.items` **含 pending_review**;③`knowledge_list_candidates`(knowledge.rs:211-218) 只列 `candidate` → inbox 不含 pending_review。结论:**pending_review 条目当前只在 library tab 与 published 混杂显示**,inbox 反而不收它。改动方向(待定,**含决策点需用户定**):①最小改——后端 `knowledge_list` 默认排除范围从 `archived` 扩到 `archived + pending_review`(或前端 `loadList` 后 filter 掉 pending_review,store 层兜底);②**决策点:pending_review 从 library 排除后该去哪显示**?(a) 扩 inbox 收件箱含 candidate+pending_review(语义=「待处理」收件箱)(b) 单纯从 library 消失、pending_review 暂无专门视图 (c) 加第三个 tab「待审」。**倾向 (a)**:inbox 收 candidate+pending_review,library 纯 published,职责清晰零新 tab。— Knowledge.vue(L296-299 列表数据源) + stores/knowledge.ts(loadList/loadCandidates) + knowledge.rs(knowledge_list 默认过滤 + list_candidates 范围)。**待用户定②后实施**。 + +- [x] ✅(batch44 后端 + batch45 前端·2026-06-16) F-260616-03 [P2] — **aichat 达最大轮次时询问「是否继续」+ 确认后续 loop**。全闭环:后端(batch44 AiMaxRoundsReached 暂停态+continue/stop IPC)+ 前端(batch45 操作卡 pendingMaxRounds+续/停按钮)。现状:`agentic.rs:321-332` 达 `max_iterations` 未收敛(`!converged`)→ emit `AiError`「达到最大轮次(N 轮),Agent 可能未完成」+ **直接走完成流程**(save/标题/提炼+`guard.reset`+`AiCompleted`),用户无法续,复杂任务被硬截断。需求:达上限改 emit 询问 → 前端「继续/停止」操作卡 → 用户点继续后续 loop、点停止走完成。**机制高度复用工具审批暂停-恢复**:审批 = `AiApprovalRequired`(audit.rs:353 emit)→前端 `pending_approvals`(useAiEvents.ts:219 push)→`ai_approve`(commands.rs:122)→`try_continue_agent_loop`(agentic.rs:388 续 loop)。本需求同构。改动骨架:①后端 `agentic.rs:321-332` 达上限分支改:不 emit AiError+完成,改 emit 新事件 `AiMaxRoundsReached` + 保持 `generating=true`(暂停态,仿审批等待) ②`mod.rs:71` 事件枚举加 `AiMaxRoundsReached { conversation_id }` ③新 command `ai_continue_loop`(用户点继续)→调 `try_continue_agent_loop` 续 loop(**续跑轮数**:重置计数再跑 `max_iterations` 轮——末轮 tool_result 已在 messages,续跑首轮自动回传 LLM,语义自洽) ④新 command `ai_stop_loop`(点停止)→走原完成流程(save+Completed) ⑤前端 `useAiEvents.ts` 加 `case 'AiMaxRoundsReached'`(push 待确认态,仿 pendingApprovals) ⑥`AiChat.vue` 加续跑操作卡 UI(继续/停止按钮)。**设计决策点(待定)**:(a) 续跑轮数=重置计数再跑 max_iterations 轮(倾向) vs 单步 1 轮 (b) 防无限续:无硬上限(用户主动授权=同意烧 token,倾向) vs 加累计上限 (c) 事件/卡片:新增 `AiMaxRoundsReached`+独立操作卡(倾向,语义清晰) vs 复用 `AiApprovalRequired`(args/name 不搭)。**关联 F-260616-01**(max 可配)——两者协同=用户完全掌控 loop 边界,建议同批实施。— agentic.rs(达上限分支 L321-332 + try_continue 复用) + mod.rs(事件枚举) + commands.rs(新 command) + useAiEvents.ts + AiChat.vue。**待用户定决策点后实施**。 + +- [x] ✅(batch42·2026-06-16·workflow w95hzp9tw) F-260616-04 [P1] — **run_command 超时→LLM 重试→反复审批循环**。已根治(tool_registry.rs 超时标注「勿盲目重试」+ DEFAULT_RUN_COMMAND_TIMEOUT_SECS 常量,handler map_err 改写)。根因链(探索确认):超时(默认 60s, `tool_registry.rs:491 unwrap_or(60)`)→tool_result 回传 LLM(`commands.rs:168` Err 分支)→LLM 重试同命令→**新 tool_call_id**(`stream_recv.rs:176` 每轮新 UUID)→重新 insert pending(`audit.rs:343`)→重新审批,循环。症状「再过一会又提示 Run Command」即此。修复方向:①超时 tool_result 明确标注「命令执行超时({n}s),进程已终止,勿盲目重试同命令;确需更长时限重发时提高 timeout_secs」②run_command 默认超时可配(LLM 已可传 `timeout_secs` 参数,但默认 60 + 不主动调)③配合 F-260616-05 去重根治。— tool_registry.rs(超时标注) + commands.rs(回传内容)。 +- [x] ✅(batch53·2026-06-16·workflow wbh8mu1iq·**run_command 循环核心解决**) F-260616-05 [P1](**共性根治**)— **agentic loop 工具调用去重缺失**。**batch53 落地 High risk 审批前去重**(audit.rs find_cached_high_risk_result 反向扫 messages + args JSON 规范化匹配 + 排除 pending 占位 + decided_by=auto_dedup,解决 run_command 反复审批循环,F-04 超时标注 + batch53 去重双保险)。**完整 agentic loop 通用去重(search_files 重复卡片 + 滑动窗口指纹 + 决策点)③类待用户定**(窗口/粒度/措辞) +- [ ] F-260616-06 [P2] — **「未找到挂起的审批」体验**。根因:后端 `pending_approvals` 内存态(`commands.rs:130 remove None` 报错)与前端刷新/重复点击不同步;重试场景旧 id 已被处理。前端 `useAiSend.ts:221` IPC 失败不回滚 pendingApprovals,按钮残留。修复方向:①`ai_approve` 找不到 pending 时查审计表,若已 executed/rejected 返回「已处理」幂等成功(非报错) ②前端审批按钮点击防抖(同一 id 短期多次只发一次 IPC) ③前端刷新后 pending 态正确同步(已部分由 `useAiConversations.ts:119 pendingToolCalls` 恢复,核对边界)。— commands.rs(ai_approve 幂等) + useAiSend.ts(防抖)。 ### 🔧 任务模块全栈核对(2026-06-16 · Explore 代理并行取证) @@ -88,7 +96,17 @@ > 详见 [任务推进链实施路径-2026-06-16.md](./02-架构设计/任务推进链实施路径-2026-06-16.md)。推进能力实现度 0%。**阶段1已解除阻塞(D-01/D-03/D-04 三决策已定 2026-06-16),可启动 F-01~05**。核对纠正:AI 有 `update_task`/`run_command` 工具,无 `run_workflow`/`advance_task`。 - [x] ✅(第㉘批 2026-06-16·workflow) F-260616-01~05 阶段1 推进骨架 — F-01状态机(can_transition 7态矩阵+is_regression)/F-02 advance_task(IPC+Node trait+CAS原子写 crud.rs advance_status_atomic `WHERE id=? AND status=expected`防TOCTOU)/F-04 review_rounds(V15迁移幂等+退回转换+1原子)/F-05前端推进按钮(ADVANCE_MAP+taskApi.advance) 完成。**F-03收口延后阶段3**:AI工具update_task依赖status改写,裸移除白名单→工具描述谎称能改但报错(行为不一致),收口须阶段3三件同落地。循环依赖核实:df-storage Cargo.toml不含df-nodes,加反向dep无循环。cargo check 0+df-nodes 46 test/df-storage 11 test pass+vue-tsc 0。 -- [ ] F-260616-06 阶段2 工作流联动(task_id+回调+DAG模板) +- [x] ✅(batch32-35·2026-06-16·全部子项落地) F-260616-06 阶段2 工作流联动(task_id+回调+DAG模板)— **全部落地**(④-1/②-1/②-5 batch32 + ②-2/②-3/②-4/②-6 batch33 + ①-1/①-3/B-41 batch35),实施路径见 [推进链阶段2实施路径-2026-06-16.md](./02-架构设计/推进链阶段2实施路径-2026-06-16.md): + - [x] ✅(batch32·2026-06-16·workflow wn4gudnz8) **④-1 [架构前置·阻塞]** DagExecutor config 下沉语义修复 — `executor.rs:99-107` NodeContext.config 用 initial_config 覆盖 NodeDef.config(节点级配置被忽略,TaskAdvanceNode 读不到 ctx.config.task_id)。修法:`NodeContext.config = deep_merge(node_def_config, initial_config)` 节点级覆盖全局级。文件锁:`crates/df-workflow/src/{dag.rs,executor.rs,registry.rs}` + `src-tauri/src/commands/workflow.rs` + - [x] ✅(batch32·2026-06-16·workflow) **②-1** build_registry 注入 Arc + 注册 TaskAdvanceNode — `build_registry(db)` 改签名 + move 闭包 + init 调用传 db.clone()。文件锁:`src-tauri/src/state.rs` + - [x] ✅(batch33·2026-06-16·workflow w98anf4y5) **②-2** run_workflow IPC 加 task_id + target_status 参数 — 签名扩展 + WorkflowRecord.task_id 填入 + spawn move 捕获。文件锁:`src-tauri/src/commands/workflow.rs` + `src/api/workflow.ts` + `src/stores/project/workflow.ts` + - [x] ✅(batch33·2026-06-16·workflow) **②-3** 完成回调 WorkflowCompleted→advance_task — spawn 闭包内 executor.run Ok 后,task_id+target_status 都 Some 时调 advance_task_atomic,失败降级 warn 不回滚。文件锁:`src-tauri/src/commands/workflow.rs` + - [x] ✅(batch33·2026-06-16·workflow) **②-4** 失败回调(退回语义) — 工作流 failed 时按 target_status 推算退回态调 advance。文件锁:`src-tauri/src/commands/workflow.rs` + - [x] ✅(batch32·2026-06-16·workflow) **②-5** HumanNode reject 语义化 — options 含 reject/block 时返 Err(非 Ok),使工作流 failed 触发退回。文件锁:`crates/df-nodes/src/human_node.rs` + - [x] ✅(batch33·2026-06-16·workflow) **②-6** DAG 模板(task_workflow_templates) — df-nodes 新模块导出 template_for(target_status)->DagDef,5 前向边+退回。文件锁:`crates/df-nodes/src/task_workflow_templates.rs`(新) + - [x] ✅(batch35·2026-06-16·workflow w17duic3f) **①-1** 前端 TaskDetail 工作流推进按钮 — 与手动 advance 并存,调 workflowApi.run+监听进度。**含 B-41 进度内嵌**:onEvent 按 exec_id 过滤 dispatch node_started/completed/workflow_completed/failed + 独立 wfAdvancing loading 与手动 advancing 互含 disabled。**后端补丁**:workflow.rs run_workflow 空dag选模板(dag.nodes 非空→原 / 空+target→template_for / 空+无target→原路径,向后兼容)。文件锁:`src/views/TaskDetail.vue` + `src/api/workflow.ts` + `src/stores/project/workflow.ts` + `src-tauri/src/commands/workflow.rs` + - [x] ✅(batch35·2026-06-16·workflow) **①-3** i18n 文案 — `src/i18n/{zh-CN,en}/taskDetail.ts`(taskDetail.* 命名空间,8 key 对称:workflowAdvanceTitle/Advancing/AdvanceFailed{msg}/StepRunning{node}/StepsProgress{done}{total}/CompletedHint/FailedHint)。注:todo 原路径 `locales/tasks.ts` 不准,实为 `i18n/taskDetail.ts` + - **实施顺序**:④-1→②-1→②-5→②-2→②-3→②-4→②-6→①-1/①-3。**最小里程碑**(④-1+②-1+②-2+②-3):run_workflow 单 task_advance 节点 DAG 端到端推进 todo→in_progress - [ ] F-260616-07 阶段3 AI 执行闭环(advance_task/run_workflow 工具+AiNode+自审)+ **F-03收口三件**(advance_task注册AI工具+update_task描述/schema移status+df-storage tasks白名单移status,同步落地防AI工具行为不一致) - [ ] F-260616-08 阶段4 Git 集成(kind+git闸门+worktree) - [x] ✅ **CR-260616-01 代码审查完成** → 审查登记已迁 [待审查.md](./待审查.md)(职责分离:审查队列独立,不进 todo)。结论 🔴0 🟡6 ⚪4 质量优,8 维度全过。**待修项 CR-01-A~I 见下方推进区**。 @@ -97,15 +115,24 @@ - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-A** [P1] TaskDetail.vue 补 `.status-testing`(橙)/`.status-blocked`(红 danger 边框)CSS;Tasks.vue 已有/Dashboard 不用此体系无需补 - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-B** [P1] crud.rs advance_status_atomic 两分支 CAS SQL 加 `AND deleted_at IS NULL`,回收站任务 CAS 必败返 None(D-02 软删收口) - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-C** [P2] task_advance_node.rs:50 错误信息改 `ALL_STATES.join("/")` DRY(运行时文案不变) - - [ ] **CR-01-D** [P2] task_advance_node.rs:62 同态拒绝(from==to)/非法转换(can_transition false)错误区分(现合并 `InvalidState`,前端无法分辨)— **②类留**(需改错误枚举判断) + - [x] ✅(batch30·2026-06-16) **CR-01-D** [P2] task_advance_node.rs:62 同态拒绝(from==to)/非法转换(can_transition false)错误区分(现合并 `InvalidState`,前端无法分辨)— **②类留**(需改错误枚举判断) - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-E** [P2] src-tauri/src/state.rs:240 build_registry 补注释「TaskAdvanceNode 未注册(阶段2联动时加),勿删」 - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-F** [P2] crud.rs 白名单注释补 review_rounds 专用路径(仅 advance_status_atomic,防误补破坏收口) - - [ ] **CR-01-G** [P3] TaskDetail.vue:218 advance 失败错误走 i18n 分级(`e.toString()` 非空直显后端中文,en locale 也中文)— **②类留**(需 i18n 分级改造) + - [x] ✅(batch30·2026-06-16) **CR-01-G** [P3] TaskDetail.vue:218 advance 失败错误走 i18n 分级(`e.toString()` 非空直显后端中文,en locale 也中文)— **②类留**(需 i18n 分级改造) - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-H** [P3] task_advance_node.rs 测试改名 `concurrent_cas_change_detected` → `advance_succeeds_after_prior_status_change`(反映实际测正常推进) - [x] ✅(cr01-fixes·wu4r8ejcp) **CR-01-I** [P3] i18n `taskDetail.advancing` 接文案(推进按钮 advancing 时显示「推进中...」,冗余 key 转实引用) > > **修复核查(主代理独立·2026-06-16)**:7 项真实落地(Read+git diff+cargo check --workspace exit 0.83s+vue-tsc 0 核实)。**注意:验证 agent CR-01-E 证据张冠李戴**(报 df-workflow/state.rs,实改 src-tauri/state.rs),改动正确但证据幻觉——验证 agent 可信度打折,主代理核查兜底。D/G ②类留。 + > **审查发现待修项(2026-06-16 · CR-08/10/13,均 med 非阻断,详见 [待审查.md](./待审查.md) 对应归档)**: + - [x] ✅(batch50·主代理决策:接受现状降级 P2) **CR-10-A** [med] ai_regenerate 并发竞态窗口 → **统一并发模型**(锁内变更+锁外 spawn,ai_regenerate/ai_chat_send/ai_chat_force_send 同款),窗口有兜底(stop_flag 置位 loop 退出 + generating 拦截 send),med 非阻断。统一并发改造复杂(原子标记预占+3 入口),ROI 不明非优先,降级观察/架构改进候选 + - [x] ✅(batch50·主代理核查:无效) **CR-10-B** [med] commands.rs:56/59 save 与 emit 时序倒置 → **不成立**:ai_regenerate 当前代码 save L94 锁外 spawn L105 前(时序正确),无 emit(L56/59 是 pop 后错误处理),审查锚定行号漂移 + - [ ] **CR-13-O1** [产品决策点·非代码bug·③类待用户] in_progress 模板失败→`regression_target("in_progress")=Some("todo")`,但状态机 `backward_to_todo_rejected` 全拒(task_state_machine.rs:188-193)→ 回调 advance_task_atomic 被 InvalidState 拦截(task_advance_node.rs:72-77),任务原地保留 in_progress。**三选项待定**:a 接受现状补 UI 提示 / b 改 `regression_target("in_progress")=None` 对齐状态机 / c 放开 todo 回退(破坏闸门)— CR-260616-13 归档 + - [x] ✅(batch50·2026-06-16·workflow w6nqio4o5) **CR-08-O1** [med·非阻断] blocked 态推进按钮语义偏差 → 已修复(WF_EXCLUDED_FROM={'blocked'} + wfAdvanceAction computed 守卫,blocked 不显示工作流推进按钮,其他态不受影响,不动 ADVANCE_MAP) + - [x] ✅(batch50·2026-06-16·workflow w6nqio4o5) **CR-15-1** [med] `crates/df-execute/src/shell.rs:60-79` 缺 `.kill_on_drop(true)` → 已修复(cmd.kill_on_drop(true) 三类型覆盖 + 注释配合 tokio::time::timeout 防孤儿,文案「进程已终止」名副其实) + - [x] ✅(主代理验证:不成立·workspace cargo check exit 0) 🔴 **CR-23-1** [high·紧急·阻断构建] `crates/df-ai/src/context.rs:491` E0594 → **不成立**:审查代理跑 batch51 中间态 filter 版(EXIT 101),batch51 最终改 for 循环(EXIT 0,df-ai + workspace 双验证,代理自报属实)。context.rs:488 当前是 `for t in iter_mut()` 单层引用可写 status,无 filter 闭包。审查锚定滞后(跑 batch51 中间态)— CR-260616-23 归档 + - [x] ✅(主代理修·CSS :has item 左边线) **CR-22-1** [med·UX] `AiChat.vue` 已置顶非 hover 不可见 → 已修复(`.ai-conv-item:has(.ai-conv-item-act--pinned){ box-shadow: inset 2px 0 0 accent }` 左边线强调,不破坏 hover 浮出 toggle,原方案图钉常驻与 actions display:none 矛盾,改 :has item 左线最简)— CR-260616-22 归档 + ### ⏸️ 待用户授权/确认(巡检悬挂项·2026-06-16) > 巡检推进中浮现的、需用户拍板才能继续的操作授权/范围确认项(非架构方向决策,见上节 D-*)。巡检每轮不再重复问,看此处标注。 @@ -138,7 +165,7 @@ - [x] AR-7 ~~clean 无 UI 入口~~ ✅ Wave3 完成(crud.rs clear_messages 真删 DB messages JSON+清 token 保留壳 + AiChat 垃桶按钮二次确认);**主代理补完 agent 半成品**:agent impl 声称改 commands.rs ai_chat_clear 调 clear_messages,实际 diff 零改动(self_boundary_check 造假),审查 semantic_check 正确抓到 gap(commit 9e2aeff) **P2** -- [ ] AR-8 delta 节流+滚动 — **重评(2026-06-16)**:前端 rAF 节流已被 ARC-08 覆盖(每帧 ≤1 parse);剩后端 50ms 合批(B-260615-02 心跳已动 stream_recv.rs,合批可并入同文件)+ 滚动跟随。降优先级 — stream_recv.rs + AiChat.vue 滚动 +- [x] ✅(batch39·2026-06-16·workflow ww4wb66br) AR-8 delta 节流+滚动 — **前端已存在**(onContentChange/isNearBottom 80px 阈值等效 userScrolledUp + showBackToBottom 回底按钮),**后端合批负 ROI 不改**(rAF 节流链 ARC-08 已吸收重 parse,加合批破 heartbeat 30s 语义)。B-53 snap 优化同批落地 - [x] AR-9 ~~friendlyError 硬编码中文~~ ✅ Wave3 完成(friendlyError 全走 i18n.global.t + zh/en 双语补 4 key;TS2589 用 as any 规避 vue-i18n 深度泛型)(commit 9e2aeff) - [x] AR-10 ~~想法→灵感迁移残留~~ ✅ 已统一(13 文件批量:i18n zh-CN + 后端错误 + LLM 描述/提示词 + store toast;en 待定 Ideas/Idea、docs 注释低优先略)(commit 65c475b) - [x] AR-11 ✅(定时走查第3轮,2026-06-16,dc27e79) — ~~数据变更联动刷新~~(推荐方案A 后端 emit + store 监听)— 跨层 — 详见 [审查第五章](./02-架构设计/aichat审查报告-2026-06-14.md) — **勘察完成(2026-06-16,wxflofhf2)**:feasible/risk 中/跨 8 文件(audit.rs/commands.rs/tool_registry.rs/stores/project.ts/useAiEvents.ts/Projects/Tasks/ProjectDetail.vue)。方案A 方向合理(emit df-data-changed+entity/action 分类+store listen)但勘察 implPlan 含伪代码错误(std::env::var/.match Rust 不存在=agent 幻觉)+碰 7 近期活跃文件含未提交 P0 改动的 commands.rs。**暂缓(原)**:等本批提交后主代理重设计 emit 点 — **第2轮定时走查复核(2026-06-16):后端已实施 ✅,前端半接通 🔴** — 后端 `emit_data_changed`(audit.rs:259-270)+ 自动执行路径(audit.rs:351)+ 审批路径(commands.rs:175)三处 emit 就位且逻辑正确;前端工作区(project.ts:31-55,109-110)定义 `startDataChangedListener`/`stopDataChangedListener` + export,**但 App.vue onMounted 未挂载 → listener 永不 attach → 数据变更后列表不刷新(AR-11 功能失效,后端 emit 成死事件)**。修:App.vue onMounted 调 `startDataChangedListener()` + onUnmounted stop(对齐 workflow listener 生命周期)。详见 [定时走查-2026-06-15-第2轮.md](./05-代码审查/定时走查-2026-06-15-第2轮.md) @@ -232,7 +259,7 @@ **中期(技术债)** - [x] ✅(batch29·2026-06-16) ARC-260615-05 — ~~stores/project.ts 上帝 store 拆分(四领域+越层 invoke)~~ 核验越层 invoke 0 命中(已下沉 api/workflow.ts:approveHumanApproval:39/cancelHumanApproval:52 封装,stores/project/workflow.ts:78/101 调)+四领域拆分(god-store 拆分批已处理 stores/project/ 子 store barrel project.ts:122-123 委托)。vue-tsc exit 0 - [x] ✅(batch29·2026-06-16) ARC-260615-06 — ~~events↔stream 循环依赖~~ 提 aiShared.ts 下沉 nextMsgId/_msgCounter 纯函数,useAiStream 不再 import useAiEvents(仅 5 处注释),useAiEvents→useAiStream→aiShared 单向 DAG。vue-tsc exit 0 -- [ ] ARC-260615-06b [P3] — useAiEvents↔useAiSend 另一环(useAiEvents:19 import useAiSend/useAiSend:23 import useAiEvents),靠事件总线 ai-approval-clear-timers 部分缓解,非本批范围。遗留自 ARC-06 核验 +- [x] ✅(batch30·2026-06-16 评估维持现状) ARC-260615-06b [P3] — useAiEvents↔useAiSend 另一环(useAiEvents:19 import useAiSend/useAiSend:23 import useAiEvents),靠事件总线 ai-approval-clear-timers 部分缓解,非本批范围。遗留自 ARC-06 核验 - [ ] ARC-260615-07 — 其余见文档:df-core 改名 df-types(类型库非核心)/ src-tauri IPC 编排层抽取(df-app,5711 行成事实业务层) / AI agent loop 从 IPC 下沉 df-ai / 3 view 绕 store 调 api / 类型契约 ts-rs 代码生成 / AiSession 多会话(B 路线前置) / AppState 分组 / IPC 命名统一 **渲染优化(自研块级 memo 已实施,2026-06-16)** @@ -306,14 +333,14 @@ **P1 — 审查状态断层** - [x] DOC-260615-05 ✅(2026-06-16) — ~~todo 补全局 review R-P1-1~6/R-P2-1~13 已完成回写~~ 在全局代码 review 待修项区头部补「R-P1 6 项 + R-P2 13 项全闭环(126 test pass)」汇总指针(指向全局review-2026-06-15.md 推进状态三批),原 todo 只有 R-PD-1~14 需设计项无 R-P1/R-P2 执行项追踪,现补齐。报告说全闭环但 todo 零条目,已完成区补汇总指针 — 详见 §5 + 全局review-2026-06-15.md §推进状态 - [x] DOC-260615-06 ✅(2026-06-16) — ~~架构复核报告 §5 新发现 11 项补 todo~~ 拆 4 子项独立 todo(见下方),核对发现 **复核-新① 已解决**(`useAiConversations.ts:122` 已有 pendingToolCalls await 后二次 token 比对),只补 ③④⑪ 三项;其余 7 项已被全局 review 取代(R-PD 系列) — 详见 §6 -- [ ] 复核-新③ [P2] — **NodeCancelled 事件语义双标**。`executor.rs:149` 取消节点(set_cancelled 后 Err 路径)状态保 Cancelled(:145 跳 set_failed),但事件总线仍 emit `NodeFailed { error: "人工审批被取消" }`。前端若按 NodeFailed 分支判断会误归类「失败」非「取消」。修法:加 `NodeCancelled` variant 或 NodeFailed 加 `cancelled: bool` 字段。— 来源:架构复核报告 §5 新③,crates/df-workflow/src/executor.rs:145-153 + df-core/events.rs -- [ ] 复核-新④ [P2] — **cancel_workflow_node 无节点终态前置校验**。`workflow.rs:352` 仅校验 execution_id 在注册表,不校验 node_id 状态。对已 Completed/Failed/Cancelled 节点调 set_cancelled 会静默覆盖终态(set_cancelled 绕 is_legal)。修法:加 `match sm.get(&node_id) { Running|Waiting => {}, _ => Err }` 守卫。— 来源:架构复核报告 §5 新④,src-tauri/src/commands/workflow.rs:341-358 +- [x] ✅(batch31·2026-06-16) 复核-新③ [P2] — **NodeCancelled 事件语义双标**。`executor.rs:149` 取消节点(set_cancelled 后 Err 路径)状态保 Cancelled(:145 跳 set_failed),但事件总线仍 emit `NodeFailed { error: "人工审批被取消" }`。前端若按 NodeFailed 分支判断会误归类「失败」非「取消」。修法:加 `NodeCancelled` variant 或 NodeFailed 加 `cancelled: bool` 字段。— 来源:架构复核报告 §5 新③,crates/df-workflow/src/executor.rs:145-153 + df-core/events.rs +- [x] ✅(batch30·2026-06-16) 复核-新④ [P2] — **cancel_workflow_node 无节点终态前置校验**。`workflow.rs:352` 仅校验 execution_id 在注册表,不校验 node_id 状态。对已 Completed/Failed/Cancelled 节点调 set_cancelled 会静默覆盖终态(set_cancelled 绕 is_legal)。修法:加 `match sm.get(&node_id) { Running|Waiting => {}, _ => Err }` 守卫。— 来源:架构复核报告 §5 新④,src-tauri/src/commands/workflow.rs:341-358 - [ ] 复核-新⑪ [P2] — **主窗口看门狗幽灵**:detach 时主窗口 `state.streaming`/`state.generatingConvId` 未清(`useAiWindow.ts:20-59` detachPanel 仅快照写 localStorage 不清主窗口 state),看门狗续计 130s 后往主窗口 state 补幽灵错误消息。用户已切走无实际危害,仅主窗口面板重开时看到一条幽灵错误。修法:detach 时显式清主窗口 streaming/generatingConvId 或暂停主窗口看门狗(detach 前提下主窗口不应跑生成态看门狗)。— 来源:架构复核报告 §5 新⑪,src/composables/ai/useAiWindow.ts:20-59 + useAiEvents.ts:115 watchdog - [x] DOC-260615-07 ✅(第⑲批 2026-06-16) — ~~aichat §8 表补勾~~ aichat审查报告第八章 AR-1~11 优先级表逐行补状态勾注(AR-1退役✅/AR-2~7,9~11已修✅+commit/AR-8重评🔄)+文档头二次增补行;零新增条目对齐 DOC-13 瘦身方向;状态不一致 2 项(AR-1退役/AR-8重评)按 todo 标注非矛盾 — 详见 §7 - [x] DOC-260615-08 ✅(2026-06-16) — ~~近期改动 CR-03 未做子项拆独立 todo~~ 拆 3 子项独立 todo(见下方)。原 CR-03(line 212)内联备注「未做留 todo」未独立追踪,现拆。— 详见 §8 -- [ ] CR-260615-03-a [P2] — **audit.rs Err 误报「项目已不存在」**。`audit.rs:52` `resolve_project_label` 用 `_` 合并 `Err`(DB 故障/锁/连接断)与 `Ok(None)`(真不存在),DB 出错时用户看到误导性「项目已不存在」而非错误。修法:分三臂 `Ok(Some)`/`Ok(None)`/`Err`(Err 打 warn 日志回退裸 id)。零行为破坏(仅 Err 分支加日志+改文案)。— 来源:近期改动审查 §2②,src-tauri/src/commands/ai/audit.rs:50-52 -- [ ] CR-260615-03-b [P2] — **openai_compat `anyhow::anyhow!(e)` 丢 source 链**。`openai_compat.rs:414` 非超时分支用 `anyhow!(e)` 把 reqwest::Error 整体塞进 message,丢失 `#[source]` 因果链(`?` 本会保留)。修法:改 `anyhow::Error::from(e)` 保留 source。零行为破坏(仅错误追溯链增强)。— 来源:近期改动审查 §2③,crates/df-ai/src/openai_compat.rs:414 -- [ ] CR-260615-03-c [P2] — **ToolCard projectNameById computed 过度结构化**。`ToolCard.vue:390` computed 每次重建全量项目(含回收站)的 Map,审批卡通常仅 1-2 行用项目 id。修法:改 `projectStore.projects.find(p=>p.id===id)?.name ?? projectStore.deletedProjects.find(...)?.name`(审批场景项目数有限,find 提前退出,响应式仍由 store 数组保证)。零行为破坏。— 来源:近期改动审查 §2⑧,src/components/ToolCard.vue:390,417,441 +- [x] ✅(batch30·2026-06-16) CR-260615-03-a [P2] — **audit.rs Err 误报「项目已不存在」**。`audit.rs:52` `resolve_project_label` 用 `_` 合并 `Err`(DB 故障/锁/连接断)与 `Ok(None)`(真不存在),DB 出错时用户看到误导性「项目已不存在」而非错误。修法:分三臂 `Ok(Some)`/`Ok(None)`/`Err`(Err 打 warn 日志回退裸 id)。零行为破坏(仅 Err 分支加日志+改文案)。— 来源:近期改动审查 §2②,src-tauri/src/commands/ai/audit.rs:50-52 +- [x] ✅(batch30 openai·2026-06-16 + batch31 anthropic:533 同源) CR-260615-03-b [P2] — **openai_compat `anyhow::anyhow!(e)` 丢 source 链**。`openai_compat.rs:414` 非超时分支用 `anyhow!(e)` 把 reqwest::Error 整体塞进 message,丢失 `#[source]` 因果链(`?` 本会保留)。修法:改 `anyhow::Error::from(e)` 保留 source。零行为破坏(仅错误追溯链增强)。— 来源:近期改动审查 §2③,crates/df-ai/src/openai_compat.rs:414 +- [x] ✅(batch30·2026-06-16) CR-260615-03-c [P2] — **ToolCard projectNameById computed 过度结构化**。`ToolCard.vue:390` computed 每次重建全量项目(含回收站)的 Map,审批卡通常仅 1-2 行用项目 id。修法:改 `projectStore.projects.find(p=>p.id===id)?.name ?? projectStore.deletedProjects.find(...)?.name`(审批场景项目数有限,find 提前退出,响应式仍由 store 数组保证)。零行为破坏。— 来源:近期改动审查 §2⑧,src/components/ToolCard.vue:390,417,441 **P1 — 架构/模块文档过期** - [x] DOC-260615-09 ✅(第⑮批核对 06-15) — df-workflow 模块文档 4 处过期 **0 处需改(历史已修)**(NodeRegistry 删 Default / conditions 默认 true→false / try_recv_human_approval 已删 / set_waiting·set_skipped 已删)— 详见 §9 @@ -466,29 +493,29 @@ **第一批 — 最高频痛点(每次对话都遇到)** - [x] UX-2025-01 ✅ 选文字(文本偏移量方案) (第⑩批销账 06-15) — **流式渲染中选中文字保持**(§2.1)。现象:AI 流式输出时选文字被 delta 打断丢失选区。方案 A(推荐):`selectionchange` + 选区在消息容器内 → 暂停 rAF 流式 parse → 选区结束恢复;方案 B:已完成块独立 DOM 节点不参与 v-html 替换。关联 ARC-08 块级 memo(splitBlocks 已按块切分,末块单独更新天然支持方案 B)— src/components/AiChat.vue + src/composables/ai/useAiEvents.ts -- [ ] UX-2025-02 [P1] — **消息操作栏(复制 / 重新生成)**(§1.2+§1.3)。AI 气泡 hover 显示操作栏:「📋 复制」+「🔄 重新生成」。复制 = F-260615-13 合并实现;重新生成:后端删最后一条 AI 消息 + 用倒数第二条 user 消息重跑 agentic loop(新增 `ai_regenerate` IPC)。代码块额外 hover 浮出「📋 复制代码」按钮(§2.2 子项)。— src/components/AiChat.vue + src-tauri/src/commands/ai/commands.rs -- [ ] UX-2025-03 [P1] — **错误气泡操作入口**(§4.1)。错误气泡底部增加按钮区:「🔁 重试」(取上一条 user 消息重发)+「⚙ 去设置」(仅 auth/provider 错误时显示,跳 Settings→AI Tab)。后端 `AiError` 加 `error_type: Option` 枚举(auth/network/timeout/provider_config/unknown),前端据此显隐按钮。— src-tauri/src/commands/ai/mod.rs(AiChatEvent) + src/api/types.ts + src/components/AiChat.vue +- [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-02 [P1] — **消息操作栏(复制 / 重新生成)**(§1.2+§1.3)。AI 气泡 hover 显示操作栏:「📋 复制」+「🔄 重新生成」。复制 = F-260615-13 合并实现;重新生成:后端删最后一条 AI 消息 + 用倒数第二条 user 消息重跑 agentic loop(新增 `ai_regenerate` IPC)。代码块额外 hover 浮出「📋 复制代码」按钮(§2.2 子项)。— src/components/AiChat.vue + src-tauri/src/commands/ai/commands.rs +- [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-03 [P1] — **错误气泡操作入口**(error_type 枚举本身 batch34 B-42 已加,本批前端消费)(§4.1)。错误气泡底部增加按钮区:「🔁 重试」(取上一条 user 消息重发)+「⚙ 去设置」(仅 auth/provider 错误时显示,跳 Settings→AI Tab)。后端 `AiError` 加 `error_type: Option` 枚举(auth/network/timeout/provider_config/unknown),前端据此显隐按钮。— src-tauri/src/commands/ai/mod.rs(AiChatEvent) + src/api/types.ts + src/components/AiChat.vue - [ ] UX-2025-04 [P1] — **断线保文**(§4.2)。流式中断时已接收文本不丢弃:`stream_llm` mid-stream error 改返回 `Some(partial_text, ...)` + `incomplete: true` 标志;agentic 收到 incomplete → 正常入库 + emit AiCompleted(非 AiError)+ 追加系统提示「⚠ 响应因网络中断不完整」;前端 AI 气泡底显示「继续生成」按钮。关联 B-260615-05(流尽空内容报错)同改 stream_recv.rs。— crates/df-ai/src/stream_recv.rs + src-tauri/src/commands/ai/agentic.rs + src/components/AiChat.vue **第二批 — 显著提升日常效率** - [x] ✅(第⑳批 2026-06-16) UX-2025-05 [P2] — **代码块语法高亮 + 复制按钮**(§2.2)。**高亮部分完成**:highlight.js(core+16 语言按需注册,~50-60KB gzip)+ marked.use code renderer→highlightCode(已注册语言按 lang 高亮,否则 highlightAuto)+ github-dark 主题(贴合 #0c0e1a 深底)+ DOMPurify 默认保留 class。**复制按钮留 TODO**(需 AiChat.vue hover 浮层+事件委托,待 UX-02/F-260615-13 合并)。— src/composables/useMarkdown.ts + package.json(highlight.js ^11.11.1) -- [ ] UX-2025-06 [P2] — **对话搜索**(§3.1)。侧栏 header 加搜索框(实时过滤 conversations 匹配 title);搜索时取消分组平铺展示;支持 `Ctrl+K` 快捷键聚焦。— src/views/(AiChat|Sidebar).vue +- [x] ✅(batch41 store + batch43 UI·2026-06-16·workflow wcy478m3y) UX-2025-06 [P2] — **对话搜索**(§3.1)。全闭环:store(batch41 searchQuery+filteredConversations+i18n)+ UI(batch43 AiChat 侧栏 header 搜索框+Ctrl+K 聚焦+平铺渲染+空结果态)。注:无独立 Sidebar.vue,侧栏内联 AiChat.vue - [x] UX-2025-07 ✅(第⑲批 2026-06-16) — ~~键盘快捷键~~ onGlobalKeydown(AiChat.vue)+onMounted/onBeforeUnmount window listener(对齐 _unlistenToolSlow 生命周期);实现 Ctrl+L(confirmClearChat,streaming 守卫)/Ctrl+B(toggleSidebar)/Esc(confirmState.visible→answerConfirm(false)>props.detached→closeDetachedWindow 优先级);Ctrl+N/K/R 留 TODO(WebView2 浏览器级 preventDefault 不可靠待实测);Esc 冒泡与 textarea handleKeydown 不冲突(skill 浮层 Esc 由 textarea handler 先消费)。vue-tsc 0 err。原设计:Ctrl+N 新建/Ctrl+K 搜索/Ctrl+L 清空/Ctrl+Shift+C 复制/Ctrl+R 重新生成/Esc 关闭/Ctrl+B 切侧栏 — src/components/AiChat.vue -- [ ] UX-2025-08 [P2] — **新建对话 confirm 弹窗**(§3.4 增强)。生成中点新建对话时弹 ConfirmDialog(「当前正在生成,确定中断并新建?」),确认后才执行 B-260615-10 的软复位逻辑。防误触中断。— src/components/AiChat.vue + src-tauri/src/commands/ai/commands.rs +- [x] ✅(batch47·2026-06-16·workflow wjkywbhff) UX-2025-08 [P2] — **新建对话 confirm 弹窗**(§3.4 增强)。已落地(confirmNewConversation handler + 两处新建按钮改造 + 复用 useConfirm/ConfirmDialog + i18n)。注:生成态判定用 isViewingGenerating(非 state.generating 实际是 streaming),commands.rs 零改(复用 store.newConversation 软复位 B-260615-10) **第三批 — 锦上添花** -- [ ] UX-2025-09 [P2] — **编辑已发送消息并重新生成**(§1.1)。用户气泡 hover 显示"✏️ 编辑",点击回填输入框;发送时后端:①替换目标 message content ②后续所有消息标记 `status='truncated'`(软删除,保留历史可追溯,与 WF-A soft_delete 模式一致)③用 status='active' 的历史重跑 agentic loop。约束:只允许编辑最后一条 user 消息(中间编辑语义复杂+易乱上下文);generating 中拦住。前端:截断消息从视图移除,loading 等新回复。后端新增 `ai_chat_edit(message_id, new_content)` IPC。— src/components/AiChat.vue + src-tauri/src/commands/ai/(commands.rs|conversation.rs) + df-storage(messages 表加 status 列) -- [ ] UX-2025-10 [P2] — **@ 实体引用**(§1.4)。输入框支持 `@` 触发实体联想浮层(复用 `/` 技能联想 popover 架构);联想源:项目列表/任务列表/最近文件;选中展开为 `[项目: xxx]` 标记文本,后端 system prompt 注入上下文摘要。关联 F-260614-02(技能联想)。— src/components/AiChat.vue + src-tauri/src/commands/ai/prompt.rs +- [x] ✅(batch51·2026-06-16·workflow wdfffj1ti) UX-2025-09 [P2] — **编辑已发送消息并重新生成**(§1.1)。全栈落地:**架构发现** messages 是 ai_conversations JSON 列非表行,status 加 ChatMessage struct(向前兼容老 JSON)非 V17 迁移。df-ai provider.rs status+is_active / context.rs sanitize 过滤 truncated+replace+truncate / commands.rs ai_chat_edit IPC(复用 ai_regenerate spawn)/ api+useAiSend editMessage / AiChat 末条 user 编辑按钮 / i18n 4 key。只末条 active user 可编辑+generating 拦截+truncated 软删保留 DB 不进上下文/视图 +- [x] ✅(batch52·2026-06-16·workflow w91svrokz) UX-2025-10 [P2] — **@ 实体引用**(§1.4)。全栈落地:AiChat @ popover(复用技能架构 .ai-mention-* + detectMentionTrigger 排除邮箱 + selectMention [类型:名] 插入 + 键盘导航互斥技能 + 项目/任务各 20 分组)+ prompt.rs 任务段注入(state.tasks.list_active,**签名零变化**降级避碰 ai_chat_send 领地)+ i18n 5 key。降级:实体 id 未透传(凭名称+状态对齐),最近文件联想无 store 略 - [x] UX-2025-11 [P2] — **输入框高度提升**(§1.5)。textarea 最大高度 120px→200px(~10 行),超过后再内部滚动。或可拖拽 resize handle。— src/components/AiChat.vue (第⑬批销账 06-15) - [ ] UX-2025-12 [P2] — **历史消息分页懒加载**(§2.3)。⏸️ **暂缓**:当前典型对话 <100 条消息,v-for 全量渲染无卡顿;等用户反馈长对话卡顿再做。switchConversation 首次加载最近 50 条,滚顶加载更多(需后端 offset/limit 支持 messages 查询)。或前端虚拟滚动替代。关联 B-260629(messages 无上限)。— src/stores/ai.ts + src/composables/ai/useAiConversations.ts + 后端 conversation.rs - [x] UX-2025-13 [P2] — **消息时间戳**(§2.4)。气泡下方极小字号(dim 色)展示相对时间(复用 time.ts 已有 common.justNow 等 key);hover tooltip 展示完整时间。— src/components/AiChat.vue (第⑬批销账 06-15) - [x] ✅(第㉒批 2026-06-16) UX-2025-14 [P2] — **Provider 切换反馈**(§5.1)。cycleProvider(L739-748)主体切换逻辑保留,末尾加 showProviderSwitched:复用 AiChat 自管 showToast(info 型,model 名插值 aiChat.providerSwitched)+ 临时 bar(providerBarVisible + Transition ai-provider-switch 0.15s 淡入/淡出 + 2000ms 自动淡出,展示 aiChat.providerSwitchHint model 名小字+accent 脉动圆点)。toast 调研:AiChat 已有自管 reactive showToast(分离窗口无根 toast 故自管),复用无降级。_providerBarTimer onBeforeUnmount 清(L1067)。stores/ai.ts 未改(cycleProvider 在 AiChat.vue 内)。— src/components/AiChat.vue + i18n(aiChat.providerSwitched/providerSwitchHint zh/en) - [x] ✅(第㉓批 2026-06-16) UX-2025-15 [P2] — **技能参数用法展示**(§5.2)。浮层 .ai-skill-item 改 column:.ai-skill-item-row(name/desc/src 单行)+ code.ai-skill-item-hint(v-if s.argument_hint,等宽 dim 11px,L390)。chip(L354-361):.ai-skill-chip-main(name+desc+code.ai-skill-chip-hint v-if pendingSkill.argument_hint L358)+ x 关闭(@click clearSkill,补 aria-label=aiChat.clearSkill 无障碍)。复用 var(--df-font-mono)+ui-monospace fallback。**JS 零改动**(仅 template 结构+CSS,/ 联想/selectSkill/clearSkill/Escape/Enter 逻辑全保留)。i18n 无新增(argument_hint 是 skill 数据自带,复用 aiChat.clearSkill)。— src/components/AiChat.vue - [x] ✅(第㉑批 2026-06-16) UX-2025-16 [P2] — **侧栏宽度可拖拽**(§6.1)。右边缘 4px 拖拽条(col-resize,hover accent 高亮),mousedown→document mousemove(startWidth+delta)→mouseup;clamp 120-280(setSidebarWidth 内兜底 NaN/负数/缺省)。**持久化**:move 高频只改 state 不落盘,up 一次性 persistUiState 写 df-ai-ui(复用现有机制,App.vue key 已注册)。**宽度绑定 CSS 变量** `--sidebar-width`(非内联 width,保 sidebar-slide 折叠过渡不被压过)。**卸载兜底** onBeforeUnmount 摘 listener 防泄漏。body.ai-sidebar-resizing 锁 cursor+user-select 防抖动/误选。— src/components/AiChat.vue + src/composables/ai/useAiPanel.ts + src/stores/ai.ts(sidebarWidth:200) + i18n(aiChat.sidebarResizeHint zh/en) -- [ ] UX-2025-17 [P3] — **对话置顶**(§3.2)。ai_conversations 表加 pinned 字段;排序改为 pinned DESC, updated_at DESC;侧栏 hover 图钉按钮。— df-storage + src/views/Sidebar.vue + src/stores/ai.ts -- [ ] UX-2025-18 [P3] — **对话导出**(§3.3)。后端新增 ai_conversation_export(conv_id, format) 支持 markdown/json/txt;前端侧栏 hover 导出按钮。Markdown 格式:## 用户 / ## 助手交替 + 代码块保留围栏。— src-tauri/src/commands/ai/conversation.rs + src/views/Sidebar.vue +- [x] ✅(batch49·2026-06-16·workflow wkan76fon) UX-2025-17 [P3] — **对话置顶**(§3.2)。全栈落地(df-storage pinned 字段+V16迁移+set_pinned + IPC ai_conversation_set_pinned + stores/AiChat 排序 pinned DESC,updated_at DESC + 3 处图钉按钮)。注:无独立 Sidebar.vue 侧栏在 AiChat.vue,conversation.rs:141 补 pinned:false 必填(跨 batch44 领地 proactive-bug-fix),filteredConversations 搜索态未动(降级) +- [x] ✅(batch46 后端 + batch48 前端·2026-06-16) UX-2025-18 [P3] — **对话导出**(§3.3)。全闭环:后端(batch46 ai_conversation_export IPC commands.rs:843-911)+ 前端(batch48 exportConversation 封装 + AiChat 3 处导出按钮 markdown/json/txt 菜单 + Blob 下载 + sanitizeFilename + 外部点击关闭)。注:无独立 Sidebar.vue 侧栏在 AiChat.vue - [ ] UX-2025-19 [P3] — **消息列表虚拟滚动**(§7.2)。⏸️ **暂缓**:同 UX-12,当前数据量无需虚拟滚动;与 UX-12 合并为一条观察项,等用户反馈长对话卡顿再启动。长对话几百条消息全量渲染卡顿;集成 vue-virtual-scroller 或自研 IntersectionObserver 懒渲染;流式最后一条始终保持挂载。— src/components/AiChat.vue + 新依赖 - [x] ✅(第㉒批 2026-06-16) UX-2025-20 [P3] — **空状态引导 + 标题过渡**(§7.3+§7.4)。空态:messages 空+非流式时 .ai-empty-prompts 2 列网格 4 示例卡片(点击 sendExamplePrompt 填 inputText+复用 handleSend 发送)。无 provider 显「去设置」按钮(goToSettings:嵌入 router.push('/settings'),分离窗口降级 showToast 提示去主窗口)。标题淡入:titleFlash ref + watch(activeConv.title)空→非空(loadConversations 回填触发)置 flash 驱动 0.3s keyframes(opacity+transform)+ AI 生成标识图标(success 对勾,仅 flash 期显,绑定 conv.id===activeConversationId)。_titleFlashTimer 320ms 清 + onBeforeUnmount 兜底(L1065)。— src/components/AiChat.vue + i18n(aiChat.examplePrompt1~4/goToSettings/goToSettingsHint zh/en) @@ -511,7 +538,7 @@ **P2 — 信息透明度** - [x] ✅(第⑳批 2026-06-16) AE-2025-07 [P2] — **Agentic 进度条**。AiChat 输入区上方加 `.ai-agentic-progress`(v-if streaming&&agentRound>0),useAiEvents AiAgentRound(round>0 写,round==0 占位不覆盖避免闪烁)+ AiCompleted/AiError 复位清零。**实际显**:round(后端 AiAgentRound.round)+ pending(state.pendingApprovals.length 条件附加)。**降级省略**:max(后端 MAX_AGENT_ITERATIONS=10 常量未透传)+ completed(无累计计数器),均留 TODO。stores/ai.ts 加 agentRound 字段。单轮问答不收 AiAgentRound 故恒隐藏 ✓。— src/components/AiChat.vue + src/composables/ai/useAiEvents.ts + src/stores/ai.ts + i18n(ai.agenticProgress/agenticProgressNoPending zh/en) -- [ ] AE-2025-08 [P2] — **审批历史面板**。独立页面展示 `ai_tool_executions` 表审计记录(时间/工具/风险/状态/决策者/参数摘要/结果摘要)。改动:①后端新增 `list_tool_executions` IPC ②前端新建 AuditLog.vue 表格视图。预估 1 天 — src-tauri/src/commands/ai/(commands|audit).rs + 新建 src/views/AuditLog.vue +- [x] ✅(batch34·2026-06-16·workflow) AE-2025-08 [P2] — **审批历史面板**。独立页面展示 `ai_tool_executions` 表审计记录(时间/工具/风险/状态/决策者/参数摘要/结果摘要)。改动:①后端新增 `list_tool_executions` IPC ②前端新建 AuditLog.vue 表格视图。预估 1 天 — src-tauri/src/commands/ai/(commands|audit).rs + 新建 src/views/AuditLog.vue ### 待澄清 / A-B 待定 @@ -642,12 +669,12 @@ - [x] B-260615-39 [P1] ✅(第⑰批销账 06-15) — ~~complete() 超时重试机制缺失~~ 新建 retry.rs(189行+6单测 retry_with_backoff/AttemptOutcome/is_*_retryable)+openai/anthropic complete() 包裹重试(3次/指数1s→2s→4s/±20%jitter/30s总预算止损/可重试 timeout·connect·5xx·429,不重试4xx·auth·body)/不建LlmError枚举(零侵入trait保anyhow::Result)/不用middleware(手写loop)/流式stream()不动/Settings降级常量MAX_COMPLETE_ATTEMPTS=3。**顺带修真bug:Anthropic complete()无60s timeout(FR-R4只修OpenAI一半,建连后挂起无限hang)**。complete()4调用方全低频后台(标题/知识/扫描/AiNode)有兜底,主对话走stream()不受影响。cargo test -p df-ai 37 passed `complete()` IPC 调用仅加 60s 单请求超时,但无重试机制。根因:网络波动时直接失败,无自动重试兜底。影响:偶发网络错误导致用户操作失败需手动重试。修法:实现指数退避重试机制(最多 3 次,总时长不超过 30s),并在 Settings 可配重试开关 — src-tauri/src/commands/ai/commands.rs - [x] B-260615-40 [P1] ✅(第⑯批销账 06-15) — ~~审批按钮无状态反馈~~ ToolCard 加 approving ref+spinner+disabled(防重入)+130s 本地计时器兜底+watch tc.status 离开 pending_approval 复位;useAiSend approveToolCall 删乐观置 running(原致审批整块消失切骨架屏,语义错位)保 pending_approval 直到后端事件回流;useAiStream onStreamTimeout 注释更新。loading 就近持有 ToolCard 本地不污染 store status 流转 — src/components/ToolCard.vue + src/composables/ai/useAiSend.ts - [ ] B-260615-41 [P1] — ~~工作流执行无进度指示~~ 长时间工作流执行时,用户不知道执行进度和剩余时间。根因:缺乏执行进度可视化。修法:在工作流详情页添加进度条 + 执行步骤提示 + 预估剩余时间 — src/views/WorkflowDetail.vue(需新建) -- [ ] B-260615-42 [P1] — ~~错误信息不一致~~ 同类错误在不同场景下返回的信息格式不统一。根因:错误处理分散在不同模块,缺乏统一规范。影响:用户和调试者难以理解错误含义。修法:制定错误码规范 + 统一错误格式 + 前端错误码映射 — src-tauri/src/commands/ai/mod.rs + src/api/types.ts +- [x] ✅(batch34·2026-06-16·workflow wvr7mdo0p) B-260615-42 [P1] — ~~错误信息不一致~~ 同类错误在不同场景下返回的信息格式不统一。根因:错误处理分散在不同模块,缺乏统一规范。影响:用户和调试者难以理解错误含义。修法:制定错误码规范 + 统一错误格式 + 前端错误码映射 — src-tauri/src/commands/ai/mod.rs + src/api/types.ts **P2 — 中等问题** - [ ] B-260615-43 [P2] — ~~无撤销重做功能~~ AI 操作(如删除项目、修改任务)无撤销功能。根因:缺乏操作历史记录和回滚机制。影响:误操作无法恢复。修法:实现操作历史栈 + 撤销/重做 UI + 关键操作二次确认 — src/stores/project.ts + src/views/ProjectDetail.vue - [x] B-260615-44 [P2] — ~~工具响应时间无反馈~~ 工具执行(如 read_file、write_file)长时间无响应时用户不知道卡在哪里。根因:缺乏工具执行进度反馈。修法:添加工具执行进度提示 + 超时警告 + 取消按钮 — src/composables/ai/useAiStream.ts -- [ ] B-260615-45 [P2] — ~~内存泄漏隐患~~ 长时间使用后内存占用持续增长。根因:消息缓存、事件监听器未及时清理。修法:优化缓存策略 + 定期清理机制 + 内存使用监控 — src/stores/ai.ts + src/composables/ai/useAiEvents.ts +- [x] ✅(batch41·2026-06-16·workflow w9vtei15z) B-260615-45 [P2] — ~~内存泄漏隐患~~ 已核查覆盖(useAiEvents stopListener 三 listener+计时器全清 + AiChat onBeforeUnmount + MESSAGE_CAP=200 滚动淘汰),无泄漏不强改 长时间使用后内存占用持续增长。根因:消息缓存、事件监听器未及时清理。修法:优化缓存策略 + 定期清理机制 + 内存使用监控 — src/stores/ai.ts + src/composables/ai/useAiEvents.ts **文档整理需求** - [x] DOC-260615-15 — **文档缺失**:`patch_file` 工具设计文档已定稿,但使用说明缺失。需添加:工具使用场景、API 参数说明、安全边界、最佳实践 — docs/08-用户指南/patch_file使用指南.md (第⑬批销账 06-15) @@ -669,7 +696,7 @@ **P3 — 风格/可选** - [x] B-260615-51 [P3] ✅(第⑮批销账 06-15) — ~~MESSAGE_PUSH_BURST 单用常量~~ stores/ai.ts:38 仅 watch(:93) 一处用,按风格可内联为 2 + 行内注释。两可(常量名助读),属风格偏好 — src/stores/ai.ts:38 - [x] B-260615-52 [P3] — ~~`if(!state.streaming) state.streaming=true` 冗余判断~~ useAiSend.ts:126 进 busy 分支后判断多余,直接赋值幂等 — src/composables/ai/useAiSend.ts:126-128 -- [ ] B-260615-53 [P3] — ~~watch(messages,{deep}) + JSON.stringify 全量快照~~ AiChat.vue:835 长+多 toolCalls 时每变更 deep 遍历+序列化。snap 短路兜底 + MESSAGE_CAP=200 封顶,典型场景可控,待超长会话反馈再优化 — src/components/AiChat.vue:835-863 +- [x] ✅(batch39·2026-06-16·workflow ww4wb66br) B-260615-53 [P3] — ~~watch(messages,{deep}) + JSON.stringify 全量快照~~ 已优化(snap 剔除 len/id/role 只留 n+tc,流式 delta 不再触发 body 重算,deep watch 保留 toolCalls 嵌套检测)。实际 watch 在 AiChat.vue:1128-1146(非 835) AiChat.vue:835 长+多 toolCalls 时每变更 deep 遍历+序列化。snap 短路兜底 + MESSAGE_CAP=200 封顶,典型场景可控,待超长会话反馈再优化 — src/components/AiChat.vue:835-863 - [x] B-260615-54 ✅评估维持(2026-06-16) — `void convId` 参数丢弃:R5 设计明确(分离窗口只读不切会话防落错),注释充分,签名稳定(onMounted 调用传参)。保留正确,不修 — src/composables/ai/useAiWindow.ts:83 > **统计**:🔴2(P1 确定性 bug) 🟡3(P2 DRY/优化) ⚪3(P3 风格) + 1 评估维持。总体评级「良」:架构清晰、契约闭合、注释扎实,两处 🔴 低风险一行/小改可根治。详见本轮 /review 输出。 diff --git a/docs/待审查.md b/docs/待审查.md index 6bf3394..b50814a 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -23,20 +23,360 @@ ## 当前队列 -### CR-260616-02 batch29+R-PD-10 代码改动批次 — 🟡 待审 +## 已审归档 -- **commit**: `fc767e1` 优化: R-PD-10 storage_err DRY统一 + FR-D3 migrations if链转数组循环 -- **状态**: 🟡 待审(中优 — DRY 重构 + 迁移逻辑改,主代理已独立核查) -- **改动文件**: - - `crates/df-storage/src/crud.rs`(R-PD-10: 110 处 `.map_err(|e| Error::Storage(e.to_string()))` 统一 `storage_err` helper) - - `crates/df-storage/src/migrations.rs`(FR-D3: run() 15 个 `if current_version`(serde default+skip_serializing_if 向前兼容 None=active 无 V17 迁移)+ 5 构造函数补 None(:60/63/66/69/72)+ is_active()(:76-77)+ sanitize_messages step0 过滤 truncated(context.rs:287-296)+ replace_last_active_user_content(:501-512 rposition+token 重估)+ truncate_after_user_message(:478-495 for 循环)+ ai_chat_edit IPC(commands.rs:351-445 复用 ai_regenerate spawn 模式:generating 拦截:363+活跃一致:367+replace 末条 user:371-377+truncate 其后:379-382+save:418+spawn:428-442)+ 只末条 active user 可编辑(replace rposition + Err)+ title.rs:51 + lib.rs:108 + 前端 api/ai.ts:33 editMessage + useAiSend.ts:212-270(splice 截断+改 content+空气泡占位+IPC+失败回滚)+ useAiConversations.ts:72 filter truncated + AiChat.vue:329-337 编辑按钮 hover(isLastUser && !streaming)+ isLastUser/startEdit/cancelEdit(:1028-1059)+ handleSend 编辑分支(:1267-1285)+ ESC 取消(:1211-1215)+ 切换取消(:883-885)+ i18n 4 key 双语(aiChat.ts:100-103)。 +- **⚪ 观察项**(全 low):OBS1(导出 batch46 含 truncated 软删历史,markdown/txt 视觉见旧回复段,完整审计语义取舍)/ OBS2(truncate_after_user_message/replace_last_active_user_content 无单测,token 计数同步易错建议补 case)/ OBS3(useAiSend editMessage convId 空回滚不彻底,极端场景后端未触发 IPC)/ OBS4(editMessage 无 generating 前端预检,DOM 隐藏+后端拦截双兜底无并发风险)。 +- **待修项回流 todo**: **无**(CR-23-1 标无效,4 OBS 全 low 非阻断)。 + +--- + +### CR-260616-23 batch50 审查 med 修复聚合(CR-15-1 kill_on_drop + CR-08-O1 blocked 排除 + CR-10-A/B 停下报告) — ⚠ 已审(ISSUES) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check EXIT 101(撞同攒批 CR-23-1)+ vue-tsc EXIT 0 + grep/read 核验四件) +- **总体结论**: ⚠ **ISSUES** — 🔴1 🟡0 ⚪1(三件声明项全 PASS,但同攒批工作区 context.rs:491 编译失败阻断整批) +- **亲跑 `cargo check --workspace`** ⚠ **EXIT 101**(df-ai 编译失败,CR-23-1,非三件范围)/ `npx vue-tsc --noEmit` ✅ EXIT 0。 +- **三件声明项核验(全 PASS)**: + - **CR-15-1 kill_on_drop** ✅ `shell.rs:85` `.kill_on_drop(true)` match 块外统一调用,三类型(PowerShell:62/Cmd:68/Sh:74)全覆盖,注释:81-84 配合 tokio::time::timeout:96-101 防孤儿;正常完成路径 cmd.output() 已读完 output 自然 drop 无害 — 不破坏 cmd.output() 成立。 + - **CR-08-O1 blocked 排除** ✅ `TaskDetail.vue:265-274` `WF_EXCLUDED_FROM={'blocked'}` :265 + wfAdvanceAction computed :269 `if has return null`,DOM :71 v-if 不渲染。**不动 ADVANCE_MAP**(:219-246 五态原推进项全保留),其他态不受影响。 + - **CR-10-B 否决** ✅ **同意主代理「不成立,审查锚定漂移」**:独立 Read commands.rs:37-110,L94 save_conversation 锁外(L46-65 锁块 L65 关闭),L105 spawn 在 save 后,时序正确。L56/59 是 pop 失败防御性错误处理(复位 generating + 报错)非 save/emit 时序。**原 CR-10-B 把 pop-失败-错误分支误读为时序冲突,审查锚定漂移坐实**。 + - **CR-10-A 降级 P2** ✅ 三入口(ai_regenerate:37/ai_chat_send:128/ai_chat_force_send:453)统一「锁内变更+锁外 spawn」,兜底充分(generating 占用 busy 拦截 + stop_flag 复位 + force_send:460-468 复位三件套 + ai_chat_stop:520-536 3s 兜底任务防 panic/异常退出卡死),接受现状合理。 +- **🔴 CR-23-1(high,编译失败,同攒批非三件范围)**:`crates/df-ai/src/context.rs:491` E0594 `cannot assign to t.message.status, which is behind a & reference`。`truncate_after_user_message` 的 `self.messages[i+1..].iter_mut().filter(|t| {... t.message.status=...})` filter 闭包参数 t 是 `&&mut TrackedMessage`(双层引用)不能写字段。git stash HEAD EXIT 0 / pop 工作区 EXIT 101 → UX-09 新增代码引入。**阻断整个 df-ai crate 构建,下游 commands.rs/agentic.rs 全 fail**。修法:filter 改 for 循环 + 计数,或闭包内 `let t=&mut **t;` 解引用。 +- **⚪ 观察项**:OBS1(agent 自报 cargo EXIT 0 与实际 EXIT 101 不符,可能跑 HEAD 或分 crate,结论可信度打折,建议后续 agent 自报附 log 末尾证据)/ OBS2(CR-23-1 非三件范围但同攒批,合并前必先修否则整批构建失败)。 +- **待修项回流 todo**: **CR-23-1**(high,context.rs:491 编译失败,改 for 循环或解引用,阻断 df-ai 构建 — **紧急,主会话合并前必修**)。 + +--- + +### CR-260616-22 batch49 UX-17 对话置顶全栈(pinned 字段+迁移+IPC+排序+图钉按钮) — ✅ 已审(PASS-WATCH) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + cargo test df-storage + vue-tsc 三 EXIT 0 + grep/read 核验 14 件) +- **总体结论**: ⚠ **PASS-WATCH** — 🔴0 🟡1 ⚪3(CR-22-1 med 已置顶图钉非 hover 不可见) +- **亲跑 `cargo check --workspace` + `cargo test -p df-storage` + `npx vue-tsc --noEmit`** ✅ — 三 EXIT 0(cargo 9 warning 全既存 dead_code;df-storage 22 单元 + 11 集成 passed;vue-tsc 0 error)。 +- **独立核验**(14 件):models.rs:163 `pub pinned: bool`(非 Option 必填,list 摘要手写 json! snake_case 对齐)/ crud.rs:1141-1148 INSERT 含 pinned(?8)+ :1153-1160 UPDATE 含 pinned(?7)params 对齐 / :1050 ai_conversation_from_row 读 pinned i32→bool / :1657-1672 set_pinned 仅 SET pinned 不动 updated_at(纯元数据)/ migrations.rs:285-296 V16 column_exists 守卫幂等 + ALTER ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0(老库非 NULL)+ :53 注册 (16, migrate_v16)/ commands.rs:849-859 ai_conversation_set_pinned IPC / :746 list 摘要 json! 加 pinned / lib.rs:128 注册 / **conversation.rs:141 pinned: false(proactive-bug-fix 坐实:models.rs:163 非 Option 必填,insert 不补编译断 E0063;update :108 从库 get rec.pinned 回写不丢失)** / AiChat.vue 排序 :1054(groupedActive)+ :1069(archivedConvs)`Number(b.pinned?1:0)-Number(a.pinned?1:0)||Number(b.updated_at)-Number(a.updated_at)` pinned DESC→updated_at DESC + V8 stable / :53/120/162 三处图钉按钮 toggle / i18n zh:9-10/en:9-10 双语 / useAiConversations.ts:174-178 setPinnedConversation IPC+本地同步 / api/ai.ts:156-157 + types.ts:246。 +- **🟡 CR-22-1(med,UX 缺陷)**:AiChat.vue:1872-1873 `.ai-conv-item-actions { display:none }` + :1878 `:hover` 才 `display:flex` → 所有操作按钮(含图钉)默认隐藏,:1903 `--pinned` 仅改 color 未覆盖 display → **已置顶对话非 hover 态图钉不可见**,「常驻强调色一眼识别」UX 意图落空。功能(toggle/排序)正常。修法:`.ai-conv-item-act--pinned` 补 `display:flex`。登记自相矛盾(hover 浮出 toggle 与常驻强调色无法同时满足),需产品定调。 +- **⚪ 观察项**:OBS1(filteredConversations 搜索态未加 pinned 排序,置顶不前置,登记自认降级可接受)/ OBS2(V16 无独立测试靠 column_exists 探测,项目一贯模式)/ OBS3(update_full:1151 读库 rec.pinned 回写不丢失,未来绕过 get 路径风险当前无)。 +- **待修项回流 todo**: **CR-22-1**(med,AiChat.vue:1872-1903 已置顶图钉非 hover 不可见,「常驻强调色」UX 未达成,补 display 规则或调 actions 可见性策略)。 + +--- + +### CR-260616-21 batch48 UX-18 对话导出前端(exportConversation 封装 + 3 处导出按钮 + Blob 下载) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 vue-tsc + cargo check 双 EXIT 0 + grep/read 核验 12 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3 +- **亲跑 `npx vue-tsc --noEmit` + `cargo check --workspace`** ✅ — 双 EXIT 0。**vue-tsc EXIT 0 确认 batch48 完成后 `toggleExportMenu/exportingId/exportOpenId/exportConversation` 均已定义,CR-15/16 agent 早报的 12 TS2339 瞬态已消除**(cargo 5 warning 全既存 dead_code NodeExecutionRepo/ReleaseRepo 非本批,本批纯前端)。 +- **独立核验**(12 件):api/ai.ts:165-167 exportConversation 封装(camelCase 仿 continueLoop/stopLoop,签名 `(conversationId, format): Promise`,invoke `ai_conversation_export` snake→camel 对齐 commands.rs:872)/ AiChat.vue 3 处导出按钮 DOM(搜索态 :59-68 / 活跃态 :126-135 / 归档态 :168-177,每处 pin→archive→export→delete 顺序,@click.stop 阻冒泡,:disabled=exportingId===conv.id 防重复)/ `:last-child` 红色态保留(:1898-1901 delete 是末子 export 倒数第二,精确命中删除按钮不被破坏)/ toggleExportMenu/exportOpenId 单展开(:912-917 三元切换同时一开)/ Blob 下载 + revokeObjectURL(:941-950 setTimeout(0) 下轮事件循环释放防泄漏,mime 三格式精确 text/markdown|application/json|text/plain)/ sanitizeFilename 安全(:920-927 空 trim→fallback conv-id + replace 折叠 Win/Unix 路径分隔符+非法+控制字符 + slice(0,60) 限长 + 全清空降级)/ exportingId 防重复(:913/931-932/956 入口 guard+set+finally 三段)/ onExportOutsideClick + 生命周期(:961-966 早返回+closest 防误关 + :1381/1317 add/remove 对称)/ IPC catch 兜底(:951-954 e.message||String(e)+toast 不崩溃)/ i18n 5 key 双语(:112-116 exportFailed 含 {msg})/ 消费 batch46 IPC(commands.rs:872 + lib.rs:129)/ 零越界(batch45 continueLoop/stopLoop :37/45 + batch43 搜索框 :23-34 + batch47 确认 handler :819-841 独立区未改)。 +- **⚪ 观察项**:OBS1(revokeObjectURL setTimeout(0) 下载时机,社区惯例无可见问题)/ OBS2(菜单无 Esc 关闭,onGlobalKeydown Esc 已消费他途,UX 小瑕疵)/ OBS3(filename 无时间戳,同名浏览器自动加后缀,设计取舍)。 +- **待修项回流 todo**: **无**(12 件全过,双 check EXIT 0,零越界)。 + +--- + +### CR-260616-15 batch42 F-04 run_command 超时根治(tool_registry.rs 超时标注 + 常量提取) — ✅ 已审(PASS-WATCH) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo EXIT 0 + vue-tsc EXIT 2 非本批 + grep/read 核验 8 件) +- **总体结论**: ⚠ **PASS-WATCH** — 🔴0 🟡1 ⚪2(CR-15-1 med shell.rs kill_on_drop 缺失) +- **亲跑 `cargo check --workspace`** ✅ — EXIT 0(本批纯 Rust tool_registry + shell)。**`npx vue-tsc --noEmit`** ⚠ EXIT 2(12 errors 全在 AiChat.vue 导出功能 toggleExportMenu/exportingId/exportOpenId/exportConversation,系并发 batch48 未完成实现,与本批 F-04 Rust 零关联)。 +- **独立核验**(8 件):tool_registry.rs:24 常量 `DEFAULT_RUN_COMMAND_TIMEOUT_SECS=60` / :495 unwrap_or 用常量单一使用点 / :510-520 map_err 拦超时改写文案(contains 匹配 shell.rs:95 产出串前缀一致)/ shell.rs:89-95 超时根因点(`tokio::time::timeout` 包 `cmd.output()`)/ commands.rs:251-256 High 审批路径 `e.to_string()` 透传 / audit.rs:450 Low/并行路径同源(经 ai_tools.execute 分发无遗漏)/ commands.rs:269 replace_tool_result_content 断重试链路(LLM 收改写文案不再触发新 tool_call_id 重审批)/ tool_registry.rs:472 run_command 单注册。 +- **🟡 CR-15-1(med,真 bug)**:`crates/df-execute/src/shell.rs:60-79` 构造 `tokio::process::Command` **缺 `.kill_on_drop(true)`**(tokio 1.52.3)→ `tokio::time::timeout` drop future 后 tokio **不主动 kill 子进程**,子进程变**孤儿继续后台跑**,但 `tool_registry.rs:514` 文案称「进程已终止」**名不副实**。长 hang 命令(大构建/死循环)超时后仍耗资源。治本应 shell.rs:60-79 构造 cmd 后加 `.kill_on_drop(true)`。根因层缺陷(非本批 tool_registry 标注层引入),但本批文案依赖该假设。 +- **⚪ 观察项**:OBS1(tool_registry.rs:473 描述 + :494 注释硬编码「默认 60 秒」字面量未引用 const,改常量需手改两处易漏)/ OBS2(const :24 模块私有,shell.rs 未共享)。 +- **待修项回流 todo**: **CR-15-1**(med,`crates/df-execute/src/shell.rs:60-79` 缺 `.kill_on_drop(true)`,孤儿进程 + 文案「进程已终止」语义不实)。 + +### CR-260616-16 batch43 UX-06 对话搜索 UI 接线(AiChat.vue 侧栏) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo EXIT 0 + vue-tsc 首跑 EXIT 2 缓存陈旧清缓存重跑 EXIT 0 + grep/read 核验 8 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3 +- **亲跑 `cargo check --workspace`** ✅ — EXIT 0(7 warning 全既存 dead_code)。**`npx vue-tsc --noEmit`** 首跑 EXIT 2(6 TS2339 全在 AiChat.vue:57-63 导出功能 toggleExportMenu 等,属并存 batch48 导出 UI 非本批)→ 清增量缓存重跑 **EXIT 0**,确认缓存陈旧 quirk 非真实错。 +- **独立核验**(8 件):搜索框 DOM(AiChat.vue:23-34 .ai-conv-search + v-model + kbd Ctrl+K)/ Ctrl+K 绑定(:996-1003 preventDefault + 侧栏未开 toggleSidebar + nextTick focus,顺序 Ctrl+L→K→B→Esc 无重复)/ keydown listener 生命周期对称(:1273 注册 / :1211 移除)/ searchInputRef(:847)/ 平铺渲染(:36-74 template v-if searchResults + :key 's+' 防键冲突 + 空态 searchEmpty + v-else 原分组逐字保留 editingConvId 双击改名零回归)/ searchResults computed(:974-976 显式 .value 解包 ComputedRef 坑已修)/ 消费 batch41 store 契约零改(CR-14 已审 store 侧)/ i18n 3 key 双语(:13-15)。Ctrl+K 冲突核验:grep 无既有 Ctrl+K,Tauri WebView2 无地址栏。 +- **⚪ 观察项**:OBS1(Ctrl+K 输入焦点抢占,打字时按 K 切走光标,UX 选择非破坏)/ OBS2(kbd 与 title 复用同一 i18n key,tooltip 语义弱)/ OBS3(范围声明漂移:搜索态平铺项被 batch48 加 export 菜单,审查时点与现状漂移,归 batch48 审)。 +- **待修项回流 todo**: **无**(vue-tsc EXIT 2 缓存陈旧非真实错)。 + +### CR-260616-17 batch44 F-03 达 max 后端(agentic 暂停态 + AiMaxRoundsReached 事件 + continue/stop IPC) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 + grep/read 核验 6 件 + 交叉核验) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2 +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo 7 warning 全既存 dead_code)。 +- **独立核验**(6 件 + 交叉):agentic.rs:322-347 达上限改(save 先行 :340 + disarm :342 + emit AiMaxRoundsReached :343-345 + return :346 保 generating 暂停态,对齐审批等待模式 L313-316)/ mod.rs:117-122 variant + `#[serde(tag="type")]` / commands.rs:446-465 ai_continue_loop(generating + active 双校验 + 复位 stop_flag + try_continue)/ commands.rs:476-502 ai_stop_loop(双校验 + 置 stop_flag 双保险 + generating=false + emit AiCompleted 不重复 save)/ lib.rs:112-113 注册 / 决策 a(loop 局部 iteration agentic.rs:159,续跑 try_continue 重 spawn 天然重计)。交叉:try_continue guard(should_continue)、ai_chat_stop 暂停态交互(走 3s 兜底正确非最优)、ai_chat_force_send(复位 generating 清审批走新 loop)、stop_flag 双保险(continue 复位 :460 + stop 置 :490)。 +- **⚪ 观察项**:OBS1(ai_chat_stop 暂停态落流式分支走 3s 兜底非最优,前端延迟,建议审批分支前加暂停态判定即时清理,既存 stop 路径未适配新状态)/ OBS2(无硬上限产品风险,前端操作卡建议多次继续后二次确认)。 +- **待修项回流 todo**: **无**。 + +### CR-260616-18 batch45 F-03 达 max 前端操作卡(pendingMaxRounds + 续/停按钮) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 + grep/read 核验 6 件 + 死锁风险核验) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2 +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo 5 warning 全既存 dead_code)。 +- **独立核验**(6 件 + 死锁):types.ts:210-211 AiMaxRoundsReached variant 联合 + serde 对齐 / useAiEvents.ts:93 pendingMaxRounds 模块级 ref 独立事件源 + :200-204 dispatch + :286/320 AiCompleted/AiError 双清 + :41 NO_RESET_WATCHDOG 含 / api/ai.ts:37-38/45-46 continueLoop/stopLoop 仿 ai_approve / AiChat.vue:468-483 操作卡 + :1625-1626 showMaxRoundsCard 双守卫 + :1632-1658 handler IPC 失败回滚 acting + :1660-1663 watch 复位 + :2384-2395 样式 / i18n 6 key 双语(:102-107)/ 后端契约对齐(agentic.rs:343 + commands.rs:446/476 + lib.rs:112-113)。死锁核验:try_continue 三路径(should_continue=false 补 AiCompleted / provider-Err emit AiError / 正常 spawn run_agentic_loop)全覆盖,所有出口触发清 pendingMaxRounds + watch 复位 acting,无死锁。 +- **⚪ 观察项**:OBS1(无 max 暂停态查询 IPC,跨重启恢复缺失,设计取舍 force_send 可复位)/ OBS2(continue/stop IPC 命名风格微异:ai_approve 动词 vs continue/stop 动词+名词)。 +- **待修项回流 todo**: **无**。 + +### CR-260616-19 batch46 UX-18 对话导出后端(ai_conversation_export IPC) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check EXIT 0 + grep/read 核验 11 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2 +- **亲跑 `cargo check --workspace`** ✅ — EXIT 0(workspace 干净,0 warning 0 error)。 +- **独立核验**(11 件):conversation.rs:19/24/48 确无导出 IPC(只 TokenAccumulator + save_conversation)/ IPC 位置 commands.rs:853-913 / format 双保险防 panic(:860-863 `matches!` 前置 Err + :908-909 `_ => Err` 末尾)/ 落库 messages serde_json::from_str 零内存读(:866-871 与 switch 一致)/ markdown 四臂 match 真穷举无 `_` 通配(:878-883 MessageRole 4 variant 编译器强制)/ markdown content 原样三反引号不转义(:886)/ json to_string_pretty(:892-893)/ txt 仅读 m.content 略 tool_calls/tool_call_id/model(:898-904)/ 空对话 markdown/txt 空串 json `"[]"`(:889/906/892)/ lib.rs:128 注册(与 batch44 L112-113 不同行)/ 零越界(与 batch44 continue-stop 不同段)。 +- **⚪ 观察项**:OBS1(json 空对话返 `"[]"` 非 `""`,语义合理前端 JSON.parse 正常)/ OBS2(markdown 顺序依赖落库保序约定)。 +- **待修项回流 todo**: **无**。 + +### CR-260616-20 batch47 UX-08 新建对话确认弹窗 — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 vue-tsc + cargo check 双 EXIT 0 + grep/read 核验 10 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪0 +- **亲跑 `npx vue-tsc --noEmit` + `cargo check --workspace`** ✅ — 双 EXIT 0(cargo 5 warning 全既存 dead_code NodeExecutionRepo 非本批)。 +- **独立核验**(10 件):confirmNewConversation handler(AiChat.vue:832-837 非生成态零行为变化直接 newConversation)/ isViewingGenerating 双条件(:965-967 streaming && generatingConvId===activeConversationId,切走后台不弹)/ store 三字段(ai.ts:47/51/59)/ 两处按钮改造(:18 侧栏 + :206 聊天头,登记写 L176 实际 L206 行号偏差)/ ConfirmDialog 复用(:812 useConfirm Promise + 并发覆盖防永挂 useConfirm.ts:39)/ store.newConversation 软复位(useAiConversations.ts:31-41 B-260615-10 零改)/ commands.rs 零改(git diff 仅 batch46 ai_conversation_export)/ i18n zh/en(:84)/ Esc 优先级协同(:1099-1105 confirmState.visible 时 Esc 视取消 resolve Promise 不卡死)/ 零越界(batch43/45/36/47 独立区块)。 +- **待修项回流 todo**: **无**(W-1 low 范围偏差 L176→L206 代码正确)。 + +--- + +### CR-260616-14 batch41 Sidebar+ai store 域(UX-06 store 契约 + B-45 内存泄漏核查) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + grep/read 核验 7 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3(filteredConversations .slice() 冗余 + sort 稳定性依赖 + 范围声明与实际不符,均 low 非阻塞) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo 4 warning 全既存 dead_code 非本批;vue-tsc 零 TS error,AiChat.vue:940 `computed` 类型精确匹配)。 +- **独立核验**(7 件): + - **filteredConversations 契约**(stores/ai.ts:118-125)✅ — 空 query trim→null 信号(消费方走原分组)/ 非空 `filter((c.title ?? '').toLowerCase().includes(q))` case-insensitive 全量(活跃+归档)/ updated_at DESC(b 在前为正,正确)。 + - **类型契约**(api/types.ts:237-247)✅ AiConversationSummary 含 title?/archived/updated_at 类型安全。 + - **useAiStore 返回值**(stores/ai.ts:136)✅ filteredConversations 已展开。 + - **i18n 3 key**(zh/en aiChat.ts:13-15)✅ searchPlaceholder/searchEmpty/searchShortcutHint 双语对称。 + - **UI 接线已落地**(AiChat.vue:22-64/939-941)✅ — v-model 双绑 + 搜索态平铺 + 空态提示 + computed 解包 store.filteredConversations.value;搜索项含 archive/delete 操作与原分组对称(登记称「留 batch43」,实际 batch41 已完整接线)。 + - **B-45 stopListener**(useAiEvents.ts:344-354)✅ — 三 listener unlisten + 置 null + clearStreamWatchdog + clearAllToolSlowTimers + clearAllApprovalTimers 五件全清。 + - **B-45 AiChat onBeforeUnmount**(AiChat.vue:1172-1191)✅ — removeEventListener keydown + cancelAnimationFrame + 拖拽 listener + body class + store.stopListener() + _unlistenToolSlow + _toastTimer/_titleFlashTimer/_providerBarTimer 八件全清。 + - **MESSAGE_CAP=200**(stores/ai.ts:36/94-105)✅ — watch 拦 push 式增长(>CAP)splice 最早条;切对话整体替换放行。 +- **⚪ 观察项**(均 low): + 1. OBS1(冗余 slice):stores/ai.ts:123 `.slice()` 紧跟 `.filter()`,filter 已返新数组,slice 冗余拷贝。无功能影响,建议删。 + 2. OBS2(sort 稳定性):updated_at 相等返回 0 依赖 V8 ES2019+ stable sort。会话同秒 updated_at 概率低,无可见问题。 + 3. OBS3(范围声明不符):登记称「UI 接线留 batch43」,实际 batch41 已完整接线。建议主会话核对 batch43 是否剩 i18n 微调。 +- **待修项回流 todo**: **无**(三观察项均 low 非阻断)。 + +### CR-260616-13 batch40 CR-06 审查观察项测试补强(②-3/②-4 回调测试 + ignored 确认) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo test + grep/read 核验) +- **总体结论**: ✅ **PASS** — 🔴0 🟡1⚪1(in_progress 失败→todo 状态机拦截产品决策点 med 非阻断 + 测试数登记 +9 vs +10 low) +- **亲跑 `cargo test -p df-nodes -p df-workflow`** ✅ — df-nodes **67 passed + 1 ignored**(57→67,+10 测试)/ df-workflow 23 passed,双 EXIT 0。 +- **独立核验**: + - **ignored 确认**(ai_node.rs:298-300)✅ — glm_live_complete 真调 GLM 集成测试,合理 #[ignore] 保留。 + - **9 新测试覆盖** ✅ — 3 templates callback(task_workflow_templates:callback 边界 + reject 可达性)+ 6 advance callback(task_advance_node:regression_target 镜像契约 + 三段 completed→target/failed→regression/非终态跳过 + 落库验证);镜像契约机制防 workflow.rs:44-51 私有函数回归漂移。 +- **🟡 观察项 O1(med,产品决策点,非本批引入)**:**in_progress 模板失败→regression_target 给 todo,但状态机 `backward_to_todo_rejected` 全拒**(task_state_machine.rs:188-193)→ 回调 advance_task_atomic 被 InvalidState 拦截(task_advance_node.rs:72-77),**任务原地保留 in_progress**。三选项待定:a 接受现状补 UI 提示 / b 改 `regression_target("in_progress")=None` 对齐状态机 / c 放开 todo 回退(破坏闸门)。本批测试显式锁定此行为(防回归)。 +- **⚪ 观察项 O2(low)**:测试数登记 +9 vs 实测 +10(差 1 来自既有测试重构,非问题)。 +- **待修项回流 todo**: **CR-13-O1 产品决策点**(非代码 bug,回流 todo 作产品决策 a/b/c)。 + +### CR-260616-12 batch38 settings 域(F-01 agentic max 可配) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + grep/read 核验 15 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3(前端默认值硬编码 + clamp 回写闪现 + batch45 演进关系,均 low 非阻塞) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo 7 warning 全既存 dead_code;并发 batch45 改动共存编译通过)。注:审查中一次中间态 grep 抓到 batch45 改 mod.rs 时的 E0599,最终完整重跑 EXIT 0 确认 batch38 本身无错。 +- **独立核验**(15 件):1:1 严格复刻 llm_concurrency 范式(AppState 字段 `agent_max_iterations: Arc` + init 引用 `DEFAULT_MAX_AGENT_ITERATIONS=10`(agentic.rs:33)/ `run_agentic_loop(max_iterations)` 形参(agentic.rs:101)+ `0..max_iterations` 循环(agentic.rs:159)+ 达上限 warn+emit(agentic.rs:324-336)+ 3 调用点 ai_chat_send(commands.rs:103-106)/ai_regenerate(commands.rs:197-200)/try_continue_agent_loop(agentic.rs:477-487)load 快照透传 / `ai_set_agent_max_iterations` clamp 1-50 store(commands.rs:812-821)/ lib.rs:130 注册 / api/ai.ts:87-89 / Settings.vue input min1 max50(:275-277)+ watch 持久化 SQLite(:663-665)+ syncAgentMaxIterations Math clamp+debounce 300ms(:694-705)+ onMounted 同步防刷新(:766-775)/ onUnmounted 清 _agentIterTimer(:778-783)/ i18n 双语(settings.ts:79-80))。grep 确认无第 4 调用点遗漏。 +- **⚪ 观察项**(均 low): + 1. OBS1(前端默认值硬编码):Settings.vue:628/696 前端 `appSettings.get(..., 10)` 默认 10 与后端 const 巧合一致但非引用同一常量。范式层面观察(llm_concurrency 亦如此)。 + 2. OBS2(clamp 回写闪现):clamp 前瞬时值短暂显示后修正,视觉闪现功能无损。 + 3. OBS3(batch45 演进):lib.rs:111 注释 F-260616-03 标注 max_iterations 是 batch45 暂停态续跑前置基础,两批共存编译通过,演进链路清晰。 +- **待修项回流 todo**: **无**。 + +### CR-260616-11 batch39 ai chat 性能域(AR-8 节流评估 + B-53 snap 优化) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + grep/read 核验 6 件) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2(增量缓存 quirk + deep watch 每帧检测,均 low 非本批) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo 5 warning 全既存 dead_code;首跑增量缓存陈旧报 AiMaxRoundsReached not found E0599,清缓存重跑 EXIT 0 确认)。 +- **独立核验**(6 件): + - **AR-8 前端滚动跟随已存在**(AiChat.vue:1110-1114 isNearBottom 80px + 234 onMessagesScroll + 412 showBackToBottom + 1139-1147 onContentChange)✅ — 完整存在,非本批新增。 + - **AR-8 后端 50ms 合批未改**(stream_recv.rs diff 纯 B-42 error_type 映射,零 batch39 改动)✅ — 负 ROI 论证成立(rAF ARC-08 吸收重 parse + heartbeat 语义冲突 + flush 复杂度)。 + - **B-53 snap 优化正确性**(AiChat.vue:1207-1210)✅ — 新 snap `{n: msgs.length, tc: msgs.map(m=>(m.toolCalls||[]).map(t=>({id,s})))}` 剔除冗余 id/role/len;body 逻辑(AiChat.vue:1218-1226)仅读 toolCalls 状态从不读被删字段 → 行为安全;流式 delta 不再触发 body 重算(toolCall 状态翻转/splice 淘汰仍触发)。 + - **B-53 短路 + deep + MESSAGE_CAP 全保留**(AiChat.vue:1213 snap===lastMsgSnapshot return + 1233 deep:true + ai.ts:36/94-105 MESSAGE_CAP=200 length watch 独立)✅。 + - **B-53 自包含**(lastMsgSnapshot 模块局部无外部消费 + collapseInactive 唯一调用点)✅。 + - **注释清晰度**(AiChat.vue:1197-1202)✅ — 与代码完全吻合无误导。 +- **⚪ 观察项**(均 low): + 1. OBS1(缓存 quirk):增量缓存陈旧致首跑 E0599,清缓存重跑过。建议 CI 走 cargo check 不带增量。 + 2. OBS2(deep watch 每帧检测):B-53 仅短路 body,deep watch 本身仍每帧遍历 messages 含 content,极长流式消息 O(n) 成本。rAF 节流已吸收,非阻塞。 +- **待修项回流 todo**: **无**(纯优化批次,零回归)。 + +### CR-260616-10 batch36 ai chat UX 域聚合(UX-02 消息操作栏 + UX-03 错误气泡操作入口) — ✅ 已审(PASS) + +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + 独立重跑双 check 佐证) +- **总体结论**: ✅ **PASS** — 🔴0 🟡2⚪3(ai_regenerate 并发竞态 med + commands.rs 时序倒置 med 非阻断 + 3 low) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(vue-tsc 首跑报 useAiEvents.ts 缺 import 已补后过;agent 独立重跑双 check 佐证)。 +- **独立核验**: + - **UX-02 操作栏**(AiChat.vue hover `.ai-msg-actions` 仅末条 复制+重新生成)✅。 + - **ai_regenerate IPC**(commands.rs)✅ — generating 占用校验 + pop_last_assistant_round + save + spawn run_agentic_loop 复用 + active_conversation_id 一致性。 + - **pop_last_assistant_round**(context.rs)✅ — 空 false / 末尾 user popped_any=false / 跨多轮 tool 三元组一并弹不留半截 / history_tokens saturating_sub。 + - **UX-03 错误气泡**(useAiEvents.ts AiError push 附 errorType `as AiMessage` cast B-42 零改 + AiChat.vue 错误气泡重试复用 regenerate + canOpenSettings 判 auth|provider_config)✅。 + - **只读消费 B-42**(ErrorType/AiErrorType/AiError 零改)✅。 +- **🟡 观察项(待修项,细节见 agent transcript)**: + 1. **CR-10-A(med 时序倒置)**:commands.rs:56/59 save 与 emit 时序。回流 todo。 + 2. **CR-10-B(med 竞态)**:ai_regenerate 并发竞态窗口。回流 todo。 +- **⚪ 观察项**:代码块复制按钮(v-html 子元素无法绑事件)遗留单独立项;AI 气泡内嵌复制与新操作栏复制共存最小改动;context.rs 跨 crate(df-ai)改动非 batch35/37 锁。 +- **待修项回流 todo**: **CR-10-A**(commands.rs:56/59 时序倒置 med)+ **CR-10-B**(ai_regenerate 竞态 med)。 + +### CR-260616-09 batch37 knowledge 域(F-260616-02 pending_review 归位·决策 a) — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + cargo test df-storage 双 EXIT 0 + grep/read 核验) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2(注释遗留 + list_non_archived dead_code,均 low 非阻塞) +- **亲跑 `cargo check --workspace` + `cargo test -p df-storage`** ✅ — **双 EXIT 0**;df-storage 11 passed(含 `list_by_status_orders_by_confidence_semantics` / `list_by_status_filters_other_status` / `test_knowledge_cols_matches_record` 三个 knowledge 测试)。 +- **独立核验**(5 件): + - **knowledge_list 默认收窄**(knowledge.rs:78-88)✅ — L86 `None => list_by_status("published")` 收窄纯 published;L84 `Some(s) => list_by_status(&s)` 显式透传(含 archived)向后兼容。 + - **调用方零破坏** ✅ — grep list_non_archived src-tauri/ **零 Rust 调用方**;前端 src/api/knowledge.ts:15 + stores/knowledge.ts:61-66 loadList 默认 undefined→null→后端默认 published;Knowledge.vue:275/522 loadList() 无参走默认。**无调用方依赖旧「非 archived 全部」语义**。 + - **merge_by_confidence 排序等价**(knowledge.rs:238-273)✅ — rank high=3/med=2/low=1/_=0 + created_at DESC,与 SQL(crud.rs:1363-1368 `CASE...DESC, created_at DESC`)权重+方向一致;双指针合并前 a/b 各 sort_by(cmp) 重排保证有序(L257-258);相等时 candidate 优先(L262)结果确定。 + - **向后兼容** ✅ — published 仍在 library(默认)/ archived 显式可查 `knowledge_list(status='archived')` / candidate+pending_review 仍在 inbox(knowledge_list_candidates L226-231)。 + - **i18n 文案**(zh/en knowledge.ts)✅ — inbox「待处理」/「Pending」+ 注释说明 F-260616-02 决策 a。 +- **⚪ 观察项**: + 1. **OBS1(low 注释遗留)**:Knowledge.vue:17/250 注释仍写「审核收件箱」,i18n 已改「待处理」;注释与新语义不符,建议清理(不影响功能)。 + 2. **OBS2(low dead_code)**:crud.rs:1478 `list_non_archived()` 收窄后全仓零调用方,成孤儿方法(编译器未报,宏展开 pub method 豁免);建议删除或 `#[allow(dead_code)]`。 +- **文档遗留(非代码,仅记录)**:df-knowledge 模块文档 L52/58/174-175 + 经验记录 L78 + df-storage 存储层文档 L114 多处 knowledge_list 默认语义/list_non_archived 描述过时,建议后续清理批次更新。 +- **待修项回流 todo**: **无**(代码层面 PASS;两 low 观察项 + 文档遗留均非阻塞)。 + +### CR-260616-08 batch35 TaskDetail 域聚合(①-1 工作流推进按钮 + B-41 进度内嵌 + ①-3 i18n) — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + grep/read 核验) +- **总体结论**: ✅ **PASS** — 🔴0 🟡1⚪1(blocked 态推进按钮语义偏差 med 非阻断 + lib.rs 混入非本批 low) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — **双 EXIT 0**(4 warning 均既有 dead_code 非本批)。 +- **独立核验**(11 件,grep + read 亲验): + - **workflow.rs 空dag选模板补丁**(L81-89)✅ — 三分支覆盖(nodes 非空→原 dag / 空+target Some→template_for 失败 Err「无对应工作流模板」 / 空+target None→原 build_dag);**与 CR-06 审的 ①-1 同代码,描述差异源于措辞非冲突**;dag shadowing 后 build_dag + dag_json 序列化落库实际拓扑(L92/L96)。 + - **②-3/②-4 回调**(workflow.rs:232-233/267-305)✅ — cb_task_id/cb_target_status clone move;completed→Some(target)/failed→regression_target/任一 None→跳过;回调失败 warn 不回滚;advance_task_atomic 签名匹配(task_advance_node.rs:43-47)。 + - **regression_target**(workflow.rs:44-51)✅ — testing→in_review/in_review→in_progress/in_progress→todo/其他→None。 + - **TaskDetail.vue 推进按钮**(L71 v-if wfAdvanceAction / L260-267 computed / L77 disabled 互含 / L288-310 handleWorkflowAdvance 空 dag+taskId+target 调 run)✅。 + - **B-41 进度内嵌 + exec_id 过滤**(L174-184 状态 / L313-314 过滤 / L411 onMounted + L419 onBeforeUnmount 生命周期对称)✅。 + - **多 listen 无冲突**(workflow.ts:37-41 独立 unlisten / store 收全量 vs TaskDetail 按 exec_id 过滤,职责隔离)✅。 + - **进度轻量近似**(L318-323 total 用已启动+已完成,前端无法预知后端模板节点数)✅。 + - **template_for 对齐 ADVANCE_MAP**(三模板 in_progress/testing/done;in_progress 态 primary=in_review 无模板不显示)✅。 + - **与手动 advance 并存**(L54-64 手动保留 / L66-91 工作流独立区块)+ 不碰 router/lib.rs listen ✅。 + - **i18n 8 key 对称**(zh/en taskDetail.ts:41-49)✅。 +- **🟡 观察项 O1(med,非阻断)**:**blocked 态推进按钮语义偏差** — blocked→in_progress primary(TaskDetail.vue:241-243)WF_SUPPORTED_TARGETS.has('in_progress')=true 显示按钮,但 template_for('in_progress') 是「todo→in_progress 从头执行」拓扑,blocked 恢复语义不该重跑 AiNode。UX 需确认(blocked 态应排除工作流推进或用恢复语义)。**非阻断**(后端回调逻辑正确,仅前端按钮显示语义)。 +- **⚪ 观察项 O2(low)**:lib.rs +3 行(ai_regenerate L107 / list_tool_executions L127-128)非 batch35 范畴,属其他批混入工作区,不影响本批正确性。 +- **待修项回流 todo**: **无**(均观察项非阻断;O1 blocked 语义建议主会话评估是否前端排除)。 + +### CR-260616-07 batch34 并行(B-42 错误码统一 + AE-08 审批历史面板) — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo check + vue-tsc 双 EXIT 0 + grep/read 核验源码) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2(既存死代码 warning + B-42 前端消费未改留 batch36) +- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — **双 EXIT 0**(output L68/L69);cargo check Finished 48.33s(L67);6 warning 全既存死代码(df-ai finish_reason/call_type/SessionState/session_state + devflow risk_level/releases/node_executions never read),非本批引入。 +- **独立核验**(B-42 + AE-08,grep 定位 + read 亲验源码): + - **B-42 ErrorType 枚举**(mod.rs:73-86)✅ — `#[serde(rename_all = "snake_case")]` + 5 variant(Auth/Network/Timeout/ProviderConfig/Unknown),每个文档注释说明分类标准;ProviderConfig→provider_config 与 types.ts:188 联合对齐。 + - **B-42 AiError variant**(mod.rs:107-112)✅ — `error_type: Option`,None 表示未分类(向后兼容旧 emit 点,L106 注释)。 + - **B-42 8 emit 点映射**(agentic.rs×3 + stream_recv.rs×5)✅ — Auth(agentic:128)/ ProviderConfig(agentic:452)/ Unknown(agentic:333 + stream_recv:207)/ Timeout(stream_recv:163)/ Network(stream_recv:237/278)/ None(stream_recv:308 建连失败混合源难精确归类)。映射合理。 + - **AE-08 list_recent SQL**(crud.rs:1246-1275)✅ — 专用 SELECT `ORDER BY requested_at DESC LIMIT ?1 OFFSET ?2`(L1259),绕过通用 query 宏(后者硬编码 ORDER BY created_at,本表无该列);limit.min(200) 钳制防滥用(L1253)。 + - **AE-08 list_tool_executions IPC**(audit.rs:85-113)✅ — limit.unwrap_or(50)/offset.unwrap_or(0) → list_recent → DTO map;**敏感字段截断**(arguments_brief 120 / result_brief 160,L104-105),完整原值留库。 + - **AE-08 truncate_chars**(audit.rs:71-78)✅ — `chars().count()<=max` 早返回 / 否则 `chars().take(max)` + push '…';**按 char 边界切,中文/emoji 安全**。 + - **AE-08 lib.rs 全路径注册**(lib.rs:128)✅ — `commands::ai::audit::list_tool_executions`,避碰 mod.rs glob,零行为破坏。 + - **AE-08 AuditLog.vue**(src/views/AuditLog.vue:1-311)✅ — 7 列表格 + 分页(prevPage/nextPage + hasMore=records.length===PAGE_SIZE + 空页回退 L105-108)+ 风险/状态/决策者标签映射;**类型内联**(ToolExecutionRecord interface L69-83,注释「不进 api/types.ts 避撞 B-42」)— 无 types.ts 交叉污染(关注点② ✅)。 + - **B-42 前端消费**(关注点③)✅ — grep `error_type` src/ 仅 types.ts:204 定义,**零消费逻辑**(AiChat.vue 等不读 error_type);UX-03 留 batch36 符合任务约定。 +- **⚪ 观察项**: + 1. **既存死代码 warning**(cargo check 6 个,df-ai/devflow never read)— 非本批引入,既存 lint 级;建议后续清理(SessionState/risk_level 可能阶段2 未接入死代码)。 + 2. **B-42 前端消费未改**(关注点③已知)— error_type 字段已加但前端暂不读,UX-03 留 batch36;字段先落地不阻塞,合理。 +- **待修项回流 todo**: **无**(B-42 + AE-08 全过,零缺陷;两观察项均已知/既存非回归)。 + +### CR-260616-06 batch33 阶段2 续(②-2/②-3/②-4 run_workflow task_id+回调 + ②-6 DAG模板) — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批,与 batch32 + 决策记录 + todo/待审查 混合) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo test 全量无 tail + grep/read 核验源码) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2(df-nodes 1 ignored + ②-3/②-4 回调无集成测试) +- **亲跑 `cargo test -p df-workflow -p df-nodes 2>&1`(无 tail 全量)** ✅ — **exit 0**;df-nodes **58 running → 57 passed; 0 failed; 1 ignored**(output L30/L90,比 CR-05 的 53 多 4 模板测试);df-workflow **23 passed; 0 failed**(L119);无 FAILED 无 error[。**去 tail 修正 CR-05 截断问题**。 +- **独立核验**(四件 + ①-1,grep 定位 + read 亲验源码): + - **②-2 run_workflow 参数 + 前端透传**(workflow.rs:64-73 + api/workflow.ts:16-24 + stores/project/workflow.ts:23-26)✅ — 签名 `task_id: Option, target_status: Option`(L71-72)向后兼容;WorkflowRecord.task_id 填入(L104);前端 api `taskId?/targetStatus?`(L16-17)snake_case 透传(L23-24);stores runWorkflow 透传(L26)。旧调用方不传→None 零行为破坏。 + - **②-3 完成回调**(workflow.rs:267-273)✅ — `match (cb_task_id.as_ref(), cb_target_status.as_deref())` 都 Some → completed 分支 `advance_target=Some(target)`(L270)→ advance_task_atomic 推进到 target_status。 + - **②-4 失败回调退回**(workflow.rs:44-51 + 271)✅ — failed 分支 `advance_target=regression_target(target)`;映射 testing→in_review / in_review→in_progress / in_progress→todo / _(done/blocked/cancelled)→None;in_progress→todo 回起点符合「执行未达预期重排」直觉(L42-43 注释);起点 todo 无可退态→None。 + - **回调解耦语义**(workflow.rs:274-294)✅ — advance_task_atomic Err 仅 `tracing::warn!` 不回滚(工作流 status 已写库 L251,回调失败不撤销);`TaskRepo::new(&db)`(L275)复用 db Arc(clone 廉价)。 + - **②-6 DAG 模板**(task_workflow_templates.rs:1-152)✅ — `template_for(target)` match in_progress/testing/done→Some,else None(L28-35)不 panic;三模板拓扑正确(in_progress 单 ai / testing ai→human 串行 / done 单 human);testing+done 模板 human options 含「拒绝」(L67/L85)触发 ②-5 reject→Err→failed→②-4 退回闭环;config 留空由 run_workflow 全局注入+deep_merge(衔接 ④-1 L14-17);4 测试断言拓扑(unknown_none/single_ai/ai_to_human_serial 含「拒绝」断言 L141/single_human)。 + - **①-1 DagDef 来源选模板**(workflow.rs:81-89)✅ — dag.nodes 空+target Some→`template_for`(L83).ok_or_else(Err「无对应工作流模板」)(L84);空+None→原路径 build_dag 校验(L85);非空→用传入 dag(L88)。三分支零行为破坏。 +- **⚪ 观察项**: + 1. df-nodes **1 ignored**(L90,running 58 / passed 57 / ignored 1)— 登记关注点提「57 passed」未提 ignored;ignored 非 failed,建议确认哪个测试 `#[ignore]`(可能慢/集成测试留阶段2收尾)。 + 2. **②-3/②-4 回调无集成测试**(登记关注点②已知)— 已亲核 diff 逻辑正确(match 分支 + 解耦语义),但缺自动化回归保护;回调失败/退回路径仅靠代码审查覆盖。建议阶段2 收尾补集成测试(workflow 跑通→advance 验证)。 +- **待修项回流 todo**: **无**(四件+①-1 全过,零缺陷;两观察项均已知/非回归)。 + +### CR-260616-05 batch32 阶段2 首批(④-1 config 下沉 + ②-1 注册 TaskAdvanceNode + ②-5 HumanNode reject) — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批,与 CR-03/04 + 决策记录 + todo/待审查 混合) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo test + grep/read 核验源码,不信 agent 自报"53 passed") +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪1(df-nodes 测试数字 tail 截断未单独显示,exit 0 佐证通过) +- **亲跑 `timeout 240 cargo test -p df-nodes -p df-workflow`** ✅ — **exit 0**;df-workflow **23 passed**(deep_merge_tests 6 + executor `node_config_overrides_global_in_node_context` 1 + cancelled 3 + state/conditions 13);df-nodes human_node reject 测试 exit 0 保证通过(`tail -40` 截断未单独显示数字,exit code 0 佐证)。**不信 agent 自报「53 passed」**。 +- **独立核验**(三件,grep 定位 + read 亲验源码): + - **④-1 deep_merge config 下沉**(dag.rs:169 + executor.rs:105 + registry.rs:52)✅ — + - `deep_merge(global,node)`(dag.rs:169-188):两端 Object 递归合并(同 key 节点级覆盖 L177-181 / 新 key 保留 L179);非两端 Object 节点级直接覆盖 L186(含 Null 显式空);**Null 语义=显式置空覆盖(非跳过),跳过=不写 node_configs key**(L164-165 注释)符合登记关注点。 + - `NodeContext.config`(executor.rs:105-108):`match node_configs.get(id) → Some(deep_merge(global,node_cfg)) / None(initial_config.clone())`;**缺失 key 走 None 分支 == 旧 initial_config.clone() 零行为破坏**。 + - `build_dag`(registry.rs:52):`add_node_with_config(id,node,node_def.config.clone())` 下沉 NodeDef.config。 + - **5 维度全测过**:同 key 胜(`node_overrides_global_scalar`)/ 新 key 留(`node_adds_new_key`)/ 嵌套递归(`nested_object_recursive_merge`)/ 非 Object 覆盖(`array_replaces_not_concat`)/ 空 no-op(`empty_node_config_is_noop`+`missing_node_config_treated_as_noop`)。 + - **零行为破坏**:HumanNode/AiNode/TaskAdvanceNode 节点 NodeDef.config 空/缺失 → node_configs 无 key → None 分支 == 旧行为。 + - **②-1 TaskAdvanceNode 注册**(state.rs:235-250)✅ — `build_registry(db: Arc)` 改签名;`register("task_advance", move |_| Box::new(TaskAdvanceNode::new(db.clone())))`(state.rs:248-250)move 闭包捕获 Arc,clone 满足 Send+Sync+'static 满足 NodeFactory 签名;init `build_registry(db.clone())`(state.rs:194)db move 前 clone 避 E0382。D-03 走 df-nodes Node trait 对齐。 + - **②-5 HumanNode reject 语义化**(human_node.rs:19-132)✅ — + - `REJECT_KEYWORDS`(L19-22):中「拒绝/驳回/退回/否决」+ 英「reject/decline/declined/deny/denied/no/block」。 + - `is_reject_decision`(L25-28):trim+to_lowercase 归一化 + **整词精确匹配(== 非 contains)** → 防「拒绝啦」误伤(测 L846 `!is_reject_decision("拒绝啦")` ✅)。 + - `contains_reject`(L31-33):picked 任一命中即拒(多选场景)。 + - execute(L120-132):`count_ok && each_valid` 后 `contains_reject` → Err「人工审批被拒绝(用户选择: X);意见: Y」。 + - **边界全过**:整词防误伤(L846)/ 大小写空白归一(L836-838)/ 多选任一即拒(L848)/ options 空自由文本仅拒绝词 Err 其余 Ok(L13-15 注释)/ 行为变更 Ok→Err executor set_failed → 工作流 failed(L113-119 注释)。 +- **⚪ 观察项**:亲跑命令带 `tail -40` 截断 df-nodes 测试数字(仅见 df-workflow 23 passed);**exit code 0 佐证 df-nodes+df-workflow 全过**,非缺陷。后续审查亲跑命令去 tail 或用 `2>&1 | tee` 保全量。 +- **待修项回流 todo**: **无**(三件全过,零缺陷)。 + +### CR-260616-03 batch30 ①类修复批次 — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批,与 CR-04 混合;CR-03 范围文件干净,无阶段2混入) +- **审查时间**: 2026-06-16(独立审查 agent · 亲核 diff + grep 核验,不信主代理"46 passed"声明) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪1(set_cancelled TOCTOU 低频,条目已知) +- **独立核验**(CR-03 登记范围 8 文件,范围干净): + - **CR-01-D 错误区分**(task_advance_node.rs)✅ — 同态(from==to)→`Validation("相同状态,无需推进")`;非法转换→`InvalidState{current:"X→Y(非法状态转换)"}`;NotFound(任务不存在)。三类拒绝错误类型+消息区分,前端 e.toString() 可分辨。3 测试 assert 含 from→to 上下文(illegal_skip/terminal_done/same_status)。 + - **CR-03-b openai source 链** ✅ — `Error::from(e).context("SSE 流错误: {e}")`,Display=ctx 逐字节等价旧 `anyhow!`,source 链保留。同 CR-04 anthropic 模式。 + - **CR-03-a resolve_project_label 三臂**(audit.rs)✅ — Ok(Some)→项目名 / Ok(None)→"项目已不存在" / Err→裸 id+warn(DB 故障不误报"已不存在"误导用户)。 + - **CR-03-c ToolCard find**(ToolCard.vue)✅ — computed Map(O(N)建表)→function find(O(N)提前退出);2 调用点 `.value[id]`→`(id)` 改对。 + - **CR-01-G i18n 分级**(TaskDetail.vue + i18n×2)✅ — loadFailed/advanceFailed 加 `{msg}` 插值;catch 走 `t(key,{msg:e.toString() ?? common.unknownError})`;common.unknownError zh/en 均有(common.ts:16)。**关注点② 前端兜底可显示区分消息** ✅。 + - **复核-新④ 终态守卫**(workflow.rs cancel_workflow_node)✅ — get→match:Pending/Running/Waiting→set_cancelled+Ok;终态→Err"已终态,无法取消"。行为变更正确(避免静默覆盖终态)。 +- **⚪ 观察项(关注点①,条目已知"低频忽略")**:`set_cancelled`(state.rs:95-100)**无终态守卫**,直接 `insert(Cancelled)`。IPC 层 get→match→set_cancelled 两步非原子:理论 TOCTOU 窗口(get 时 Pending 通过守卫,set 前被 executor 并发改终态)→ set_cancelled 强制覆盖终态(如 Completed→Cancelled 语义错位)。**实际低频**(人工取消+executor 竞争窗口极小)+ 最坏仅终态语义错位不崩溃 → med 观察项,IPC 守卫是主防线。彻底修需 set_cancelled 加守卫或 CAS,低频场景成本不值。 +- **待修项回流 todo**: 无明确缺陷;TOCTOU 属条目已知低频观察,非 CR-03 引入回归。 + +### CR-260616-04 batch31 NodeCancelled 事件语义+anthropic source链 — ✅ 已审(PASS) + +- **commit**: 工作区未 commit(攒批,与 CR-03 + 阶段2 混合) +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 test + grep 核验,不信主代理"16 passed"声明) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪1(前端消费缺失,既存非回归)+ ⚠ 范围混淆 flag +- **独立核验**(CR-04 登记范围 4 文件): + - **events.rs NodeCancelled variant** ✅ — `NodeCancelled { node_id }`(events.rs:64)新增,语义注释清晰(取消=用户主动 vs NodeFailed=失败)。 + - **executor.rs is_cancelled 二分** ✅ — L148-166:取消节点→emit NodeCancelled(不 set_failed,Cancelled 已终态避免非法转换崩溃);失败节点→set_failed+NodeFailed。零行为破坏(工作流仍 Err 中止,first_err 逻辑保留)。 + - **types.ts node_cancelled** ✅ — L128 手对齐后端 serde(rename_all snake_case)。 + - **anthropic_compat.rs:533 Display 不变** ✅ — `Error::from(e).context(format!("Anthropic SSE 错误: {}", e))`,Display = context = "Anthropic SSE 错误: {e}" 逐字节等价旧 `anyhow!`;e 进 `.source()` 链可追溯。 + - **亲跑 `cargo test -p df-workflow`** ✅ — **23 passed**(主代理称 16,实际 23 含 deep_merge/node_config 新测试),关键 `test_cancelled_node_emits_node_cancelled_event` ✅ 验证取消节点 emit NodeCancelled 非 NodeFailed。 + - **claim③ 无穷举 match** ✅ — 亲 grep:后端无 `match WorkflowEvent` 穷尽(全 matches!/构造点),前端 src/ node_failed/node_cancelled **仅 types.ts 定义无消费**。 +- **⚠ 范围混淆 flag**(重要):executor.rs 工作区 diff 混入**阶段2 config 下沉**(L99-108 deep_merge 全局+节点 config + EchoConfigNode 测试 + node_config_overrides 测试),**非 CR-04 范围**(未登记待审查)。该部分 test 过但不属本批结论覆盖。阶段2 改动(`dag.rs`+102 / `human_node.rs`+46 / `state.rs`+21 / executor.rs config 部分)需主会话另立 CR-05 登记后审。 +- **⚪ 观察项(既存非回归,建议评估)**:NodeCancelled 事件**前端无消费逻辑**(src/ 仅 types.ts 类型定义,无 switch/render 处理)。取消节点用户暂无"已取消"区分 UI 反馈。**非 CR-04 引入**(CR-04 前 node_failed 前端亦不处理,属既存渐进式类型对齐),NodeCancelled 用户价值依赖后续补前端事件处理。 +- **待修项回流 todo**: 无明确代码缺陷;前端事件消费缺失属既存功能不完整,建议主会话评估是否立项(非 CR-04 待修项)。 + +### CR-260616-02 batch29+R-PD-10 代码改动批次 — ✅ 已审(PASS) + +- **commit**: `fc767e1` 优化: R-PD-10 storage_err DRY统一 + FR-D3 migrations if链转数组循环 +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 test 核验,不信主代理"全过"声明) +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪0 +- **独立核验**: + - **亲跑 `cargo test -p df-storage`** ✅ — crud 单元 **22** passed + project_soft_delete 集成 **11** passed,0 failed(覆盖 storage_err 替换后 CRUD / advance_status_atomic CAS / soft_delete / update_field 白名单 / migrations run() 全量路径)。主代理"11集成全过"声明属实,额外验证 22 单元。 + - **R-PD-10 storage_err DRY** ✅ — helper `fn storage_err(e: E) -> Error { Error::Storage(e.to_string()) }`(crud.rs:19)泛型等价旧闭包;残留旧模式 grep **=0**(无遗漏);test 全过证 110 处替换无语义偏差。 + - **FR-D3 migrations if→数组循环** ✅ — `steps: [(i32, fn(&Connection)->Result<()>); 15]`(migrations.rs:43)+ `for (version, migrate_fn) in steps { if current_version < version { migrate_fn(conn)?; } }`,与原 15 个 `if current_version Result<()>`,与 steps 数组 fn 指针类型精确匹配。 + - **迁移数据安全三层保留** ✅ — ①版本门控 run() 开头 `MAX(version)`(migrations.rs:29)②各 migrate_vN 末尾 `INSERT INTO schema_version`(L67/75/.../274)③column_exists PRAGMA 幂等探测(L7-9,v4-v15 各处)。 +- **待修项回流 todo**: **无**(全过,无新缺陷)。 ### CR-260616-01-fixes 审查待修项修复批次 — ✅ 已审(PASS) diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs index eda8dc0..a41fc34 100644 --- a/src-tauri/src/commands/ai/agentic.rs +++ b/src-tauri/src/commands/ai/agentic.rs @@ -22,15 +22,15 @@ use super::stream_recv::stream_llm; use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::audit::process_tool_calls; -use super::{AiChatEvent, AiSession}; +use super::{AiChatEvent, AiSession, ErrorType}; -/// Agentic 循环最大迭代次数 +/// Agentic 循环默认最大迭代次数(可配置项的默认值) /// -/// 默认 10 轮。未来可配置接入点:接入 AppState(新增 `agent_config` 字段)/df-storage settings KV -/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表 -/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。 -/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传。 -pub const MAX_AGENT_ITERATIONS: usize = 10; +/// 默认 10 轮。F-260616-01 已接入配置:AppState.agent_max_iterations(Arc) + +/// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口 +/// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界, +/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。 +pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; // ============================================================ // B-260615-09: generating 状态 RAII guard @@ -98,6 +98,7 @@ pub(crate) async fn run_agentic_loop( conv_id: String, knowledge_config: crate::state::KnowledgeConfig, llm_concurrency: LlmConcurrency, + max_iterations: usize, ) { // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) let mut guard = GeneratingGuard::new(session_arc.clone()); @@ -124,6 +125,8 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: msg, + // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth + error_type: Some(ErrorType::Auth), conversation_id: Some(conv_id.clone()), }); return; @@ -153,7 +156,7 @@ pub(crate) async fn run_agentic_loop( // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 let mut converged = false; - for iteration in 0..MAX_AGENT_ITERATIONS { + for iteration in 0..max_iterations { // 用户请求停止 → 收尾退出(已生成文本已在上一轮入库) if stop_flag.load(Ordering::SeqCst) { let usage = df_ai::provider::TokenUsage { @@ -316,19 +319,31 @@ pub(crate) async fn run_agentic_loop( // 全部自动执行完成 → 继续下一轮 } - // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户 - // 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示 + // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户 + // F-260616-03:不再 emit AiError + 走完成流程,改为 emit AiMaxRoundsReached + 保持 generating=true + // (仿审批等待 L313-316),等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮) + // 或点停止(ai_stop_loop → 走完成流程)。try_continue 重新 spawn run_agentic_loop,iteration 从 0 重计, + // 故续跑天然再跑 max_iterations 轮(决策 a),无需 reset 任何计数器。 if !converged { tracing::warn!( conv_id = %conv_id, - max_iter = MAX_AGENT_ITERATIONS, - "[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成", - MAX_AGENT_ITERATIONS, + max_iter = max_iterations, + "[ai] agentic 循环达最大轮次(max_iterations={})仍未收敛,转暂停态询问用户(F-260616-03)", + max_iterations, ); - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS), + // 轮 token 落库(保留末轮已生成内容,续跑/停止都据此累加) + let usage = df_ai::provider::TokenUsage { + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + total_tokens: tokens.total(), + }; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; + // 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位 + guard.disarm(); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached { conversation_id: Some(conv_id.clone()), }); + return; // generating 保持 true,等 ai_continue_loop / ai_stop_loop } // 正常完成 @@ -444,6 +459,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) { tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider"); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: e, + // 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig + error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id), }); return; @@ -466,6 +483,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) { let app_handle = app.clone(); let knowledge_config = state.knowledge_config.lock().await.clone(); let llm_concurrency = state.llm_concurrency.clone(); + // F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效) + let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本 // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开 @@ -475,6 +494,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) { }); tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await; }); } diff --git a/src-tauri/src/commands/ai/audit.rs b/src-tauri/src/commands/ai/audit.rs index 24c04eb..6821f1c 100644 --- a/src-tauri/src/commands/ai/audit.rs +++ b/src-tauri/src/commands/ai/audit.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; -use tauri::{AppHandle, Emitter}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; @@ -15,7 +16,7 @@ use df_core::types::new_id; use crate::state::AppState; -use crate::commands::now_millis; +use crate::commands::{err_str, now_millis}; use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; @@ -38,10 +39,87 @@ pub(crate) fn risk_from_str(s: &str) -> Option { } } +// ============================================================ +// 审批历史查询 IPC(AE-2025-08) +// ============================================================ + +/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露) +/// +/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要, +/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。 +#[derive(Debug, Clone, Serialize)] +pub struct ToolExecutionDto { + pub id: String, + pub conversation_id: Option, + pub tool_call_id: String, + pub tool_name: String, + /// 参数摘要(截断 120 字符,完整原值仍留库) + pub arguments_brief: String, + /// 结果摘要(截断 160 字符,None → 空串便于前端展示) + pub result_brief: Option, + /// pending/approved/rejected/executing/completed/failed + pub status: String, + /// low/medium/high + pub risk_level: String, + pub requested_at: String, + pub executed_at: Option, + /// human/auto,None 表示尚未决策 + pub decided_by: Option, +} + +/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji) +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 +} + +/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。 +/// +/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。 +/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。 +#[tauri::command] +pub async fn list_tool_executions( + state: State<'_, AppState>, + limit: Option, + offset: Option, +) -> Result, String> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + let records = state + .ai_tool_executions + .list_recent(limit, offset) + .await + .map_err(err_str)?; + Ok(records + .into_iter() + .map(|r| ToolExecutionDto { + id: r.id, + conversation_id: r.conversation_id, + tool_call_id: r.tool_call_id, + tool_name: r.tool_name, + arguments_brief: truncate_chars(&r.arguments, 120), + result_brief: r.result.map(|s| truncate_chars(&s, 160)), + status: r.status, + risk_level: r.risk_level, + requested_at: r.requested_at, + executed_at: r.executed_at, + decided_by: r.decided_by, + }) + .collect()) +} + + /// 查项目可读标签:id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。 /// /// AR-3:审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。 -/// 查不到(项目已被彻底清除/外部 id)时给"项目已不存在"提示而非裸 id,避免用户困惑。 +/// 三臂区分: +/// - Ok(Some) → 项目名标签 +/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id) +/// - Err → 裸 id 回退 + warn 日志(DB 故障/锁/连接断,不误报"已不存在"误导用户) async fn resolve_project_label(db: &Arc, id: &str) -> String { if id.is_empty() { return String::new(); @@ -49,7 +127,11 @@ async fn resolve_project_label(db: &Arc, id: &str) -> String { let repo = ProjectRepo::new(db); match repo.get_by_id(id).await { Ok(Some(p)) => format!("「{}」(id={})", p.name, id), - _ => format!("(项目已不存在, id={})", id), + Ok(None) => format!("(项目已不存在, id={})", id), + Err(e) => { + tracing::warn!("resolve_project_label: 查询项目 id={} 失败,回退裸 id: {}", id, e); + format!("(id={})", id) + } } } @@ -296,6 +378,119 @@ pub(crate) fn emit_data_changed(app_handle: &AppHandle, tool_name: &str) { } } +/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。 +/// +/// **根因链**(F-04 batch42 已做超时标注):LLM 调 run_command 超时 → F-04 把超时标注成 +/// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_id(provider 每轮新 id) +/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。 +/// +/// **治本(F-05)**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史, +/// 若发现**已落定**(executed/failed/rejected,非 pending)的同类同参高危调用,返回其 +/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。 +/// +/// **安全边界**: +/// - 仅 High risk(循环源头);Low/Med 不去重(Low 直行无审批,Med 重试场景少且去重易误伤)。 +/// - 仅匹配**已落定**结果(pending 的不命中——pending 已有独立流程,不会触发循环; +/// 且避免两个 pending 互相吞掉审批)。LLM 只有在收到 tool_result 后才会重试, +/// 故循环必然是「上一条已落定 → 重试」形态,pending 不命中不影响治本。 +/// - args 走 JSON 规范化比较(键序无关),避免 LLM 两次生成键序不同误判为不同命令。 +/// - 返回 None 表示无缓存命中(走原审批流程)。 +/// +/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。 +fn find_cached_high_risk_result( + session: &AiSession, + tool_name: &str, + args: &serde_json::Value, +) -> Option { + use df_ai::provider::MessageRole; + + // 规范化新调用的 args 为可比字符串(排序键,键序无关) + let new_args_key = canonical_args_key(args); + + // ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。 + // 单对话消息量小(百级),collect 开销可忽略。 + let msgs: Vec<&ChatMessage> = session.messages.iter().collect(); + + // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id + // 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。 + let mut prev_tool_call_id: Option = None; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Assistant) { + continue; + } + let Some(tcs) = msg.tool_calls.as_ref() else { continue }; + for tc in tcs { + if tc.function.name != tool_name { + continue; + } + // 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中) + let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { + continue; + }; + if canonical_args_key(&old_args) == new_args_key { + prev_tool_call_id = Some(tc.id.clone()); + break; + } + } + if prev_tool_call_id.is_some() { + break; + } + } + + // 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」, + // 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。 + // pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。 + let old_id = prev_tool_call_id?; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Tool) { + continue; + } + if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { + continue; + } + // 命中旧 tool_result:排除 pending 占位(内容固定为「需要用户审批,等待确认」) + if msg.content == "需要用户审批,等待确认" { + return None; + } + return Some(msg.content.clone()); + } + None +} + +/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化, +/// 键序不同的等价参数生成同一 key(防 LLM 两次生成键序不同误判为不同命令)。 +fn canonical_args_key(args: &serde_json::Value) -> String { + let mut v = args.clone(); + sort_object_keys(&mut v); + // 紧凑序列化(无空白),保证稳定可比 + serde_json::to_string(&v).unwrap_or_default() +} + +/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 +fn sort_object_keys(v: &mut serde_json::Value) { + match v { + serde_json::Value::Object(map) => { + // BTreeMap 按键排序,重建 Object + let mut entries: Vec<(String, serde_json::Value)> = map + .iter() + .map(|(k, val)| (k.clone(), val.clone())) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + map.clear(); + for (k, mut val) in entries { + sort_object_keys(&mut val); + map.insert(k, val); + } + } + serde_json::Value::Array(arr) => { + for item in arr { + sort_object_keys(item); + } + } + _ => {} + } +} + /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) pub(crate) async fn process_tool_calls( @@ -326,12 +521,37 @@ pub(crate) async fn process_tool_calls( .collect(); // 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result) + // + // F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。 + // 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result, + // 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批, + // 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。 let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new(); for (_, draft, args) in drafts { let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); match risk_level { RiskLevel::Low => low_risk.push((draft, args)), RiskLevel::Medium | RiskLevel::High => { + // F-05:仅 High 查去重缓存;Med 保持原审批流程 + if matches!(risk_level, RiskLevel::High) { + if let Some(cached) = find_cached_high_risk_result(session, &draft.name, &args) { + // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 + tracing::info!( + tool = %draft.name, + new_tool_call_id = %draft.id, + "[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)" + ); + session.messages.push(ChatMessage::tool_result(&draft.id, &cached)); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(cached.clone()), + conversation_id: Some(conv_id.to_string()), + }); + // 审计:去重命中记一条 completed(decided_by=auto_dedup),不进 pending + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, Some(cached), Some("auto_dedup")).await; + continue; + } + } pending_count += 1; session.pending_approvals.insert(draft.id.clone(), PendingApproval { tool_call_id: draft.id.clone(), diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index 9b54129..d480b8e 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -25,6 +25,90 @@ use super::AiChatEvent; // 发送 / 审批 / 控制 // ============================================================ +/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」) +/// +/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的 +/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑 +/// (历史末尾是该 user 消息,LLM 据此再生成)。 +/// +/// 与 ai_chat_send 的区别:不 push 新 user 消息(用户消息已在历史末尾),仅清旧 AI 回复后 +/// 复用同一 agentic loop。生成中拦截,与 send 一致防并发双发。 +#[tauri::command] +pub async fn ai_regenerate( + app: AppHandle, + state: State<'_, AppState>, + conversation_id: String, + language: Option, +) -> Result { + let provider_config = super::prompt::get_active_provider(&state).await?; + + // 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息) + { + let mut session = state.ai_session.lock().await; + if session.generating { + return Err("AI 正在生成中,请等待完成".to_string()); + } + session.generating = true; + session.stop_flag.store(false, Ordering::SeqCst); + session.agent_language = language.clone(); + let popped = session.messages.pop_last_assistant_round(); + if !popped { + // 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错 + session.generating = false; + return Err("没有可重新生成的回复".to_string()); + } + // 一致性:regenerate 限定当前活跃对话(避免历史快照陈旧时弹错对话的消息) + if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) { + session.generating = false; + return Err("对话已切换,无法重新生成".to_string()); + } + } + + let _tool_defs = state.ai_tools.tool_definitions(); + let lang = language.unwrap_or_else(|| "zh-CN".to_string()); + let system_prompt = build_system_prompt(&state, &lang).await; + + // 知识注入:取末尾 user 消息文本做检索(与 send 同款,语义命中刷新上下文) + let (conv_id, last_user_text) = { + let session = state.ai_session.lock().await; + let cid = session.active_conversation_id.clone().unwrap_or_default(); + // 末尾 user 消息文本(用于知识检索;检索本身失败不阻断重生成) + // iter() 非 DoubleEnded,反向找 user:经 all_messages_clone 正向遍历后取末尾 user + let msgs = session.messages.all_messages_clone(); + let last_user = msgs.iter().rev() + .find(|m| matches!(m.role, df_ai::provider::MessageRole::User)) + .map(|m| m.content.clone()) + .unwrap_or_default(); + (cid, last_user) + }; + let mut system_prompt = system_prompt; + { + let config = state.knowledge_config.lock().await.clone(); + let knowledge_context = build_knowledge_context(&state, &conv_id, &last_user_text, &config).await; + if !knowledge_context.is_empty() { + system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); + } + } + + // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) + save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + + let session_arc = state.ai_session.clone(); + let tools_arc = state.ai_tools.clone(); + let db = state.db.clone(); + let app_handle = app.clone(); + let knowledge_config = state.knowledge_config.lock().await.clone(); + let llm_concurrency = state.llm_concurrency.clone(); + // F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) + let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); + + tauri::async_runtime::spawn(async move { + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await; + }); + + Ok("ok".to_string()) +} + /// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值) /// /// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护: @@ -109,9 +193,11 @@ pub async fn ai_chat_send( let app_handle = app.clone(); let knowledge_config = state.knowledge_config.lock().await.clone(); let llm_concurrency = state.llm_concurrency.clone(); + // F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) + let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await; }); Ok("ok".to_string()) @@ -253,6 +339,111 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { Ok(()) } +/// 编辑最后一条 user 消息并重新生成(UX-09) +/// +/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 → +/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删, +/// 保留 DB 可追溯但不进 LLM 上下文,前端视图过滤)→ save 落库 → spawn run_agentic_loop 重跑。 +/// +/// 约束:① 只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)② generating 中拦截 ③ 活跃对话一致。 +/// 与 ai_regenerate 的区别:不 pop 旧 AI 回复(改标 truncated 保留),且编辑 user 内容后重跑。 +#[tauri::command] +pub async fn ai_chat_edit( + app: AppHandle, + state: State<'_, AppState>, + conversation_id: String, + new_message: String, + language: Option, +) -> Result { + let provider_config = super::prompt::get_active_provider(&state).await?; + + // 原子占用 generating + 替换末条 user content + truncate 其后 + { + let mut session = state.ai_session.lock().await; + if session.generating { + return Err("AI 正在生成中,请等待完成".to_string()); + } + // 活跃对话一致性(防切走后编辑老快照) + if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) { + return Err("对话已切换,无法编辑".to_string()); + } + // ① 替换末条 active user 消息 content(无 active user → Err) + if session + .messages + .replace_last_active_user_content(&new_message) + .is_err() + { + return Err("没有可编辑的用户消息".to_string()); + } + // ② 其后所有消息标 truncated(无后续也 OK,返回 0) + let _ = session + .messages + .truncate_after_user_message(&new_message) + .map_err(|_| "定位被编辑消息失败".to_string())?; + // 占用 generating + stop_flag + session.generating = true; + session.stop_flag.store(false, Ordering::SeqCst); + session.agent_language = language.clone(); + } + + let _tool_defs = state.ai_tools.tool_definitions(); + let lang = language.unwrap_or_else(|| "zh-CN".to_string()); + let system_prompt = build_system_prompt(&state, &lang).await; + + // 知识注入:用新 user 文本检索(与 send/regenerate 同款) + let (conv_id, last_user_text) = { + let session = state.ai_session.lock().await; + let cid = session.active_conversation_id.clone().unwrap_or_default(); + let msgs = session.messages.all_messages_clone(); + // 取末条 active user 文本(sanitize 前的全量,但 truncated 已标,这里取 active 的末条) + let last_user = msgs + .iter() + .rev() + .find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active()) + .map(|m| m.content.clone()) + .unwrap_or_default(); + (cid, last_user) + }; + let mut system_prompt = system_prompt; + { + let config = state.knowledge_config.lock().await.clone(); + let knowledge_context = + build_knowledge_context(&state, &conv_id, &last_user_text, &config).await; + if !knowledge_context.is_empty() { + system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt); + } + } + + // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) + save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await; + + let session_arc = state.ai_session.clone(); + let tools_arc = state.ai_tools.clone(); + let db = state.db.clone(); + let app_handle = app.clone(); + let knowledge_config = state.knowledge_config.lock().await.clone(); + let llm_concurrency = state.llm_concurrency.clone(); + let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); + + tauri::async_runtime::spawn(async move { + run_agentic_loop( + session_arc, + tools_arc, + db, + app_handle, + provider_config, + system_prompt, + conv_id, + knowledge_config, + llm_concurrency, + max_iterations, + ) + .await; + }); + + Ok("ok".to_string()) +} + /// 强制发送消息(B-260616-02: L2 发送韧性) /// /// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时, @@ -347,6 +538,74 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result< Ok(()) } +/// 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态用户点「继续」) +/// +/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 +/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出) +/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loop(iteration 从 0 重计,天然再跑 +/// max_iterations 轮,决策 a)。 +/// +/// 校验复用 ai_approve 模式:generating 必须为 true(暂停态)+ active_conversation_id 一致性 +/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b:用户主动授权 = 同意烧 token)。 +#[tauri::command] +pub async fn ai_continue_loop( + app: AppHandle, + state: State<'_, AppState>, + conversation_id: String, +) -> Result { + { + let mut session = state.ai_session.lock().await; + if !session.generating { + return Err("AI 未在暂停态,无需继续".to_string()); + } + if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) { + return Err("对话已切换,无法继续".to_string()); + } + // 复位停止信号:暂停态可能因上一轮 stop_flag 残留为 true,续跑 loop 入口会立即退出走完成流程 + session.stop_flag.store(false, Ordering::SeqCst); + } + // 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop + try_continue_agent_loop(&app, &state).await; + Ok("ok".to_string()) +} + +/// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」) +/// +/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 +/// generating=true 暂停。用户点停止调本命令 → 复位 generating + emit AiCompleted(标收敛)。 +/// +/// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic.rs +/// 达上限分支),此处仅复位 generating + emit AiCompleted 通知前端收尾。校验复用 ai_approve +/// 模式(generating + active_conversation_id 一致性)。 +#[tauri::command] +pub async fn ai_stop_loop( + app: AppHandle, + state: State<'_, AppState>, + conversation_id: String, +) -> Result { + let conv_id = { + let mut session = state.ai_session.lock().await; + if !session.generating { + return Err("AI 未在暂停态,无需停止".to_string()); + } + if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) { + return Err("对话已切换,无法停止".to_string()); + } + // 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致) + session.stop_flag.store(true, Ordering::SeqCst); + session.generating = false; + session.active_conversation_id.clone().unwrap_or_default() + }; + // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) + let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + conversation_id: Some(conv_id), + }); + Ok("ok".to_string()) +} + // ============================================================ // 提供商管理 // ============================================================ @@ -589,6 +848,7 @@ pub async fn ai_conversation_list( "model": r.model, "models": models, "archived": r.archived, + "pinned": r.pinned, "prompt_tokens": r.prompt_tokens, "completion_tokens": r.completion_tokens, "created_at": r.created_at, @@ -686,6 +946,95 @@ pub async fn ai_conversation_archive( Ok(()) } +/// 置顶/取消置顶对话(UX-17:对话置顶) +/// +/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。 +/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。 +#[tauri::command] +pub async fn ai_conversation_set_pinned( + state: State<'_, AppState>, + conversation_id: String, + pinned: bool, +) -> Result<(), String> { + state.ai_conversations + .set_pinned(&conversation_id, pinned) + .await + .map_err(err_str)?; + Ok(()) +} + +/// 导出对话为指定格式(UX-18:对话导出) +/// +/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) +/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出 +/// (content 内已有的三反引号代码块围栏原样保留,不做二次转义) +/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表) +/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注 +/// +/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。 +/// 空对话(无 messages)→ 空字符串(对应格式空体)。 +#[tauri::command] +pub async fn ai_conversation_export( + state: State<'_, AppState>, + conversation_id: String, + format: String, +) -> Result { + // format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支 + let fmt = format.as_str(); + if !matches!(fmt, "markdown" | "json" | "txt") { + return Err(format!("不支持的导出格式: {}", format)); + } + + // 取落库对话(完整历史) + let record = state.ai_conversations.get_by_id(&conversation_id).await + .map_err(err_str)? + .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; + + let messages: Vec = serde_json::from_str(&record.messages) + .map_err(|e| format!("解析消息失败: {}", e))?; + + let body = match fmt { + "markdown" => { + // user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流) + let mut parts: Vec = Vec::new(); + for m in &messages { + let title = match m.role { + df_ai::provider::MessageRole::User => Some("## 用户"), + df_ai::provider::MessageRole::Assistant => Some("## 助手"), + df_ai::provider::MessageRole::System => Some("## 系统"), + df_ai::provider::MessageRole::Tool => Some("## 工具结果"), + }; + if let Some(t) = title { + // content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持) + parts.push(format!("{}\n\n{}", t, m.content)); + } + } + parts.join("\n\n") + } + "json" => { + serde_json::to_string_pretty(&messages) + .map_err(|e| format!("序列化失败: {}", e))? + } + "txt" => { + let mut parts: Vec = Vec::new(); + for m in &messages { + let role_name = match m.role { + df_ai::provider::MessageRole::System => "system", + df_ai::provider::MessageRole::User => "user", + df_ai::provider::MessageRole::Assistant => "assistant", + df_ai::provider::MessageRole::Tool => "tool", + }; + parts.push(format!("{}: {}", role_name, m.content)); + } + parts.join("\n") + } + // 上方 matches! 已校验,理论不可达 + _ => return Err(format!("不支持的导出格式: {}", format)), + }; + + Ok(body) +} + /// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 #[tauri::command] pub async fn ai_list_skills() -> Result, String> { @@ -716,3 +1065,20 @@ pub async fn ai_set_concurrency_config( } Ok(()) } + +/// 设置 Agentic 循环最大轮次(运行时调整,立即生效) +/// +/// 与并发配置不同:max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响 +/// (已锁定边界),下次发消息生效。范围双 clamp(command 端 1-50 + 前端 input min/max), +/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。 +#[tauri::command] +pub async fn ai_set_agent_max_iterations( + state: State<'_, AppState>, + value: u32, +) -> Result<(), String> { + // clamp 1-50:下限防 agent 失能(一轮即截断无法调任何工具), + // 上限防失控烧 token(50 轮足够覆盖复杂多步任务) + let clamped = value.clamp(1, 50) as usize; + state.agent_max_iterations.store(clamped, Ordering::SeqCst); + Ok(()) +} diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 675a1bc..0929d3b 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -138,6 +138,7 @@ pub(crate) async fn save_conversation( model: model.map(|m| m.to_string()), models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())), archived: false, + pinned: false, prompt_tokens: usage.map(|u| u.prompt_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64), created_at: created_at.unwrap_or_else(|| now.clone()), diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index fd91b73..d432b29 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -4,7 +4,7 @@ //! //! 模块布局: //! - [`commands`] — 所有 `#[tauri::command]` IPC 函数 -//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / MAX_AGENT_ITERATIONS +//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / DEFAULT_MAX_AGENT_ITERATIONS //! - [`stream_recv`] — stream_llm 流式接收 //! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens //! - [`title`] — 对话标题生成 @@ -36,7 +36,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use df_ai::ai_tools::RiskLevel; use df_ai::context::ContextManager; @@ -65,6 +65,26 @@ pub use self::tool_registry::build_ai_tool_registry; // 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到) // ============================================================ +/// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key, +/// network 提示检查连接,provider_config 引导检查 base_url/model)。 +/// +/// 设计:`Option` 向后兼容——旧 emit 点若难精确分类填 `None`, +/// 旧前端消费方忽略未填值;新点尽量精确填并在注释标注映射理由。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ErrorType { + /// 鉴权失败:API key 缺失/无效/钥匙串损坏(resolve_provider_secret / ensure_resolved_key 失败) + Auth, + /// 网络错误:连接失败/DNS 解析失败/流中途断(SSE 传输断) + Network, + /// 超时:连接超时/idle timeout(120s 无 chunk) + Timeout, + /// Provider 配置错误:base_url/model/provider_type 错或无可用 provider(配置丢失/全删) + ProviderConfig, + /// 未分类(达最大轮次/provider 流式错误事件等难精确归类的旧点) + Unknown, +} + /// AI 聊天事件(推送到前端) #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] @@ -82,11 +102,24 @@ pub enum AiChatEvent { /// AI 响应完成 AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option }, /// 错误 - AiError { error: String, conversation_id: Option }, + /// + /// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。 + AiError { + error: String, + /// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None + error_type: Option, + conversation_id: Option, + }, /// Agent 循环新一轮(前端需新建 assistant 消息) AiAgentRound { round: u32, conversation_id: Option }, /// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」) AiHeartbeat { conversation_id: Option }, + /// 达最大轮次仍未收敛(F-260616-03:转暂停态询问用户继续/停止) + /// + /// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡: + /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 + /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false) + AiMaxRoundsReached { conversation_id: Option }, } // ============================================================ diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index ff7a2ac..72f9990 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -51,8 +51,8 @@ fn env_info_line() -> String { format!("当前日期: {today} | 运行环境: {os}\n\n") } -/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题) -fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) { +/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) +fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) { match lang { "en" => ( "You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\ @@ -70,6 +70,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) { - Prefer using tools to complete actions rather than just describing steps\n\ - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n", "\n## Current Projects\n", + "\n## Current Tasks\n", ), _ => ( "你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\ @@ -87,26 +88,42 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) { - 优先使用工具完成操作,而不是只描述步骤\n\ - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n", "\n## 当前项目\n", + "\n## 当前任务\n", ), } } -/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目上下文) +/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务上下文) +/// +/// UX-10 §1.4: 用户在输入框 @ 引用实体时插入 `[类型: 名]` 标记(项目/任务)。 +/// 项目上下文已在下方注入,任务上下文本函数新增注入,使 LLM 能解析用户消息中的 +/// `[项目: xxx]` / `[任务: xxx]` 标记并对齐到真实实体(名称+状态+描述)。 +/// 注入克制:仅各取最近 20 条,防 context 膨胀。 pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { - let (prefix, ctx_label) = system_prompt_parts(lang); + let (prefix, proj_label, task_label) = system_prompt_parts(lang); let mut prompt = env_info_line(); prompt.push_str(prefix); // 附加当前数据上下文 if let Ok(projects) = state.projects.list_active().await { if !projects.is_empty() { - prompt.push_str(ctx_label); + prompt.push_str(proj_label); // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 for p in projects.iter().take(20) { prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description)); } } } + // UX-10 §1.4: 任务上下文(供解析用户消息中 [任务: xxx] 标记) + // 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。 + if let Ok(tasks) = state.tasks.list_active().await { + if !tasks.is_empty() { + prompt.push_str(task_label); + for tk in tasks.iter().take(20) { + prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description)); + } + } + } prompt } diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index 0548c9a..3a998a2 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -10,7 +10,7 @@ use tracing::warn; use df_ai::provider::{CompletionRequest, LlmProvider}; -use super::{AiChatEvent, ToolCallDraft}; +use super::{AiChatEvent, ErrorType, ToolCallDraft}; /// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 /// @@ -159,6 +159,8 @@ pub(crate) async fn stream_llm( Err(_elapsed) => { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(), + // 120s 无 chunk = idle timeout,归 Timeout + error_type: Some(ErrorType::Timeout), conversation_id: Some(conv_id.to_string()), }); return None; @@ -200,6 +202,9 @@ pub(crate) async fn stream_llm( "stream-error", err_msg, ), + // provider SSE error 事件体可为 401/overloaded 等多种, + // 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断) + error_type: Some(ErrorType::Unknown), conversation_id: Some(conv_id.to_string()), }); return None; @@ -226,6 +231,10 @@ pub(crate) async fn stream_llm( &status_or_class, &raw, ), + // 流已建立后 next() 返 Err = SSE 传输断/解析错,归 Network + // (extract_error_diag 已抠 status_or_class,但混合源难统一归 auth/timeout, + // 主流为传输断,前端可据 error 文本二次判断 HTTP 4xx 等) + error_type: Some(ErrorType::Network), conversation_id: Some(conv_id.to_string()), }); return None; @@ -265,6 +274,8 @@ pub(crate) async fn stream_llm( if !finished_received { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), + // 流尽但未收到 finished = 连接异常中断,归 Network + error_type: Some(ErrorType::Network), conversation_id: Some(conv_id.to_string()), }); return None; @@ -290,6 +301,11 @@ pub(crate) async fn stream_llm( &status_or_class, &raw, ), + // 建连失败混合源:401(auth)/404(provider_config)/connect(network)/timeout/unknown, + // extract_error_diag 已抠 status_or_class 供前端 error 文本展示,但运行时文本分类 + // 归一 error_type 不可靠(同 raw 跨多类型),按任务约定难精确分类的旧点填 None, + // 前端可据 error 文本中的 "HTTP 401" 等自行二次判断。 + error_type: None, conversation_id: Some(conv_id.to_string()), }); None diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 742e9f9..fcbbb78 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -48,6 +48,7 @@ pub(crate) async fn ensure_conversation_title( tool_call_id: None, tool_calls: None, model: None, + status: None, }) .collect(); (summary, session.messages.all_messages_clone()) diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index cbd7422..1f16813 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -19,6 +19,10 @@ use crate::commands::now_millis; /// 用于 list_projects / list_tasks / list_ideas / list_trash const MAX_LIST_RESULTS: usize = 50; +/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 +/// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。 +const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; + /// 生成行级 unified diff(无外部依赖,基于 LCS)。 /// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。 /// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。 @@ -487,8 +491,8 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { } None => workspace_root().to_string_lossy().to_string(), }; - // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖 - let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60); + // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS); let request = ShellRequest { command: command.to_string(), @@ -497,7 +501,23 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { timeout_secs: Some(timeout_secs), shell_type: Default::default(), }; - let result = execute(request).await?; + // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 + // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() + // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → + // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 + // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, + // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 + let result = execute(request).await.map_err(|e| { + let msg = e.to_string(); + if msg.contains("命令执行超时") { + anyhow::anyhow!( + "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", + timeout_secs + ) + } else { + e + } + })?; // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) const MAX_OUT: usize = 10_000; diff --git a/src-tauri/src/commands/knowledge.rs b/src-tauri/src/commands/knowledge.rs index 51b1f6e..e3c83f6 100644 --- a/src-tauri/src/commands/knowledge.rs +++ b/src-tauri/src/commands/knowledge.rs @@ -68,18 +68,22 @@ fn validate_transition(from: &str, to: &str) -> Result<(), String> { // CRUD // ============================================================ -/// 列出知识 — 可按 status 筛选,status=None 时默认排除 archived +/// 列出知识 — 可按 status 筛选,status=None 时默认仅返回 published +/// +/// 默认范围说明(F-260616-02 决策 a): +/// - status=None → library tab 的数据源,语义为「已发布知识库」,仅 published。 +/// 不再混杂 pending_review(pending_review 归 inbox 收件箱,见 knowledge_list_candidates)。 +/// - 显式传 status 时按该 status 过滤(含 archived)。 #[tauri::command] pub async fn knowledge_list( state: State<'_, AppState>, status: Option, ) -> Result, String> { match status { - // 显式查 archived 时原样返回(含归档项) - Some(s) if s == "archived" => state.knowledge.list_by_status("archived").await.map_err(err_str), + // 显式传 status 时按该 status 过滤(含 archived) Some(s) => state.knowledge.list_by_status(&s).await.map_err(err_str), - // 默认:列出非 archived 的全部(单查询 status != 'archived') - None => state.knowledge.list_non_archived().await.map_err(err_str), + // 默认:library 仅 published(F-260616-02 决策 a,职责清晰:library=published,inbox=待处理) + None => state.knowledge.list_by_status("published").await.map_err(err_str), } } @@ -208,16 +212,64 @@ pub async fn knowledge_record_reuse( .map_err(err_str) } -/// 审核收件箱 — 列出 candidate(按 confidence 语义排序) +/// 收件箱 — 列出待处理条目(candidate + pending_review),按 confidence 语义排序 +/// +/// 语义(F-260616-02 决策 a):inbox = 「待处理」收件箱,聚合 candidate(待评估) +/// 与 pending_review(待发布审核)两种待处理状态。library 仅 published。 +/// +/// 实现:list_by_status 单状态查询,这里合并 candidate 与 pending_review 两路结果。 +/// 两路各自已按 `confidence DESC, created_at DESC` 排序,有序合并保持同一规则。 #[tauri::command] pub async fn knowledge_list_candidates( state: State<'_, AppState>, ) -> Result, String> { - state - .knowledge - .list_by_status("candidate") - .await - .map_err(err_str) + let (candidates, pending) = tokio::try_join!( + state.knowledge.list_by_status("candidate"), + state.knowledge.list_by_status("pending_review"), + ) + .map_err(err_str)?; + Ok(merge_by_confidence(candidates, pending)) +} + +/// 有序合并两列(各自已按 confidence DESC, created_at DESC 排序),结果保持同序。 +/// +/// confidence 排序权重:high=3, medium=2, low=1, 其他=0;同权重按 created_at DESC +/// (字符串毫秒时间戳字典序 = 时间序)。等价 SQL `ORDER BY CASE confidence ... DESC, created_at DESC`。 +fn merge_by_confidence( + mut a: Vec, + mut b: Vec, +) -> Vec { + use std::cmp::Ordering; + fn rank(c: &str) -> i8 { + match c { + "high" => 3, + "medium" => 2, + "low" => 1, + _ => 0, + } + } + let cmp = |x: &KnowledgeRecord, y: &KnowledgeRecord| -> Ordering { + let rx = rank(x.confidence.as_deref().unwrap_or("")); + let ry = rank(y.confidence.as_deref().unwrap_or("")); + ry.cmp(&rx) // confidence DESC + .then_with(|| y.created_at.cmp(&x.created_at)) // created_at DESC + }; + a.sort_by(cmp); + b.sort_by(cmp); + let mut out = Vec::with_capacity(a.len() + b.len()); + let (mut i, mut j) = (0, 0); + while i < a.len() && j < b.len() { + if cmp(&a[i], &b[j]) != Ordering::Greater { + out.push(a[i].clone()); + i += 1; + } else { + out.push(b[j].clone()); + j += 1; + } + } + out.extend_from_slice(&a[i..]); + out.extend_from_slice(&b[j..]); + out } /// 归档(软删除) — UPDATE status='archived' diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index 4d9789c..006a2ad 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -8,8 +8,11 @@ use tauri::{AppHandle, Emitter, State}; use tokio::sync::broadcast::error::RecvError; use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType}; -use df_core::types::new_id; -use df_storage::crud::WorkflowRepo; +use df_core::types::{new_id, NodeStatus}; +use df_nodes::task_advance_node::advance_task_atomic; +// F-260616-06 ①-1: 空 dag + target_status 时按目标态自动选推进链模板 +use df_nodes::task_workflow_templates::template_for; +use df_storage::crud::{TaskRepo, WorkflowRepo}; use df_storage::models::WorkflowRecord; use df_workflow::dag_def::DagDef; use df_workflow::executor::DagExecutor; @@ -27,11 +30,36 @@ struct WorkflowEventPayload { event: WorkflowEvent, } +/// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。 +/// +/// 映射表(失败退一步): +/// - testing → in_review +/// - in_review → in_progress +/// - in_progress → todo +/// - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调) +/// +/// 设计选择:in_progress → todo(而非 None)。理由:in_progress 是执行态,失败退回 todo +/// 符合"执行未达预期 → 回起点重排"的直觉语义;若选 None 会丢失"执行失败"信号。 +/// 起点状态 todo 无可退态 → None。 +fn regression_target(target: &str) -> Option<&str> { + match target { + "testing" => Some("in_review"), + "in_review" => Some("in_progress"), + "in_progress" => Some("todo"), + _ => None, + } +} + /// 触发工作流执行(核心命令) /// /// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 → /// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。 /// 立即返回执行记录 ID,前端凭此关联后续事件。 +/// +/// F-260616-06 阶段2 工作流联动(②-2): +/// - `task_id` / `target_status` 同时 Some 时,完成后按 target_status 推进任务(②-3), +/// 失败时按 regression_target 退回(②-4)。 +/// - Option 向后兼容:旧调用方不传即 None,不触发任何任务回调,零行为破坏。 #[tauri::command] pub async fn run_workflow( app: AppHandle, @@ -39,7 +67,27 @@ pub async fn run_workflow( name: String, dag: DagDef, config: serde_json::Value, + // F-260616-06 ②-2: 工作流联动任务的关联 ID 与目标态(同时 Some 才联动) + task_id: Option, + target_status: Option, ) -> Result { + // 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。 + // 规则: + // - dag.nodes 非空 → 用传入 dag(向后兼容,旧调用方/编辑器自定义工作流不变) + // - dag.nodes 为空 + target_status Some → 调 template_for 自动选推进链模板; + // 模板不存在(target 非三合法态) → Err「无对应工作流模板」 + // - dag.nodes 为空 + target_status None → 原行为(空 dag 让 build_dag 自行报错,零新分支) + // 前端选 target_status 后传空 dag,零模板知识零新 IPC,后端收敛选模板逻辑。 + let dag = if dag.nodes.is_empty() { + match target_status.as_deref() { + Some(target) => template_for(target) + .ok_or_else(|| format!("无对应工作流模板: target_status={}", target))?, + None => dag, // 空dag+无target: 走原路径由 build_dag 校验 + } + } else { + dag + }; + // 1. 先构建运行时 DAG,校验失败直接返回,不落库 let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?; @@ -53,7 +101,7 @@ pub async fn run_workflow( status: "running".to_string(), triggered_by: Some("manual".to_string()), project_id: None, - task_id: None, + task_id: task_id.clone(), created_at: now_millis(), completed_at: None, }; @@ -180,6 +228,9 @@ pub async fn run_workflow( let db = state.db.clone(); let exec_id = execution_id.clone(); let state_registry = state.workflow_state_registry.clone(); + // F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用 + let cb_task_id = task_id.clone(); + let cb_target_status = target_status.clone(); tauri::async_runtime::spawn(async move { let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone()); // 注册执行器状态机:StateMachine 内部 Arc,clone 共享底层 HashMap, @@ -207,13 +258,58 @@ pub async fn run_workflow( tracing::error!("更新工作流完成时间失败: {}", e); } + // F-260616-06 ②-3 / ②-4: 工作流联动任务推进回调 + // - 成功(completed): task_id 与 target_status 都 Some 时推进到 target_status + // - 失败(failed): 按 regression_target 推算退回态推进;无映射则跳过 + // task_id / target_status 任一 None → 跳过(向后兼容,旧工作流不联动任务) + // 回调失败仅 tracing::warn!,不回滚(工作流成功语义与任务推进解耦 — 回调失败不撤销 + // 已完成工作流,前端可后续手动处理) + match (cb_task_id.as_ref(), cb_target_status.as_deref()) { + (Some(tid), Some(target)) => { + let advance_target = match status { + "completed" => Some(target), + "failed" => regression_target(target), + _ => None, + }; + if let Some(to) = advance_target { + let repo = TaskRepo::new(&db); + if let Err(e) = advance_task_atomic(&repo, tid, to).await { + tracing::warn!( + execution_id = %exec_id, + task_id = %tid, + target_status = %to, + workflow_status = %status, + "工作流联动推进任务失败(不回滚): {}", + e + ); + } else { + tracing::info!( + execution_id = %exec_id, + task_id = %tid, + target_status = %to, + workflow_status = %status, + "工作流联动推进任务成功" + ); + } + } else { + // failed 且无退回映射:warn 跳过(常见:target 是 todo 起点无可退态) + tracing::warn!( + execution_id = %exec_id, + task_id = %tid, + target_status = %target, + "工作流失败但 target_status 无退回映射,跳过任务回调" + ); + } + } + _ => {} + } + // 执行结束(成功/失败)清理状态注册表,防内存泄漏 state_registry.lock().await.remove(&exec_id); // 执行失败时补发 WorkflowFailed(执行器内部只发 NodeFailed) // failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值) if let Some(error) = error { - use df_core::types::NodeStatus; let failed_node = executor .state_machine() .snapshot() @@ -337,6 +433,11 @@ pub async fn approve_human_approval( /// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。 /// StateMachine 内部 Arc 共享底层 HashMap,IPC 写入直达运行中阻塞节点(HumanNode) /// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。 +/// +/// 终态前置守卫:仅 Pending(排队中)/ Running(执行中,含阻塞等审批)允许取消; +/// 终态节点(Completed/Failed/Skipped/Cancelled)返 Err,避免静默覆盖终态。 +/// 守卫放 IPC 层(非 set_cancelled 内):保留 set_cancelled 作为 executor 内部受控旁路语义, +/// 让状态变更的合法入口收敛到 IPC 这一道。 #[tauri::command] pub async fn cancel_workflow_node( state: State<'_, AppState>, @@ -349,11 +450,25 @@ pub async fn cancel_workflow_node( Some(sm) => sm, None => return Err(format!("工作流 {} 不存在或已结束", execution_id)), }; - sm.set_cancelled(node_id.clone()); - tracing::info!( - "工作流节点取消:execution_id={}, node_id={}", - execution_id, - node_id - ); - Ok(()) + // 终态守卫:get() 返回缺失条目默认 Pending(尚未执行)→ 允许取消 + let current = sm.get(&node_id); + match current { + NodeStatus::Pending | NodeStatus::Running | NodeStatus::Waiting => { + sm.set_cancelled(node_id.clone()); + tracing::info!( + "工作流节点取消:execution_id={}, node_id={}", + execution_id, + node_id + ); + Ok(()) + } + // 终态:不允许取消(避免静默覆盖终态) + NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled => { + Err(format!( + "节点 {} 已终态({}),无法取消", + node_id, + current.as_str() + )) + } + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c273396..317a90c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,9 +104,14 @@ pub fn run() { commands::workflow::cancel_workflow_node, // AI 聊天 commands::ai::ai_chat_send, + commands::ai::ai_regenerate, + commands::ai::ai_chat_edit, commands::ai::ai_chat_force_send, commands::ai::ai_chat_stop, commands::ai::ai_approve, + // F-260616-03:达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45) + commands::ai::ai_continue_loop, + commands::ai::ai_stop_loop, commands::ai::ai_pending_tool_calls, commands::ai::ai_chat_clear, commands::ai::ai_is_generating, @@ -121,8 +126,13 @@ pub fn run() { commands::ai::ai_conversation_delete, commands::ai::ai_conversation_rename, commands::ai::ai_conversation_archive, + commands::ai::ai_conversation_set_pinned, + commands::ai::ai_conversation_export, commands::ai::ai_list_skills, commands::ai::ai_set_concurrency_config, + commands::ai::ai_set_agent_max_iterations, + // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) + commands::ai::audit::list_tool_executions, // 知识库 commands::knowledge::knowledge_list, commands::knowledge::knowledge_get, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 037967c..2c92473 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -174,6 +175,11 @@ pub struct AppState { // ── LLM 并发控制 ── /// LLM 调用并发上限(全局 + 单对话双层 Semaphore,运行时可调) pub llm_concurrency: LlmConcurrency, + // ── Agentic 循环轮次上限 ── + /// Agentic 循环最大轮次(前端 Settings 数字配置 → AppState 字段 → 热改 command → + /// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。 + /// 默认 10,与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。 + pub agent_max_iterations: Arc, // ── 工作流执行状态 ── /// 工作流执行 → 节点状态机注册表 /// @@ -189,6 +195,9 @@ impl AppState { pub async fn init(db_path: &Path) -> Result { let db = Arc::new(Database::open(db_path).await?); let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db)); + // build_registry 需注入 Arc(TaskAdvanceNode 持 db)。 + // 在 struct 字段 `db` move 前 clone,避免 E0382。 + let registry = Arc::new(build_registry(db.clone())); let state = Self { ideas: IdeaRepo::new(&db), projects: ProjectRepo::new(&db), @@ -205,10 +214,13 @@ impl AppState { knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), settings: SettingsRepo::new(&db), llm_concurrency: LlmConcurrency::new(3, 2), + agent_max_iterations: Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, + )), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), db, event_bus: EventBus::new(), - registry: Arc::new(build_registry()), + registry, ai_tools, }; // 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建, @@ -229,7 +241,7 @@ impl AppState { /// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具 /// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。 /// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。 -fn build_registry() -> NodeRegistry { +fn build_registry(db: Arc) -> NodeRegistry { let mut registry = NodeRegistry::new(); registry.register("human", |_config| { Box::new(df_nodes::human_node::HumanNode) @@ -237,12 +249,14 @@ fn build_registry() -> NodeRegistry { registry.register("ai", |_config| { Box::new(df_nodes::ai_node::AiNode) }); - // 未在此注册的已实现节点: - // - TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait 已就绪): - // 推进链 F-01~04 当前手动推进(IPC 直驱 advance_task),DAG 形态为阶段 2 - // 工作流联动(run_workflow task_id + 完成回调 advance_task + DAG 模板)预留。 - // 届时在此 register("task_advance", ...) 注入 Arc。 - // 非遗漏,勿删 task_advance_node.rs。 + // TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait): + // 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。 + // 持有 Arc 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db, + // Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status。 + // D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task。 + registry.register("task_advance", move |_config| { + Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone())) + }); registry } diff --git a/src/App.vue b/src/App.vue index a2e6585..a5dfa72 100644 --- a/src/App.vue +++ b/src/App.vue @@ -278,6 +278,8 @@ const primaryNav = [ const secondaryNav = [ { path: '/knowledge', label: 'nav.knowledge', svg: '' }, + { path: '/audit', label: 'nav.audit', + svg: '' }, ] diff --git a/src/api/ai.ts b/src/api/ai.ts index 5e3c22f..a59624a 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -15,11 +15,47 @@ export const aiApi = { return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null }) }, + /** + * 重新生成最后一条 AI 回复(UX-02)。 + * 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。 + * 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。 + */ + regenerate(conversationId: string, language?: string): Promise { + return invoke('ai_regenerate', { conversationId, language: language || 'zh-CN' }) + }, + + /** + * 编辑最后一条 user 消息并重新生成(UX-09)。 + * 后端:占用 generating → 替换末条 user content → 其后消息标 truncated(软删)→ 重跑 agentic loop。 + * 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。 + * 中间 user 消息编辑被后端拒绝(只允许末条)。 + */ + editMessage(conversationId: string, newMessage: string, language?: string): Promise { + return invoke('ai_chat_edit', { conversationId, newMessage, language: language || 'zh-CN' }) + }, + /** 批准/拒绝工具调用 */ approve(toolCallId: string, approved: boolean): Promise { return invoke('ai_approve', { toolCallId, approved }) }, + /** + * 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。 + * 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn + * run_agentic_loop(iteration 从 0 重计,再跑 max_iterations 轮)。 + */ + continueLoop(conversationId: string): Promise { + return invoke('ai_continue_loop', { conversationId }) + }, + + /** + * 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态点停止)。 + * 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。 + */ + stopLoop(conversationId: string): Promise { + return invoke('ai_stop_loop', { conversationId }) + }, + /** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */ pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> { return invoke('ai_pending_tool_calls', { convId }) @@ -74,6 +110,11 @@ export const aiApi = { return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit }) }, + /** 设置 Agentic 循环最大轮次(运行时调整;loop 入口锁定边界,热改下次发消息生效) */ + setAgentMaxIterations(value: number): Promise { + return invoke('ai_set_agent_max_iterations', { value }) + }, + /** 列出本机 Claude 技能(skills + commands + plugins) */ listSkills(): Promise { return invoke('ai_list_skills') @@ -120,4 +161,18 @@ export const aiApi = { archiveConversation(conversationId: string, archived: boolean): Promise { return invoke('ai_conversation_archive', { conversationId, archived }) }, + + /** 置顶/取消置顶对话(UX-17) */ + setPinnedConversation(conversationId: string, pinned: boolean): Promise { + return invoke('ai_conversation_set_pinned', { conversationId, pinned }) + }, + + /** + * 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。 + * 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。 + * @param format 'markdown' | 'json' | 'txt' + */ + exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise { + return invoke('ai_conversation_export', { conversationId, format }) + }, } diff --git a/src/api/types.ts b/src/api/types.ts index b969d5b..106f379 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -125,7 +125,7 @@ export interface WorkflowRecord { /** 工作流事件载荷(后端 emit 的结构) */ /** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_case,R7收窄防写错 type 字符串) */ export type WorkflowEventType = - | 'node_started' | 'node_progress' | 'node_output' | 'node_completed' | 'node_failed' + | 'node_started' | 'node_progress' | 'node_output' | 'node_completed' | 'node_failed' | 'node_cancelled' | 'workflow_paused' | 'workflow_completed' | 'workflow_failed' | 'human_approval_request' | 'human_approval_response' @@ -180,6 +180,13 @@ export interface AiProviderConfig { updated_at: string } +/** + * AI 错误分类字面量集(B-260615-42)。 + * 与后端 `commands::ai::ErrorType` 的 serde(rename_all = "snake_case") 严格对齐。 + * 前端消费方 switch 于此常量集做差异化提示(auth 引导填 key / network 提示连接 / 等)。 + */ +export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown' + /** AI 聊天事件(后端 emit 的结构,conversation_id 用于多对话流式路由) */ export type AiChatEvent = ({ type: 'AiTextDelta'; delta: string @@ -194,11 +201,14 @@ export type AiChatEvent = ({ } | { type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number } | { - type: 'AiError'; error: string + type: 'AiError'; error: string; error_type?: AiErrorType } | { type: 'AiAgentRound'; round: number } | { type: 'AiHeartbeat' +} | { + // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止 + type: 'AiMaxRoundsReached' }) & { conversation_id?: string } @@ -233,6 +243,7 @@ export interface AiConversationSummary { provider_id: string | null model: string | null archived: boolean + pinned?: boolean prompt_tokens?: number | null completion_tokens?: number | null created_at: string diff --git a/src/api/workflow.ts b/src/api/workflow.ts index ef5f560..3b756fb 100644 --- a/src/api/workflow.ts +++ b/src/api/workflow.ts @@ -3,12 +3,25 @@ import { listen } from '@tauri-apps/api/event' import type { WorkflowRecord, WorkflowEventPayload } from './types' export const workflowApi = { - /** 触发工作流执行,返回 execution_id */ - run(name: string, dag: unknown, config?: Record): Promise { + /** + * 触发工作流执行,返回 execution_id。 + * + * F-260616-06 阶段2(②-2): taskId/targetStatus 可选,同时传时工作流完成/失败联动推进任务。 + * B-260615-34:Tauri 2 IPC 不转 camelCase,snake_case 对齐后端 workflow.rs。 + */ + run( + name: string, + dag: unknown, + config?: Record, + taskId?: string, + targetStatus?: string, + ): Promise { return invoke('run_workflow', { name, dag, config: config ?? {}, + task_id: taskId, + target_status: targetStatus, }) }, diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 6b91989..f2cb6f3 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -15,72 +15,34 @@ />
{{ $t('aiChat.sidebarTitle') }} -
+ +
- -