新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地

This commit is contained in:
2026-06-16 12:41:13 +08:00
parent 212a927eee
commit 7d5cd4c89a
62 changed files with 4576 additions and 248 deletions

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ coverage/
# 损坏备份(FR-S7 write_file 事故残留)+临时文件 # 损坏备份(FR-S7 write_file 事故残留)+临时文件
*.broken.bak *.broken.bak
tmp/ tmp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/

View File

@@ -529,8 +529,13 @@ impl LlmProvider for AnthropicCompatProvider {
.map(move |event| match event { .map(move |event| match event {
Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)), Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)),
Err(e) => { 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 事件流错误"); error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e)) Err(anyhow::Error::from(e).context(ctx))
} }
}); });

View File

@@ -287,6 +287,14 @@ impl ContextManager {
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet; use std::collections::HashSet;
// step 0UX-09过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| m.is_active())
.collect();
// step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id // step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id
let resolved_ids: HashSet<&str> = messages let resolved_ids: HashSet<&str> = messages
.iter() .iter()
@@ -432,6 +440,77 @@ impl ContextManager {
true 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 消息后,将其后所有消息标记为 truncatedUX-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<usize, ()> {
// 反向找末条 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 消息的 contentUX-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() 等) /// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> { pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message) self.messages.iter().map(|t| &t.message)

View File

@@ -505,8 +505,13 @@ impl LlmProvider for OpenAICompatProvider {
.map(move |event| match event { .map(move |event| match event {
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)), Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
Err(e) => { Err(e) => {
error!("SSE 流错误: {}", e); // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
Err(anyhow::anyhow!("SSE 流错误: {}", e)) // 丢掉 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))
} }
}); });

View File

@@ -48,23 +48,33 @@ pub struct ChatMessage {
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯) /// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
} }
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> 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<String>) -> Self { pub fn user(content: impl Into<String>) -> 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<String>) -> Self { pub fn assistant(content: impl Into<String>) -> 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<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> 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<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> 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"))
} }
} }

View File

@@ -61,6 +61,12 @@ pub enum WorkflowEvent {
node_id: NodeId, node_id: NodeId,
error: String, error: String,
}, },
/// 节点执行被取消(如 HumanNode 人工审批取消:set_cancelled 后节点返回 Err,
/// 状态保 Cancelled 终态)。语义有别于 NodeFailed(失败):取消是用户主动行为,
/// 前端按取消归「取消」展示,不应归类「失败」。
NodeCancelled {
node_id: NodeId,
},
/// 工作流暂停(等待外部输入) /// 工作流暂停(等待外部输入)
WorkflowPaused { WorkflowPaused {
reason: String, reason: String,

View File

@@ -78,6 +78,12 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
} }
}; };
// 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 { if let Some(dir) = &request.working_dir {
cmd.current_dir(dir); cmd.current_dir(dir);
} }

View File

@@ -6,6 +6,32 @@ use tokio::sync::broadcast;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use df_core::events::{SelectType, WorkflowEvent}; 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; pub struct HumanNode;
@@ -84,6 +110,26 @@ impl Node for HumanNode {
let each_valid = picked.iter().all(|d| !d.is_empty()) let each_valid = picked.iter().all(|d| !d.is_empty())
&& (options.is_empty() || picked.iter().all(|d| options.contains(d))); && (options.is_empty() || picked.iter().all(|d| options.contains(d)));
if count_ok && each_valid { if count_ok && each_valid {
// F-260616-06 阶段2: 拒绝语义化。
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
// 下游无法据 failed 触发退回/重做。
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
// "人工审批被拒绝(用户选择: <decision>)",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 取首项(向后兼容下游消费者) // 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
let primary = picked.first().cloned().unwrap_or_default(); let primary = picked.first().cloned().unwrap_or_default();
return Ok(NodeOutput::from_value(serde_json::json!({ return Ok(NodeOutput::from_value(serde_json::json!({
@@ -636,4 +682,169 @@ mod tests {
assert_eq!(out.data["decision"], json!("同意")); assert_eq!(out.data["decision"], json!("同意"));
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值"); 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);
}
} }

View File

@@ -5,3 +5,4 @@ pub mod human_node;
pub mod script_node; pub mod script_node;
pub mod task_advance_node; pub mod task_advance_node;
pub mod task_state_machine; pub mod task_state_machine;
pub mod task_workflow_templates;

View File

@@ -30,9 +30,12 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
/// - `target_status`:目标状态(7 态之一,snake_case) /// - `target_status`:目标状态(7 态之一,snake_case)
/// ///
/// 流程: /// 流程:
/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示) /// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链) /// 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 /// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功) /// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
/// ///
@@ -57,17 +60,18 @@ pub async fn advance_task_atomic(
.await? .await?
.ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?; .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(); let from = current.status.as_str();
if from == target_status { if from == target_status {
return Err(df_core::error::Error::InvalidState { return Err(df_core::error::Error::Validation(format!(
current: from.to_string(), "相同状态 {from:?},无需推进"
expected: target_status.to_string(), )));
});
} }
if !can_transition(from, target_status) { if !can_transition(from, target_status) {
return Err(df_core::error::Error::InvalidState { return Err(df_core::error::Error::InvalidState {
current: from.to_string(), current: format!("{from}→{target_status}(非法状态转换)"),
expected: target_status.to_string(), expected: target_status.to_string(),
}); });
} }
@@ -258,26 +262,48 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); 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] #[tokio::test]
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap(); repo.insert(rec("t1", "done")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); 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] #[tokio::test]
async fn same_status_rejected() { async fn same_status_rejected() {
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
// 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", "in_progress")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); 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] #[tokio::test]
@@ -339,4 +365,191 @@ mod tests {
assert_eq!(r.status, "in_progress"); assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); 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<String>(非 &str):completed 分支回传入的 target_status(非 'static),
/// failed 分支回 regression_target 的常量。统一 String 避免生命周期冲突。
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
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 分支)
}
} }

View File

@@ -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<DagDef> {
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 失败回调路径不可达)"
);
}
}
}

View File

@@ -1047,6 +1047,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
model: row.get("model")?, model: row.get("model")?,
models: row.get("models")?, models: row.get("models")?,
archived: row.get::<_, i32>("archived")? != 0, archived: row.get::<_, i32>("archived")? != 0,
pinned: row.get::<_, i32>("pinned")? != 0,
prompt_tokens: row.get("prompt_tokens")?, prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?, completion_tokens: row.get("completion_tokens")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
@@ -1137,10 +1138,11 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, prompt_tokens, completion_tokens, created_at, updated_at) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, 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.prompt_tokens, rec.completion_tokens,
rec.created_at, rec.updated_at rec.created_at, rec.updated_at
], ],
@@ -1148,9 +1150,10 @@ impl_repo!(
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( 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![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, 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.prompt_tokens, rec.completion_tokens,
rec.updated_at, rec.id rec.updated_at, rec.id
], ],
@@ -1237,6 +1240,42 @@ impl AiToolExecutionRepo {
.await .await
.map_err(storage_err)? .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<Vec<AiToolExecutionRecord>> {
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!( impl_repo!(
@@ -1611,6 +1650,26 @@ impl AiConversationRepo {
.await .await
.map_err(storage_err)? .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<bool> {
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)?
}
} }
// ============================================================ // ============================================================

View File

@@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
let steps: [(i32, fn(&Connection) -> Result<()>); 15] = [ let steps: [(i32, fn(&Connection) -> Result<()>); 16] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -50,6 +50,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(13, migrate_v13), (13, migrate_v13),
(14, migrate_v14), (14, migrate_v14),
(15, migrate_v15), (15, migrate_v15),
(16, migrate_v16),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -276,6 +277,24 @@ fn migrate_v15(conn: &Connection) -> Result<()> {
Ok(()) 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 /// V1 建表 SQL
const V1_SQL: &str = " const V1_SQL: &str = "
-- 想法表 -- 想法表

View File

@@ -160,6 +160,7 @@ pub struct AiConversationRecord {
pub model: Option<String>, pub model: Option<String>,
pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重) pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重)
pub archived: bool, // 是否归档(侧栏折叠展示) pub archived: bool, // 是否归档(侧栏折叠展示)
pub pinned: bool, // 是否置顶(排序置前;UX-17)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库) pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库) pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub created_at: String, pub created_at: String,

View File

@@ -22,6 +22,12 @@ pub struct Dag {
pub nodes: HashMap<NodeId, Box<dyn Node>>, pub nodes: HashMap<NodeId, Box<dyn Node>>,
/// 边集合 /// 边集合
pub edges: Vec<Edge>, pub edges: Vec<Edge>,
/// 节点级配置DagDef.nodes[id].config 提取自 build_dagrun 时与全局 config deep_merge
///
/// 语义节点级覆盖全局级。DagExecutor 构造 NodeContext 时执行
/// `deep_merge(initial_config, node_configs[id])`,节点定义里写的同名 key 优先于
/// run_workflow 传入的全局 config。
pub node_configs: HashMap<NodeId, serde_json::Value>,
} }
impl Dag { impl Dag {
@@ -30,6 +36,7 @@ impl Dag {
Self { Self {
nodes: HashMap::new(), nodes: HashMap::new(),
edges: Vec::new(), edges: Vec::new(),
node_configs: HashMap::new(),
} }
} }
@@ -38,6 +45,19 @@ impl Dag {
self.nodes.insert(id, node); self.nodes.insert(id, node);
} }
/// 添加节点(附带节点级配置)
///
/// 节点级配置在 DagExecutor 构造 NodeContext 时覆盖全局 configdeep_merge
pub fn add_node_with_config(
&mut self,
id: NodeId,
node: Box<dyn Node>,
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) { pub fn add_edge(&mut self, source: NodeId, target: NodeId) {
self.edges.push(Edge { self.edges.push(Edge {
@@ -135,3 +155,85 @@ impl Default for Dag {
Self::new() Self::new()
} }
} }
/// 深度合并两个 JSON 值:节点级 `node` 覆盖全局级 `global`。
///
/// 规则:
/// - 两端均为 Object递归合并同 key 时节点级覆盖全局级,新增 key 各自保留)。
/// - 非两端 Object节点级直接覆盖全局级包括 `Null`,符合 JSON 显式空语义)。
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 keynode_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]})
);
}
}

View File

@@ -99,7 +99,13 @@ impl DagExecutor {
let ctx = NodeContext { let ctx = NodeContext {
node_id: node_id.clone(), node_id: node_id.clone(),
inputs, 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(), execution_id: self.execution_id.clone(),
event_bus: self.event_bus.clone(), event_bus: self.event_bus.clone(),
// 共享执行器状态机,使节点(如 HumanNode能读取真实状态而非空状态机 // 共享执行器状态机,使节点(如 HumanNode能读取真实状态而非空状态机
@@ -142,16 +148,24 @@ impl DagExecutor {
let error_msg = e.to_string(); let error_msg = e.to_string();
// 已取消的节点(如 HumanNode 审批取消)跳过 set_failed: // 已取消的节点(如 HumanNode 审批取消)跳过 set_failed:
// Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(B-03b-R1) // 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.state_machine.set_failed(node_id.clone())?;
}
self.event_bus self.event_bus
.send(WorkflowEvent::NodeFailed { .send(WorkflowEvent::NodeFailed {
node_id: node_id.clone(), node_id: node_id.clone(),
error: error_msg, error: error_msg,
}) })
.await; .await;
// 同层多个失败时只报告第一个 }
// 取消与失败一致:节点返回 Err 即中止后续层(run 返回 Err);
// 仅事件类型区分,工作流结果语义保持不变(零行为破坏)。
if first_err.is_none() { if first_err.is_none() {
first_err = Some(e.context(format!("节点 {} 执行失败", node_id))); first_err = Some(e.context(format!("节点 {} 执行失败", node_id)));
} }
@@ -210,6 +224,35 @@ mod tests {
/// 测试节点:直接返回错误 /// 测试节点:直接返回错误
struct FailNode; 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] #[async_trait]
impl Node for FailNode { impl Node for FailNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult { 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;
// 构造 DagDefn1 节点写 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] #[tokio::test]
async fn test_same_layer_runs_in_parallel() { async fn test_same_layer_runs_in_parallel() {
// 两个无依赖节点位于同一层,各 sleep 100ms // 两个无依赖节点位于同一层,各 sleep 100ms
@@ -369,4 +453,46 @@ mod tests {
NodeStatus::Cancelled 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<WorkflowEvent> = 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
);
}
} }

View File

@@ -46,7 +46,10 @@ impl NodeRegistry {
let mut node_ids: std::collections::HashSet<&String> = std::collections::HashSet::new(); let mut node_ids: std::collections::HashSet<&String> = std::collections::HashSet::new();
for (id, node_def) in &def.nodes { for (id, node_def) in &def.nodes {
let node = self.create(&node_def.node_type, &node_def.config)?; let node = self.create(&node_def.node_type, &node_def.config)?;
dag.add_node(id.clone(), node); // 节点级 config 下沉到 Dag.node_configsDagExecutor.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); node_ids.insert(id);
} }

View File

@@ -449,6 +449,30 @@
- **边界**:取消分支在 B-07 + `set_cancelled` 补齐前恒 false等价无取消功能不残跨工作流并发 HumanNode 在 B-06 修前有 Response 错配风险。 - **边界**:取消分支在 B-07 + `set_cancelled` 补齐前恒 false等价无取消功能不残跨工作流并发 HumanNode 在 B-06 修前有 Response 错配风险。
- **状态**:📐 设计完成2026-06-14未实施。详见 [B-03-人工审批响应机制-2026-06-14.md](./B-03-人工审批响应机制-2026-06-14.md)。 - **状态**:📐 设计完成2026-06-14未实施。详见 [B-03-人工审批响应机制-2026-06-14.md](./B-03-人工审批响应机制-2026-06-14.md)。
## 任务推进链7 态状态机 + 工作流联动)
> tasks 表从 todo→done 的状态推进链路。阶段17 态状态机 + 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-taskD-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 层保持 thintask.rs 仅 3 行转发)守住 D3 不让业务逻辑下沉 IPC。前端对齐后端 7 态D-260616-01types.rs:131激活 InReview/Testing/Blocked 三闸门态)。
- **状态**:✅ 阶段1 落地commit d2cb38c7态状态机 + 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<NodeId, Value>`build_dag 填入 NodeDef.configrun 合并下沉。
- **原因/取舍**原实现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 agentdag.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延续此约定。硬编码模板随代码版本管理零运行时配置开销。
- **状态**:📐 设计定稿待实施(②-6batch33+)。
### 工作流回调语义:成功与任务推进解耦,失败按 target 退回(②-3/②-4/②-5
- **决策**:工作流完成后回调 advance_task 的语义——**成功**executor Oktask_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 回调)。
## 需求与待办 ## 需求与待办
> 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。 > 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。

View File

@@ -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<Database>,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<NodeId, Value>`,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<Database>)` + 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<Database>) -> 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(避免节点间状态不一致),工作流级统一。

View File

@@ -75,7 +75,7 @@
### 知识删除语义knowledge_archive 软删除(命名统一)[2026-06-13] ### 知识删除语义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)` 默认仅返回 publishedlibrary 纯已发布,[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 也走同一路径。 - **原因/取舍**idea/task/project 的 `delete_xxx` 都是硬删DELETE FROM若 knowledge 也叫 delete 却做归档API 语义混淆(调用方期望数据消失,实际还在 DB。conversation 模块已有正确先例archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
- **状态**:✅ 已实施Tier 1 - **状态**:✅ 已实施Tier 1

View File

@@ -49,13 +49,13 @@ candidate ──→ pending_review ──→ published ──→ archived
| Command | 说明 | | Command | 说明 |
|---------|------| |---------|------|
| `knowledge_list(status?)` | 全量列表,默认排除 archived | | `knowledge_list(status?)` | 列表,默认仅 publishedlibrary 纯已发布F-260616-02 决策 a显式传 status 按该状态过滤(含 archived |
| `knowledge_get(id)` | 单条查询 | | `knowledge_get(id)` | 单条查询 |
| `knowledge_search(query, kind?, limit?)` | LIKE 检索top-N≤3 | | `knowledge_search(query, kind?, limit?)` | LIKE 检索top-N≤3 |
| `knowledge_create(input)` | 创建status=candidate| | `knowledge_create(input)` | 创建status=candidate|
| `knowledge_update_status(id, status)` | 状态转换(含合法矩阵校验)| | `knowledge_update_status(id, status)` | 状态转换(含合法矩阵校验)|
| `knowledge_record_reuse(id)` | reuse_count +1 | | `knowledge_record_reuse(id)` | reuse_count +1 |
| `knowledge_list_candidates()` | 审核收件箱(按 confidence 排序)| | `knowledge_list_candidates()` | 待处理收件箱(candidate+pending_review按 confidence 排序)|
| `knowledge_archive(id)` | 软删除 | | `knowledge_archive(id)` | 软删除 |
| `knowledge_get_config()` | 读取 KnowledgeConfig | | `knowledge_get_config()` | 读取 KnowledgeConfig |
| `knowledge_save_config(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-storage 存储层](./df-storage-存储层-2026-06-12.md) — knowledges 表结构 + 向量工具函数
- [df-ai AI 集成模块](./df-ai-AI集成模块-2026-06-12.md) — hybrid_search / generate_embedding / extract - [df-ai AI 集成模块](./df-ai-AI集成模块-2026-06-12.md) — hybrid_search / generate_embedding / extract
- [功能决策记录](../02-架构设计/功能决策记录-2026-06-14.md) — 检索方案演进决策 - [功能决策记录](../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<Mutex<KnowledgeConfig>>`,未落库。应用重启后 `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 限制降低了冲突概率,但随知识积累会逐渐显现

View File

@@ -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<AtomicUsize>`(默认 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<AtomicUsize>`(默认 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_reviewstore 层兜底);②**决策点pending_review 从 library 排除后该去哪显示**(a) 扩 inbox 收件箱含 candidate+pending_review语义=「待处理」收件箱)(b) 单纯从 library 消失、pending_review 暂无专门视图 (c) 加第三个 tab「待审」。**倾向 (a)**inbox 收 candidate+pending_reviewlibrary 纯 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 代理并行取证) ### 🔧 任务模块全栈核对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`。 > 详见 [任务推进链实施路径-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。 - [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<Database> + 注册 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-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 - [ ] F-260616-08 阶段4 Git 集成kind+git闸门+worktree
- [x]**CR-260616-01 代码审查完成** → 审查登记已迁 [待审查.md](./待审查.md)(职责分离:审查队列独立,不进 todo)。结论 🔴0 🟡6 ⚪4 质量优,8 维度全过。**待修项 CR-01-A~I 见下方推进区**。 - [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-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-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(运行时文案不变) - [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-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,防误补破坏收口) - [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-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 转实引用) - [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)**: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 ### ⏸️ 待用户授权/确认巡检悬挂项·2026-06-16
> 巡检推进中浮现的、需用户拍板才能继续的操作授权/范围确认项(非架构方向决策,见上节 D-*)。巡检每轮不再重复问,看此处标注。 > 巡检推进中浮现的、需用户拍板才能继续的操作授权/范围确认项(非架构方向决策,见上节 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 正确抓到 gapcommit 9e2aeff - [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 正确抓到 gapcommit 9e2aeff
**P2** **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 keyTS2589 用 as any 规避 vue-i18n 深度泛型)(commit 9e2aeff) - [x] AR-9 ~~friendlyError 硬编码中文~~ ✅ Wave3 完成friendlyError 全走 i18n.global.t + zh/en 双语补 4 keyTS2589 用 as any 规避 vue-i18n 深度泛型)(commit 9e2aeff)
- [x] AR-10 ~~想法→灵感迁移残留~~ ✅ 已统一13 文件批量i18n zh-CN + 后端错误 + LLM 描述/提示词 + store toasten 待定 Ideas/Idea、docs 注释低优先略)(commit 65c475b) - [x] AR-10 ~~想法→灵感迁移残留~~ ✅ 已统一13 文件批量i18n zh-CN + 后端错误 + LLM 描述/提示词 + store toasten 待定 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) - [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-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 - [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-app5711 行成事实业务层) / AI agent loop 从 IPC 下沉 df-ai / 3 view 绕 store 调 api / 类型契约 ts-rs 代码生成 / AiSession 多会话(B 路线前置) / AppState 分组 / IPC 命名统一 - [ ] ARC-260615-07 — 其余见文档df-core 改名 df-types类型库非核心/ src-tauri IPC 编排层抽取(df-app5711 行成事实业务层) / AI agent loop 从 IPC 下沉 df-ai / 3 view 绕 store 调 api / 类型契约 ts-rs 代码生成 / AiSession 多会话(B 路线前置) / AppState 分组 / IPC 命名统一
**渲染优化(自研块级 memo 已实施2026-06-16** **渲染优化(自研块级 memo 已实施2026-06-16**
@@ -306,14 +333,14 @@
**P1 — 审查状态断层** **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-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 - [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 - [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
- [ ] 复核-新④ [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] ✅(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 - [ ] 复核-新⑪ [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-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 - [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 - [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
- [ ] 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 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
- [ ] 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-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 — 架构/模块文档过期** **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 - [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 块级 memosplitBlocks 已按块切分,末块单独更新天然支持方案 B— src/components/AiChat.vue + src/composables/ai/useAiEvents.ts - [x] UX-2025-01 ✅ 选文字(文本偏移量方案) (第⑩批销账 06-15) — **流式渲染中选中文字保持**§2.1。现象AI 流式输出时选文字被 delta 打断丢失选区。方案 A(推荐)`selectionchange` + 选区在消息容器内 → 暂停 rAF 流式 parse → 选区结束恢复;方案 B已完成块独立 DOM 节点不参与 v-html 替换。关联 ARC-08 块级 memosplitBlocks 已按块切分,末块单独更新天然支持方案 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 - [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
- [ ] UX-2025-03 [P1] — **错误气泡操作入口**§4.1)。错误气泡底部增加按钮区:「🔁 重试」(取上一条 user 消息重发)+「⚙ 去设置」(仅 auth/provider 错误时显示,跳 Settings→AI Tab。后端 `AiError``error_type: Option<ErrorType>` 枚举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-03 [P1] — **错误气泡操作入口**(error_type 枚举本身 batch34 B-42 已加,本批前端消费)§4.1)。错误气泡底部增加按钮区:「🔁 重试」(取上一条 user 消息重发)+「⚙ 去设置」(仅 auth/provider 错误时显示,跳 Settings→AI Tab。后端 `AiError``error_type: Option<ErrorType>` 枚举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 - [ ] 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) - [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 - [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 列) - [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 不进上下文/视图
- [ ] 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] ✅(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) - [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 - [ ] 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 等 keyhover tooltip 展示完整时间。— src/components/AiChat.vue (第⑬批销账 06-15) - [x] UX-2025-13 [P2] — **消息时间戳**§2.4)。气泡下方极小字号(dim 色)展示相对时间(复用 time.ts 已有 common.justNow 等 keyhover 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-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-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) - [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 - [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 搜索态未动(降级)
- [ ] 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] ✅(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 + 新依赖 - [ ] 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) - [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 — 信息透明度** **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) - [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 待定 ### 待澄清 / 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-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 - [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-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 — 中等问题** **P2 — 中等问题**
- [ ] B-260615-43 [P2] — ~~无撤销重做功能~~ AI 操作(如删除项目、修改任务)无撤销功能。根因:缺乏操作历史记录和回滚机制。影响:误操作无法恢复。修法:实现操作历史栈 + 撤销/重做 UI + 关键操作二次确认 — src/stores/project.ts + src/views/ProjectDetail.vue - [ ] 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 - [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) - [x] DOC-260615-15 — **文档缺失**`patch_file` 工具设计文档已定稿但使用说明缺失。需添加工具使用场景、API 参数说明、安全边界、最佳实践 — docs/08-用户指南/patch_file使用指南.md (第⑬批销账 06-15)
@@ -669,7 +696,7 @@
**P3 — 风格/可选** **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-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 - [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 - [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 输出。 > **统计**🔴2(P1 确定性 bug) 🟡3(P2 DRY/优化) ⚪3(P3 风格) + 1 评估维持。总体评级「良」:架构清晰、契约闭合、注释扎实,两处 🔴 低风险一行/小改可根治。详见本轮 /review 输出。

View File

@@ -23,20 +23,360 @@
## 当前队列 ## 当前队列
### CR-260616-02 batch29+R-PD-10 代码改动批次 — 🟡 待审 ## 已审归档
- **commit**: `fc767e1` 优化: R-PD-10 storage_err DRY统一 + FR-D3 migrations if链转数组循环 ### CR-260616-26 batch53 F-05 去重根治(audit.rs High risk 审批前缓存命中去重) — ✅ 已审(PASS)
- **状态**: 🟡 待审(中优 — DRY 重构 + 迁移逻辑改,主代理已独立核查)
- **改动文件**: - **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 **附 log 末尾证据** + grep/read 核验全件 + 边界核验)
- `crates/df-storage/src/crud.rs`(R-PD-10: 110 处 `.map_err(|e| Error::Storage(e.to_string()))` 统一 `storage_err` helper) - **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3
- `crates/df-storage/src/migrations.rs`(FR-D3: run() 15 个 `if current_version<N` 块改 `[(i32, fn); 15]` step 数组循环,零行为变更) - **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo Finished 0.68s + vue-tsc log 空;5 warning 全既存 dead_code 非本批)。**无中间态误报**(附 log 末尾)。
- **主代理核查**: cargo check -p df-storage exit 0 + cargo test -p df-storage 11 集成全过(含 open_in_memory→run 全量迁移路径)+ git diff 边界合规(2 文件符合 df-storage 域) - **逐件核验(全 PASS)**:find_cached_high_risk_result(audit.rs:400-458 反向扫 `msgs.iter().rev()` + tool_name 过滤 :423 + canonical_args_key 规范化比较 :430 + 旧 id 反查 tool_result :444-456)+ High 分支查缓存命中回传(:536-553 guard High → push tool_result 新 tool_call_id :544 + emit AiToolCallCompleted :545-549 + 审计 completed/auto_dedup :551 + continue :552 跳过 pending insert)+ 仅 High 去重(:534 Medium|High 共分支 + :536 内嵌 High guard,Med 走原审批 :555-573)+ pending 占位不命中(:452 占位串「需要用户审批等待确认」return None,与 push :564 全角逗号一致)+ args 规范化(sort_object_keys :462-492 sort_by 字典序 + 递归值/Array + to_string 紧凑稳定)+ 不同命令/参数不命中(timeout 重发可重试)+ tool_call_id 来源修正(stream_recv.rs:181 draft.id 来自 tc_delta.id 非 stream_recv UUID)+ 零越界(纯 audit.rs 3 函数 + High 分支,未碰 batch42/44/51)。
- **审查关注点**: ①R-PD-10 helper 泛型 `E: ToString` 全 110 处类型兼容 ②FR-D3 step 数组循环行为等价(同 current_version 同序执行同 migrate_vN)③迁移数据安全(PRAGMA 幂等探测 + schema_version INSERT 保留) - **关键边界核验**:二次循环自吞(第一次重试命中缓存 push tool_result → 第二次重试 find_cached 反扫命中第一次 tool_call 又跳过审批)= **期望行为**(每次重试跳过审批断循环,非 bug)。
- **范围外**: ARC-05/06 验证性无代码改动(此前批次已闭环,本次仅核验)+ DOC 文档(V13→V15 表数对齐,低审) - **⚪ 观察项(全 low)**:OBS1(canonical_args_key to_string unwrap_or_default 理论序列化失败空串误等价,实际 JSON 不会失败)/ OBS2(空参数同名 High 工具误命中,当前工具集无)/ OBS3(去重命中 tool_result 下轮被命中 = 期望行为断循环)。
- **待修项回流 todo**: **无**(全件 PASS,3 OBS 全 low)。完整 agentic loop 通用去重(search_files 重复卡片 + 滑动窗口)③类待用户,本批仅 run_command 循环核心。
--- ---
## 已审归档 ### CR-260616-25 batch52 UX-10 @实体引用全栈(@ popover + prompt 任务段注入) — ✅ 已审(PASS)
- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 **附 log 末尾证据** + grep/read 核验全件)
- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3
- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双真实 EXIT 0(cargo Finished 0.72s + vue-tsc log 空 0 error;5 warning 全既存 dead_code 非本批)。**无中间态误报**(附 log 末尾,呼应 CR-23-1 教训防 E0594 类瞬态漂移)。
- **逐件核验(全 PASS)**:@ popover 复用 `.ai-skill-popover`(AiChat.vue:563)+ 联想状态三件(mentionOpen/Index/Start :992/993/995 独立于 skill)+ detectMentionTrigger 邮箱排除(:927-953 prev 非空白 break)+ selectMention `[类型: 名]` 插入(:1057-1079)+ mentionItems 项目+任务各 slice(0,20)(:1009-1037)+ mentionGroups 分组(:1046-1049)+ mentionTotal 空/无匹配区分(:1040-1043)+ **键盘导航与技能联想互斥**(:902-920 watch inputText skill 块优先强制关 mention + selectSkill/selectMention 互清,skillOpen 与 mentionOpen 不可同时 true)+ Enter 不冲突(:1433-1456 popover 开 selectMention preventDefault 覆盖发送,关 fallthrough handleSend)+ prompt.rs:117-126 任务段(state.tasks.list_active take(20) + 注入 title/status/description 镜像项目段范式 :108-115)+ 签名零变化(build_system_prompt :102 4 调用方 commands.rs:69/161/391 + agentic.rs:478 零改)+ 降级合理(prompt 走后端 state repo :119 非前端 mentionEntities 透传,避碰 batch45/48/49/51 领地)+ 实体 id 未透传(仅 `[类型:名]` 文本,重名任务风险低已知降级)+ i18n 5 key 双语(aiChat.ts:76-80)。
- **⚪ 观察项(全 low)**:OBS1(mentionHint 死键定义无 .vue 消费,建议删或激活)/ OBS2(mentionQuery :1003-1006 slice 到串尾非光标前,光标中移时偏差,常规用法无影响)/ OBS3(desc 仅 popover 展示不进 prompt,prompt 任务段用 tk.description 摘要列间接影响 LLM,语义链路清晰)。
- **待修项回流 todo**: **无**(全件 PASS,3 OBS 全 low)。
---
### CR-260616-24 batch51 UX-09 编辑已发送消息并重新生成全栈(messages JSON status + ai_chat_edit IPC + AiChat 编辑 UI) — ✅ 已审(PASS)
- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 + grep/read 核验全件 + **CR-23-1 独立核验**)
- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪4
- **亲跑 `cargo check --workspace` + `npx vue-tsc --noEmit`** ✅ — 双 EXIT 0(cargo Finished 0.84s,5 warning 全既存 dead_code 非本批)。
- **🔴 CR-23-1 独立核验:不成立** ✅ — `context.rs:478-495` 当前是 **for 循环**(L488 `for t in ...iter_mut()` + L490 `t.message.status=Some("truncated")` + L491 `count += 1;` 计数非 filter 闭包体),workspace cargo check 真实 EXIT 0(全量非分 crate)。原 CR-23 agent 报 E0594 是**中间态误报**(batch51 最终 for 循环规避双层引用,呼应 CR-21/18/16 瞬态漂移)。**主代理声明属实,CR-23-1 标无效**。
- **逐件核验(全 PASS)**:provider.rs:54-55 ChatMessage `status: Option<String>`(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<string>`,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<AiConversationSummary[] | null>` 类型精确匹配)。
- **独立核验**(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<AtomicUsize>` + 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<ErrorType>`,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<String>, target_status: Option<String>`(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<Database>)` 改签名;`register("task_advance", move |_| Box::new(TaskAdvanceNode::new(db.clone())))`(state.rs:248-250)move 闭包捕获 Arc<Database>,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: ToString>(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<N { migrate_vN(conn)?; }` **逐字等价**(顺序[1..15]+条件`<version`+`?`传播)。零行为变更属实。
- **migrate_vN 签名匹配** ✅ — migrate_v1~v15 全 `fn(&Connection) -> 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) ### CR-260616-01-fixes 审查待修项修复批次 — ✅ 已审(PASS)

View File

@@ -22,15 +22,15 @@ use super::stream_recv::stream_llm;
use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::audit::process_tool_calls; 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 轮。F-260616-01 已接入配置:AppState.agent_max_iterations(Arc<AtomicUsize>) +
/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表 /// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口
/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。 /// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界,
/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传 /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)
pub const MAX_AGENT_ITERATIONS: usize = 10; pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
// ============================================================ // ============================================================
// B-260615-09: generating 状态 RAII guard // B-260615-09: generating 状态 RAII guard
@@ -98,6 +98,7 @@ pub(crate) async fn run_agentic_loop(
conv_id: String, conv_id: String,
knowledge_config: crate::state::KnowledgeConfig, knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency, llm_concurrency: LlmConcurrency,
max_iterations: usize,
) { ) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone()); let mut guard = GeneratingGuard::new(session_arc.clone());
@@ -124,6 +125,8 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await; guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: msg, error: msg,
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
}); });
return; return;
@@ -153,7 +156,7 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false; let mut converged = false;
for iteration in 0..MAX_AGENT_ITERATIONS { for iteration in 0..max_iterations {
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库) // 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
if stop_flag.load(Ordering::SeqCst) { if stop_flag.load(Ordering::SeqCst) {
let usage = df_ai::provider::TokenUsage { let usage = df_ai::provider::TokenUsage {
@@ -316,19 +319,31 @@ pub(crate) async fn run_agentic_loop(
// 全部自动执行完成 → 继续下一轮 // 全部自动执行完成 → 继续下一轮
} }
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户 // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户
// 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示 // 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 { if !converged {
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
max_iter = MAX_AGENT_ITERATIONS, max_iter = max_iterations,
"[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成", "[ai] agentic 循环达最大轮次(max_iterations={})仍未收敛,转暂停态询问用户(F-260616-03)",
MAX_AGENT_ITERATIONS, max_iterations,
); );
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { // 轮 token 落库(保留末轮已生成内容,续跑/停止都据此累加)
error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS), 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()), 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"); tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider");
let _ = app.emit("ai-chat-event", AiChatEvent::AiError { let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e, error: e,
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
}); });
return; return;
@@ -466,6 +483,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.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 消息:审批(通过/拒绝)后新一轮文本 // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开 // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
@@ -475,6 +494,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
}); });
tauri::async_runtime::spawn(async move { 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;
}); });
} }

View File

@@ -3,7 +3,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; 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::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
@@ -15,7 +16,7 @@ use df_core::types::new_id;
use crate::state::AppState; use crate::state::AppState;
use crate::commands::now_millis; use crate::commands::{err_str, now_millis};
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
@@ -38,10 +39,87 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
} }
} }
// ============================================================
// 审批历史查询 IPCAE-2025-08
// ============================================================
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
///
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
#[derive(Debug, Clone, Serialize)]
pub struct ToolExecutionDto {
pub id: String,
pub conversation_id: Option<String>,
pub tool_call_id: String,
pub tool_name: String,
/// 参数摘要(截断 120 字符,完整原值仍留库)
pub arguments_brief: String,
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
pub result_brief: Option<String>,
/// pending/approved/rejected/executing/completed/failed
pub status: String,
/// low/medium/high
pub risk_level: String,
pub requested_at: String,
pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策
pub decided_by: Option<String>,
}
/// 截断字符串到 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<u32>,
offset: Option<u32>,
) -> Result<Vec<ToolExecutionDto>, 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 返回空串。 /// 查项目可读标签id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。
/// ///
/// AR-3审批卡片需显示对象名而非裸 id用户反馈"只返回 ID 不知道是什么数据")。 /// AR-3审批卡片需显示对象名而非裸 id用户反馈"只返回 ID 不知道是什么数据")。
/// 查不到(项目已被彻底清除/外部 id)时给"项目已不存在"提示而非裸 id,避免用户困惑。 /// 三臂区分:
/// - Ok(Some) → 项目名标签
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id
/// - Err → 裸 id 回退 + warn 日志DB 故障/锁/连接断,不误报"已不存在"误导用户)
async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String { async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
if id.is_empty() { if id.is_empty() {
return String::new(); return String::new();
@@ -49,7 +127,11 @@ async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
let repo = ProjectRepo::new(db); let repo = ProjectRepo::new(db);
match repo.get_by_id(id).await { match repo.get_by_id(id).await {
Ok(Some(p)) => format!("{}」(id={})", p.name, id), 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_idprovider 每轮新 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<String> {
use df_ai::provider::MessageRole;
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
let new_args_key = canonical_args_key(args);
// ContextManager::iter 返回 impl Iterator非 DoubleEndedcollect 成 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<String> = 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::<serde_json::Value>(&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_allMed/High 进审批门控 /// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成) /// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls( pub(crate) async fn process_tool_calls(
@@ -326,12 +521,37 @@ pub(crate) async fn process_tool_calls(
.collect(); .collect();
// 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result // 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
//
// F-260616-05High 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(); let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
for (_, draft, args) in drafts { for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
match risk_level { match risk_level {
RiskLevel::Low => low_risk.push((draft, args)), RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::High => { 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; pending_count += 1;
session.pending_approvals.insert(draft.id.clone(), PendingApproval { session.pending_approvals.insert(draft.id.clone(), PendingApproval {
tool_call_id: draft.id.clone(), tool_call_id: draft.id.clone(),

View File

@@ -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<String>,
) -> Result<String, String> {
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 查后端真值) /// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值)
/// ///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护: /// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
@@ -109,9 +193,11 @@ pub async fn ai_chat_send(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.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 { 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()) Ok("ok".to_string())
@@ -253,6 +339,111 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
Ok(()) 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<String>,
) -> Result<String, String> {
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 发送韧性) /// 强制发送消息B-260616-02: L2 发送韧性)
/// ///
/// 当后端 generating=true 残留HMR/异常退出等)导致 sendMessage 被拦截时, /// 当后端 generating=true 残留HMR/异常退出等)导致 sendMessage 被拦截时,
@@ -347,6 +538,74 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
Ok(()) 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_loopiteration 从 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<String, String> {
{
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<String, String> {
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, "model": r.model,
"models": models, "models": models,
"archived": r.archived, "archived": r.archived,
"pinned": r.pinned,
"prompt_tokens": r.prompt_tokens, "prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens, "completion_tokens": r.completion_tokens,
"created_at": r.created_at, "created_at": r.created_at,
@@ -686,6 +946,95 @@ pub async fn ai_conversation_archive(
Ok(()) 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<String, String> {
// 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<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let body = match fmt {
"markdown" => {
// user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
let mut parts: Vec<String> = 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<String> = 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 三类),供前端 `/` 联想 /// 列出本机 Claude 技能skills + commands + plugins 三类),供前端 `/` 联想
#[tauri::command] #[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> { pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
@@ -716,3 +1065,20 @@ pub async fn ai_set_concurrency_config(
} }
Ok(()) Ok(())
} }
/// 设置 Agentic 循环最大轮次(运行时调整,立即生效)
///
/// 与并发配置不同max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响
/// (已锁定边界),下次发消息生效。范围双 clampcommand 端 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 失能(一轮即截断无法调任何工具),
// 上限防失控烧 token50 轮足够覆盖复杂多步任务)
let clamped = value.clamp(1, 50) as usize;
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
Ok(())
}

View File

@@ -138,6 +138,7 @@ pub(crate) async fn save_conversation(
model: model.map(|m| m.to_string()), model: model.map(|m| m.to_string()),
models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())), models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())),
archived: false, archived: false,
pinned: false,
prompt_tokens: usage.map(|u| u.prompt_tokens as i64), prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
completion_tokens: usage.map(|u| u.completion_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64),
created_at: created_at.unwrap_or_else(|| now.clone()), created_at: created_at.unwrap_or_else(|| now.clone()),

View File

@@ -4,7 +4,7 @@
//! //!
//! 模块布局: //! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数 //! - [`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 流式接收 //! - [`stream_recv`] — stream_llm 流式接收
//! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens //! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens
//! - [`title`] — 对话标题生成 //! - [`title`] — 对话标题生成
@@ -36,7 +36,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use serde::Serialize; use serde::{Deserialize, Serialize};
use df_ai::ai_tools::RiskLevel; use df_ai::ai_tools::RiskLevel;
use df_ai::context::ContextManager; use df_ai::context::ContextManager;
@@ -65,6 +65,26 @@ pub use self::tool_registry::build_ai_tool_registry;
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到) // 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================ // ============================================================
/// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。
///
/// 设计:`Option<ErrorType>` 向后兼容——旧 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 聊天事件(推送到前端) /// AI 聊天事件(推送到前端)
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
@@ -82,11 +102,24 @@ pub enum AiChatEvent {
/// AI 响应完成 /// AI 响应完成
AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> }, AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> },
/// 错误 /// 错误
AiError { error: String, conversation_id: Option<String> }, ///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
AiError {
error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息) /// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> }, AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」) /// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> }, AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛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<String> },
} }
// ============================================================ // ============================================================

View File

@@ -51,8 +51,8 @@ fn env_info_line() -> String {
format!("当前日期: {today} | 运行环境: {os}\n\n") 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 { match lang {
"en" => ( "en" => (
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\ "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\ - 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", - 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 Projects\n",
"\n## Current Tasks\n",
), ),
_ => ( _ => (
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\ "你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\
@@ -87,26 +88,42 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
- 优先使用工具完成操作,而不是只描述步骤\n\ - 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n", - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n",
"\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 { 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(); let mut prompt = env_info_line();
prompt.push_str(prefix); prompt.push_str(prefix);
// 附加当前数据上下文 // 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await { if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() { if !projects.is_empty() {
prompt.push_str(ctx_label); prompt.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
for p in projects.iter().take(20) { for p in projects.iter().take(20) {
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description)); 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 prompt
} }

View File

@@ -10,7 +10,7 @@ use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider}; use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ToolCallDraft}; use super::{AiChatEvent, ErrorType, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 /// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
/// ///
@@ -159,6 +159,8 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => { Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(), error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
// 120s 无 chunk = idle timeout,归 Timeout
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
return None; return None;
@@ -200,6 +202,9 @@ pub(crate) async fn stream_llm(
"stream-error", "stream-error",
err_msg, err_msg,
), ),
// provider SSE error 事件体可为 401/overloaded 等多种,
// 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断)
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
return None; return None;
@@ -226,6 +231,10 @@ pub(crate) async fn stream_llm(
&status_or_class, &status_or_class,
&raw, &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()), conversation_id: Some(conv_id.to_string()),
}); });
return None; return None;
@@ -265,6 +274,8 @@ pub(crate) async fn stream_llm(
if !finished_received { if !finished_received {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
// 流尽但未收到 finished = 连接异常中断,归 Network
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
return None; return None;
@@ -290,6 +301,11 @@ pub(crate) async fn stream_llm(
&status_or_class, &status_or_class,
&raw, &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()), conversation_id: Some(conv_id.to_string()),
}); });
None None

View File

@@ -48,6 +48,7 @@ pub(crate) async fn ensure_conversation_title(
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
model: None, model: None,
status: None,
}) })
.collect(); .collect();
(summary, session.messages.all_messages_clone()) (summary, session.messages.all_messages_clone())

View File

@@ -19,6 +19,10 @@ use crate::commands::now_millis;
/// 用于 list_projects / list_tasks / list_ideas / list_trash /// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50; 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 /// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。 /// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。 /// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
@@ -487,8 +491,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
} }
None => workspace_root().to_string_lossy().to_string(), None => workspace_root().to_string_lossy().to_string(),
}; };
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖 // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60); let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest { let request = ShellRequest {
command: command.to_string(), command: command.to_string(),
@@ -497,7 +501,23 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
shell_type: Default::default(), 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,尾部保留-报错堆栈在末尾) // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000; const MAX_OUT: usize = 10_000;

View File

@@ -68,18 +68,22 @@ fn validate_transition(from: &str, to: &str) -> Result<(), String> {
// CRUD // 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] #[tauri::command]
pub async fn knowledge_list( pub async fn knowledge_list(
state: State<'_, AppState>, state: State<'_, AppState>,
status: Option<String>, status: Option<String>,
) -> Result<Vec<KnowledgeRecord>, String> { ) -> Result<Vec<KnowledgeRecord>, String> {
match status { match status {
// 显式查 archived 时原样返回(含归档项) // 显式传 status 时按该 status 过滤(含 archived)
Some(s) if s == "archived" => state.knowledge.list_by_status("archived").await.map_err(err_str),
Some(s) => state.knowledge.list_by_status(&s).await.map_err(err_str), Some(s) => state.knowledge.list_by_status(&s).await.map_err(err_str),
// 默认:列出非 archived 的全部(单查询 status != 'archived') // 默认:library 仅 published(F-260616-02 决策 a,职责清晰:library=published,inbox=待处理)
None => state.knowledge.list_non_archived().await.map_err(err_str), 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) .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] #[tauri::command]
pub async fn knowledge_list_candidates( pub async fn knowledge_list_candidates(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<KnowledgeRecord>, String> { ) -> Result<Vec<KnowledgeRecord>, String> {
state let (candidates, pending) = tokio::try_join!(
.knowledge state.knowledge.list_by_status("candidate"),
.list_by_status("candidate") state.knowledge.list_by_status("pending_review"),
.await )
.map_err(err_str) .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<KnowledgeRecord>,
mut b: Vec<KnowledgeRecord>,
) -> Vec<KnowledgeRecord> {
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' /// 归档(软删除) — UPDATE status='archived'

View File

@@ -8,8 +8,11 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType}; use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
use df_core::types::new_id; use df_core::types::{new_id, NodeStatus};
use df_storage::crud::WorkflowRepo; 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_storage::models::WorkflowRecord;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
use df_workflow::executor::DagExecutor; use df_workflow::executor::DagExecutor;
@@ -27,11 +30,36 @@ struct WorkflowEventPayload {
event: WorkflowEvent, 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) → 后台异步执行 → /// 流程build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
/// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。 /// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。
/// 立即返回执行记录 ID前端凭此关联后续事件。 /// 立即返回执行记录 ID前端凭此关联后续事件。
///
/// F-260616-06 阶段2 工作流联动(②-2):
/// - `task_id` / `target_status` 同时 Some 时,完成后按 target_status 推进任务(②-3),
/// 失败时按 regression_target 退回(②-4)。
/// - Option 向后兼容:旧调用方不传即 None,不触发任何任务回调,零行为破坏。
#[tauri::command] #[tauri::command]
pub async fn run_workflow( pub async fn run_workflow(
app: AppHandle, app: AppHandle,
@@ -39,7 +67,27 @@ pub async fn run_workflow(
name: String, name: String,
dag: DagDef, dag: DagDef,
config: serde_json::Value, config: serde_json::Value,
// F-260616-06 ②-2: 工作流联动任务的关联 ID 与目标态(同时 Some 才联动)
task_id: Option<String>,
target_status: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// 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校验失败直接返回不落库 // 1. 先构建运行时 DAG校验失败直接返回不落库
let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?; 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(), status: "running".to_string(),
triggered_by: Some("manual".to_string()), triggered_by: Some("manual".to_string()),
project_id: None, project_id: None,
task_id: None, task_id: task_id.clone(),
created_at: now_millis(), created_at: now_millis(),
completed_at: None, completed_at: None,
}; };
@@ -180,6 +228,9 @@ pub async fn run_workflow(
let db = state.db.clone(); let db = state.db.clone();
let exec_id = execution_id.clone(); let exec_id = execution_id.clone();
let state_registry = state.workflow_state_registry.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 { tauri::async_runtime::spawn(async move {
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone()); let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
// 注册执行器状态机StateMachine 内部 Arc<Mutex>clone 共享底层 HashMap // 注册执行器状态机StateMachine 内部 Arc<Mutex>clone 共享底层 HashMap
@@ -207,13 +258,58 @@ pub async fn run_workflow(
tracing::error!("更新工作流完成时间失败: {}", e); 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); state_registry.lock().await.remove(&exec_id);
// 执行失败时补发 WorkflowFailed执行器内部只发 NodeFailed // 执行失败时补发 WorkflowFailed执行器内部只发 NodeFailed
// failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值) // failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值)
if let Some(error) = error { if let Some(error) = error {
use df_core::types::NodeStatus;
let failed_node = executor let failed_node = executor
.state_machine() .state_machine()
.snapshot() .snapshot()
@@ -337,6 +433,11 @@ pub async fn approve_human_approval(
/// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。 /// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。
/// StateMachine 内部 Arc<Mutex> 共享底层 HashMapIPC 写入直达运行中阻塞节点HumanNode /// StateMachine 内部 Arc<Mutex> 共享底层 HashMapIPC 写入直达运行中阻塞节点HumanNode
/// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。 /// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。
///
/// 终态前置守卫:仅 Pending排队中/ Running执行中含阻塞等审批允许取消
/// 终态节点Completed/Failed/Skipped/Cancelled返 Err避免静默覆盖终态。
/// 守卫放 IPC 层(非 set_cancelled 内):保留 set_cancelled 作为 executor 内部受控旁路语义,
/// 让状态变更的合法入口收敛到 IPC 这一道。
#[tauri::command] #[tauri::command]
pub async fn cancel_workflow_node( pub async fn cancel_workflow_node(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -349,6 +450,10 @@ pub async fn cancel_workflow_node(
Some(sm) => sm, Some(sm) => sm,
None => return Err(format!("工作流 {} 不存在或已结束", execution_id)), None => return Err(format!("工作流 {} 不存在或已结束", execution_id)),
}; };
// 终态守卫get() 返回缺失条目默认 Pending尚未执行→ 允许取消
let current = sm.get(&node_id);
match current {
NodeStatus::Pending | NodeStatus::Running | NodeStatus::Waiting => {
sm.set_cancelled(node_id.clone()); sm.set_cancelled(node_id.clone());
tracing::info!( tracing::info!(
"工作流节点取消execution_id={}, node_id={}", "工作流节点取消execution_id={}, node_id={}",
@@ -357,3 +462,13 @@ pub async fn cancel_workflow_node(
); );
Ok(()) Ok(())
} }
// 终态:不允许取消(避免静默覆盖终态)
NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled => {
Err(format!(
"节点 {} 已终态({}),无法取消",
node_id,
current.as_str()
))
}
}
}

View File

@@ -104,9 +104,14 @@ pub fn run() {
commands::workflow::cancel_workflow_node, commands::workflow::cancel_workflow_node,
// AI 聊天 // AI 聊天
commands::ai::ai_chat_send, commands::ai::ai_chat_send,
commands::ai::ai_regenerate,
commands::ai::ai_chat_edit,
commands::ai::ai_chat_force_send, commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop, commands::ai::ai_chat_stop,
commands::ai::ai_approve, 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_pending_tool_calls,
commands::ai::ai_chat_clear, commands::ai::ai_chat_clear,
commands::ai::ai_is_generating, commands::ai::ai_is_generating,
@@ -121,8 +126,13 @@ pub fn run() {
commands::ai::ai_conversation_delete, commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive, commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned,
commands::ai::ai_conversation_export,
commands::ai::ai_list_skills, commands::ai::ai_list_skills,
commands::ai::ai_set_concurrency_config, 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_list,
commands::knowledge::knowledge_get, commands::knowledge::knowledge_get,

View File

@@ -3,6 +3,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -174,6 +175,11 @@ pub struct AppState {
// ── LLM 并发控制 ── // ── LLM 并发控制 ──
/// LLM 调用并发上限(全局 + 单对话双层 Semaphore运行时可调 /// LLM 调用并发上限(全局 + 单对话双层 Semaphore运行时可调
pub llm_concurrency: LlmConcurrency, pub llm_concurrency: LlmConcurrency,
// ── Agentic 循环轮次上限 ──
/// Agentic 循环最大轮次(前端 Settings 数字配置 → AppState 字段 → 热改 command →
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
/// 默认 10与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
pub agent_max_iterations: Arc<AtomicUsize>,
// ── 工作流执行状态 ── // ── 工作流执行状态 ──
/// 工作流执行 → 节点状态机注册表 /// 工作流执行 → 节点状态机注册表
/// ///
@@ -189,6 +195,9 @@ impl AppState {
pub async fn init(db_path: &Path) -> Result<Self> { pub async fn init(db_path: &Path) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?); let db = Arc::new(Database::open(db_path).await?);
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db)); let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db));
// build_registry 需注入 Arc<Database>(TaskAdvanceNode 持 db)。
// 在 struct 字段 `db` move 前 clone,避免 E0382。
let registry = Arc::new(build_registry(db.clone()));
let state = Self { let state = Self {
ideas: IdeaRepo::new(&db), ideas: IdeaRepo::new(&db),
projects: ProjectRepo::new(&db), projects: ProjectRepo::new(&db),
@@ -205,10 +214,13 @@ impl AppState {
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
settings: SettingsRepo::new(&db), settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2), 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())), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
db, db,
event_bus: EventBus::new(), event_bus: EventBus::new(),
registry: Arc::new(build_registry()), registry,
ai_tools, ai_tools,
}; };
// 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建, // 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建,
@@ -229,7 +241,7 @@ impl AppState {
/// 需求。需要脚本执行能力时新建独立 BuildNode白名单 + 项目目录锚定 + 复用 AI 工具 /// 需求。需要脚本执行能力时新建独立 BuildNode白名单 + 项目目录锚定 + 复用 AI 工具
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。 /// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
/// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。 /// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。
fn build_registry() -> NodeRegistry { fn build_registry(db: Arc<Database>) -> NodeRegistry {
let mut registry = NodeRegistry::new(); let mut registry = NodeRegistry::new();
registry.register("human", |_config| { registry.register("human", |_config| {
Box::new(df_nodes::human_node::HumanNode) Box::new(df_nodes::human_node::HumanNode)
@@ -237,12 +249,14 @@ fn build_registry() -> NodeRegistry {
registry.register("ai", |_config| { registry.register("ai", |_config| {
Box::new(df_nodes::ai_node::AiNode) Box::new(df_nodes::ai_node::AiNode)
}); });
// 未在此注册的已实现节点: // TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
// - TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait 已就绪): // 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
// 推进链 F-01~04 当前手动推进(IPC 直驱 advance_task),DAG 形态为阶段 2 // 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db,
// 工作流联动(run_workflow task_id + 完成回调 advance_task + DAG 模板)预留 // Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status
// 届时在此 register("task_advance", ...) 注入 Arc<Database> // D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task
// 非遗漏,勿删 task_advance_node.rs。 registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
});
registry registry
} }

View File

@@ -278,6 +278,8 @@ const primaryNav = [
const secondaryNav = [ const secondaryNav = [
{ path: '/knowledge', label: 'nav.knowledge', { path: '/knowledge', label: 'nav.knowledge',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' }, svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
{ path: '/audit', label: 'nav.audit',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' },
] ]
</script> </script>

View File

@@ -15,11 +15,47 @@ export const aiApi = {
return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null }) 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<string> {
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<string> {
return invoke('ai_chat_edit', { conversationId, newMessage, language: language || 'zh-CN' })
},
/** 批准/拒绝工具调用 */ /** 批准/拒绝工具调用 */
approve(toolCallId: string, approved: boolean): Promise<string> { approve(toolCallId: string, approved: boolean): Promise<string> {
return invoke('ai_approve', { toolCallId, approved }) 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<string> {
return invoke('ai_continue_loop', { conversationId })
},
/**
* 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态点停止)。
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
*/
stopLoop(conversationId: string): Promise<string> {
return invoke('ai_stop_loop', { conversationId })
},
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */ /** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> { pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> {
return invoke('ai_pending_tool_calls', { convId }) return invoke('ai_pending_tool_calls', { convId })
@@ -74,6 +110,11 @@ export const aiApi = {
return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit }) return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit })
}, },
/** 设置 Agentic 循环最大轮次运行时调整loop 入口锁定边界,热改下次发消息生效) */
setAgentMaxIterations(value: number): Promise<void> {
return invoke('ai_set_agent_max_iterations', { value })
},
/** 列出本机 Claude 技能skills + commands + plugins */ /** 列出本机 Claude 技能skills + commands + plugins */
listSkills(): Promise<SkillInfo[]> { listSkills(): Promise<SkillInfo[]> {
return invoke('ai_list_skills') return invoke('ai_list_skills')
@@ -120,4 +161,18 @@ export const aiApi = {
archiveConversation(conversationId: string, archived: boolean): Promise<void> { archiveConversation(conversationId: string, archived: boolean): Promise<void> {
return invoke('ai_conversation_archive', { conversationId, archived }) return invoke('ai_conversation_archive', { conversationId, archived })
}, },
/** 置顶/取消置顶对话(UX-17) */
setPinnedConversation(conversationId: string, pinned: boolean): Promise<void> {
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<string> {
return invoke('ai_conversation_export', { conversationId, format })
},
} }

View File

@@ -125,7 +125,7 @@ export interface WorkflowRecord {
/** 工作流事件载荷(后端 emit 的结构) */ /** 工作流事件载荷(后端 emit 的结构) */
/** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_caseR7收窄防写错 type 字符串) */ /** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_caseR7收窄防写错 type 字符串) */
export type WorkflowEventType = 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' | 'workflow_paused' | 'workflow_completed' | 'workflow_failed'
| 'human_approval_request' | 'human_approval_response' | 'human_approval_request' | 'human_approval_response'
@@ -180,6 +180,13 @@ export interface AiProviderConfig {
updated_at: string 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 用于多对话流式路由) */ /** AI 聊天事件(后端 emit 的结构conversation_id 用于多对话流式路由) */
export type AiChatEvent = ({ export type AiChatEvent = ({
type: 'AiTextDelta'; delta: string type: 'AiTextDelta'; delta: string
@@ -194,11 +201,14 @@ export type AiChatEvent = ({
} | { } | {
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number 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: 'AiAgentRound'; round: number
} | { } | {
type: 'AiHeartbeat' type: 'AiHeartbeat'
} | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached'
}) & { }) & {
conversation_id?: string conversation_id?: string
} }
@@ -233,6 +243,7 @@ export interface AiConversationSummary {
provider_id: string | null provider_id: string | null
model: string | null model: string | null
archived: boolean archived: boolean
pinned?: boolean
prompt_tokens?: number | null prompt_tokens?: number | null
completion_tokens?: number | null completion_tokens?: number | null
created_at: string created_at: string

View File

@@ -3,12 +3,25 @@ import { listen } from '@tauri-apps/api/event'
import type { WorkflowRecord, WorkflowEventPayload } from './types' import type { WorkflowRecord, WorkflowEventPayload } from './types'
export const workflowApi = { export const workflowApi = {
/** 触发工作流执行,返回 execution_id */ /**
run(name: string, dag: unknown, config?: Record<string, unknown>): Promise<string> { * 触发工作流执行,返回 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<string, unknown>,
taskId?: string,
targetStatus?: string,
): Promise<string> {
return invoke('run_workflow', { return invoke('run_workflow', {
name, name,
dag, dag,
config: config ?? {}, config: config ?? {},
task_id: taskId,
target_status: targetStatus,
}) })
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -383,16 +383,15 @@ onBeforeUnmount(() => {
const parsed = computed(() => parseResult(props.tc.result)) const parsed = computed(() => parseResult(props.tc.result))
/** /**
* id 项目名映射(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目, * id 项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故同时查 projects deletedProjects,无则返回 undefined(调用方走 fallback)。 * 故查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
* 复用 project store 已加载列表,不新增网络请求。 * 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证(find 在 computed/渲染上下文中触发依赖追踪)
* 审批卡通常仅 1-2 行引用项目 id,find 比 computed 全量建 Map 更省。
*/ */
const projectNameById = computed<Record<string, string>>(() => { function projectNameById(id: string): string | undefined {
const map: Record<string, string> = {} return projectStore.projects.find(p => p.id === id)?.name
for (const p of projectStore.projects) map[p.id] = p.name ?? projectStore.deletedProjects.find(p => p.id === id)?.name
for (const p of projectStore.deletedProjects) if (!(p.id in map)) map[p.id] = p.name }
return map
})
/** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */ /** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */
const PROJECT_ID_TOOL_ARG: Record<string, 'id' | 'project_id'> = { const PROJECT_ID_TOOL_ARG: Record<string, 'id' | 'project_id'> = {
@@ -414,7 +413,7 @@ function displayArgValue(arg: { key: string; val: unknown }): string {
if (projectArgKey && arg.key === projectArgKey) { if (projectArgKey && arg.key === projectArgKey) {
const id = typeof arg.val === 'string' ? arg.val : '' const id = typeof arg.val === 'string' ? arg.val : ''
if (id) { if (id) {
const name = projectNameById.value[id] const name = projectNameById(id)
if (name) return t('aiTool.projectLabel', { name }) if (name) return t('aiTool.projectLabel', { name })
return t('aiTool.projectIdNotFound', { id }) return t('aiTool.projectIdNotFound', { id })
} }
@@ -438,7 +437,7 @@ function toolDisplayName(tc: AiToolCallInfo): string {
case 'update_project': { case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name] const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : '' const id = idKey ? argString(tc.args, idKey) : ''
const name = id ? projectNameById.value[id] : '' const name = id ? projectNameById(id) : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey) return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey)

View File

@@ -67,7 +67,9 @@ export async function switchConversation(id: string) {
} }
state.messages = rawMsgs state.messages = rawMsgs
.filter((m: any) => m.role !== 'tool') // 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({ .map((m: any, i: number) => ({
id: `loaded-${i}`, id: `loaded-${i}`,
role: m.role, role: m.role,
@@ -170,6 +172,15 @@ async function archiveConversation(id: string, archived: boolean) {
notifyConversationChanged() notifyConversationChanged()
} }
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */
async function setPinnedConversation(id: string, pinned: boolean) {
await aiApi.setPinnedConversation(id, pinned)
// 本地同步置顶态(排序 computed 读 pinned,立即重排)
const conv = state.conversations.find(c => c.id === id)
if (conv) conv.pinned = pinned
notifyConversationChanged()
}
/** 折叠/展开归档分组 */ /** 折叠/展开归档分组 */
function toggleArchivedFold() { function toggleArchivedFold() {
state.archivedCollapsed = !state.archivedCollapsed state.archivedCollapsed = !state.archivedCollapsed
@@ -190,6 +201,7 @@ export function useAiConversations() {
deleteConversation, deleteConversation,
renameConversation, renameConversation,
archiveConversation, archiveConversation,
setPinnedConversation,
toggleArchivedFold, toggleArchivedFold,
toggleSidebar, toggleSidebar,
} }

View File

@@ -10,6 +10,7 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出) //! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event' import { listen, emit } from '@tauri-apps/api/event'
import { ref } from 'vue'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -18,7 +19,7 @@ import { nextMsgId } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend' import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
import { loadConversations } from './useAiConversations' import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types' import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。 // composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。 // 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
@@ -36,7 +37,8 @@ const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 // B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。 // 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError']) // F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。 // B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval, // 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -81,6 +83,15 @@ function clearAllToolSlowTimers(): void {
_toolSlowNotified.clear() _toolSlowNotified.clear()
} }
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。
//
// 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号,
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */ /** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() { export function notifyConversationChanged() {
emit('ai-conversation-changed', {}) emit('ai-conversation-changed', {})
@@ -186,6 +197,13 @@ export function handleEvent(event: AiChatEvent) {
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
break break
case 'AiMaxRoundsReached':
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
pendingMaxRounds.value = true
break
case 'AiToolCallStarted': { case 'AiToolCallStarted': {
const info: AiToolCallInfo = { const info: AiToolCallInfo = {
id: event.id, id: event.id,
@@ -265,6 +283,7 @@ export function handleEvent(event: AiChatEvent) {
state.streaming = false state.streaming = false
state.generatingConvId = null state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) { if (isShowTokenUsage()) {
state.lastTokenUsage = { state.lastTokenUsage = {
@@ -298,15 +317,20 @@ export function handleEvent(event: AiChatEvent) {
state.currentText = '' state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次 state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text') localStorage.removeItem('df-ai-text')
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
state.messages.push({ state.messages.push({
id: `err-${nextMsgId()}`, id: `err-${nextMsgId()}`,
role: 'assistant', role: 'assistant',
content: friendlyError(event.error), content: friendlyError(event.error),
isError: true, isError: true,
errorType: event.error_type,
timestamp: Date.now(), timestamp: Date.now(),
}) } as AiMessage)
break break
} }
} }

View File

@@ -140,6 +140,135 @@ async function doSend(text: string, skill?: string, force = false) {
} }
} }
/**
* 重新生成最后一条 AI 回复(UX-02)。
*
* 与 doSend 的区别:
* - 不 push 新 user 消息(触发它的 user 消息已在历史末尾)
* - 仅 push 一个空 AI 气泡占位 + 置 streaming,后端弹出旧 AI 回复后重跑 loop
* - 失败回滚只移除占位气泡(不移除 user 消息)
*
* 后端 ai_regenerate 占用 generating 防并发双发,此处不再额外入队/预检。
*/
async function regenerate() {
// 末尾须有 AI 回复可重生成(后端也会校验,前端先挡避免空跑)
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') {
throw new Error(t('aiChat.regenerateUnavailable'))
}
// 移除末尾旧 AI 气泡(后端也弹 history;前端先弹避免占位前残留旧文),push 空气泡占位
state.messages.pop()
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.regenerateUnavailable'))
}
try {
await aiApi.regenerate(convId, lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/**
* 编辑最后一条 user 消息并重新生成(UX-09)。
*
* 与 regenerate 的区别:
* - regenerate:末条须是 assistant,pop 旧 AI 回复后重跑
* - editMessage:末条须是 user(中间编辑被后端拒绝),就地改其 content,截断其后所有消息,
* push 空气泡占位后重跑。被截断消息后端标 truncated(软删保留 DB),前端视图层同步移除。
*
* 后端 ai_chat_edit 占用 generating 防并发双发,此处不再额外入队/预检。
*
* @param newMessage 编辑后的新内容(替换末条 user 消息 content)
*/
async function editMessage(newMessage: string) {
if (!newMessage.trim()) return
// 末条须是 user(中间编辑语义复杂,拒绝);后端也会校验,前端先挡避免空跑
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'user') {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断末条 user 之后的消息(此时 last 即末条 user,其后应已有 assistant 回复或为空)
// 找到末条 user 的下标,移除其后所有消息
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
if (lastUserIdx < 0) {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断其后(保留末条 user 本身,改其 content)
state.messages.splice(lastUserIdx + 1)
msgs[lastUserIdx].content = newMessage.trim()
// push 空气泡占位(新 AI 回复将流入)
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 编辑重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.editNotLastUser'))
}
try {
await aiApi.editMessage(convId, newMessage.trim(), lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */ /** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */
export function drainQueue() { export function drainQueue() {
if (state.queue.length === 0) return if (state.queue.length === 0) return
@@ -281,6 +410,8 @@ async function stopChat() {
export function useAiSend() { export function useAiSend() {
return { return {
sendMessage, sendMessage,
regenerate,
editMessage,
approveToolCall, approveToolCall,
batchApprove, batchApprove,
drainQueue, drainQueue,

View File

@@ -6,9 +6,15 @@ export default {
doubleClickToRename: 'Double-click to rename', doubleClickToRename: 'Double-click to rename',
archive: 'Archive', archive: 'Archive',
unarchive: 'Unarchive', unarchive: 'Unarchive',
pinConversation: 'Pin',
unpinConversation: 'Unpin',
archived: 'Archived', archived: 'Archived',
archivedCount: 'Archived ({n})', archivedCount: 'Archived ({n})',
emptyConversation: 'No conversations', emptyConversation: 'No conversations',
// UX-06 §3.1: conversation search (live title filter, Ctrl+K to focus)
searchPlaceholder: 'Search chats…',
searchEmpty: 'No matching chats',
searchShortcutHint: 'Ctrl+K',
// ── Time grouping (sidebar) ── // ── Time grouping (sidebar) ──
today: 'Today', today: 'Today',
@@ -62,10 +68,17 @@ export default {
// ── Skill ── // ── Skill ──
clearSkill: 'Clear skill', clearSkill: 'Clear skill',
placeholderWithSkill: 'Skill selected. Type arguments and press Enter', placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
placeholderDefault: 'Type a message... (/ for skills)', placeholderDefault: 'Type a message... (/ for skills, @ to mention)',
skillNoMatch: 'No matching skill', skillNoMatch: 'No matching skill',
skillNotLoaded: 'No local skills loaded', skillNotLoaded: 'No local skills loaded',
// ── UX-10: @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
mentionHint: '@ to mention project/task',
mentionGroupProject: 'Projects',
mentionGroupTask: 'Tasks',
mentionNoMatch: 'No matching entity',
mentionEmpty: 'No project/task to mention',
// ── Send ── // ── Send ──
stopGenerating: 'Stop generating', stopGenerating: 'Stop generating',
@@ -76,11 +89,43 @@ export default {
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.', confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
clearChat: 'Clear', clearChat: 'Clear',
confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.', confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.',
// UX-08 §3.4: generate-in-progress confirm when starting a new chat (only when generating, otherwise no behavior change)
confirmNewConvWhileGenerating: 'Generation is in progress. Interrupt it and start a new chat?',
toastSendFail: 'Send failed: {msg}', toastSendFail: 'Send failed: {msg}',
// ── Message copy (F-260615-13) ── // ── Message copy (F-260615-13) ──
copyMsg: 'Copy message', copyMsg: 'Copy message',
copied: 'Copied', copied: 'Copied',
copyFailed: 'Copy failed', copyFailed: 'Copy failed',
// ── Message actions bar (UX-02) ──
regenerate: 'Regenerate',
regenerateFailed: 'Regenerate failed: {msg}',
regenerateUnavailable: 'No reply to regenerate',
// ── UX-09: edit last user message and regenerate ──
editMessage: 'Edit',
editMessageFailed: 'Edit failed: {msg}',
editNotLastUser: 'Only the last user message can be edited',
placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)',
// ── Error bubble actions (UX-03) ──
retry: 'Retry',
goToSettingsAction: 'Open settings',
// ── F-260616-03: max iterations reached pause action card ──
maxRoundsReached: 'Max rounds reached. Continue?',
maxRoundsHint: 'Continue runs another full round; stop ends with current results',
continueLoop: 'Continue',
stopLoop: 'Stop',
continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop failed: {msg}',
// ── UX-18: conversation export (sidebar hover, pick format to download) ──
exportConversation: 'Export chat',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: 'Plain text',
exportFailed: 'Export failed: {msg}',
}, },
} }

View File

@@ -4,7 +4,8 @@ export default {
add: '+ New Knowledge', add: '+ New Knowledge',
tab: { tab: {
library: 'Library', library: 'Library',
inbox: 'Inbox', // inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
inbox: 'Pending',
}, },
searchPlaceholder: 'Search title, tags, content...', searchPlaceholder: 'Search title, tags, content...',
categoryAll: 'All', categoryAll: 'All',

View File

@@ -5,6 +5,7 @@ export default {
projects: 'Projects', projects: 'Projects',
tasks: 'Tasks', tasks: 'Tasks',
knowledge: 'Knowledge', knowledge: 'Knowledge',
audit: 'Audit Log',
settings: 'Settings', settings: 'Settings',
workspace: 'Workspace', workspace: 'Workspace',
traceability: 'Traceability', traceability: 'Traceability',

View File

@@ -76,6 +76,9 @@ export default {
labelPerConvConcurrency: 'Per-conversation concurrency', labelPerConvConcurrency: 'Per-conversation concurrency',
descPerConvConcurrency: 'Concurrent LLM calls within one conversation (main loop / title / distill shared)', descPerConvConcurrency: 'Concurrent LLM calls within one conversation (main loop / title / distill shared)',
labelAgentMaxIterations: 'Agent max iterations',
descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (1-50, default 10); changes take effect on the next message',
// ===== Knowledge base panel ===== // ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base', panelKnowledge: '📚 Knowledge base',
labelAutoExtract: 'Auto-distill knowledge', labelAutoExtract: 'Auto-distill knowledge',

View File

@@ -5,7 +5,7 @@ export default {
refresh: 'Refresh', refresh: 'Refresh',
// Status hints // Status hints
loading: 'Loading...', loading: 'Loading...',
loadFailed: 'Failed to load', loadFailed: 'Failed to load: {msg}',
// Panel // Panel
infoTitle: 'Task Info', infoTitle: 'Task Info',
// Field labels // Field labels
@@ -36,6 +36,16 @@ export default {
cancel: 'Cancel Task', cancel: 'Cancel Task',
}, },
advancing: 'Advancing...', advancing: 'Advancing...',
advanceFailed: 'Failed to advance task', advanceFailed: 'Failed to advance task: {msg}',
// F-260616-06 ①-1 / B-41 Workflow advance (linked to task, auto-selects pipeline template by target_status)
workflowAdvanceTitle: 'Workflow Advance',
workflowAdvance: 'Run Workflow',
workflowAdvancing: 'Workflow advancing...',
workflowAdvanceFailed: 'Workflow advance failed: {msg}',
// B-41 Inline progress (lightweight, listens to NodeStarted/NodeCompleted/WorkflowFailed)
workflowStepRunning: 'Running: {node}',
workflowStepsProgress: '{done} / {total} steps done',
workflowCompletedHint: 'Workflow completed, task advanced',
workflowFailedHint: 'Workflow failed, task rolled back',
}, },
} }

View File

@@ -6,9 +6,15 @@ export default {
doubleClickToRename: '双击重命名', doubleClickToRename: '双击重命名',
archive: '归档', archive: '归档',
unarchive: '取消归档', unarchive: '取消归档',
pinConversation: '置顶',
unpinConversation: '取消置顶',
archived: '已归档', archived: '已归档',
archivedCount: '已归档 ({n})', archivedCount: '已归档 ({n})',
emptyConversation: '暂无对话', emptyConversation: '暂无对话',
// UX-06 §3.1:对话搜索(实时过滤标题,Ctrl+K 聚焦)
searchPlaceholder: '搜索对话…',
searchEmpty: '无匹配对话',
searchShortcutHint: 'Ctrl+K',
// ── 时间分组(侧栏) ── // ── 时间分组(侧栏) ──
today: '今天', today: '今天',
@@ -62,10 +68,17 @@ export default {
// ── 技能 ── // ── 技能 ──
clearSkill: '取消技能', clearSkill: '取消技能',
placeholderWithSkill: '已选技能,输入参数后回车', placeholderWithSkill: '已选技能,输入参数后回车',
placeholderDefault: '输入消息.../ 触发技能)', placeholderDefault: '输入消息.../ 触发技能@ 引用实体',
skillNoMatch: '无匹配技能', skillNoMatch: '无匹配技能',
skillNotLoaded: '未读取到本机技能', skillNotLoaded: '未读取到本机技能',
// ── UX-10: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
mentionHint: '@ 引用项目/任务',
mentionGroupProject: '项目',
mentionGroupTask: '任务',
mentionNoMatch: '无匹配实体',
mentionEmpty: '暂无可引用的项目/任务',
// ── 发送 ── // ── 发送 ──
stopGenerating: '停止生成', stopGenerating: '停止生成',
@@ -76,11 +89,43 @@ export default {
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。', confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
clearChat: '清空', clearChat: '清空',
confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。', confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。',
// UX-08 §3.4:生成中点新建对话确认弹窗(仅生成中触发,非生成中零行为变化)
confirmNewConvWhileGenerating: '当前正在生成,确定中断并新建对话?',
toastSendFail: '发送失败:{msg}', toastSendFail: '发送失败:{msg}',
// ── 消息拷贝(F-260615-13) ── // ── 消息拷贝(F-260615-13) ──
copyMsg: '复制消息', copyMsg: '复制消息',
copied: '已复制', copied: '已复制',
copyFailed: '复制失败', copyFailed: '复制失败',
// ── 消息操作栏(UX-02) ──
regenerate: '重新生成',
regenerateFailed: '重新生成失败:{msg}',
regenerateUnavailable: '没有可重新生成的回复',
// ── UX-09: 编辑末条 user 消息并重新生成 ──
editMessage: '编辑',
editMessageFailed: '编辑失败:{msg}',
editNotLastUser: '只能编辑最后一条用户消息',
placeholderEditing: '编辑消息后回车重新生成Esc 取消)',
// ── 错误气泡操作入口(UX-03) ──
retry: '重试',
goToSettingsAction: '去设置',
// ── F-260616-03: 达 max_iterations 暂停态操作卡 ──
maxRoundsReached: '已达最大轮次,是否继续?',
maxRoundsHint: '继续将再跑一轮上限;停止则按当前结果收尾',
continueLoop: '继续',
stopLoop: '停止',
continueLoopFailed: '继续失败:{msg}',
stopLoopFailed: '停止失败:{msg}',
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
exportConversation: '导出对话',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: '纯文本',
exportFailed: '导出失败:{msg}',
}, },
} }

View File

@@ -5,14 +5,15 @@ export default {
// 顶层 Tab // 顶层 Tab
tab: { tab: {
library: '知识库', library: '知识库',
inbox: '审核收件箱', // inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
inbox: '待处理',
}, },
// 搜索 // 搜索
searchPlaceholder: '搜索标题、标签、内容...', searchPlaceholder: '搜索标题、标签、内容...',
// 分类 // 分类
categoryAll: '全部', categoryAll: '全部',
// 列表空状态 // 列表空状态
emptyInbox: '✅ 暂无待审核条目', emptyInbox: '✅ 暂无待处理条目',
emptyLibrary: '📭 没有匹配的知识', emptyLibrary: '📭 没有匹配的知识',
// 详情头部操作 // 详情头部操作
edit: '✏️ 编辑', edit: '✏️ 编辑',

View File

@@ -5,6 +5,7 @@ export default {
projects: '项目', projects: '项目',
tasks: '任务', tasks: '任务',
knowledge: '知识库', knowledge: '知识库',
audit: '审批历史',
settings: '设置', settings: '设置',
workspace: '工作区', workspace: '工作区',
traceability: '追溯', traceability: '追溯',

View File

@@ -76,6 +76,9 @@ export default {
labelPerConvConcurrency: '单对话并发上限', labelPerConvConcurrency: '单对话并发上限',
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)', descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
labelAgentMaxIterations: 'Agent 最大轮次',
descAgentMaxIterations: 'Agentic 循环流式→工具→回传的最大轮次1-50默认 10热改下次发消息生效',
// ===== 知识库面板 ===== // ===== 知识库面板 =====
panelKnowledge: '📚 知识库', panelKnowledge: '📚 知识库',
labelAutoExtract: '自动提炼知识', labelAutoExtract: '自动提炼知识',

View File

@@ -5,7 +5,7 @@ export default {
refresh: '刷新', refresh: '刷新',
// 状态提示 // 状态提示
loading: '加载中...', loading: '加载中...',
loadFailed: '加载失败', loadFailed: '加载失败: {msg}',
// 面板 // 面板
infoTitle: '任务信息', infoTitle: '任务信息',
// 字段标签 // 字段标签
@@ -36,6 +36,16 @@ export default {
cancel: '取消任务', // * → cancelled cancel: '取消任务', // * → cancelled
}, },
advancing: '推进中...', advancing: '推进中...',
advanceFailed: '推进任务失败', advanceFailed: '推进任务失败: {msg}',
// F-260616-06 ①-1 / B-41 工作流推进(联动任务,按 target_status 自动选推进链模板)
workflowAdvanceTitle: '工作流推进',
workflowAdvance: '工作流推进', // 按钮(默认入口)
workflowAdvancing: '工作流推进中...',
workflowAdvanceFailed: '工作流推进失败: {msg}',
// B-41 内嵌进度(轻量,听 NodeStarted/NodeCompleted/WorkflowFailed)
workflowStepRunning: '执行中: {node}',
workflowStepsProgress: '已完成 {done} / {total} 步',
workflowCompletedHint: '工作流完成,任务已推进',
workflowFailedHint: '工作流失败,任务已退回',
}, },
} }

View File

@@ -59,6 +59,12 @@ const routes = [
component: () => import('../views/Knowledge.vue'), component: () => import('../views/Knowledge.vue'),
meta: { title: '知识库', icon: 'icon-book' }, meta: { title: '知识库', icon: 'icon-book' },
}, },
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
meta: { title: '审批历史', icon: 'icon-audit' },
},
{ {
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',

View File

@@ -19,7 +19,7 @@
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑 //! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
//! - state 为模块级单例,全应用共享同一引用 //! - state 为模块级单例,全应用共享同一引用
import { reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '@/api/types' import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '@/api/types'
/** /**
@@ -73,6 +73,9 @@ export const state = reactive({
agentRound: 0, agentRound: 0,
// 归档分组折叠态(默认折叠) // 归档分组折叠态(默认折叠)
archivedCollapsed: true, archivedCollapsed: true,
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
searchQuery: '',
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计) // token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null, lastTokenUsage: null as { prompt: number; completion: number; total: number } | null,
convTokenTotal: null as { prompt: number; completion: number; total: number } | null, convTokenTotal: null as { prompt: number; completion: number; total: number } | null,
@@ -101,6 +104,25 @@ watch(
}, },
) )
/**
* 对话搜索过滤结果(UX-06 §3.1)。
*
* 行为:
* - searchQuery 为空 → 返回 null(信号:消费方走原时间分组 groupedActive / 归档分组)。
* - 非空 → 在全部对话(活跃+归档)中按标题做 case-insensitive 子串匹配,平铺返回
* (取消分组),按 updated_at DESC 排序,最近改过的在最前。
*
* 设计:过滤逻辑放 store 而非组件,供主窗口/分离窗口两处侧栏复用同一结果;
* 消费方仅需 `store.filteredConversations` 即得数组或 null,UI 连接在侧栏视图内完成。
*/
const filteredConversations = computed<AiConversationSummary[] | null>(() => {
const q = state.searchQuery.trim().toLowerCase()
if (!q) return null
return state.conversations
.filter(c => (c.title ?? '').toLowerCase().includes(q))
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0))
})
/** /**
* AI Store 统一入口 — 返回单例 state + 全部方法。 * AI Store 统一入口 — 返回单例 state + 全部方法。
* *
@@ -110,6 +132,7 @@ watch(
export function useAiStore() { export function useAiStore() {
return { return {
state, state,
filteredConversations,
...useAiEvents(), ...useAiEvents(),
...useAiStream(), ...useAiStream(),
...useAiSend(), ...useAiSend(),

View File

@@ -55,7 +55,9 @@ export function parseTags(tags: string | null): string[] {
} }
export function useKnowledgeStore() { export function useKnowledgeStore() {
// ── 加载列表(默认排除 archived) ── // ── 加载列表(library:默认仅 published,F-260616-02 决策 a) ──
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。
// 显式 status 仍透传(如 status='archived' 查归档)。
async function loadList(status?: string) { async function loadList(status?: string) {
const seq = ++_itemsReqSeq const seq = ++_itemsReqSeq
state.loading = true state.loading = true
@@ -72,7 +74,8 @@ export function useKnowledgeStore() {
} }
} }
// ── 加载审核收件箱(候选列表) ── // ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ──
// 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。
async function loadCandidates() { async function loadCandidates() {
const seq = ++_itemsReqSeq const seq = ++_itemsReqSeq
try { try {

View File

@@ -12,8 +12,18 @@ const t = ((i18n as any).global.t as (k: string) => string).bind((i18n as any).g
* src/api/workflow.ts,本子 store 不再直接 invoke IPC。 * src/api/workflow.ts,本子 store 不再直接 invoke IPC。
*/ */
export function createWorkflowStore() { export function createWorkflowStore() {
async function runWorkflow(name: string, dag: unknown, config?: Record<string, unknown>) { /**
return await workflowApi.run(name, dag, config) * 触发工作流执行。
* F-260616-06 阶段2(②-2): taskId/targetStatus 可选,同时传时工作流完成/失败联动推进任务。
*/
async function runWorkflow(
name: string,
dag: unknown,
config?: Record<string, unknown>,
taskId?: string,
targetStatus?: string,
) {
return await workflowApi.run(name, dag, config, taskId, targetStatus)
} }
async function loadWorkflowExecutions() { async function loadWorkflowExecutions() {

311
src/views/AuditLog.vue Normal file
View File

@@ -0,0 +1,311 @@
<template>
<div class="audit-log">
<header class="page-header">
<h1>审批历史</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="reload">刷新</button>
</div>
</header>
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)参数与结果仅展示摘要,完整数据留存本地数据库</p>
<!-- 状态条 -->
<div v-if="loading" class="empty-state">加载中</div>
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div>
<!-- 表格 -->
<div v-else class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th class="col-time">请求时间</th>
<th class="col-tool">工具</th>
<th class="col-risk">风险</th>
<th class="col-status">状态</th>
<th class="col-decided">决策者</th>
<th class="col-args">参数摘要</th>
<th class="col-result">结果摘要</th>
</tr>
</thead>
<tbody>
<tr v-for="r in records" :key="r.id">
<td class="col-time">
<div class="time-rel">{{ formatRelativeZh(r.requested_at) }}</div>
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
</td>
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
<td class="col-risk">
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
</td>
<td class="col-status">
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td class="col-decided">
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
<span v-else class="decided-none"></span>
</td>
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="!loading && !errorMsg" class="pager">
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button>
<span class="pager-info"> {{ page }} {{ hasMore ? '' : '(末页)' }}</span>
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { formatRelativeZh, formatDate } from '@/utils/time'
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
interface ToolExecutionRecord {
id: string
conversation_id: string | null
tool_call_id: string
tool_name: string
arguments_brief: string
result_brief: string | null
status: string
risk_level: string
requested_at: string
executed_at: string | null
decided_by: string | null
}
const PAGE_SIZE = 50
const records = ref<ToolExecutionRecord[]>([])
const offset = ref(0)
const loading = ref(false)
const errorMsg = ref('')
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
const hasMore = computed(() => records.value.length === PAGE_SIZE)
async function load() {
loading.value = true
errorMsg.value = ''
try {
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
limit: PAGE_SIZE,
offset: offset.value,
})
records.value = list
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
if (list.length === 0 && offset.value > 0) {
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
} catch (e) {
errorMsg.value = String(e)
} finally {
loading.value = false
}
}
function reload() {
offset.value = 0
void load()
}
function prevPage() {
if (offset.value === 0) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
void load()
}
function nextPage() {
offset.value += PAGE_SIZE
void load()
}
// ── 标签 / 样式映射 ──
function riskLabel(r: string): string {
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r
}
function riskClass(r: string): string {
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
}
function statusLabel(s: string): string {
return ({
pending: '待审批',
approved: '已批准',
rejected: '已拒绝',
executing: '执行中',
completed: '已完成',
failed: '失败',
} as Record<string, string>)[s] ?? s
}
function statusClass(s: string): string {
return ({
pending: 'status-pending',
approved: 'status-approved',
rejected: 'status-rejected',
executing: 'status-executing',
completed: 'status-completed',
failed: 'status-failed',
} as Record<string, string>)[s] ?? 'status-pending'
}
function decidedLabel(d: string): string {
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
}
function decidedClass(d: string): string {
return d === 'human' ? 'decided-human' : 'decided-auto'
}
onMounted(() => {
void load()
})
</script>
<style scoped>
.audit-log {
padding: var(--df-pad-page, 24px);
max-width: 1200px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.header-actions { display: flex; gap: 10px; }
.page-desc {
font-size: 12px;
color: var(--df-text-dim);
margin-bottom: 16px;
}
/* ===== 表格 ===== */
.audit-table-wrap {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: auto;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.audit-table thead th {
text-align: left;
padding: 10px 12px;
font-weight: 500;
font-size: 11px;
color: var(--df-text-dim);
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
position: sticky;
top: 0;
background: var(--df-bg-card);
}
.audit-table tbody td {
padding: 8px 12px;
border-bottom: 0.5px solid var(--df-border);
color: var(--df-text-secondary);
vertical-align: top;
}
.audit-table tbody tr:last-child td { border-bottom: none; }
.audit-table tbody tr:hover td { background: var(--df-sidebar-hover, rgba(255,255,255,0.03)); }
.col-time { min-width: 120px; }
.col-tool { min-width: 130px; }
.col-risk { width: 56px; }
.col-status { width: 80px; }
.col-decided { width: 64px; }
.col-args { min-width: 200px; }
.col-result { min-width: 220px; }
.time-rel { font-size: 12px; color: var(--df-text-secondary); }
.time-abs { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
.tool-name {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-accent);
background: var(--df-accent-bg, rgba(123,111,240,0.08));
padding: 1px 6px;
border-radius: var(--df-radius-xs);
}
.brief {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-text-secondary);
word-break: break-all;
white-space: pre-wrap;
}
/* 风险 badge */
.risk-badge {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.risk-medium { background: rgba(255,152,0,0.2); color: #ff9800; }
.risk-high { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 状态 tag */
.status-tag {
display: inline-block;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: var(--df-radius-sm);
white-space: nowrap;
}
.status-pending { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-approved { background: rgba(76,175,80,0.2); color: var(--df-success, #4caf50); }
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.status-executing { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-completed { background: rgba(76,175,80,0.15); color: var(--df-success, #4caf50); }
.status-failed { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 决策者 tag */
.decided-tag {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.decided-none { color: var(--df-text-dim); }
/* ===== 分页 ===== */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 20px;
}
.pager-info {
font-size: 12px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.empty-state {
text-align: center;
padding: 60px 20px;
font-size: 14px;
color: var(--df-text-dim);
}
</style>

View File

@@ -266,6 +266,17 @@
@change="syncConcurrencyConfig" /> @change="syncConcurrencyConfig" />
</div> </div>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAgentMaxIterations') }}</span>
<span class="setting-desc">{{ $t('settings.descAgentMaxIterations') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
v-model.number="settings.agentMaxIterations"
@change="syncAgentMaxIterations" />
</div>
</div>
</div> </div>
</section> </section>
@@ -613,6 +624,8 @@ const settings = reactive({
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false), showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3), llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2), llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 循环最大轮次,默认 10与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
}) })
function applyTheme(theme: string) { function applyTheme(theme: string) {
@@ -647,6 +660,10 @@ watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v) void appSettings.set('df-llm-per-conv-concurrency', v)
}) })
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
})
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC) // 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器 // 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
@@ -669,6 +686,24 @@ function syncConcurrencyConfig() {
}, 300) }, 300)
} }
// 同步 Agentic 最大轮次到后端(debounce 300ms)
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
// 双 clamp此处 1-50 + 后端 command 再 clamp 1-50防越界输入致 loop 失效/失控
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxIterations() {
// clamp 1-50防越界(过小致 agent 失能、过大致烧 token)
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
if (_agentIterTimer) clearTimeout(_agentIterTimer)
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
}
}, 300)
}
watch(() => settings.language, (val) => { watch(() => settings.language, (val) => {
void appSettings.set('df-language', val) void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换 // 同步 i18n 实例,让界面立即跟随语言切换
@@ -735,11 +770,14 @@ onMounted(() => {
loadKnowledgeConfig() loadKnowledgeConfig()
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值) // 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig() syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
syncAgentMaxIterations()
}) })
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3) // 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
onUnmounted(() => { onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer) if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer) if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
if (_toastTimer) clearTimeout(_toastTimer) if (_toastTimer) clearTimeout(_toastTimer)
}) })

View File

@@ -63,6 +63,32 @@
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button> >{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div> </div>
</div> </div>
<!-- F-260616-06 -1 / B-41 工作流推进按钮(联动任务, target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 {in_progress, testing, done}
( template_for 有对应模板)时显示手动 advance 仍走 advance_task IPC;工作流推进
run_workflow, AiNode/HumanNode 执行后再由后端回调推进任务
独立 loading 标志 wfAdvancing advancing 互不干扰 -->
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
<div class="advance-actions">
<button
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
</div>
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
<div v-if="wfAdvancing || wfProgressHint" class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
</div>
<div class="info-item"> <div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span> <span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value"> <span class="value">
@@ -119,6 +145,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { taskApi, projectApi } from '@/api' import { taskApi, projectApi } from '@/api'
import { workflowApi } from '@/api'
import { formatDate } from '@/utils/time' import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
import { import {
@@ -127,7 +154,7 @@ import {
priorityLabel, priorityLabel,
priorityClass, priorityClass,
} from '../constants/project' } from '../constants/project'
import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types' import type { TaskRecord, ProjectRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -138,6 +165,24 @@ const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([]) const projects = ref<ProjectRecord[]>([])
const advancing = ref(false) const advancing = ref(false)
// ============================================================
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
// ------------------------------------------------------------
// wfAdvancing:推进中态;wfExecId:当前监听的工作流执行 ID(过滤 workflow-event);
// wfTotalNodes/wfDoneCount/wfRunningNode:轻量进度(听 NodeStarted/NodeCompleted);
// wfResult: 'completed' | 'failed' | null —— 终态提示,完成/失败后自动清空(由新一次推进重置)。
const wfAdvancing = ref(false)
const wfExecId = ref<string | null>(null)
const wfTotalNodes = ref(0)
const wfDoneCount = ref(0)
const wfRunningNode = ref<string>('')
const wfResult = ref<'completed' | 'failed' | null>(null)
const wfProgressHint = computed(() => wfResult.value !== null)
const wfCompletedHint = computed(() => wfResult.value === 'completed')
const wfFailedHint = computed(() => wfResult.value === 'failed')
const wfDoneTotal = computed(() => wfTotalNodes.value)
const taskId = computed(() => route.params.id as string) const taskId = computed(() => route.params.id as string)
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装 // B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
@@ -206,6 +251,28 @@ const advanceActions = computed<AdvanceAction[]>(() => {
return ADVANCE_MAP[s] ?? [] return ADVANCE_MAP[s] ?? []
}) })
// F-260616-06 ①-1: 工作流推进按钮的目标态。
// 仅当当前态的 primary(前向推进)目标 ∈ template_for 支持的三态 {in_progress, testing, done} 时显示。
// 非前向(退回/阻塞/取消)不走工作流(无对应模板,且语义上工作流只做前向推进)。
// ADVANCE_MAP 各行的首个 primary 项:todo→in_progress, in_review→testing, testing→done, blocked→in_progress
// in_progress 的 primary 是 in_review(无模板,不显示工作流推进按钮)。
const WF_SUPPORTED_TARGETS = new Set(['in_progress', 'testing', 'done'])
// CR-08-O1: blocked 是阻塞态(任务被阻塞),其 primary=in_progress 虽 ∈ WF_SUPPORTED_TARGETS,
// 但 template_for('in_progress') 是「todo→in_progress 从头执行」拓扑(AiNode 等重新跑一遍),
// blocked 恢复语义不该重跑 AiNode(任务已有上下文/进度,只需恢复非从头执行)。
// 最小修法:blocked 不显示工作流推进按钮,用户手动 advance(blocked→in_progress 走后端状态机)或先解除阻塞。
// (引入 blocked 恢复模板需后端 template_for 扩展,复杂度高,非阻断 med 项留待 UX 决策。)
const WF_EXCLUDED_FROM = new Set(['blocked'])
const wfAdvanceAction = computed<AdvanceAction | null>(() => {
const s = task.value?.status
if (!s) return null
if (WF_EXCLUDED_FROM.has(s)) return null
const actions = ADVANCE_MAP[s] ?? []
const primary = actions.find(a => a.variant === 'btn-primary')
if (!primary) return null
return WF_SUPPORTED_TARGETS.has(primary.target) ? primary : null
})
async function handleAdvance(target: string) { async function handleAdvance(target: string) {
if (!task.value || advancing.value) return if (!task.value || advancing.value) return
advancing.value = true advancing.value = true
@@ -215,12 +282,87 @@ async function handleAdvance(target: string) {
task.value = updated task.value = updated
errorMsg.value = '' errorMsg.value = ''
} catch (e: any) { } catch (e: any) {
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed') errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
} finally { } finally {
advancing.value = false advancing.value = false
} }
} }
// F-260616-06 ①-1: 工作流推进 — 传空 dag + target_status,后端自动选推进链模板。
// B-41: 监听 workflow-event 显示轻量进度;完成/失败后任务态由后端回调推进(②-3/②-4),
// df-data-changed 自动刷新 task(AR-11 已通),此处不直接改 task.value。
// name 用推进链标识(模板内未硬编码 task_id,运行时由后端 config 注入,前端只传 topology hint)。
async function handleWorkflowAdvance(target: string) {
if (!task.value || wfAdvancing.value || advancing.value) return
wfAdvancing.value = true
wfResult.value = null
wfRunningNode.value = ''
wfDoneCount.value = 0
wfTotalNodes.value = 0
try {
const execId = await workflowApi.run(
`task-advance:${target}`,
{}, // 空 dag → 后端 template_for(target) 选模板
{}, // 全局 config 留空(节点级 config 由模板/运行时注入)
task.value.id,
target,
)
wfExecId.value = execId
errorMsg.value = ''
} catch (e: any) {
wfAdvancing.value = false
wfExecId.value = null
errorMsg.value = t('taskDetail.workflowAdvanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
}
}
// B-41: 处理单个工作流事件(由 onEvent 回调 dispatch)。按 execution_id 过滤当前推进。
function handleWorkflowEvent(payload: WorkflowEventPayload) {
if (!wfExecId.value || payload.execution_id !== wfExecId.value) return
const evt = payload.event
switch (evt?.type) {
case 'node_started': {
// 第一次 NodeStarted 时初始化 total(后端模板节点数,无法前端预知,
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
const node = String(evt.node_id ?? '')
wfRunningNode.value = node
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
break
}
case 'node_completed': {
wfDoneCount.value += 1
wfRunningNode.value = ''
break
}
case 'workflow_completed': {
wfAdvancing.value = false
wfRunningNode.value = ''
wfResult.value = 'completed'
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
setTimeout(() => {
if (wfResult.value === 'completed') wfResult.value = null
}, 3000)
break
}
case 'workflow_failed':
case 'node_failed': {
if (evt?.type === 'workflow_failed') {
wfAdvancing.value = false
wfRunningNode.value = ''
wfResult.value = 'failed'
setTimeout(() => {
if (wfResult.value === 'failed') wfResult.value = null
}, 3000)
}
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
break
}
default:
break
}
}
async function load() { async function load() {
loading.value = true loading.value = true
errorMsg.value = '' errorMsg.value = ''
@@ -234,7 +376,7 @@ async function load() {
projects.value = ps projects.value = ps
} catch (e: any) { } catch (e: any) {
task.value = null task.value = null
errorMsg.value = e?.toString() ?? t('taskDetail.loadFailed') errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -252,6 +394,8 @@ watch(taskId, () => { load() })
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体), // 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
// 故此本地监听 entity=task/project 时重载当前 task。 // 故此本地监听 entity=task/project 时重载当前 task。
let _unlistenDataChanged: (() => void) | null = null let _unlistenDataChanged: (() => void) | null = null
// B-41: 工作流推进事件 unlistener(听 NodeStarted/NodeCompleted/WorkflowFailed 显示轻量进度)
let _unlistenWorkflowEvent: (() => void) | null = null
onMounted(async () => { onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
@@ -268,10 +412,18 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e) console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
} }
// B-41: 工作流推进事件监听(复用 workflowApi.onEvent, 与 workflow store 的全局监听并存无冲突 —
// 多个 listen 各自接收全量事件,本视图按 execution_id 过滤只处理自己发起的推进)
try {
_unlistenWorkflowEvent = await workflowApi.onEvent(handleWorkflowEvent)
} catch (e) {
console.error('[TaskDetail] 启动 workflow-event 监听失败:', e)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null } if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
}) })
</script> </script>
@@ -317,6 +469,19 @@ onBeforeUnmount(() => {
gap: 8px; gap: 8px;
} }
/* B-41 工作流推进轻量进度提示 */
.wf-progress {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 6px;
font-size: 12px;
color: var(--df-text-secondary);
}
.wf-progress-count { color: var(--df-text-dim); }
.wf-progress-hint { color: var(--df-success); }
.wf-progress-hint-fail { color: var(--df-danger); }
/* F-04 review 轮次徽章 */ /* F-04 review 轮次徽章 */
.review-rounds-badge { .review-rounds-badge {
font-size: 11px; font-size: 11px;