fix(flow): 修复Rhai脚本执行错误处理并优化变量解析逻辑

refactor(engine): 重构Rhai表达式错误处理为枚举类型
fix(script_rhai): 修正脚本文件读取和执行失败的错误返回
perf(testrun): 优化前端测试面板日志去重和显示逻辑
This commit is contained in:
2025-09-22 20:25:05 +08:00
parent 3362268575
commit cb0d829884
5 changed files with 59 additions and 18 deletions

View File

@ -122,8 +122,27 @@ fn eval_rhai_expr_bool(expr: &str, ctx: &serde_json::Value) -> bool {
} }
} }
// 通用:评估 Rhai 表达式并转换为 serde_json::Value失败返回 None // 通用:评估 Rhai 表达式并转换为 serde_json::Value失败返回错误
pub(crate) fn eval_rhai_expr_json(expr: &str, ctx: &serde_json::Value) -> Result<serde_json::Value, String> { #[derive(Debug, Clone)]
pub enum RhaiExecError {
Compile { message: String },
Runtime { message: String },
Serde { message: String },
}
impl std::fmt::Display for RhaiExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RhaiExecError::Compile { message } => write!(f, "compile error: {}", message),
RhaiExecError::Runtime { message } => write!(f, "runtime error: {}", message),
RhaiExecError::Serde { message } => write!(f, "serde error: {}", message),
}
}
}
impl std::error::Error for RhaiExecError {}
pub(crate) fn eval_rhai_expr_json(expr: &str, ctx: &serde_json::Value) -> Result<serde_json::Value, RhaiExecError> {
// 构造作用域并注入 ctx // 构造作用域并注入 ctx
let mut scope = rhai::Scope::new(); let mut scope = rhai::Scope::new();
let dyn_ctx = match rhai::serde::to_dynamic(ctx.clone()) { Ok(d) => d, Err(_) => rhai::Dynamic::UNIT }; let dyn_ctx = match rhai::serde::to_dynamic(ctx.clone()) { Ok(d) => d, Err(_) => rhai::Dynamic::UNIT };
@ -131,12 +150,12 @@ pub(crate) fn eval_rhai_expr_json(expr: &str, ctx: &serde_json::Value) -> Result
// 先从缓存读取 AST未命中则编译并写入缓存然后执行 // 先从缓存读取 AST未命中则编译并写入缓存然后执行
let cached = AST_CACHE.with(|c| c.borrow().get(expr).cloned()); let cached = AST_CACHE.with(|c| c.borrow().get(expr).cloned());
let eval = |ast: &AST, scope: &mut rhai::Scope| -> Result<serde_json::Value, String> { let eval = |ast: &AST, scope: &mut rhai::Scope| -> Result<serde_json::Value, RhaiExecError> {
RHIA_ENGINE.with(|eng| { RHIA_ENGINE.with(|eng| {
eng.borrow() eng.borrow()
.eval_ast_with_scope::<rhai::Dynamic>(scope, ast) .eval_ast_with_scope::<rhai::Dynamic>(scope, ast)
.map_err(|e| e.to_string()) .map_err(|e| RhaiExecError::Runtime { message: e.to_string() })
.and_then(|d| rhai::serde::from_dynamic(&d).map_err(|e| e.to_string())) .and_then(|d| rhai::serde::from_dynamic(&d).map_err(|e| RhaiExecError::Serde { message: e.to_string() }))
}) })
}; };
@ -154,7 +173,7 @@ pub(crate) fn eval_rhai_expr_json(expr: &str, ctx: &serde_json::Value) -> Result
}); });
eval(&ast, &mut scope) eval(&ast, &mut scope)
} }
Err(e) => Err(e.to_string()), Err(e) => Err(RhaiExecError::Compile { message: e.to_string() }),
} }
} }

View File

@ -6,6 +6,7 @@ use std::time::Instant;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use tracing::{debug, info}; use tracing::{debug, info};
use anyhow::anyhow;
// crate // crate
use crate::flow::domain::{NodeDef, NodeId}; use crate::flow::domain::{NodeDef, NodeId};
@ -46,7 +47,7 @@ pub fn exec_rhai_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
info!(target = "udmin.flow", node=%node_id.0, err=%e.to_string(), "script task: failed to read Rhai file"); info!(target = "udmin.flow", node=%node_id.0, err=%e.to_string(), "script task: failed to read Rhai file");
return Ok(()); return Err(anyhow!("failed to read Rhai file: {}", e));
} }
}; };
let script = code; let script = code;
@ -71,7 +72,8 @@ pub fn exec_rhai_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::
} }
} }
Err(err) => { Err(err) => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, err=%err, "script task: Rhai file execution failed, ctx unchanged"); info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, err=%err.to_string(), "script task: Rhai file execution failed, ctx unchanged");
return Err(anyhow!("Rhai file execution failed: {}", err));
} }
} }
Ok(()) Ok(())
@ -126,7 +128,8 @@ impl Executor for ScriptRhaiTask {
} }
} }
Err(err) => { Err(err) => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%script_preview, err=%err, "script_rhai task: inline execution failed, ctx unchanged"); info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%script_preview, err=%err.to_string(), "script_rhai task: inline execution failed, ctx unchanged");
return Err(anyhow!("Rhai inline execution failed: {}", err));
} }
} }
return Ok(()); return Ok(());

View File

@ -55,9 +55,7 @@ fn resolve_assign_value(ctx: &Value, v: &Value) -> Value {
} }
} }
// ctx[...] / ctx. 前缀 -> 表达式求值 // ctx[...] / ctx. 前缀 -> 表达式求值
if s_trim.starts_with("ctx[") || s_trim.starts_with("ctx.") { return eval_rhai_expr_json(s_trim, ctx).unwrap_or_else(|_| V::Null);
return eval_rhai_expr_json(s_trim, ctx).unwrap_or_else(|_| V::Null);
}
} }
v.get("content").cloned().unwrap_or(V::Null) v.get("content").cloned().unwrap_or(V::Null)
} }

View File

@ -108,7 +108,23 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
? customService.runStreamWS(values, { ? customService.runStreamWS(values, {
onNode: (evt) => { onNode: (evt) => {
if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) })); if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) }));
if (evt.logs && evt.logs.length) setStreamLogs((prev: string[]) => [...prev, ...evt.logs!]); if (evt.logs && evt.logs.length) {
const normalizeLog = (s: string) => s.replace(/\r/g, '').trim();
const dedupLogs = (arr: string[]) => {
const seen = new Set<string>();
const res: string[] = [];
for (const s of arr) {
const key = normalizeLog(s);
if (!seen.has(key)) {
seen.add(key);
res.push(s);
}
}
return res;
};
const incoming = evt.logs!;
setStreamLogs((prev) => dedupLogs([...(prev || []), ...incoming]));
}
}, },
onError: (evt) => { onError: (evt) => {
const msg = evt.message || I18n.t('Run failed'); const msg = evt.message || I18n.t('Run failed');
@ -282,13 +298,18 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */} {/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
{streamMode && isRunning && ( {streamMode && isRunning && (
<> <>
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse /> <NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
</>
)}
{/* 展示后端返回的执行信息:仅在非流式或流式已结束时显示,避免与实时输出重复 */}
{(!streamMode || !isRunning) && (
<>
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
</> </>
)} )}
{/* 展示后端返回的执行信息 */}
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
</div> </div>
); );

File diff suppressed because one or more lines are too long