feat(backend): 新增 QuickJS 运行时支持 JavaScript 执行器

refactor(backend): 重构 script_js 执行器实现 JavaScript 文件/内联脚本执行
feat(backend): 变量节点支持表达式/引用快捷语法输入
docs: 添加变量节点使用文档说明快捷语法功能
style(frontend): 调整测试面板样式和布局
fix(frontend): 修复测试面板打开时自动关闭节点编辑侧栏
build(backend): 添加 rquickjs 依赖用于 JavaScript 执行
This commit is contained in:
2025-09-20 17:35:36 +08:00
parent baa787934a
commit 296f0ae9f6
11 changed files with 357 additions and 41 deletions

View File

@ -1,7 +1,9 @@
use async_trait::async_trait;
use async_trait::async_trait
;
use serde_json::Value;
use tracing::{debug, info};
use std::time::Instant;
use std::fs;
use crate::flow::task::Executor;
use crate::flow::domain::{NodeDef, NodeId};
@ -20,34 +22,136 @@ fn truncate_str(s: &str, max: usize) -> String {
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)
}
fn exec_js_script(node_id: &NodeId, script: &str, ctx: &mut Value) -> anyhow::Result<()> {
use rquickjs::{Runtime, Context as JsContext, Ctx, FromJs, Value as JsValue};
if script.trim().is_empty() {
info!(target = "udmin.flow", node=%node_id.0, "script_js task: empty script, skip");
return Ok(());
}
let start = Instant::now();
let preview = truncate_str(script, 200);
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: will execute JavaScript script");
let before_ctx = ctx.clone();
// QuickJS 运行
let rt = Runtime::new()?;
let qctx = JsContext::full(&rt)?;
// 在 JS 中执行并取回 ctx
let res: anyhow::Result<Option<Value>> = qctx.with(|q: Ctx<'_>| {
// 将当前 ctx 作为 JSON 字符串注入到 JS 全局对象
let global = q.globals();
let ctx_json = serde_json::to_string(&before_ctx).unwrap_or_else(|_| "{}".to_string());
global.set("__CTX_JSON", ctx_json)?;
// 包装脚本,确保返回序列化后的 ctx 字符串(用字符串拼接避免 format! 花括号转义问题)
let mut wrapped = String::new();
wrapped.push_str("(function(){ try { var ctx = JSON.parse(globalThis.__CTX_JSON); ");
wrapped.push_str(script);
wrapped.push_str(" ; return (typeof ctx === 'undefined') ? undefined : JSON.stringify(ctx); } catch (e) { throw e; } })()");
// 执行并获取返回值
let v: JsValue = q.eval(wrapped)?;
// 如果结果为 undefined/null则认为未修改
if v.is_null() || v.is_undefined() {
Ok(None)
} else {
// 将返回的 JS 值转换为 Rust String然后解析为 serde_json::Value
let s: String = String::from_js(&q, v)?;
let new_ctx: Value = serde_json::from_str(&s)?;
Ok(Some(new_ctx))
}
});
let dur_ms = start.elapsed().as_millis();
match res {
Ok(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_js task: 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_js task: ctx shallow diff");
}
}
Ok(None) => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, "script_js task: script returned no ctx, ctx unchanged");
}
Err(e) => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, err=%e.to_string(), preview=%preview, "script_js task: execution failed, ctx unchanged");
}
}
Ok(())
}
fn exec_js_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::Result<()> {
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_js task: failed to read JS file");
return Ok(());
}
};
if code.trim().is_empty() {
info!(target = "udmin.flow", node=%node_id.0, "script_js task: empty JS file, skip");
return Ok(());
}
exec_js_script(node_id, &code, ctx)
}
#[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 指定的脚本文件路径
// 1) 文件脚本优先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(());
debug!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: will execute JS file");
return exec_js_file(node_id, &path, ctx);
}
// 兼容 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(());
// 2) inline 脚本(支持 String 或 { script | expr }),优先读取 nodes.<id>,再回退到根级
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 {
return exec_js_script(node_id, &script, ctx);
}
info!(target = "udmin.flow", node=%node_id.0, "script_js task: no script found, skip");
Ok(())
}

View File

@ -1,5 +1,5 @@
use async_trait::async_trait;
use serde_json::Value;
use serde_json::{Value, json};
use tracing::info;
use crate::flow::task::Executor;
@ -26,7 +26,39 @@ fn resolve_assign_value(ctx: &Value, v: &Value) -> Value {
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
match t {
"constant" => v.get("content").cloned().unwrap_or(V::Null),
"constant" => {
// 支持两种扩展写法:
// 1) 若常量为字符串且形如 ctx[...] / ctx. 开头,则按 Rhai 表达式求值
// 2) 若常量为字符串且形如 ${path.a.b},则等价于 ref: { content: ["path","a","b"] }
if let Some(s) = v.get("content").and_then(|c| c.as_str()) {
let s_trim = s.trim();
// ${a.b.c} -> ref
if s_trim.starts_with("${") && s_trim.ends_with('}') {
let inner = s_trim[2..s_trim.len()-1].trim();
// 解析为路径段,支持 a.b.c 或 a["b"]["c"] 的简化处理
let normalized = inner
.replace('[', ".")
.replace(']', "")
.replace('"', "")
.replace('\'', "");
let parts: Vec<String> = normalized
.trim_matches('.')
.split('.')
.filter(|seg| !seg.is_empty())
.map(|seg| seg.to_string())
.collect();
if !parts.is_empty() {
let ref_json = json!({"type":"ref","content": parts});
return resolve_assign_value(ctx, &ref_json);
}
}
// ctx[...] / ctx. 前缀 -> 表达式求值
if s_trim.starts_with("ctx[") || s_trim.starts_with("ctx.") {
return eval_rhai_expr_json(s_trim, ctx).unwrap_or(V::Null);
}
}
v.get("content").cloned().unwrap_or(V::Null)
}
"ref" => {
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
let parts: Vec<String> = v