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:
215
backend/src/flow/executors/condition.rs
Normal file
215
backend/src/flow/executors/condition.rs
Normal file
@ -0,0 +1,215 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value as V;
|
||||
use tracing::info;
|
||||
|
||||
pub(crate) fn eval_condition_json(ctx: &serde_json::Value, cond: &serde_json::Value) -> Result<bool> {
|
||||
// 新增:若 cond 为数组,按 AND 语义评估(全部为 true 才为 true)
|
||||
if let Some(arr) = cond.as_array() {
|
||||
let mut all_true = true;
|
||||
for (idx, item) in arr.iter().enumerate() {
|
||||
let ok = eval_condition_json(ctx, item)?;
|
||||
info!(target = "udmin.flow", index = idx, result = %ok, "condition group item (AND)");
|
||||
if !ok { all_true = false; }
|
||||
}
|
||||
info!(target = "udmin.flow", count = arr.len(), result = %all_true, "condition group evaluated (AND)");
|
||||
return Ok(all_true);
|
||||
}
|
||||
|
||||
// 支持前端 Condition 组件导出的: { left:{type, content}, operator, right? }
|
||||
let left = cond.get("left").ok_or_else(|| anyhow::anyhow!("missing left"))?;
|
||||
let op_raw = cond.get("operator").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let right_raw = cond.get("right");
|
||||
|
||||
// 解析弱等于标记:当右值 schema.extra.weak 为 true 时,对字符串比较采用忽略大小写与首尾空白的弱等于
|
||||
let weak_eq = right_raw
|
||||
.and_then(|r| r.get("schema"))
|
||||
.and_then(|s| s.get("extra"))
|
||||
.and_then(|e| e.get("weak"))
|
||||
.and_then(|b| b.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let lval = resolve_value(ctx, left)?;
|
||||
let rval = match right_raw { Some(v) => Some(resolve_value(ctx, v)?), None => None };
|
||||
|
||||
// 归一化操作符:忽略大小写,替换下划线为空格
|
||||
let op = op_raw.trim().to_lowercase().replace('_', " ");
|
||||
|
||||
// 工具函数
|
||||
fn to_f64(v: &V) -> Option<f64> {
|
||||
match v {
|
||||
V::Number(n) => n.as_f64(),
|
||||
V::String(s) => s.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn is_empty_val(v: &V) -> bool {
|
||||
match v {
|
||||
V::Null => true,
|
||||
V::String(s) => s.trim().is_empty(),
|
||||
V::Array(a) => a.is_empty(),
|
||||
V::Object(m) => m.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn norm_str(s: &str) -> String { s.trim().to_lowercase() }
|
||||
fn json_equal(a: &V, b: &V, weak: bool) -> bool {
|
||||
match (a, b) {
|
||||
// 数字:做宽松比较(字符串转数字)
|
||||
(V::Number(_), V::Number(_)) | (V::Number(_), V::String(_)) | (V::String(_), V::Number(_)) => {
|
||||
match (to_f64(a), to_f64(b)) { (Some(x), Some(y)) => x == y, _ => a == b }
|
||||
}
|
||||
// 字符串:若 weak 则忽略大小写与首尾空白
|
||||
(V::String(sa), V::String(sb)) if weak => norm_str(sa) == norm_str(sb),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
fn contains(left: &V, right: &V, weak: bool) -> bool {
|
||||
match (left, right) {
|
||||
(V::String(s), V::String(t)) => {
|
||||
if weak { norm_str(s).contains(&norm_str(t)) } else { s.contains(t) }
|
||||
}
|
||||
(V::Array(arr), r) => arr.iter().any(|x| json_equal(x, r, weak)),
|
||||
(V::Object(map), V::String(key)) => {
|
||||
if weak { map.keys().any(|k| norm_str(k) == norm_str(key)) } else { map.contains_key(key) }
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn in_op(left: &V, right: &V, weak: bool) -> bool {
|
||||
match right {
|
||||
V::Array(arr) => arr.iter().any(|x| json_equal(left, x, weak)),
|
||||
V::Object(map) => match left { V::String(k) => {
|
||||
if weak { map.keys().any(|kk| norm_str(kk) == norm_str(k)) } else { map.contains_key(k) }
|
||||
}, _ => false },
|
||||
V::String(hay) => match left { V::String(needle) => {
|
||||
if weak { norm_str(hay).contains(&norm_str(needle)) } else { hay.contains(needle) }
|
||||
}, _ => false },
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn bool_like(v: &V) -> bool {
|
||||
match v {
|
||||
V::Bool(b) => *b,
|
||||
V::Null => false,
|
||||
V::Number(n) => n.as_f64().map(|x| x != 0.0).unwrap_or(false),
|
||||
V::String(s) => {
|
||||
let s_l = s.trim().to_lowercase();
|
||||
if s_l == "true" { true } else if s_l == "false" { false } else { !s_l.is_empty() }
|
||||
}
|
||||
V::Array(a) => !a.is_empty(),
|
||||
V::Object(m) => !m.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
let res = match (op.as_str(), &lval, &rval) {
|
||||
// 等于 / 不等于(适配所有 JSON 类型;数字按 f64 比较,其他走深度相等)
|
||||
("equal" | "equals" | "==" | "eq", l, Some(r)) => json_equal(l, r, weak_eq),
|
||||
("not equal" | "!=" | "not equals" | "neq", l, Some(r)) => !json_equal(l, r, weak_eq),
|
||||
|
||||
// 数字比较
|
||||
("greater than" | ">" | "gt", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a > b, _ => false },
|
||||
("greater than or equal" | ">=" | "gte" | "ge", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a >= b, _ => false },
|
||||
("less than" | "<" | "lt", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a < b, _ => false },
|
||||
("less than or equal" | "<=" | "lte" | "le", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a <= b, _ => false },
|
||||
|
||||
// 包含 / 不包含(字符串、数组、对象(键))
|
||||
("contains", l, Some(r)) => contains(l, r, weak_eq),
|
||||
("not contains", l, Some(r)) => !contains(l, r, weak_eq),
|
||||
|
||||
// 成员关系:left in right / not in
|
||||
("in", l, Some(r)) => in_op(l, r, weak_eq),
|
||||
("not in" | "nin", l, Some(r)) => !in_op(l, r, weak_eq),
|
||||
|
||||
// 为空 / 非空(字符串、数组、对象、null)
|
||||
("is empty" | "empty" | "isempty", l, _) => is_empty_val(l),
|
||||
("is not empty" | "not empty" | "notempty", l, _) => !is_empty_val(l),
|
||||
|
||||
// 布尔判断(对各类型进行布尔化)
|
||||
("is true" | "is true?" | "istrue", l, _) => bool_like(l),
|
||||
("is false" | "isfalse", l, _) => !bool_like(l),
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// 记录调试日志,便于定位条件为何未命中
|
||||
let l_dbg = match &lval { V::String(s) => format!("\"{}\"", s), _ => format!("{}", lval) };
|
||||
let r_dbg = match &rval { Some(V::String(s)) => format!("\"{}\"", s), Some(v) => format!("{}", v), None => "<none>".to_string() };
|
||||
info!(target = "udmin.flow", op=%op, weak=%weak_eq, left=%l_dbg, right=%r_dbg, result=%res, "condition eval");
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_value(ctx: &serde_json::Value, v: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match t {
|
||||
"constant" => Ok(v.get("content").cloned().unwrap_or(V::Null)),
|
||||
"ref" => {
|
||||
// content: [nodeId, field]
|
||||
if let Some(arr) = v.get("content").and_then(|v| v.as_array()) {
|
||||
if arr.len() >= 2 {
|
||||
if let (Some(node), Some(field)) = (arr[0].as_str(), arr[1].as_str()) {
|
||||
let val = ctx
|
||||
.get("nodes")
|
||||
.and_then(|n| n.get(node))
|
||||
.and_then(|m| m.get(field))
|
||||
.cloned()
|
||||
.or_else(|| ctx.get(field).cloned())
|
||||
.unwrap_or(V::Null);
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(V::Null)
|
||||
}
|
||||
"expression" => {
|
||||
let expr = v.get("content").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if expr.trim().is_empty() { return Ok(V::Null); }
|
||||
Ok(crate::flow::engine::eval_rhai_expr_json(expr, ctx).unwrap_or(V::Null))
|
||||
}
|
||||
_ => Ok(V::Null),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn cond_eq_const(left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"left": {"type": "constant", "content": left},
|
||||
"operator": "eq",
|
||||
"right": {"type": "constant", "content": right}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn and_group_all_true() {
|
||||
let ctx = json!({});
|
||||
let group = json!([
|
||||
cond_eq_const(json!(100), json!(100)),
|
||||
json!({
|
||||
"left": {"type": "constant", "content": 100},
|
||||
"operator": ">",
|
||||
"right": {"type": "constant", "content": 10}
|
||||
})
|
||||
]);
|
||||
let ok = eval_condition_json(&ctx, &group).unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn and_group_has_false() {
|
||||
let ctx = json!({});
|
||||
let group = json!([
|
||||
cond_eq_const(json!(100), json!(10)), // false
|
||||
json!({
|
||||
"left": {"type": "constant", "content": 100},
|
||||
"operator": ">",
|
||||
"right": {"type": "constant", "content": 10}
|
||||
})
|
||||
]);
|
||||
let ok = eval_condition_json(&ctx, &group).unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,8 @@
|
||||
pub mod http;
|
||||
pub mod db;
|
||||
pub mod db;
|
||||
// removed: pub mod expr;
|
||||
pub mod variable;
|
||||
pub mod script_rhai;
|
||||
pub mod script_js;
|
||||
pub mod script_python;
|
||||
pub mod condition;
|
||||
54
backend/src/flow/executors/script_js.rs
Normal file
54
backend/src/flow/executors/script_js.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, info};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::flow::task::Executor;
|
||||
use crate::flow::domain::{NodeDef, NodeId};
|
||||
|
||||
fn read_node_script_file(ctx: &Value, node_id: &str, lang_key: &str) -> Option<String> {
|
||||
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
|
||||
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
|
||||
return m.get(lang_key).and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let s = s.replace('\n', " ").replace('\r', " ");
|
||||
if s.len() <= max { s } else { format!("{}…", &s[..max]) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ScriptJsTask;
|
||||
|
||||
#[async_trait]
|
||||
impl Executor for ScriptJsTask {
|
||||
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
||||
let start = Instant::now();
|
||||
|
||||
// 优先 nodes.<id>.scripts.js 指定的脚本文件路径
|
||||
if let Some(path) = read_node_script_file(ctx, &node_id.0, "js") {
|
||||
let preview = truncate_str(&path, 120);
|
||||
info!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: JavaScript file execution not implemented yet (skipped)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 兼容 inline 配置(暂不执行,仅提示)
|
||||
let inline = ctx.get("script")
|
||||
.or_else(|| ctx.get("expr"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
if let Some(code) = inline {
|
||||
let preview = truncate_str(&code, 200);
|
||||
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: inline script provided, but execution not implemented");
|
||||
let _elapsed = start.elapsed().as_millis();
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_js task: JavaScript execution not implemented yet (skipped)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_js task: no script found, skip");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
54
backend/src/flow/executors/script_python.rs
Normal file
54
backend/src/flow/executors/script_python.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, info};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::flow::task::Executor;
|
||||
use crate::flow::domain::{NodeDef, NodeId};
|
||||
|
||||
fn read_node_script_file(ctx: &Value, node_id: &str, lang_key: &str) -> Option<String> {
|
||||
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
|
||||
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
|
||||
return m.get(lang_key).and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let s = s.replace('\n', " ").replace('\r', " ");
|
||||
if s.len() <= max { s } else { format!("{}…", &s[..max]) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ScriptPythonTask;
|
||||
|
||||
#[async_trait]
|
||||
impl Executor for ScriptPythonTask {
|
||||
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
||||
let start = Instant::now();
|
||||
|
||||
// 优先 nodes.<id>.scripts.python 指定的脚本文件路径
|
||||
if let Some(path) = read_node_script_file(ctx, &node_id.0, "python") {
|
||||
let preview = truncate_str(&path, 120);
|
||||
info!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_python task: Python file execution not implemented yet (skipped)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 兼容 inline 配置(暂不执行,仅提示)
|
||||
let inline = ctx.get("script")
|
||||
.or_else(|| ctx.get("expr"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
if let Some(code) = inline {
|
||||
let preview = truncate_str(&code, 200);
|
||||
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_python task: inline script provided, but execution not implemented");
|
||||
let _elapsed = start.elapsed().as_millis();
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_python task: Python execution not implemented yet (skipped)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_python task: no script found, skip");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
135
backend/src/flow/executors/script_rhai.rs
Normal file
135
backend/src/flow/executors/script_rhai.rs
Normal file
@ -0,0 +1,135 @@
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, info};
|
||||
use std::fs;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::flow::domain::NodeId;
|
||||
use crate::flow::engine::eval_rhai_expr_json;
|
||||
use crate::flow::task::Executor;
|
||||
use crate::flow::domain::NodeDef;
|
||||
use async_trait::async_trait;
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
let s = s.replace('\n', " ").replace('\r', " ");
|
||||
if s.len() <= max { s } else { format!("{}…", &s[..max]) }
|
||||
}
|
||||
|
||||
fn shallow_diff(before: &Value, after: &Value) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
use std::collections::BTreeSet;
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
let mut modified = Vec::new();
|
||||
let (Some(bm), Some(am)) = (before.as_object(), after.as_object()) else {
|
||||
if before != after { modified.push("<root>".to_string()); }
|
||||
return (added, removed, modified);
|
||||
};
|
||||
let bkeys: BTreeSet<_> = bm.keys().cloned().collect();
|
||||
let akeys: BTreeSet<_> = am.keys().cloned().collect();
|
||||
for k in akeys.difference(&bkeys) { added.push((*k).to_string()); }
|
||||
for k in bkeys.difference(&akeys) { removed.push((*k).to_string()); }
|
||||
for k in akeys.intersection(&bkeys) {
|
||||
let key = (*k).to_string();
|
||||
if bm.get(&key) != am.get(&key) { modified.push(key); }
|
||||
}
|
||||
(added, removed, modified)
|
||||
}
|
||||
|
||||
pub fn exec_rhai_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::Result<()> {
|
||||
let start = Instant::now();
|
||||
let code = match fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
info!(target = "udmin.flow", node=%node_id.0, err=%e.to_string(), "script task: failed to read Rhai file");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let script = code;
|
||||
if script.trim().is_empty() {
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script task: empty Rhai file, skip");
|
||||
return Ok(());
|
||||
}
|
||||
let preview = truncate_str(&script, 200);
|
||||
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script task: will execute Rhai file");
|
||||
|
||||
let before_ctx = ctx.clone();
|
||||
let wrapped = format!("{{ {} ; ctx }}", script);
|
||||
let res = eval_rhai_expr_json(&wrapped, ctx);
|
||||
let dur_ms = start.elapsed().as_millis();
|
||||
match res {
|
||||
Some(new_ctx) => {
|
||||
let (added, removed, modified) = shallow_diff(&before_ctx, &new_ctx);
|
||||
*ctx = new_ctx;
|
||||
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, added=%added.len(), removed=%removed.len(), modified=%modified.len(), "script task: Rhai file executed and ctx updated");
|
||||
if !(added.is_empty() && removed.is_empty() && modified.is_empty()) {
|
||||
debug!(target = "udmin.flow", node=%node_id.0, ?added, ?removed, ?modified, "script task: ctx shallow diff");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, "script task: Rhai file execution failed, ctx unchanged");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_node_script_file(ctx: &Value, node_id: &str) -> Option<String> {
|
||||
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
|
||||
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
|
||||
return m.get("rhai").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ScriptRhaiTask;
|
||||
|
||||
#[async_trait]
|
||||
impl Executor for ScriptRhaiTask {
|
||||
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
||||
let start = Instant::now();
|
||||
|
||||
// 1) 文件脚本优先:nodes.<id>.scripts.rhai -> 直接执行文件
|
||||
if let Some(path) = read_node_script_file(ctx, &node_id.0) {
|
||||
return exec_rhai_file(node_id, &path, ctx);
|
||||
}
|
||||
|
||||
// 2) inline 脚本(支持 String 或 { script | expr })
|
||||
let cfg: Option<String> = ctx.get("nodes")
|
||||
.and_then(|nodes| nodes.get(&node_id.0))
|
||||
.and_then(|n| n.get("script").or_else(|| n.get("expr")))
|
||||
.and_then(|v| match v { Value::String(s) => Some(s.clone()), Value::Object(m) => m.get("script").or_else(|| m.get("expr")).and_then(|x| x.as_str()).map(|s| s.to_string()), _ => None })
|
||||
.or_else(|| ctx.get("script").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
.or_else(|| ctx.get("expr").and_then(|v| v.as_str()).map(|s| s.to_string()));
|
||||
|
||||
if let Some(script) = cfg {
|
||||
if script.trim().is_empty() {
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_rhai task: empty inline script, skip");
|
||||
return Ok(());
|
||||
}
|
||||
let script_preview = truncate_str(&script, 200);
|
||||
debug!(target = "udmin.flow", node=%node_id.0, preview=%script_preview, "script_rhai task: will execute Rhai inline script");
|
||||
|
||||
let before_ctx = ctx.clone();
|
||||
let wrapped = format!("{{ {} ; ctx }}", script);
|
||||
let res = super::super::engine::eval_rhai_expr_json(&wrapped, ctx);
|
||||
let dur_ms = start.elapsed().as_millis();
|
||||
match res {
|
||||
Some(new_ctx) => {
|
||||
let (added, removed, modified) = shallow_diff(&before_ctx, &new_ctx);
|
||||
*ctx = new_ctx;
|
||||
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, added=%added.len(), removed=%removed.len(), modified=%modified.len(), "script_rhai task: inline executed and ctx updated");
|
||||
if !(added.is_empty() && removed.is_empty() && modified.is_empty()) {
|
||||
debug!(target = "udmin.flow", node=%node_id.0, ?added, ?removed, ?modified, "script_rhai task: ctx shallow diff");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%script_preview, "script_rhai task: inline execution failed, ctx unchanged");
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(target = "udmin.flow", node=%node_id.0, "script_rhai task: no script found, skip");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
133
backend/src/flow/executors/variable.rs
Normal file
133
backend/src/flow/executors/variable.rs
Normal file
@ -0,0 +1,133 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use tracing::info;
|
||||
|
||||
use crate::flow::task::Executor;
|
||||
use crate::flow::domain::{NodeDef, NodeId};
|
||||
use crate::flow::engine::eval_rhai_expr_json;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VariableTask;
|
||||
|
||||
fn resolve_assign_value(ctx: &Value, v: &Value) -> Value {
|
||||
use serde_json::Value as V;
|
||||
// helper: get by object path
|
||||
fn get_by_path<'a>(mut cur: &'a V, path: &[&str]) -> Option<&'a V> {
|
||||
for seg in path {
|
||||
match cur {
|
||||
V::Object(map) => {
|
||||
if let Some(next) = map.get(*seg) { cur = next; } else { return None; }
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match t {
|
||||
"constant" => v.get("content").cloned().unwrap_or(V::Null),
|
||||
"ref" => {
|
||||
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
|
||||
let parts: Vec<String> = v
|
||||
.get("content")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
if parts.is_empty() { return V::Null; }
|
||||
|
||||
// Prefer nodes.<nodeId>.* if node id is provided
|
||||
if parts.len() >= 1 {
|
||||
let node_id = &parts[0];
|
||||
let rest: Vec<&str> = parts.iter().skip(1).map(|s| s.as_str()).collect();
|
||||
// 1) direct: nodes.<nodeId>.<rest...>
|
||||
let mut path_nodes: Vec<&str> = vec!["nodes", node_id.as_str()];
|
||||
path_nodes.extend(rest.iter().copied());
|
||||
if let Some(val) = get_by_path(ctx, &path_nodes) { return val.clone(); }
|
||||
// 2) HTTP shortcut: nodes.<nodeId>.http_response.<rest...> (e.g., [node, "body"])
|
||||
let mut path_http: Vec<&str> = vec!["nodes", node_id.as_str(), "http_response"];
|
||||
path_http.extend(rest.iter().copied());
|
||||
if let Some(val) = get_by_path(ctx, &path_http) { return val.clone(); }
|
||||
}
|
||||
// Fallback: interpret as top-level path: ctx[parts[0]][parts[1]]...
|
||||
let path_top: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
|
||||
if let Some(val) = get_by_path(ctx, &path_top) { return val.clone(); }
|
||||
// Additional fallback: if looks like [nodeId, ...rest] but nodes.* missing, try top-level with rest only
|
||||
if parts.len() >= 2 {
|
||||
let rest_only: Vec<&str> = parts.iter().skip(1).map(|s| s.as_str()).collect();
|
||||
if let Some(val) = get_by_path(ctx, &rest_only) { return val.clone(); }
|
||||
}
|
||||
|
||||
V::Null
|
||||
}
|
||||
"expression" => {
|
||||
let expr = v.get("content").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if expr.trim().is_empty() { return V::Null; }
|
||||
eval_rhai_expr_json(expr, ctx).unwrap_or(V::Null)
|
||||
}
|
||||
_ => {
|
||||
// fallback: if content exists, treat as constant
|
||||
v.get("content").cloned().unwrap_or(V::Null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executor for VariableTask {
|
||||
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
||||
// 读取 variable 配置:仅节点级
|
||||
let node_id_str = &node_id.0;
|
||||
let cfg = match ctx.get("nodes") {
|
||||
Some(nodes) => nodes.get(node_id_str).and_then(|n| n.get("variable")).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(cfg) = cfg else {
|
||||
info!(target = "udmin.flow", node=%node_id.0, "variable task: no config found, skip");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// 支持 { assign: [...] } 或直接为数组
|
||||
let assigns: Vec<Value> = match &cfg {
|
||||
Value::Array(arr) => arr.clone(),
|
||||
Value::Object(m) => m.get("assign").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
|
||||
_ => vec![],
|
||||
};
|
||||
if assigns.is_empty() {
|
||||
info!(target = "udmin.flow", node=%node_id.0, "variable task: empty assign list, skip");
|
||||
// 移除 variable 节点配置,避免出现在最终 ctx
|
||||
if let Value::Object(map) = ctx { if let Some(Value::Object(nodes)) = map.get_mut("nodes") { nodes.remove(node_id_str); } }
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut applied = 0usize;
|
||||
for item in assigns {
|
||||
let op = item.get("operator").and_then(|v| v.as_str()).unwrap_or("assign");
|
||||
let left = item.get("left").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let right = item.get("right").unwrap_or(&Value::Null);
|
||||
if left.is_empty() { continue; }
|
||||
let val = resolve_assign_value(ctx, right);
|
||||
|
||||
if let Value::Object(map) = ctx {
|
||||
let exists = map.contains_key(left);
|
||||
let do_set = match op {
|
||||
"declare" => !exists,
|
||||
_ => true,
|
||||
};
|
||||
if do_set {
|
||||
map.insert(left.to_string(), val);
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行完成后,移除 variable 节点,避免出现在最终 ctx
|
||||
if let Value::Object(map) = ctx {
|
||||
if let Some(Value::Object(nodes)) = map.get_mut("nodes") {
|
||||
nodes.remove(node_id_str);
|
||||
}
|
||||
}
|
||||
|
||||
info!(target = "udmin.flow", node=%node_id.0, count=%applied, "variable task: assigned variables");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user