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:
2025-09-19 13:41:52 +08:00
parent 81757eecf5
commit 62789fce42
25 changed files with 1651 additions and 313 deletions

View File

@ -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"));
}
}