新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ coverage/
|
|||||||
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
|
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
|
||||||
*.broken.bak
|
*.broken.bak
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||||
|
workflows/
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 0(UX-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 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||||
|
///
|
||||||
|
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||||
|
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||||
|
///
|
||||||
|
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||||
|
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||||
|
/// 找不到返回 Err(()),调用方据此报错。
|
||||||
|
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<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 消息的 content(UX-09 编辑重生成)。
|
||||||
|
///
|
||||||
|
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||||
|
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||||
|
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||||
|
let pos = self.messages.iter().rposition(|t| {
|
||||||
|
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||||
|
});
|
||||||
|
let Some(i) = pos else { return Err(()) };
|
||||||
|
let old_tokens = self.messages[i].token_count;
|
||||||
|
self.messages[i].message.content = new_content.to_string();
|
||||||
|
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||||
|
self.messages[i].token_count = new_tokens;
|
||||||
|
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
/// 只读迭代(兼容 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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 分支)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
crates/df-nodes/src/task_workflow_templates.rs
Normal file
211
crates/df-nodes/src/task_workflow_templates.rs
Normal 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 失败回调路径不可达)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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 = "
|
||||||
-- 想法表
|
-- 想法表
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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_dag,run 时与全局 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 时覆盖全局 config(deep_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 时不写入该 key(node_configs 缺失即用全局)。
|
||||||
|
///
|
||||||
|
/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`,
|
||||||
|
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
|
||||||
|
pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde_json::Value {
|
||||||
|
use serde_json::Value;
|
||||||
|
match (global, node) {
|
||||||
|
(Value::Object(g), Value::Object(n)) => {
|
||||||
|
let mut merged = g.clone();
|
||||||
|
for (k, nv) in n {
|
||||||
|
// 同 key 递归(支持嵌套对象局部覆盖);不同类型时递归退化为「节点级直接覆盖」
|
||||||
|
let gv = merged.get(k).cloned();
|
||||||
|
let merged_v = match gv {
|
||||||
|
Some(gv) => deep_merge(&gv, nv),
|
||||||
|
None => nv.clone(),
|
||||||
|
};
|
||||||
|
merged.insert(k.clone(), merged_v);
|
||||||
|
}
|
||||||
|
Value::Object(merged)
|
||||||
|
}
|
||||||
|
// 非两端 Object:节点级覆盖全局级(含 Null、标量、数组)
|
||||||
|
_ => node.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod deep_merge_tests {
|
||||||
|
use super::deep_merge;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_overrides_global_scalar() {
|
||||||
|
assert_eq!(deep_merge(&json!({"a": 1}), &json!({"a": 2})), json!({"a": 2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_adds_new_key() {
|
||||||
|
assert_eq!(
|
||||||
|
deep_merge(&json!({"a": 1}), &json!({"b": 2})),
|
||||||
|
json!({"a": 1, "b": 2})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_object_recursive_merge() {
|
||||||
|
assert_eq!(
|
||||||
|
deep_merge(&json!({"o": {"x": 1, "y": 2}}), &json!({"o": {"y": 9}})),
|
||||||
|
json!({"o": {"x": 1, "y": 9}})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_node_config_is_noop() {
|
||||||
|
// 核心保现有调用方行为:节点 config 为空对象 → 结果等同全局(零行为破坏)
|
||||||
|
let global = json!({"title": "确认", "options": ["同意"]});
|
||||||
|
assert_eq!(deep_merge(&global, &json!({})), global);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_node_config_treated_as_noop() {
|
||||||
|
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照:
|
||||||
|
// 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。
|
||||||
|
let global = json!({"a": 1});
|
||||||
|
assert_eq!(deep_merge(&global, &json!(null)), json!(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn array_replaces_not_concat() {
|
||||||
|
// 非两端 Object(数组)→ 节点级直接覆盖,不拼接
|
||||||
|
assert_eq!(
|
||||||
|
deep_merge(&json!({"arr": [1, 2]}), &json!({"arr": [3]})),
|
||||||
|
json!({"arr": [3]})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
// 构造 DagDef:n1 节点写 config={foo:"bar"};n2 节点 config 空(验证回退全局)
|
||||||
|
let mut def = crate::dag_def::DagDef::new();
|
||||||
|
def.add_node("n1".to_string(), "echo".to_string(), serde_json::json!({ "foo": "bar" }));
|
||||||
|
def.add_node("n2".to_string(), "echo".to_string(), serde_json::json!({}));
|
||||||
|
|
||||||
|
// 注册 echo 工厂 → EchoConfigNode(读 config.foo)
|
||||||
|
let mut registry = NodeRegistry::new();
|
||||||
|
registry.register("echo", |_cfg| {
|
||||||
|
Box::new(EchoConfigNode { key: "foo".to_string() })
|
||||||
|
});
|
||||||
|
|
||||||
|
let dag = registry.build_dag(&def).expect("build_dag");
|
||||||
|
|
||||||
|
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||||
|
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||||
|
let outputs = executor
|
||||||
|
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||||
|
.await
|
||||||
|
.expect("run");
|
||||||
|
|
||||||
|
// n1:节点级 foo="bar" 覆盖全局 "GLOBAL"
|
||||||
|
assert_eq!(
|
||||||
|
outputs["n1"].data["echo"],
|
||||||
|
serde_json::json!("bar"),
|
||||||
|
"节点级 config 应覆盖全局"
|
||||||
|
);
|
||||||
|
// n2:节点 config 空 → 回退全局 foo="GLOBAL"(零行为破坏现有调用方)
|
||||||
|
assert_eq!(
|
||||||
|
outputs["n2"].data["echo"],
|
||||||
|
serde_json::json!("GLOBAL"),
|
||||||
|
"空节点 config 应回退全局"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_configs:DagExecutor.run 构造 NodeContext 时
|
||||||
|
// deep_merge(initial_config, node_configs[id])(节点级覆盖全局级),解决此前
|
||||||
|
// initial_config.clone() 直接当 ctx.config、忽略 NodeDef.config 的问题。
|
||||||
|
dag.add_node_with_config(id.clone(), node, node_def.config.clone());
|
||||||
node_ids.insert(id);
|
node_ids.insert(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 的状态推进链路。阶段1(7 态状态机 + advance_task CAS 原子写 + 软删除)已落地;阶段2(工作流联动:task_id + 完成回调 advance_task + DAG 模板)进行中。详细实施路径见 [任务推进链实施路径-2026-06-16.md](./任务推进链实施路径-2026-06-16.md) / [推进链阶段2实施路径-2026-06-16.md](./推进链阶段2实施路径-2026-06-16.md)。关联决策 D-260616-01~04(前端7态对齐 / 软删除 / Node trait 归属 / 阶段1先行)。
|
||||||
|
|
||||||
|
### advance_task / 状态机走 df-nodes Node trait,不复活 df-task(D-260616-03)
|
||||||
|
- **决策**:任务推进业务逻辑(`can_transition_to` 状态机 / `advance_task` 原子写 / 闸门节点)落在 **df-nodes crate 的 Node trait 扩展**(`task_state_machine.rs` / `task_advance_node.rs`),IPC 层 thin 入口。不复活 2026-06-12 刚因零引用删除(cf017f8)的 df-task crate,不塞 commands/task.rs。
|
||||||
|
- **原因/取舍**:① 对齐 D3「业务逻辑在 df-nodes 实现(Node trait 纯接口,df-workflow/src/node.rs:67)」原则;② 复活一个零引用刚删的 crate 是制造新死码,df-nodes 补 `df-storage` 依赖读 TaskRecord 即可(核实无循环依赖);③ IPC 层保持 thin(task.rs 仅 3 行转发)守住 D3 不让业务逻辑下沉 IPC。前端对齐后端 7 态(D-260616-01,types.rs:131,激活 InReview/Testing/Blocked 三闸门态)。
|
||||||
|
- **状态**:✅ 阶段1 落地(commit d2cb38c:7态状态机 + advance_task CAS + 软删除,25 测试);🚧 阶段2 进行中(batch32:注册 TaskAdvanceNode + config 下沉 + HumanNode reject)。
|
||||||
|
|
||||||
|
### DagExecutor config 下沉:节点级覆盖全局级 deep_merge(④-1)
|
||||||
|
- **决策**:`DagExecutor.run` 构造 `NodeContext.config` 从「`initial_config.clone()` 覆盖一切」改为「`deep_merge(node_def_config, initial_config)`」——节点级配置覆盖全局级(节点定义优先)。Dag 加 `node_configs: HashMap<NodeId, Value>`,build_dag 填入 NodeDef.config,run 合并下沉。
|
||||||
|
- **原因/取舍**:原实现(executor.rs:99-107)用全局 initial_config 覆盖 NodeDef.config,**节点级配置被完全忽略**——TaskAdvanceNode.execute 读 `ctx.config.task_id` 拿到全局 config 而非节点定义写的 task_id,节点参数化失效。这是阶段2 的架构前置阻塞点:不修则 TaskAdvanceNode 无法从 DAG 接收 task_id。选「节点级覆盖全局级」(节点定义优先)而非全局覆盖节点级,因节点是更具体的配置源。deep_merge 对 Object 递归合并,非 Object 节点级直接覆盖。现有 HumanNode/AiNode 也受益(它们当前读 ctx.config 拿全局 config,但无人通过 NodeDef.config 定义节点参数故未暴露)。
|
||||||
|
- **状态**:🚧 实施中(batch32 ④-1 agent:dag.rs + executor.rs + registry.rs + workflow.rs,配 deep_merge / node_config_overrides 测试)。
|
||||||
|
|
||||||
|
### DAG 模板硬编码,不建 workflow_defs 表(②-6)
|
||||||
|
- **决策**:任务推进的工作流 DAG 模板(todo→in_progress / in_review→testing / testing→done 三条推进边 + 退回)**硬编码**在 `df-nodes/task_workflow_templates.rs`(导出 `template_for(target_status) -> DagDef`),**不建 workflow_defs 表**。`tasks.workflow_def_id` 字段留 None。
|
||||||
|
- **原因/取舍**:模板数量少且稳定(3 条推进边 + 退回),建表需 CRUD UI + 版本管理 + 关联维护,过度工程。[业务系统设计-2026-06-12.md](./业务系统设计-2026-06-12.md) 确认 workflow_defs 从未建表(工作流定义 dag_json 内嵌 workflow_executions),延续此约定。硬编码模板随代码版本管理,零运行时配置开销。
|
||||||
|
- **状态**:📐 设计定稿待实施(②-6,batch33+)。
|
||||||
|
|
||||||
|
### 工作流回调语义:成功与任务推进解耦,失败按 target 退回(②-3/②-4/②-5)
|
||||||
|
- **决策**:工作流完成后回调 advance_task 的语义——**成功**(executor Ok):task_id + target_status 都 Some 时调 `advance_task_atomic`,回调失败只 warn 不回滚工作流(工作流已完成是事实,任务推进失败前端提示手动处理);**失败**(executor Err):按 target_status 推算退回态(testing→in_review / in_review→in_progress)调 advance,或加 `failure_target_status` 参数。HumanNode reject(②-5)从 Ok 改返 Err,使审查拒绝走 failed 触发退回。
|
||||||
|
- **原因/取舍**:工作流成功与任务推进是两个独立事实,解耦避免「工作流成功但任务推进失败时回滚已完成工作流」的复杂性;失败退回让审查拒绝能回流上一态(review_rounds+1)。CAS 已防回调与手动 advance 并发撞(advance_task_atomic 捕获 InvalidState 降级)。跨表事务缺失(阶段2 回调失败降级,阶段3/4 补 Database.transaction())。
|
||||||
|
- **状态**:📐 设计定稿待实施(batch32 做 ②-5 HumanNode reject 语义化,batch33 做 ②-3/②-4 回调)。
|
||||||
|
|
||||||
## 需求与待办
|
## 需求与待办
|
||||||
|
|
||||||
> 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。
|
> 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。
|
||||||
|
|||||||
93
docs/02-架构设计/推进链阶段2实施路径-2026-06-16.md
Normal file
93
docs/02-架构设计/推进链阶段2实施路径-2026-06-16.md
Normal 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(避免节点间状态不一致),工作流级统一。
|
||||||
@@ -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)` 默认仅返回 published(library 纯已发布,[2026-06-16] F-260616-02 决策 a 收窄:pending_review 归 `knowledge_list_candidates` 收件箱,不再混杂 library)。
|
||||||
- **原因/取舍**:idea/task/project 的 `delete_xxx` 都是硬删(DELETE FROM),若 knowledge 也叫 delete 却做归档,API 语义混淆(调用方期望数据消失,实际还在 DB)。conversation 模块已有正确先例(archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
|
- **原因/取舍**:idea/task/project 的 `delete_xxx` 都是硬删(DELETE FROM),若 knowledge 也叫 delete 却做归档,API 语义混淆(调用方期望数据消失,实际还在 DB)。conversation 模块已有正确先例(archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
|
||||||
- **状态**:✅ 已实施(Tier 1)
|
- **状态**:✅ 已实施(Tier 1)
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ candidate ──→ pending_review ──→ published ──→ archived
|
|||||||
|
|
||||||
| Command | 说明 |
|
| Command | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| `knowledge_list(status?)` | 全量列表,默认排除 archived |
|
| `knowledge_list(status?)` | 列表,默认仅 published(library 纯已发布,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 限制降低了冲突概率,但随知识积累会逐渐显现
|
||||||
|
|||||||
73
docs/todo.md
73
docs/todo.md
@@ -60,7 +60,15 @@
|
|||||||
|
|
||||||
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
|
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
|
||||||
|
|
||||||
- [ ] F-260616-01 [P2] — **aichat Agentic 最大循环轮次支持配置(Settings)**。现状:`MAX_AGENT_ITERATIONS=10` 硬编码常量(`agentic.rs:33`),复杂 agentic 任务未收敛即被截断,用户无法调。**黄金参考:`llm_concurrency`**——项目唯一成熟的「前端数字配置→AppState 字段→热改 command→透传 loop 形参」范式,本需求 1:1 复刻。改动 8 处:①`state.rs` AppState 加 `agent_max_iterations: Arc<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_review,store 层兜底);②**决策点:pending_review 从 library 排除后该去哪显示**?(a) 扩 inbox 收件箱含 candidate+pending_review(语义=「待处理」收件箱)(b) 单纯从 library 消失、pending_review 暂无专门视图 (c) 加第三个 tab「待审」。**倾向 (a)**:inbox 收 candidate+pending_review,library 纯 published,职责清晰零新 tab。— Knowledge.vue(L296-299 列表数据源) + stores/knowledge.ts(loadList/loadCandidates) + knowledge.rs(knowledge_list 默认过滤 + list_candidates 范围)。**待用户定②后实施**。
|
||||||
|
|
||||||
|
- [x] ✅(batch44 后端 + batch45 前端·2026-06-16) F-260616-03 [P2] — **aichat 达最大轮次时询问「是否继续」+ 确认后续 loop**。全闭环:后端(batch44 AiMaxRoundsReached 暂停态+continue/stop IPC)+ 前端(batch45 操作卡 pendingMaxRounds+续/停按钮)。现状:`agentic.rs:321-332` 达 `max_iterations` 未收敛(`!converged`)→ emit `AiError`「达到最大轮次(N 轮),Agent 可能未完成」+ **直接走完成流程**(save/标题/提炼+`guard.reset`+`AiCompleted`),用户无法续,复杂任务被硬截断。需求:达上限改 emit 询问 → 前端「继续/停止」操作卡 → 用户点继续后续 loop、点停止走完成。**机制高度复用工具审批暂停-恢复**:审批 = `AiApprovalRequired`(audit.rs:353 emit)→前端 `pending_approvals`(useAiEvents.ts:219 push)→`ai_approve`(commands.rs:122)→`try_continue_agent_loop`(agentic.rs:388 续 loop)。本需求同构。改动骨架:①后端 `agentic.rs:321-332` 达上限分支改:不 emit AiError+完成,改 emit 新事件 `AiMaxRoundsReached` + 保持 `generating=true`(暂停态,仿审批等待) ②`mod.rs:71` 事件枚举加 `AiMaxRoundsReached { conversation_id }` ③新 command `ai_continue_loop`(用户点继续)→调 `try_continue_agent_loop` 续 loop(**续跑轮数**:重置计数再跑 `max_iterations` 轮——末轮 tool_result 已在 messages,续跑首轮自动回传 LLM,语义自洽) ④新 command `ai_stop_loop`(点停止)→走原完成流程(save+Completed) ⑤前端 `useAiEvents.ts` 加 `case 'AiMaxRoundsReached'`(push 待确认态,仿 pendingApprovals) ⑥`AiChat.vue` 加续跑操作卡 UI(继续/停止按钮)。**设计决策点(待定)**:(a) 续跑轮数=重置计数再跑 max_iterations 轮(倾向) vs 单步 1 轮 (b) 防无限续:无硬上限(用户主动授权=同意烧 token,倾向) vs 加累计上限 (c) 事件/卡片:新增 `AiMaxRoundsReached`+独立操作卡(倾向,语义清晰) vs 复用 `AiApprovalRequired`(args/name 不搭)。**关联 F-260616-01**(max 可配)——两者协同=用户完全掌控 loop 边界,建议同批实施。— agentic.rs(达上限分支 L321-332 + try_continue 复用) + mod.rs(事件枚举) + commands.rs(新 command) + useAiEvents.ts + AiChat.vue。**待用户定决策点后实施**。
|
||||||
|
|
||||||
|
- [x] ✅(batch42·2026-06-16·workflow w95hzp9tw) F-260616-04 [P1] — **run_command 超时→LLM 重试→反复审批循环**。已根治(tool_registry.rs 超时标注「勿盲目重试」+ DEFAULT_RUN_COMMAND_TIMEOUT_SECS 常量,handler map_err 改写)。根因链(探索确认):超时(默认 60s, `tool_registry.rs:491 unwrap_or(60)`)→tool_result 回传 LLM(`commands.rs:168` Err 分支)→LLM 重试同命令→**新 tool_call_id**(`stream_recv.rs:176` 每轮新 UUID)→重新 insert pending(`audit.rs:343`)→重新审批,循环。症状「再过一会又提示 Run Command」即此。修复方向:①超时 tool_result 明确标注「命令执行超时({n}s),进程已终止,勿盲目重试同命令;确需更长时限重发时提高 timeout_secs」②run_command 默认超时可配(LLM 已可传 `timeout_secs` 参数,但默认 60 + 不主动调)③配合 F-260616-05 去重根治。— tool_registry.rs(超时标注) + commands.rs(回传内容)。
|
||||||
|
- [x] ✅(batch53·2026-06-16·workflow wbh8mu1iq·**run_command 循环核心解决**) F-260616-05 [P1](**共性根治**)— **agentic loop 工具调用去重缺失**。**batch53 落地 High risk 审批前去重**(audit.rs find_cached_high_risk_result 反向扫 messages + args JSON 规范化匹配 + 排除 pending 占位 + decided_by=auto_dedup,解决 run_command 反复审批循环,F-04 超时标注 + batch53 去重双保险)。**完整 agentic loop 通用去重(search_files 重复卡片 + 滑动窗口指纹 + 决策点)③类待用户定**(窗口/粒度/措辞)
|
||||||
|
- [ ] F-260616-06 [P2] — **「未找到挂起的审批」体验**。根因:后端 `pending_approvals` 内存态(`commands.rs:130 remove None` 报错)与前端刷新/重复点击不同步;重试场景旧 id 已被处理。前端 `useAiSend.ts:221` IPC 失败不回滚 pendingApprovals,按钮残留。修复方向:①`ai_approve` 找不到 pending 时查审计表,若已 executed/rejected 返回「已处理」幂等成功(非报错) ②前端审批按钮点击防抖(同一 id 短期多次只发一次 IPC) ③前端刷新后 pending 态正确同步(已部分由 `useAiConversations.ts:119 pendingToolCalls` 恢复,核对边界)。— commands.rs(ai_approve 幂等) + useAiSend.ts(防抖)。
|
||||||
|
|
||||||
### 🔧 任务模块全栈核对(2026-06-16 · Explore 代理并行取证)
|
### 🔧 任务模块全栈核对(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 正确抓到 gap(commit 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 正确抓到 gap(commit 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 key;TS2589 用 as any 规避 vue-i18n 深度泛型)(commit 9e2aeff)
|
- [x] AR-9 ~~friendlyError 硬编码中文~~ ✅ Wave3 完成(friendlyError 全走 i18n.global.t + zh/en 双语补 4 key;TS2589 用 as any 规避 vue-i18n 深度泛型)(commit 9e2aeff)
|
||||||
- [x] AR-10 ~~想法→灵感迁移残留~~ ✅ 已统一(13 文件批量:i18n zh-CN + 后端错误 + LLM 描述/提示词 + store toast;en 待定 Ideas/Idea、docs 注释低优先略)(commit 65c475b)
|
- [x] AR-10 ~~想法→灵感迁移残留~~ ✅ 已统一(13 文件批量:i18n zh-CN + 后端错误 + LLM 描述/提示词 + store toast;en 待定 Ideas/Idea、docs 注释低优先略)(commit 65c475b)
|
||||||
- [x] AR-11 ✅(定时走查第3轮,2026-06-16,dc27e79) — ~~数据变更联动刷新~~(推荐方案A 后端 emit + store 监听)— 跨层 — 详见 [审查第五章](./02-架构设计/aichat审查报告-2026-06-14.md) — **勘察完成(2026-06-16,wxflofhf2)**:feasible/risk 中/跨 8 文件(audit.rs/commands.rs/tool_registry.rs/stores/project.ts/useAiEvents.ts/Projects/Tasks/ProjectDetail.vue)。方案A 方向合理(emit df-data-changed+entity/action 分类+store listen)但勘察 implPlan 含伪代码错误(std::env::var/.match Rust 不存在=agent 幻觉)+碰 7 近期活跃文件含未提交 P0 改动的 commands.rs。**暂缓(原)**:等本批提交后主代理重设计 emit 点 — **第2轮定时走查复核(2026-06-16):后端已实施 ✅,前端半接通 🔴** — 后端 `emit_data_changed`(audit.rs:259-270)+ 自动执行路径(audit.rs:351)+ 审批路径(commands.rs:175)三处 emit 就位且逻辑正确;前端工作区(project.ts:31-55,109-110)定义 `startDataChangedListener`/`stopDataChangedListener` + export,**但 App.vue onMounted 未挂载 → listener 永不 attach → 数据变更后列表不刷新(AR-11 功能失效,后端 emit 成死事件)**。修:App.vue onMounted 调 `startDataChangedListener()` + onUnmounted stop(对齐 workflow listener 生命周期)。详见 [定时走查-2026-06-15-第2轮.md](./05-代码审查/定时走查-2026-06-15-第2轮.md)
|
- [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-app,5711 行成事实业务层) / 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-app,5711 行成事实业务层) / 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 块级 memo(splitBlocks 已按块切分,末块单独更新天然支持方案 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 块级 memo(splitBlocks 已按块切分,末块单独更新天然支持方案 B)— src/components/AiChat.vue + src/composables/ai/useAiEvents.ts
|
||||||
- [ ] UX-2025-02 [P1] — **消息操作栏(复制 / 重新生成)**(§1.2+§1.3)。AI 气泡 hover 显示操作栏:「📋 复制」+「🔄 重新生成」。复制 = F-260615-13 合并实现;重新生成:后端删最后一条 AI 消息 + 用倒数第二条 user 消息重跑 agentic loop(新增 `ai_regenerate` IPC)。代码块额外 hover 浮出「📋 复制代码」按钮(§2.2 子项)。— src/components/AiChat.vue + src-tauri/src/commands/ai/commands.rs
|
- [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 等 key);hover tooltip 展示完整时间。— src/components/AiChat.vue (第⑬批销账 06-15)
|
- [x] UX-2025-13 [P2] — **消息时间戳**(§2.4)。气泡下方极小字号(dim 色)展示相对时间(复用 time.ts 已有 common.justNow 等 key);hover tooltip 展示完整时间。— src/components/AiChat.vue (第⑬批销账 06-15)
|
||||||
- [x] ✅(第㉒批 2026-06-16) UX-2025-14 [P2] — **Provider 切换反馈**(§5.1)。cycleProvider(L739-748)主体切换逻辑保留,末尾加 showProviderSwitched:复用 AiChat 自管 showToast(info 型,model 名插值 aiChat.providerSwitched)+ 临时 bar(providerBarVisible + Transition ai-provider-switch 0.15s 淡入/淡出 + 2000ms 自动淡出,展示 aiChat.providerSwitchHint model 名小字+accent 脉动圆点)。toast 调研:AiChat 已有自管 reactive showToast(分离窗口无根 toast 故自管),复用无降级。_providerBarTimer onBeforeUnmount 清(L1067)。stores/ai.ts 未改(cycleProvider 在 AiChat.vue 内)。— src/components/AiChat.vue + i18n(aiChat.providerSwitched/providerSwitchHint zh/en)
|
- [x] ✅(第㉒批 2026-06-16) UX-2025-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 输出。
|
||||||
|
|||||||
360
docs/待审查.md
360
docs/待审查.md
@@ -23,20 +23,360 @@
|
|||||||
|
|
||||||
## 当前队列
|
## 当前队列
|
||||||
|
|
||||||
### CR-260616-02 batch29+R-PD-10 代码改动批次 — 🟡 待审
|
## 已审归档
|
||||||
|
|
||||||
- **commit**: `fc767e1` 优化: R-PD-10 storage_err DRY统一 + FR-D3 migrations if链转数组循环
|
### 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 审批历史查询 IPC(AE-2025-08)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
||||||
|
///
|
||||||
|
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
||||||
|
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ToolExecutionDto {
|
||||||
|
pub id: String,
|
||||||
|
pub conversation_id: Option<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_id(provider 每轮新 id)
|
||||||
|
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
|
||||||
|
///
|
||||||
|
/// **治本(F-05)**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史,
|
||||||
|
/// 若发现**已落定**(executed/failed/rejected,非 pending)的同类同参高危调用,返回其
|
||||||
|
/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。
|
||||||
|
///
|
||||||
|
/// **安全边界**:
|
||||||
|
/// - 仅 High risk(循环源头);Low/Med 不去重(Low 直行无审批,Med 重试场景少且去重易误伤)。
|
||||||
|
/// - 仅匹配**已落定**结果(pending 的不命中——pending 已有独立流程,不会触发循环;
|
||||||
|
/// 且避免两个 pending 互相吞掉审批)。LLM 只有在收到 tool_result 后才会重试,
|
||||||
|
/// 故循环必然是「上一条已落定 → 重试」形态,pending 不命中不影响治本。
|
||||||
|
/// - args 走 JSON 规范化比较(键序无关),避免 LLM 两次生成键序不同误判为不同命令。
|
||||||
|
/// - 返回 None 表示无缓存命中(走原审批流程)。
|
||||||
|
///
|
||||||
|
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
|
||||||
|
fn find_cached_high_risk_result(
|
||||||
|
session: &AiSession,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> Option<String> {
|
||||||
|
use df_ai::provider::MessageRole;
|
||||||
|
|
||||||
|
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||||
|
let new_args_key = canonical_args_key(args);
|
||||||
|
|
||||||
|
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||||
|
// 单对话消息量小(百级),collect 开销可忽略。
|
||||||
|
let msgs: Vec<&ChatMessage> = session.messages.iter().collect();
|
||||||
|
|
||||||
|
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||||
|
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||||
|
let mut prev_tool_call_id: Option<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_all),Med/High 进审批门控
|
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/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-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||||
|
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||||
|
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||||
|
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
|
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(),
|
||||||
|
|||||||
@@ -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_loop(iteration 从 0 重计,天然再跑
|
||||||
|
/// max_iterations 轮,决策 a)。
|
||||||
|
///
|
||||||
|
/// 校验复用 ai_approve 模式:generating 必须为 true(暂停态)+ active_conversation_id 一致性
|
||||||
|
/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b:用户主动授权 = 同意烧 token)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_continue_loop(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<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 不受影响
|
||||||
|
/// (已锁定边界),下次发消息生效。范围双 clamp(command 端 1-50 + 前端 input min/max),
|
||||||
|
/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_set_agent_max_iterations(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
value: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// clamp 1-50:下限防 agent 失能(一轮即截断无法调任何工具),
|
||||||
|
// 上限防失控烧 token(50 轮足够覆盖复杂多步任务)
|
||||||
|
let clamped = value.clamp(1, 50) as usize;
|
||||||
|
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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> },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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> 共享底层 HashMap,IPC 写入直达运行中阻塞节点(HumanNode)
|
/// StateMachine 内部 Arc<Mutex> 共享底层 HashMap,IPC 写入直达运行中阻塞节点(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()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export interface WorkflowRecord {
|
|||||||
/** 工作流事件载荷(后端 emit 的结构) */
|
/** 工作流事件载荷(后端 emit 的结构) */
|
||||||
/** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_case,R7收窄防写错 type 字符串) */
|
/** 工作流事件类型字面量联合(对应后端 WorkflowEvent serde tag=type rename_all=snake_case,R7收窄防写错 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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '✏️ 编辑',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default {
|
|||||||
projects: '项目',
|
projects: '项目',
|
||||||
tasks: '任务',
|
tasks: '任务',
|
||||||
knowledge: '知识库',
|
knowledge: '知识库',
|
||||||
|
audit: '审批历史',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
workspace: '工作区',
|
workspace: '工作区',
|
||||||
traceability: '追溯',
|
traceability: '追溯',
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export default {
|
|||||||
labelPerConvConcurrency: '单对话并发上限',
|
labelPerConvConcurrency: '单对话并发上限',
|
||||||
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
|
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
|
||||||
|
|
||||||
|
labelAgentMaxIterations: 'Agent 最大轮次',
|
||||||
|
descAgentMaxIterations: 'Agentic 循环(流式→工具→回传)的最大轮次(1-50,默认 10);热改下次发消息生效',
|
||||||
|
|
||||||
// ===== 知识库面板 =====
|
// ===== 知识库面板 =====
|
||||||
panelKnowledge: '📚 知识库',
|
panelKnowledge: '📚 知识库',
|
||||||
labelAutoExtract: '自动提炼知识',
|
labelAutoExtract: '自动提炼知识',
|
||||||
|
|||||||
@@ -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: '工作流失败,任务已退回',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
311
src/views/AuditLog.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user