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:
@ -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(())
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user