feat: 新增条件节点和多语言脚本支持
refactor(flow): 将Decision节点重命名为Condition节点 feat(flow): 新增多语言脚本执行器(Rhai/JS/Python) feat(flow): 实现变量映射和执行功能 feat(flow): 添加条件节点执行逻辑 feat(frontend): 为开始/结束节点添加多语言描述 test: 添加yaml条件转换测试 chore: 移除废弃的storage模块
This commit is contained in:
@ -17,7 +17,7 @@ pub struct FlowDSL {
|
||||
pub struct NodeDSL {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub kind: String, // start / end / task / decision
|
||||
pub kind: String, // 节点类型:start / end / task / condition(开始/结束/任务/条件)
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
@ -46,7 +46,7 @@ impl From<FlowDSL> for super::domain::ChainDef {
|
||||
kind: match n.kind.to_lowercase().as_str() {
|
||||
"start" => super::domain::NodeKind::Start,
|
||||
"end" => super::domain::NodeKind::End,
|
||||
"decision" => super::domain::NodeKind::Decision,
|
||||
"decision" | "condition" => super::domain::NodeKind::Condition,
|
||||
_ => super::domain::NodeKind::Task,
|
||||
},
|
||||
name: n.name,
|
||||
@ -66,131 +66,188 @@ impl From<FlowDSL> for super::domain::ChainDef {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== New: Parse design_json (free layout JSON) to ChainDef =====
|
||||
// ===== 将 design_json(前端自由布局 JSON)解析为 ChainDef =====
|
||||
|
||||
/// Build ChainDef from design_json (front-end flow JSON)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DesignSyntax {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<NodeSyntax>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<EdgeSyntax>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeSyntax {
|
||||
pub id: String,
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: String, // 取值: start | end | condition | http | db | task(开始/结束/条件/HTTP/数据库/通用任务)
|
||||
#[serde(default)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EdgeSyntax {
|
||||
#[serde(alias = "sourceNodeID", alias = "source", alias = "from")]
|
||||
pub from: String,
|
||||
#[serde(alias = "targetNodeID", alias = "target", alias = "to")]
|
||||
pub to: String,
|
||||
#[serde(default)]
|
||||
pub source_port_id: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
/// 从 design_json(前端流程 JSON)构建 ChainDef
|
||||
fn validate_design(design: &DesignSyntax) -> anyhow::Result<()> {
|
||||
use std::collections::HashSet;
|
||||
let mut ids = HashSet::new();
|
||||
for n in &design.nodes {
|
||||
// 节点 ID 不能为空,且在一个流程内必须唯一
|
||||
if n.id.trim().is_empty() { bail!("node id is required"); }
|
||||
if !ids.insert(n.id.clone()) { bail!("duplicate node id: {}", n.id); }
|
||||
}
|
||||
// 确保至少包含一个开始节点与一个结束节点
|
||||
let mut start = 0usize;
|
||||
let mut end = 0usize;
|
||||
for n in &design.nodes {
|
||||
match n.kind.as_str() {
|
||||
"start" => start += 1,
|
||||
"end" => end += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(start >= 1, "flow must have at least one start node");
|
||||
anyhow::ensure!(end >= 1, "flow must have at least one end node");
|
||||
|
||||
// 校验边的引用合法性(from/to 必须存在且指向已知节点)
|
||||
for e in &design.edges {
|
||||
if e.from.is_empty() || e.to.is_empty() { bail!("edge must have both from and to"); }
|
||||
if !ids.contains(&e.from) { bail!("edge from references unknown node: {}", e.from); }
|
||||
if !ids.contains(&e.to) { bail!("edge to references unknown node: {}", e.to); }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_chain_from_design(design: &DesignSyntax) -> anyhow::Result<super::domain::ChainDef> {
|
||||
use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef};
|
||||
|
||||
let mut nodes: Vec<NodeDef> = Vec::new();
|
||||
for n in &design.nodes {
|
||||
let kind = match n.kind.as_str() {
|
||||
"start" => NodeKind::Start,
|
||||
"end" => NodeKind::End,
|
||||
"condition" => NodeKind::Condition,
|
||||
_ => NodeKind::Task,
|
||||
};
|
||||
// 从节点 data.title 读取名称,若不存在则为空字符串
|
||||
let name = n.data.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
// 将可执行类型映射到任务标识(用于绑定任务实现)
|
||||
let mut task = match n.kind.as_str() {
|
||||
"http" => Some("http".to_string()),
|
||||
"db" => Some("db".to_string()),
|
||||
"variable" => Some("variable".to_string()),
|
||||
// 脚本节点:按语言拆分
|
||||
"script" | "expr" | "script_rhai" => Some("script_rhai".to_string()),
|
||||
"script_js" | "javascript" | "js" => Some("script_js".to_string()),
|
||||
"script_python" | "python" | "py" => Some("script_python".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
// 兼容/推断:根据 data.scripts.* 或 inline script/expr 推断脚本类型
|
||||
if task.is_none() {
|
||||
if let Some(obj) = n.data.get("scripts").and_then(|v| v.as_object()) {
|
||||
if obj.get("js").is_some() { task = Some("script_js".to_string()); }
|
||||
else if obj.get("python").is_some() { task = Some("script_python".to_string()); }
|
||||
else if obj.get("rhai").is_some() { task = Some("script_rhai".to_string()); }
|
||||
}
|
||||
}
|
||||
if task.is_none() {
|
||||
if n.data.get("script").is_some() || n.data.get("expr").is_some() {
|
||||
task = Some("script_rhai".to_string());
|
||||
}
|
||||
}
|
||||
nodes.push(NodeDef { id: NodeId(n.id.clone()), kind, name, task });
|
||||
}
|
||||
|
||||
// 预统计每个 from 节点的出边数量,用于启发式:条件节点仅一条出边且包含多个条件时,默认组装 AND
|
||||
use std::collections::HashMap;
|
||||
let mut out_deg: HashMap<&str, usize> = HashMap::new();
|
||||
for e in &design.edges { *out_deg.entry(e.from.as_str()).or_insert(0) += 1; }
|
||||
|
||||
// 兼容旧版的基于 sourcePortID 的条件编码:
|
||||
// 若边上带有 source_port_id,则在源节点(条件节点)的 data.conditions 中查找同 key 的条件值,作为边的 condition
|
||||
// 新增:当 source_port_id 为空,或取值为 and/all/group/true 时,将该条件节点内所有 conditions 的 value 组成数组,按 AND 语义挂到该边上
|
||||
// 进一步新增启发式:当源为条件节点且其出边仅有 1 条,且该节点内包含多个 conditions,则即便 source_port_id 指向了具体 key,也按 AND 组装
|
||||
let mut links: Vec<LinkDef> = Vec::new();
|
||||
for e in &design.edges {
|
||||
let mut cond: Option<String> = None;
|
||||
if let Some(src) = design.nodes.iter().find(|x| x.id == e.from) {
|
||||
if src.kind.as_str() == "condition" {
|
||||
let conds = src.data.get("conditions").and_then(|v| v.as_array());
|
||||
let conds_len = conds.map(|a| a.len()).unwrap_or(0);
|
||||
let only_one_out = out_deg.get(src.id.as_str()).copied().unwrap_or(0) == 1;
|
||||
match &e.source_port_id {
|
||||
Some(spid) => {
|
||||
let spid_l = spid.to_lowercase();
|
||||
let mut want_group = spid_l == "and" || spid_l == "all" || spid_l == "group" || spid_l == "true";
|
||||
if !want_group && only_one_out && conds_len > 1 {
|
||||
// 启发式回退:单出边 + 多条件 => 组装为 AND 组
|
||||
want_group = true;
|
||||
}
|
||||
if want_group {
|
||||
if let Some(arr) = conds {
|
||||
let mut values: Vec<Value> = Vec::new();
|
||||
for item in arr { if let Some(v) = item.get("value").cloned() { values.push(v); } }
|
||||
if !values.is_empty() { if let Ok(s) = serde_json::to_string(&Value::Array(values)) { cond = Some(s); } }
|
||||
}
|
||||
} else {
|
||||
if let Some(arr) = conds {
|
||||
if let Some(item) = arr.iter().find(|c| c.get("key").and_then(|v| v.as_str()) == Some(spid.as_str())) {
|
||||
if let Some(val) = item.get("value") { if let Ok(s) = serde_json::to_string(val) { cond = Some(s); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 没有指定具体端口:将该节点的全部条件组成 AND 组
|
||||
if let Some(arr) = conds {
|
||||
let mut values: Vec<Value> = Vec::new();
|
||||
for item in arr { if let Some(v) = item.get("value").cloned() { values.push(v); } }
|
||||
if !values.is_empty() { if let Ok(s) = serde_json::to_string(&Value::Array(values)) { cond = Some(s); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
links.push(LinkDef { from: NodeId(e.from.clone()), to: NodeId(e.to.clone()), condition: cond });
|
||||
}
|
||||
|
||||
Ok(ChainDef { name: design.name.clone(), nodes, links })
|
||||
}
|
||||
|
||||
// Rewire external API to typed syntax -> validate -> build
|
||||
pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::ChainDef> {
|
||||
use super::domain::{ChainDef, NodeKind};
|
||||
|
||||
// Accept both JSON object and stringified JSON
|
||||
let parsed: Option<Value> = match design {
|
||||
Value::String(s) => serde_json::from_str::<Value>(s).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let design = parsed.as_ref().unwrap_or(design);
|
||||
let mut syntax: DesignSyntax = serde_json::from_value(design.clone())?;
|
||||
|
||||
let name = design
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (nodes, valid_ids, nodes_arr) = parse_nodes(design)?;
|
||||
|
||||
// Basic structure validations
|
||||
let start_cnt = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Start)).count();
|
||||
anyhow::ensure!(start_cnt >= 1, "flow must have at least one start node");
|
||||
let end_cnt = nodes.iter().filter(|n| matches!(n.kind, NodeKind::End)).count();
|
||||
anyhow::ensure!(end_cnt >= 1, "flow must have at least one end node");
|
||||
|
||||
let links = parse_edges(design, &nodes_arr, &valid_ids)?;
|
||||
|
||||
Ok(ChainDef { name, nodes, links })
|
||||
}
|
||||
|
||||
fn parse_nodes(design: &Value) -> anyhow::Result<(
|
||||
Vec<super::domain::NodeDef>,
|
||||
std::collections::HashSet<String>,
|
||||
Vec<Value>,
|
||||
)> {
|
||||
use super::domain::{NodeDef, NodeId, NodeKind};
|
||||
use anyhow::bail;
|
||||
|
||||
let nodes_arr = design.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
let mut nodes: Vec<NodeDef> = Vec::new();
|
||||
let mut id_set: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for n in &nodes_arr {
|
||||
let id = n.get("id").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
if id.is_empty() { bail!("node id is required"); }
|
||||
if !id_set.insert(id.to_string()) { bail!("duplicate node id: {id}"); }
|
||||
let t = n.get("type").and_then(|v| v.as_str()).unwrap_or("task");
|
||||
let name_field = n
|
||||
.get("data")
|
||||
.and_then(|d| d.get("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let kind = match t {
|
||||
"start" => NodeKind::Start,
|
||||
"end" => NodeKind::End,
|
||||
"condition" => NodeKind::Decision,
|
||||
_ => NodeKind::Task,
|
||||
};
|
||||
// Map type to task executor id (only for executable nodes). Others will be None.
|
||||
let task = match t {
|
||||
"http" => Some("http".to_string()),
|
||||
"db" => Some("db".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
nodes.push(NodeDef { id: NodeId(id.to_string()), kind, name: name_field, task });
|
||||
}
|
||||
|
||||
Ok((nodes, id_set, nodes_arr))
|
||||
}
|
||||
|
||||
fn parse_edges(
|
||||
design: &Value,
|
||||
nodes_arr: &Vec<Value>,
|
||||
valid_ids: &std::collections::HashSet<String>,
|
||||
) -> anyhow::Result<Vec<super::domain::LinkDef>> {
|
||||
use super::domain::{LinkDef, NodeId};
|
||||
use anyhow::bail;
|
||||
|
||||
let mut links: Vec<LinkDef> = Vec::new();
|
||||
// fill source_port_id for backward compat if edges carry sourcePortID
|
||||
if let Some(arr) = design.get("edges").and_then(|v| v.as_array()) {
|
||||
for e in arr {
|
||||
let from = e
|
||||
.get("sourceNodeID")
|
||||
.or_else(|| e.get("source"))
|
||||
.or_else(|| e.get("from"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let to = e
|
||||
.get("targetNodeID")
|
||||
.or_else(|| e.get("target"))
|
||||
.or_else(|| e.get("to"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if from.is_empty() || to.is_empty() { bail!("edge must have both from and to"); }
|
||||
if !valid_ids.contains(&from) { bail!("edge from references unknown node: {from}"); }
|
||||
if !valid_ids.contains(&to) { bail!("edge to references unknown node: {to}"); }
|
||||
|
||||
// Try build structured condition for edges from a condition node via sourcePortID mapping
|
||||
let mut cond: Option<String> = None;
|
||||
for (i, e) in arr.iter().enumerate() {
|
||||
if let Some(spid) = e.get("sourcePortID").and_then(|v| v.as_str()) {
|
||||
// find source node
|
||||
if let Some(src_node) = nodes_arr.iter().find(|n| n.get("id").and_then(|v| v.as_str()) == Some(from.as_str())) {
|
||||
if src_node.get("type").and_then(|v| v.as_str()) == Some("condition") {
|
||||
if let Some(conds) = src_node.get("data").and_then(|d| d.get("conditions")).and_then(|v| v.as_array()) {
|
||||
if let Some(item) = conds.iter().find(|c| c.get("key").and_then(|v| v.as_str()) == Some(spid)) {
|
||||
if let Some(val) = item.get("value") {
|
||||
// store JSON string for engine to interpret at runtime
|
||||
if let Ok(s) = serde_json::to_string(val) { cond = Some(s); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if i < syntax.edges.len() {
|
||||
syntax.edges[i].source_port_id = Some(spid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
links.push(LinkDef { from: NodeId(from), to: NodeId(to), condition: cond });
|
||||
}
|
||||
}
|
||||
Ok(links)
|
||||
|
||||
validate_design(&syntax)?;
|
||||
build_chain_from_design(&syntax)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -203,26 +260,24 @@ mod tests {
|
||||
let design = json!({
|
||||
"name": "demo",
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start", "data": {"title": "start"}},
|
||||
{"id": "t1", "type": "http", "data": {"title": "call"}},
|
||||
{"id": "e1", "type": "end", "data": {"title": "end"}}
|
||||
{"id": "n1", "type": "start", "data": {"title": "Start"}},
|
||||
{"id": "n2", "type": "http", "data": {"title": "HTTP"}},
|
||||
{"id": "n3", "type": "end", "data": {"title": "End"}}
|
||||
],
|
||||
"edges": [
|
||||
{"sourceNodeID": "s1", "targetNodeID": "t1"},
|
||||
{"sourceNodeID": "t1", "targetNodeID": "e1"}
|
||||
{"from": "n1", "to": "n2"},
|
||||
{"from": "n2", "to": "n3"}
|
||||
]
|
||||
});
|
||||
let chain = chain_from_design_json(&design).expect("ok");
|
||||
assert_eq!(chain.name, "demo");
|
||||
let chain = chain_from_design_json(&design).unwrap();
|
||||
assert_eq!(chain.nodes.len(), 3);
|
||||
assert_eq!(chain.links.len(), 2);
|
||||
assert_eq!(chain.nodes.iter().find(|n| matches!(n.kind, super::super::domain::NodeKind::Start)).is_some(), true);
|
||||
assert_eq!(chain.nodes.iter().find(|n| matches!(n.kind, super::super::domain::NodeKind::End)).is_some(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_node_id_should_error() {
|
||||
let design = json!({
|
||||
"name": "demo",
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "start"},
|
||||
{"id": "n1", "type": "end"}
|
||||
@ -230,44 +285,35 @@ mod tests {
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&design).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("duplicate node id"));
|
||||
assert!(err.to_string().contains("duplicate node id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_start_or_end_should_error() {
|
||||
let only_start = json!({
|
||||
let design = json!({
|
||||
"name": "demo",
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start"},
|
||||
{"id": "t1", "type": "task"}
|
||||
{"id": "n1", "type": "start"}
|
||||
],
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&only_start).unwrap_err();
|
||||
assert!(format!("{err}").contains("end"));
|
||||
|
||||
let only_end = json!({
|
||||
"nodes": [
|
||||
{"id": "e1", "type": "end"}
|
||||
],
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&only_end).unwrap_err();
|
||||
assert!(format!("{err}").contains("start"));
|
||||
let err = chain_from_design_json(&design).unwrap_err();
|
||||
assert!(err.to_string().contains("at least one end"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_ref_unknown_node_should_error() {
|
||||
let design = json!({
|
||||
"name": "demo",
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start"},
|
||||
{"id": "e1", "type": "end"}
|
||||
{"id": "n1", "type": "start"},
|
||||
{"id": "n2", "type": "end"}
|
||||
],
|
||||
"edges": [
|
||||
{"sourceNodeID": "s1", "targetNodeID": "x"}
|
||||
{"from": "n1", "to": "n3"}
|
||||
]
|
||||
});
|
||||
let err = chain_from_design_json(&design).unwrap_err();
|
||||
assert!(format!("{err}").contains("unknown node"));
|
||||
assert!(err.to_string().contains("edge to references unknown node"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user