diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/JavascriptCodeEngineProps.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/JavascriptCodeEngineProps.java new file mode 100644 index 00000000..ab7adce8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/JavascriptCodeEngineProps.java @@ -0,0 +1,39 @@ +package tech.easyflow.ai.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * JavaScript 代码执行引擎配置。 + */ +@Configuration +@ConfigurationProperties(prefix = "node.code-engine.javascript") +public class JavascriptCodeEngineProps { + + /** + * 单次脚本执行超时时间,单位毫秒。 + */ + private long timeoutMs = 5000L; + + /** + * 获取单次脚本执行超时时间。 + * + * @return 超时时间,单位毫秒 + */ + public long getTimeoutMs() { + return timeoutMs; + } + + /** + * 设置单次脚本执行超时时间。 + * + * @param timeoutMs 超时时间,单位毫秒 + */ + public void setTimeoutMs(long timeoutMs) { + if (timeoutMs <= 0L) { + throw new IllegalArgumentException( + "node.code-engine.javascript.timeout-ms 必须大于 0"); + } + this.timeoutMs = timeoutMs; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowConfigService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowConfigService.java index ef005e28..043f7d01 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowConfigService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowConfigService.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import tech.easyflow.ai.config.BochaaiProps; import tech.easyflow.ai.config.CodeEngineProps; +import tech.easyflow.ai.config.JavascriptCodeEngineProps; import tech.easyflow.ai.easyagentsflow.code.PythonRuntimeEngine; import tech.easyflow.ai.easyagentsflow.code.UnavailableCodeRuntimeEngine; import tech.easyflow.ai.node.*; @@ -42,6 +43,8 @@ public class TinyFlowConfigService { @Resource private CodeEngineProps codeEngineProps; @Resource + private JavascriptCodeEngineProps javascriptCodeEngineProps; + @Resource private CodeEngineCapabilityService codeEngineCapabilityService; public void initProvidersAndNodeParsers(ChainParser chainParser) { @@ -107,6 +110,9 @@ public class TinyFlowConfigService { public void setCodeRuntimeEngineProvider() { CodeRuntimeEngineManager manager = CodeRuntimeEngineManager.getInstance(); + manager.configureJavascriptRuntimeEngine(javascriptCodeEngineProps.getTimeoutMs()); + log.info("已配置 JavaScript 代码执行引擎: timeoutMs={}", + javascriptCodeEngineProps.getTimeoutMs()); PythonRuntimeEngine.ProbeResult probeResult; String pythonCommand = codeEngineProps.getCommand(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java index 7ea0bc5c..d4179aba 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java @@ -7,9 +7,11 @@ import com.easyagents.flow.core.chain.NodeStatus; import com.easyagents.flow.core.chain.repository.ChainStateRepository; import com.easyagents.flow.core.chain.repository.NodeStateRepository; import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.easyagents.flow.core.code.impl.JavascriptExecutionException; import org.springframework.stereotype.Component; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; +import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.web.exceptions.BusinessException; import javax.annotation.Resource; @@ -89,7 +91,7 @@ public class TinyFlowService { res.setStatus(chainState.getStatus().getValue()); ExceptionSummary chainError = chainState.getError(); if (chainError != null) { - res.setMessage(chainError.getRootCauseClass() + " --> " + chainError.getRootCauseMessage()); + res.setMessage(formatError(chainError)); } Map executeResult = chainState.getExecuteResult(); if (executeResult != null && !executeResult.isEmpty()) { @@ -118,7 +120,7 @@ public class TinyFlowService { if (nodeState != null) { ExceptionSummary error = nodeState.getError(); if (error != null) { - node.setMessage(error.getRootCauseClass() + " --> " + error.getRootCauseMessage()); + node.setMessage(formatError(error)); } } @@ -135,4 +137,31 @@ public class TinyFlowService { node.setSuspendForParameters(chainState.getSuspendForParameters()); } } + + /** + * 将执行异常转换为试运行界面可读的错误信息。 + * + * @param error 持久化的异常摘要 + * @return 可展示的错误信息 + */ + private String formatError(ExceptionSummary error) { + if (JavascriptExecutionException.class.getName() + .equals(error.getExceptionClass()) + && StringUtil.hasText(error.getMessage())) { + return error.getMessage(); + } + String rootClass = StringUtil.hasText(error.getRootCauseClass()) + ? error.getRootCauseClass() + : error.getExceptionClass(); + String rootMessage = StringUtil.hasText(error.getRootCauseMessage()) + ? error.getRootCauseMessage() + : error.getMessage(); + if (StringUtil.noText(rootClass)) { + return rootMessage; + } + if (StringUtil.noText(rootMessage)) { + return rootClass; + } + return rootClass + " --> " + rootMessage; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/config/JavascriptCodeEnginePropsTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/config/JavascriptCodeEnginePropsTest.java new file mode 100644 index 00000000..8d6d5cf5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/config/JavascriptCodeEnginePropsTest.java @@ -0,0 +1,49 @@ +package tech.easyflow.ai.config; + +import org.junit.Assert; +import org.junit.Test; + +/** + * JavaScript 代码执行引擎配置测试。 + */ +public class JavascriptCodeEnginePropsTest { + + /** + * 验证正数超时配置可以正常写入。 + */ + @Test + public void shouldAcceptPositiveTimeout() { + JavascriptCodeEngineProps props = + new JavascriptCodeEngineProps(); + + props.setTimeoutMs(1000L); + + Assert.assertEquals(1000L, props.getTimeoutMs()); + } + + /** + * 验证非正数超时配置会立即失败。 + */ + @Test + public void shouldRejectNonPositiveTimeout() { + assertInvalidTimeout(0L); + assertInvalidTimeout(-1L); + } + + /** + * 验证指定超时值无法写入配置。 + * + * @param timeoutMs 非法超时值 + */ + private void assertInvalidTimeout(long timeoutMs) { + JavascriptCodeEngineProps props = + new JavascriptCodeEngineProps(); + try { + props.setTimeoutMs(timeoutMs); + Assert.fail("非正数超时配置应执行失败"); + } catch (IllegalArgumentException exception) { + Assert.assertTrue(exception.getMessage().contains( + "必须大于 0")); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java index 740817c7..67ab4856 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java @@ -2,11 +2,13 @@ package tech.easyflow.ai.easyagentsflow.service; import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainStatus; +import com.easyagents.flow.core.chain.ExceptionSummary; import com.easyagents.flow.core.chain.NodeState; import com.easyagents.flow.core.chain.NodeStatus; import com.easyagents.flow.core.chain.repository.ChainStateRepository; import com.easyagents.flow.core.chain.repository.NodeStateRepository; import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.easyagents.flow.core.code.impl.JavascriptExecutionException; import org.junit.Assert; import org.junit.Test; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; @@ -137,6 +139,50 @@ public class TinyFlowServiceTest { .load(EXECUTE_ID, NODE_ID); } + /** + * 验证 JavaScript 执行错误使用面向试运行用户的定位信息。 + * + * @throws Exception 测试依赖注入失败时抛出 + */ + @Test + public void shouldExposeJavascriptExecutionMessage() + throws Exception { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + ChainStateRepository chainStateRepository = + mock(ChainStateRepository.class); + NodeStateRepository nodeStateRepository = + mock(NodeStateRepository.class); + String message = + "JavaScript 语法错误(第 2 行,第 3 列):Unexpected token"; + ExceptionSummary error = new ExceptionSummary( + new JavascriptExecutionException( + message, + new IllegalArgumentException("raw parser error"))); + ChainState chainState = new ChainState(); + chainState.setStatus(ChainStatus.FAILED); + chainState.setError(error); + NodeState nodeState = new NodeState(); + nodeState.setStatus(NodeStatus.FAILED); + nodeState.setError(error); + when(chainExecutor.getChainStateRepository()) + .thenReturn(chainStateRepository); + when(chainExecutor.getNodeStateRepository()) + .thenReturn(nodeStateRepository); + when(chainStateRepository.load(EXECUTE_ID)) + .thenReturn(chainState); + when(nodeStateRepository.load(EXECUTE_ID, NODE_ID)) + .thenReturn(nodeState); + TinyFlowService service = service(chainExecutor); + + ChainInfo result = service.getChainStatus( + EXECUTE_ID, List.of(node(NodeStatus.READY))); + + Assert.assertEquals(message, result.getMessage()); + Assert.assertEquals( + message, + result.getNodes().get(NODE_ID).getMessage()); + } + /** * 创建带指定初始状态的设计器节点。 * diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/ExecResult.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/ExecResult.vue index 20f05801..02a54e4a 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/ExecResult.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/ExecResult.vue @@ -37,11 +37,15 @@ watch( () => props.initSignal, () => { result.value = ''; + success.value = false; }, ); watch( () => props.pollingData, (newVal) => { + if (!newVal) { + return; + } if (newVal.status === 20) { ElMessage.success($t('message.success')); result.value = newVal.result; @@ -83,7 +87,7 @@ function getResult(res: any) { -
+
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue index 3ca6194f..ecaf0099 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue @@ -7,7 +7,6 @@ import { CircleCloseFilled, SuccessFilled, VideoPause, - WarningFilled, } from '@element-plus/icons-vue'; import { ElAlert, @@ -35,7 +34,6 @@ const props = defineProps(); const emit = defineEmits(['resume']); const nodes = ref([]); const nodeStatusMap = ref>({}); -const isChainError = ref(false); const activeNames = ref([]); const userControlledExpansion = ref(false); const confirmParams = ref({}); @@ -91,13 +89,14 @@ watch( return; } const currentNodes = newVal.nodes || {}; - if (newVal.status === 21) { - isChainError.value = true; - chainErrMsg.value = newVal.message; - } + chainErrMsg.value = newVal.status === 21 ? newVal.message : ''; if (![20, 21].includes(newVal.status)) { confirmBtnLoading.value = false; } + let autoExpandNodeId: string | undefined; + const failedNodeId = Object.keys(currentNodes).find( + (nodeId) => Number(currentNodes[nodeId]?.status) === 21, + ); for (const nodeId in currentNodes) { const previousNodeState = nodeStatusMap.value[nodeId]; const currentNodeState = currentNodes[nodeId]; @@ -113,7 +112,13 @@ watch( previousStatus !== currentStatus && shouldAutoExpandStatus(currentStatus) ) { - activeNames.value = [nodeId]; + autoExpandNodeId = nodeId; + } + } + if (!userControlledExpansion.value && !props.expandAll) { + const nextActiveNodeId = failedNodeId || autoExpandNodeId; + if (nextActiveNodeId) { + activeNames.value = [nextActiveNodeId]; } } }, @@ -123,7 +128,6 @@ watch( () => props.initSignal, () => { nodeStatusMap.value = {}; - isChainError.value = false; confirmBtnLoading.value = false; chainErrMsg.value = ''; userControlledExpansion.value = false; @@ -191,7 +195,13 @@ function handleConfirm(node: any) { diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/ExecResult.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/ExecResult.test.ts new file mode 100644 index 00000000..1dd4fad2 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/ExecResult.test.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import ExecResult from '../ExecResult.vue'; + +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +describe('execResult', () => { + it('结束节点执行失败时展示工作流错误信息', async () => { + const wrapper = mount(ExecResult, { + props: { + initSignal: false, + nodeJson: [ + { + original: { + data: { + outputDefs: [], + }, + type: 'endNode', + }, + }, + ], + pollingData: undefined, + workflowId: 'workflow-1', + }, + global: { + stubs: { + ShowJson: { + props: ['value'], + template: '
{{ value }}
', + }, + }, + }, + }); + + await wrapper.setProps({ + pollingData: { + message: 'JavaScript 执行失败(第 3 行,第 5 列):boom', + status: 21, + }, + }); + + expect(wrapper.get('[data-test="show-json"]').text()).toContain( + 'JavaScript 执行失败(第 3 行,第 5 列):boom', + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowSteps.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowSteps.test.ts index a2ec2cc4..bef5fc15 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowSteps.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowSteps.test.ts @@ -134,4 +134,29 @@ describe('workflowSteps', () => { }); expect(getCollapseItems(wrapper)[0]?.classes()).toContain('is-active'); }); + + it('工作流失败时优先展开失败节点并展示错误信息', async () => { + const wrapper = mountWorkflowSteps(); + + await wrapper.setProps({ + pollingData: { + message: 'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token', + nodes: { + 'node-a': { result: { text: 'A 完成' }, status: 20 }, + 'node-b': { + message: + 'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token', + status: 21, + }, + }, + status: 21, + }, + }); + + expect(getCollapseItems(wrapper)[0]?.classes()).not.toContain('is-active'); + expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active'); + expect(wrapper.text()).toContain( + 'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token', + ); + }); });