diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java
index 30e898e6..c848cb79 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java
@@ -18,10 +18,12 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
+import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginService;
@@ -29,6 +31,7 @@ import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
+import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
@@ -91,11 +94,15 @@ public class PluginItemController extends BaseCurdController pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List nodes) {
+ assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
@@ -229,10 +237,33 @@ public class PluginItemController extends BaseCurdController pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map confirmParams) {
- chainExecutor.resumeAsync(executeId, confirmParams);
+ assertPluginTestExecutionOwnership(executeId);
+ workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
+ /**
+ * 校验插件试运行实例由当前登录用户发起。
+ *
+ * @param executeId 执行实例 ID
+ */
+ private void assertPluginTestExecutionOwnership(String executeId) {
+ if (StrUtil.isBlank(executeId)) {
+ throw new BusinessException("执行ID不能为空");
+ }
+ WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
+ if (record == null) {
+ throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
+ }
+ LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
+ if (currentAccount == null
+ || currentAccount.getId() == null
+ || record.getCreatedBy() == null
+ || !currentAccount.getId().toString().equals(record.getCreatedBy())) {
+ throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
+ }
+ }
+
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowChatController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowChatController.java
index e2091282..36450a4f 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowChatController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowChatController.java
@@ -1,6 +1,5 @@
package tech.easyflow.admin.controller.ai;
-import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
@@ -14,6 +13,7 @@ import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
@@ -64,6 +64,8 @@ public class WorkflowChatController {
@Resource
private ChainExecutor chainExecutor;
@Resource
+ private WorkflowResumeService workflowResumeService;
+ @Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
@@ -171,19 +173,8 @@ public class WorkflowChatController {
@JsonBody("confirmParams")
Map confirmParams
) {
- WorkflowExecResult record = assertExecutionOwnership(executeId);
- if (record.getStatus() != null
- && (record.getStatus() == ChainStatus.SUCCEEDED.getValue()
- || record.getStatus() == ChainStatus.FAILED.getValue()
- || record.getStatus() == ChainStatus.CANCELLED.getValue())) {
- throw new BusinessException("当前工作流执行已结束");
- }
- chainExecutor.resumeAsync(
- executeId,
- confirmParams == null
- ? new LinkedHashMap<>()
- : new LinkedHashMap<>(confirmParams)
- );
+ assertExecutionOwnership(executeId);
+ workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java
index b8257f5c..c5b3d916 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java
@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService;
@@ -94,6 +95,8 @@ public class WorkflowController extends BaseCurdController resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map confirmParams) {
- if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
- throw new BusinessException(
- 409,
- 40901,
- "当前执行状态不可恢复,仅暂停中的工作流允许恢复");
- }
+ workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowPublicChatService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowPublicChatService.java
index da618e33..7d3fc1cc 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowPublicChatService.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowPublicChatService.java
@@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
@@ -46,6 +47,7 @@ public class WorkflowPublicChatService {
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
+ private final WorkflowResumeService workflowResumeService;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
@@ -57,6 +59,7 @@ public class WorkflowPublicChatService {
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
+ WorkflowResumeService workflowResumeService,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
@@ -67,6 +70,7 @@ public class WorkflowPublicChatService {
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
+ this.workflowResumeService = workflowResumeService;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
@@ -185,17 +189,8 @@ public class WorkflowPublicChatService {
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
- WorkflowExecResult record = assertExecutionOwnership(
- context, executeId);
- if (isTerminal(record.getStatus())) {
- throw new BusinessException("当前工作流执行已结束");
- }
- chainExecutor.resumeAsync(
- executeId,
- confirmParams == null
- ? new LinkedHashMap<>()
- : new LinkedHashMap<>(confirmParams)
- );
+ assertExecutionOwnership(context, executeId);
+ workflowResumeService.resume(executeId, confirmParams);
}
/**
@@ -253,13 +248,6 @@ public class WorkflowPublicChatService {
return record;
}
- private boolean isTerminal(Integer status) {
- return status != null
- && (status == ChainStatus.SUCCEEDED.getValue()
- || status == ChainStatus.FAILED.getValue()
- || status == ChainStatus.CANCELLED.getValue());
- }
-
private Map buildExecutionDetail(
WorkflowExecResult record,
List steps,
diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java
index a63dc5b8..6d65e34d 100644
--- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java
+++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java
@@ -7,21 +7,27 @@ import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
+import tech.easyflow.ai.entity.WorkflowExecResult;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
+import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
+import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -68,6 +74,61 @@ public class PluginItemControllerTest {
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
}
+ /**
+ * 验证当前用户不能恢复其他用户发起的插件试运行实例。
+ */
+ @Test
+ public void testResumeShouldRejectAnotherUsersExecution() {
+ PluginItemService pluginItemService = mock(PluginItemService.class);
+ WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
+ WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
+ WorkflowExecResult record = new WorkflowExecResult();
+ record.setCreatedBy(BigInteger.ONE.toString());
+ when(execResultService.getByExecKey("execution-1")).thenReturn(record);
+
+ PluginItemController controller = new PluginItemController(pluginItemService);
+ setField(controller, "workflowExecResultService", execResultService);
+ setField(controller, "workflowResumeService", resumeService);
+ LoginAccount currentAccount = new LoginAccount();
+ currentAccount.setId(BigInteger.TWO);
+
+ try (MockedStatic login = mockStatic(SaTokenUtil.class)) {
+ login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
+ BusinessException error = Assert.expectThrows(
+ BusinessException.class,
+ () -> controller.pluginToolTestResume("execution-1", Map.of())
+ );
+ Assert.assertEquals(error.getHttpStatus(), 403);
+ Assert.assertEquals(error.getErrorCode(), 403);
+ }
+ verifyNoInteractions(resumeService);
+ }
+
+ /**
+ * 验证当前用户可以恢复自己发起的插件试运行实例。
+ */
+ @Test
+ public void testResumeShouldAllowExecutionOwner() {
+ PluginItemService pluginItemService = mock(PluginItemService.class);
+ WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
+ WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
+ WorkflowExecResult record = new WorkflowExecResult();
+ record.setCreatedBy(BigInteger.ONE.toString());
+ when(execResultService.getByExecKey("execution-1")).thenReturn(record);
+
+ PluginItemController controller = new PluginItemController(pluginItemService);
+ setField(controller, "workflowExecResultService", execResultService);
+ setField(controller, "workflowResumeService", resumeService);
+ LoginAccount currentAccount = new LoginAccount();
+ currentAccount.setId(BigInteger.ONE);
+
+ try (MockedStatic login = mockStatic(SaTokenUtil.class)) {
+ login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
+ controller.pluginToolTestResume("execution-1", Map.of("choice", "A"));
+ }
+ verify(resumeService).resume("execution-1", Map.of("choice", "A"));
+ }
+
/**
* 创建插件工具。
*
diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowPublicChatServiceTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowPublicChatServiceTest.java
index a6823b25..7f8a1ca2 100644
--- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowPublicChatServiceTest.java
+++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowPublicChatServiceTest.java
@@ -12,6 +12,7 @@ import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
@@ -162,6 +163,8 @@ public class WorkflowPublicChatServiceTest {
WorkflowChatEventStream eventStream = mock(
WorkflowChatEventStream.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
+ WorkflowResumeService workflowResumeService =
+ mock(WorkflowResumeService.class);
WorkflowExecResultService execResultService = mock(
WorkflowExecResultService.class);
WorkflowExecStepService execStepService = mock(
@@ -196,6 +199,7 @@ public class WorkflowPublicChatServiceTest {
accessGuard,
eventStream,
chainExecutor,
+ workflowResumeService,
execResultService,
execStepService
);
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java
index b6b20f01..14f383d4 100644
--- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java
@@ -18,6 +18,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
@@ -70,6 +71,8 @@ public class PublicWorkflowController {
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
+ private WorkflowResumeService workflowResumeService;
+ @Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@@ -250,14 +253,7 @@ public class PublicWorkflowController {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
- if (!chainExecutor.resumeAsyncIfSuspended(
- executeId,
- confirmParams)) {
- throw new BusinessException(
- 409,
- 40901,
- "当前执行状态不可恢复,仅暂停中的工作流允许恢复");
- }
+ workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java
index 03fc31af..f5fa1a65 100644
--- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java
@@ -394,6 +394,7 @@ public final class WorkflowRunAsyncErrorProfile
*/
private boolean isStableBusinessCode(int code) {
return (code >= 40011 && code <= 40017)
+ || code == 40031
|| (code >= 40101 && code <= 40103)
|| (code >= 40301 && code <= 40302)
|| (code >= 40401 && code <= 40402)
@@ -412,7 +413,8 @@ public final class WorkflowRunAsyncErrorProfile
* @return 对外 HTTP 状态
*/
private int normalizeHttpStatus(int code, int fallback) {
- if (code >= 40011 && code <= 40017) {
+ if ((code >= 40011 && code <= 40017)
+ || code == 40031) {
return 400;
}
if (code >= 40101 && code <= 40103) {
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java
index 8cecbf26..247fa2c0 100644
--- a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java
@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PublishStatus;
@@ -44,6 +45,7 @@ public class PublicWorkflowControllerBehaviorTest {
private PublicWorkflowController controller;
private ChainExecutor chainExecutor;
+ private WorkflowResumeService workflowResumeService;
private TinyFlowService tinyFlowService;
private HttpServletRequest request;
@@ -54,6 +56,7 @@ public class PublicWorkflowControllerBehaviorTest {
public void setUp() {
controller = new PublicWorkflowController();
chainExecutor = Mockito.mock(ChainExecutor.class);
+ workflowResumeService = Mockito.mock(WorkflowResumeService.class);
tinyFlowService = Mockito.mock(TinyFlowService.class);
WorkflowApiPermissionService permissionService =
Mockito.mock(WorkflowApiPermissionService.class);
@@ -79,6 +82,10 @@ public class PublicWorkflowControllerBehaviorTest {
controller,
"chainExecutor",
chainExecutor);
+ ReflectionTestUtils.setField(
+ controller,
+ "workflowResumeService",
+ workflowResumeService);
ReflectionTestUtils.setField(
controller,
"tinyFlowService",
@@ -108,10 +115,12 @@ public class PublicWorkflowControllerBehaviorTest {
public void resumeShouldRejectNonSuspendedExecution() {
when(request.getRequestURI()).thenReturn(
"/public-api/workflow/resume");
- when(chainExecutor.resumeAsyncIfSuspended(
- EXECUTE_ID,
- Map.of("approved", true)))
- .thenReturn(false);
+ Mockito.doThrow(new BusinessException(
+ 409,
+ 40901,
+ "当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
+ .when(workflowResumeService)
+ .resume(EXECUTE_ID, Map.of("approved", true));
try {
controller.resume(
@@ -124,7 +133,7 @@ public class PublicWorkflowControllerBehaviorTest {
Assert.assertEquals(40901, exception.getErrorCode());
}
- verify(chainExecutor).resumeAsyncIfSuspended(
+ verify(workflowResumeService).resume(
EXECUTE_ID,
Map.of("approved", true));
}
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java
index fcc1d3d0..ec334c20 100644
--- a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java
@@ -167,6 +167,28 @@ public class WorkflowRunAsyncErrorProfileTest {
resolution.modelAndView.getModel().get("message"));
}
+ /**
+ * 验证确认节点恢复校验保留专用错误码,不回退为运行参数错误。
+ */
+ @Test
+ public void shouldKeepResumeValidationCode() {
+ Resolution resolution = resolve(
+ "/public-api/workflow/resume",
+ MediaType.APPLICATION_JSON_VALUE,
+ new BusinessException(
+ 400,
+ 40031,
+ "确认参数[模板类型]包含未配置选项"));
+
+ Assert.assertEquals(400, resolution.response.getStatus());
+ Assert.assertEquals(
+ 40031,
+ resolution.modelAndView.getModel().get("errorCode"));
+ Assert.assertEquals(
+ "确认参数[模板类型]包含未配置选项",
+ resolution.modelAndView.getModel().get("message"));
+ }
+
/**
* 验证 API Key 无效和两层权限错误保持可区分。
*/
diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java
index c26f0e5d..113dd912 100644
--- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java
+++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java
@@ -13,6 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
+import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.annotation.UsePermission;
@@ -54,6 +55,8 @@ public class UcWorkflowController extends BaseCurdController resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map confirmParams) {
- if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
- throw new BusinessException(
- 409,
- 40901,
- "当前执行状态不可恢复,仅暂停中的工作流允许恢复");
- }
+ workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
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 819bf454..54128ed1 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
@@ -142,9 +142,12 @@ public class TinyFlowService {
node.setResult(resolved);
}
- // 只有当参数不为空时才覆盖
- if (chainState.getSuspendForParameters() != null) {
+ if (nodeState != null
+ && nodeState.getStatus() == NodeStatus.SUSPEND
+ && chainState.getSuspendForParameters() != null) {
node.setSuspendForParameters(chainState.getSuspendForParameters());
+ } else {
+ node.setSuspendForParameters(null);
}
}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java
index 8211a68f..0be15574 100644
--- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java
@@ -3,6 +3,8 @@ package tech.easyflow.ai.easyagentsflow.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
+import com.easyagents.flow.core.chain.DataType;
+import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -53,6 +55,9 @@ public class WorkflowCheckService {
private static final String TYPE_END = "endNode";
private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_CONDITION = "conditionNode";
+ private static final String TYPE_CONFIRM = "confirmNode";
+ private static final Set CONFIRM_ARRAY_LEFT_OPERATORS = Set.of(
+ "contains", "notContains", "isEmpty", "isNotEmpty");
private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_MAKE_FILE = "make-file";
@@ -184,6 +189,8 @@ public class WorkflowCheckService {
}
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
checkConditionConfigurations(nodes, issues, issueKeys);
+ checkConfirmConfigurations(nodes, issues, issueKeys);
+ checkConfirmOutputReferences(nodes, issues, issueKeys);
List edges = new ArrayList<>();
Set edgeIds = new HashSet<>();
@@ -514,6 +521,288 @@ public class WorkflowCheckService {
}
}
+ /**
+ * 校验用户确认节点配置及其对外输出定义。
+ */
+ private void checkConfirmConfigurations(
+ List nodes,
+ List issues,
+ Set issueKeys) {
+ for (NodeView node : nodes) {
+ if (!TYPE_CONFIRM.equals(node.type)) {
+ continue;
+ }
+ if (node.data == null) {
+ addIssue(
+ issues,
+ issueKeys,
+ "CONFIRM_CONFIGURATION_INVALID",
+ "用户确认节点配置不能为空",
+ node.id,
+ null,
+ node.name);
+ continue;
+ }
+
+ ConfirmNode configuration;
+ try {
+ validateConfirmConfigurationTypes(node.data);
+ checkConfirmOutputDefinitions(
+ node,
+ Boolean.TRUE.equals(node.data.get("multiple")),
+ issues,
+ issueKeys);
+ configuration = node.data.toJavaObject(ConfirmNode.class);
+ configuration.validateConfiguration();
+ } catch (Exception exception) {
+ addIssue(
+ issues,
+ issueKeys,
+ "CONFIRM_CONFIGURATION_INVALID",
+ "用户确认节点配置无效: " + shortError(exception),
+ node.id,
+ null,
+ node.name);
+ continue;
+ }
+ }
+ }
+
+ private void validateConfirmConfigurationTypes(JSONObject data) {
+ for (String key : data.keySet()) {
+ if (!ConfirmNode.SUPPORTED_CONFIGURATION_KEYS.contains(key)) {
+ throw new IllegalArgumentException(
+ "用户确认节点包含无效配置字段: " + key);
+ }
+ }
+ if (!(data.get("message") instanceof String)) {
+ throw new IllegalArgumentException("用户确认节点提示内容必须为字符串");
+ }
+ if (!(data.get("multiple") instanceof Boolean)) {
+ throw new IllegalArgumentException("用户确认节点选择方式必须为布尔值");
+ }
+
+ Object optionsValue = data.get("options");
+ if (optionsValue == null) {
+ return;
+ }
+ if (!(optionsValue instanceof JSONArray options)) {
+ throw new IllegalArgumentException("用户确认节点选项必须为数组");
+ }
+ for (Object option : options) {
+ if (!(option instanceof String)) {
+ throw new IllegalArgumentException("用户确认节点选项内容必须为字符串");
+ }
+ }
+ }
+
+ private void checkConfirmOutputDefinitions(
+ NodeView node,
+ boolean multiple,
+ List issues,
+ Set issueKeys) {
+ Object outputDefsValue = node.data.get("outputDefs");
+ if (!(outputDefsValue instanceof JSONArray outputDefs)
+ || outputDefs.size() != 1) {
+ addConfirmOutputIssue(node, issues, issueKeys);
+ return;
+ }
+ Object outputValue = outputDefs.get(0);
+ if (!(outputValue instanceof JSONObject output)) {
+ addConfirmOutputIssue(node, issues, issueKeys);
+ return;
+ }
+ String expectedType = multiple
+ ? DataType.Array_String.toString()
+ : DataType.String.toString();
+ if (trimToNull(output.getString("name")) == null
+ || !expectedType.equals(
+ trimToNull(output.getString("dataType")))) {
+ addConfirmOutputIssue(node, issues, issueKeys);
+ }
+ }
+
+ private void addConfirmOutputIssue(
+ NodeView node,
+ List issues,
+ Set issueKeys) {
+ addIssue(
+ issues,
+ issueKeys,
+ "CONFIRM_OUTPUT_SCHEMA_INVALID",
+ "用户确认节点必须配置唯一非空输出参数,且类型与选择方式一致",
+ node.id,
+ null,
+ node.name);
+ }
+
+ /**
+ * 校验下游节点保存的确认输出引用仍然存在且类型一致。
+ */
+ private void checkConfirmOutputReferences(
+ List nodes,
+ List issues,
+ Set issueKeys) {
+ ConfirmOutputIndex confirmOutputs = new ConfirmOutputIndex();
+ for (NodeView node : nodes) {
+ if (!TYPE_CONFIRM.equals(node.type) || node.data == null) {
+ continue;
+ }
+ try {
+ ConfirmNode configuration = node.data.toJavaObject(ConfirmNode.class);
+ configuration.validateConfiguration();
+ confirmOutputs.put(
+ node.id,
+ configuration.resolveOutputName(),
+ configuration.isMultiple()
+ ? DataType.Array_String.toString()
+ : DataType.String.toString());
+ } catch (Exception ignored) {
+ // 配置错误已由 checkConfirmConfigurations 给出精确问题。
+ }
+ }
+ if (confirmOutputs.isEmpty()) {
+ return;
+ }
+ for (NodeView node : nodes) {
+ if (node.data != null) {
+ checkConfirmOutputReferences(
+ node.data,
+ node,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ }
+ }
+ }
+
+ private void checkConfirmOutputReferences(
+ Object value,
+ NodeView consumer,
+ ConfirmOutputIndex confirmOutputs,
+ List issues,
+ Set issueKeys) {
+ if (value instanceof JSONObject object) {
+ if ("ref".equals(trimToNull(object.getString("refType")))) {
+ checkConfirmOutputReference(
+ object.getString("ref"),
+ object.getString("dataType"),
+ true,
+ consumer,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ }
+ String leftType = checkConfirmOutputReference(
+ object.getString("leftRef"),
+ null,
+ false,
+ consumer,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ String operator = trimToNull(object.getString("operator"));
+ if (DataType.Array_String.toString().equals(leftType)
+ && !CONFIRM_ARRAY_LEFT_OPERATORS.contains(operator)) {
+ addConfirmConditionTypeIssue(
+ object.getString("leftRef"),
+ operator,
+ consumer,
+ issues,
+ issueKeys);
+ }
+ if ("ref".equals(trimToNull(object.getString("rightType")))) {
+ String rightType = checkConfirmOutputReference(
+ object.getString("rightRef"),
+ null,
+ false,
+ consumer,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ if (DataType.Array_String.toString().equals(rightType)) {
+ addConfirmConditionTypeIssue(
+ object.getString("rightRef"),
+ operator,
+ consumer,
+ issues,
+ issueKeys);
+ }
+ }
+ for (Object child : object.values()) {
+ checkConfirmOutputReferences(
+ child,
+ consumer,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ }
+ return;
+ }
+ if (value instanceof JSONArray array) {
+ for (Object child : array) {
+ checkConfirmOutputReferences(
+ child,
+ consumer,
+ confirmOutputs,
+ issues,
+ issueKeys);
+ }
+ }
+ }
+
+ private String checkConfirmOutputReference(
+ String rawReference,
+ String rawActualType,
+ boolean requireActualType,
+ NodeView consumer,
+ ConfirmOutputIndex confirmOutputs,
+ List issues,
+ Set issueKeys) {
+ String reference = trimToNull(rawReference);
+ if (reference == null) {
+ return null;
+ }
+ String expectedType = confirmOutputs.get(reference);
+ if (expectedType == null
+ && !confirmOutputs.referencesConfirmNode(reference)) {
+ return null;
+ }
+ String actualType = trimToNull(rawActualType);
+ boolean invalidType = actualType == null
+ ? requireActualType
+ : !Objects.equals(expectedType, actualType);
+ if (expectedType == null || invalidType) {
+ addIssue(
+ issues,
+ issueKeys,
+ "CONFIRM_OUTPUT_REFERENCE_INVALID",
+ "用户确认输出引用不存在或类型已变化: "
+ + reference,
+ consumer.id,
+ null,
+ consumer.name);
+ }
+ return expectedType;
+ }
+
+ private void addConfirmConditionTypeIssue(
+ String reference,
+ String operator,
+ NodeView consumer,
+ List issues,
+ Set issueKeys) {
+ addIssue(
+ issues,
+ issueKeys,
+ "CONFIRM_OUTPUT_REFERENCE_INVALID",
+ "用户确认多选输出不支持当前条件操作符: "
+ + trimToNull(reference) + " (" + operator + ")",
+ consumer.id,
+ null,
+ consumer.name);
+ }
+
/**
* 将 JSON 条件规则转换为运行时规则对象。
*
@@ -1674,6 +1963,35 @@ public class WorkflowCheckService {
return result;
}
+ private static class ConfirmOutputIndex {
+ private final Map outputTypes = new HashMap<>();
+ private final Set nodeIds = new HashSet<>();
+
+ private void put(String nodeId, String outputName, String dataType) {
+ nodeIds.add(nodeId);
+ outputTypes.put(nodeId + "." + outputName, dataType);
+ }
+
+ private String get(String reference) {
+ return outputTypes.get(reference);
+ }
+
+ private boolean isEmpty() {
+ return outputTypes.isEmpty();
+ }
+
+ private boolean referencesConfirmNode(String reference) {
+ int separator = reference.indexOf('.');
+ while (separator > 0) {
+ if (nodeIds.contains(reference.substring(0, separator))) {
+ return true;
+ }
+ separator = reference.indexOf('.', separator + 1);
+ }
+ return false;
+ }
+ }
+
private void throwIfFailed(WorkflowCheckResult result) {
if (result == null) {
return;
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeService.java
new file mode 100644
index 00000000..a5851ca5
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeService.java
@@ -0,0 +1,49 @@
+package tech.easyflow.ai.easyagentsflow.service;
+
+import com.easyagents.flow.core.chain.ChainResumeException;
+import com.easyagents.flow.core.chain.runtime.ChainExecutor;
+import org.springframework.stereotype.Service;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+import javax.annotation.Resource;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 统一校验并恢复暂停中的工作流。
+ */
+@Service
+public class WorkflowResumeService {
+
+ @Resource
+ private ChainExecutor chainExecutor;
+
+ /**
+ * 恢复暂停实例,并将引擎校验失败转换为稳定的接口错误。
+ *
+ * @param executeId 工作流实例 ID
+ * @param variables 用户提交的确认参数
+ */
+ public void resume(String executeId, Map variables) {
+ Map submitted = variables == null
+ ? new LinkedHashMap<>()
+ : new LinkedHashMap<>(variables);
+ final boolean resumed;
+ try {
+ resumed = chainExecutor.resumeAsyncIfSuspended(
+ executeId, submitted);
+ } catch (ChainResumeException exception) {
+ throw new BusinessException(
+ 400,
+ 40031,
+ exception.getMessage(),
+ exception);
+ }
+ if (!resumed) {
+ throw new BusinessException(
+ 409,
+ 40901,
+ "当前执行状态不可恢复,仅暂停中的工作流允许恢复");
+ }
+ }
+}
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 0a98a4a1..dd79ac45 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
@@ -6,6 +6,7 @@ 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.Parameter;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
@@ -148,6 +149,56 @@ public class TinyFlowServiceTest {
.load(EXECUTE_ID, NODE_ID);
}
+ /**
+ * 验证链级挂起参数只返回给真正暂停的节点。
+ *
+ * @throws Exception 测试依赖注入失败时抛出
+ */
+ @Test
+ public void shouldAttachSuspendParametersOnlyToSuspendedNode()
+ throws Exception {
+ ChainExecutor chainExecutor = mock(ChainExecutor.class);
+ ChainStateRepository chainStateRepository =
+ mock(ChainStateRepository.class);
+ NodeStateRepository nodeStateRepository =
+ mock(NodeStateRepository.class);
+ ChainState chainState = new ChainState();
+ chainState.setStatus(ChainStatus.SUSPEND);
+ Parameter parameter = new Parameter();
+ parameter.setName("selection__confirm-1");
+ chainState.setSuspendForParameters(List.of(parameter));
+ NodeState suspendedState = new NodeState();
+ suspendedState.setStatus(NodeStatus.SUSPEND);
+ NodeState readyState = new NodeState();
+ readyState.setStatus(NodeStatus.READY);
+ when(chainExecutor.getChainStateRepository())
+ .thenReturn(chainStateRepository);
+ when(chainExecutor.getNodeStateRepository())
+ .thenReturn(nodeStateRepository);
+ when(chainStateRepository.load(EXECUTE_ID))
+ .thenReturn(chainState);
+ when(nodeStateRepository.load(EXECUTE_ID, "confirm-1"))
+ .thenReturn(suspendedState);
+ when(nodeStateRepository.load(EXECUTE_ID, "confirm-2"))
+ .thenReturn(readyState);
+ TinyFlowService service = service(chainExecutor);
+ NodeInfo first = new NodeInfo();
+ first.setNodeId("confirm-1");
+ NodeInfo second = new NodeInfo();
+ second.setNodeId("confirm-2");
+ second.setSuspendForParameters(List.of(parameter));
+
+ ChainInfo result = service.getChainStatus(
+ EXECUTE_ID, List.of(first, second));
+
+ Assert.assertEquals(
+ List.of(parameter),
+ result.getNodes().get("confirm-1")
+ .getSuspendForParameters());
+ Assert.assertNull(result.getNodes().get("confirm-2")
+ .getSuspendForParameters());
+ }
+
/**
* 验证 JavaScript 执行错误使用面向试运行用户的定位信息。
*
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
index e903c5f0..63af966b 100644
--- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
@@ -525,6 +525,374 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "LOOP_PARENT_CYCLE");
}
+ /**
+ * 验证用户确认选项与输出定义一致时可以保存。
+ */
+ @Test
+ public void testSaveShouldPassValidConfirmContract() throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ String content = workflowJson(
+ array(node(
+ "confirm-1",
+ "confirmNode",
+ null,
+ confirmData(false, "String"))),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertTrue(result.isPassed());
+ }
+
+ /**
+ * 验证用户确认节点拒绝重复的选项内容。
+ */
+ @Test
+ public void testSaveShouldBlockDuplicateConfirmOptionContents()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.getJSONArray("options").add("第一议题");
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
+ }
+
+ /**
+ * 验证用户确认节点拒绝非字符串选项。
+ */
+ @Test
+ public void testSaveShouldBlockNonStringConfirmOption()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.getJSONArray("options").add(1);
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
+ }
+
+ /**
+ * 验证选择方式不能依赖 JSON 隐式类型转换。
+ */
+ @Test
+ public void testSaveShouldBlockNonBooleanConfirmMode()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.put("multiple", "false");
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
+ }
+
+ /**
+ * 验证最终确认契约拒绝继续保存已废弃配置。
+ */
+ @Test
+ public void testSaveShouldBlockRetiredConfirmData()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.put("async", true);
+ confirm.put("fields", new JSONArray());
+ confirm.put("confirms", new JSONArray());
+ confirm.put("schemaVersion", 1);
+ confirm.put("unknownLegacySetting", true);
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
+ }
+
+ /**
+ * 验证多选模式必须声明数组输出。
+ */
+ @Test
+ public void testSaveShouldBlockMismatchedConfirmOutputType()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ String content = workflowJson(
+ array(node(
+ "confirm-1",
+ "confirmNode",
+ null,
+ confirmData(true, "String"))),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
+ }
+
+ /**
+ * 验证确认节点可以使用用户配置的输出参数名称及下游引用。
+ */
+ @Test
+ public void testSaveShouldPassConfiguredConfirmOutputName()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(
+ false, "String", "templateChoice");
+ JSONObject consumer = data("下游节点");
+ consumer.put("parameters", array(refParameter(
+ "input", "confirm-1.templateChoice", "String")));
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null, confirm),
+ node("code-1", "codeNode", null, consumer)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertTrue(result.isPassed());
+ }
+
+ /**
+ * 验证空输出定义不能利用空类型绕过字段契约校验。
+ */
+ @Test
+ public void testSaveShouldBlockEmptyConfirmOutputDefinition()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.put("outputDefs", array(new JSONObject()));
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
+ }
+
+ /**
+ * 验证字符串编码的输出定义不能通过 JSON 隐式类型转换。
+ */
+ @Test
+ public void testSaveShouldBlockStringEncodedConfirmOutputDefinition()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ confirm.put(
+ "outputDefs",
+ "[{\"name\":\"selection\",\"dataType\":\"String\"}]");
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
+ }
+
+ /**
+ * 验证输出定义数组中的字符串元素不能被隐式解析为对象。
+ */
+ @Test
+ public void testSaveShouldBlockStringElementConfirmOutputDefinition()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject confirm = confirmData(false, "String");
+ JSONArray outputDefs = new JSONArray();
+ outputDefs.add("{\"name\":\"selection\",\"dataType\":\"String\"}");
+ confirm.put("outputDefs", outputDefs);
+ String content = workflowJson(
+ array(node("confirm-1", "confirmNode", null, confirm)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
+ }
+
+ /**
+ * 验证不存在的确认输出会阻止下游引用。
+ */
+ @Test
+ public void testSaveShouldBlockMissingConfirmOutputReference()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject consumer = data("下游节点");
+ consumer.put("parameters", array(refParameter(
+ "input", "confirm-1.removed", "String")));
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null,
+ confirmData(false, "String")),
+ node("code-1", "codeNode", null, consumer)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
+ /**
+ * 验证手工导入的带点节点 ID 也能识别失效确认输出引用。
+ */
+ @Test
+ public void testSaveShouldBlockMissingConfirmOutputForDottedNodeId()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject consumer = data("下游节点");
+ consumer.put("parameters", array(refParameter(
+ "input", "confirm.group-1.removed", "String")));
+ String content = workflowJson(
+ array(
+ node("confirm.group-1", "confirmNode", null,
+ confirmData(false, "String")),
+ node("code-1", "codeNode", null, consumer)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
+ /**
+ * 验证切换选择方式后,保存会拦截仍声明旧类型的下游引用。
+ */
+ @Test
+ public void testSaveShouldBlockMismatchedConfirmOutputReferenceType()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject consumer = data("下游节点");
+ consumer.put("parameters", array(refParameter(
+ "input", "confirm-1.selection", "Array")));
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null,
+ confirmData(false, "String")),
+ node("code-1", "codeNode", null, consumer)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
+ /**
+ * 验证普通参数引用不能通过省略类型绕过确认输出契约。
+ */
+ @Test
+ public void testSaveShouldBlockConfirmOutputReferenceWithoutType()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject reference = refParameter(
+ "input", "confirm-1.selection", "String");
+ reference.remove("dataType");
+ JSONObject consumer = data("下游节点");
+ consumer.put("parameters", array(reference));
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null,
+ confirmData(false, "String")),
+ node("code-1", "codeNode", null, consumer)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
+ /**
+ * 验证条件节点直接保存的左右引用也受确认输出契约约束。
+ */
+ @Test
+ public void testSaveShouldBlockMissingConfirmOutputConditionReference()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject condition = conditionData(
+ "equals", "ref", "confirm-1.removed");
+ JSONObject rule = condition.getJSONArray("branches")
+ .getJSONObject(0)
+ .getJSONArray("rules")
+ .getJSONObject(0);
+ rule.put("leftRef", "confirm-1.selection");
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null,
+ confirmData(false, "String")),
+ node("condition-1", "conditionNode", null, condition)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
+ /**
+ * 验证确认字段切成多选后,不兼容的条件操作符会在保存阶段被阻止。
+ */
+ @Test
+ public void testSaveShouldBlockIncompatibleConfirmArrayConditionReference()
+ throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject condition = conditionData(
+ "regexMatch", "fixed", "^AGENDA$");
+ condition.getJSONArray("branches")
+ .getJSONObject(0)
+ .getJSONArray("rules")
+ .getJSONObject(0)
+ .put("leftRef", "confirm-1.selection");
+ String content = workflowJson(
+ array(
+ node("confirm-1", "confirmNode", null,
+ confirmData(true, "Array")),
+ node("condition-1", "conditionNode", null, condition)),
+ new JSONArray());
+
+ WorkflowCheckResult result = service.checkContent(
+ content, WorkflowCheckStage.SAVE, null);
+
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
+ }
+
@Test
public void testSaveShouldPassForValidDraft() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
@@ -1011,6 +1379,14 @@ public class WorkflowCheckServiceTest {
return array;
}
+ private static JSONArray stringArray(String... values) {
+ JSONArray array = new JSONArray();
+ for (String value : values) {
+ array.add(value);
+ }
+ return array;
+ }
+
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
JSONObject node = new JSONObject();
node.put("id", id);
@@ -1080,6 +1456,25 @@ public class WorkflowCheckServiceTest {
return data;
}
+ private static JSONObject confirmData(
+ boolean multiple, String outputType) {
+ return confirmData(multiple, outputType, "selection");
+ }
+
+ private static JSONObject confirmData(
+ boolean multiple, String outputType, String outputName) {
+ JSONObject output = new JSONObject();
+ output.put("name", outputName);
+ output.put("dataType", outputType);
+
+ JSONObject data = data("用户确认");
+ data.put("message", "请选择会议纪要模板");
+ data.put("multiple", multiple);
+ data.put("options", stringArray("第一议题", "审议类", "听取类"));
+ data.put("outputDefs", array(output));
+ return data;
+ }
+
/**
* 创建显式循环节点数据。
*
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeServiceTest.java
new file mode 100644
index 00000000..807282ae
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowResumeServiceTest.java
@@ -0,0 +1,62 @@
+package tech.easyflow.ai.easyagentsflow.service;
+
+import com.easyagents.flow.core.chain.ChainResumeException;
+import com.easyagents.flow.core.chain.runtime.ChainExecutor;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+import java.util.Map;
+import java.lang.reflect.Field;
+
+/**
+ * {@link WorkflowResumeService} 恢复错误契约测试。
+ */
+public class WorkflowResumeServiceTest {
+
+ private WorkflowResumeService service;
+ private ChainExecutor chainExecutor;
+
+ @Before
+ public void setUp() throws Exception {
+ service = new WorkflowResumeService();
+ chainExecutor = Mockito.mock(ChainExecutor.class);
+ Field field = WorkflowResumeService.class.getDeclaredField(
+ "chainExecutor");
+ field.setAccessible(true);
+ field.set(service, chainExecutor);
+ }
+
+ @Test
+ public void shouldMapInvalidConfirmationToBadRequest() {
+ Mockito.when(chainExecutor.resumeAsyncIfSuspended(
+ "execute-1", Map.of("choice", "UNKNOWN")))
+ .thenThrow(new ChainResumeException("包含未配置选项"));
+
+ try {
+ service.resume("execute-1", Map.of("choice", "UNKNOWN"));
+ Assert.fail("invalid option must be rejected");
+ } catch (BusinessException exception) {
+ Assert.assertEquals(400, exception.getHttpStatus());
+ Assert.assertEquals(40031, exception.getErrorCode());
+ Assert.assertEquals("包含未配置选项", exception.getMessage());
+ }
+ }
+
+ @Test
+ public void shouldMapNonSuspendedInstanceToConflict() {
+ Mockito.when(chainExecutor.resumeAsyncIfSuspended(
+ "execute-1", Map.of("choice", "AGENDA")))
+ .thenReturn(false);
+
+ try {
+ service.resume("execute-1", Map.of("choice", "AGENDA"));
+ Assert.fail("non-suspended instance must be rejected");
+ } catch (BusinessException exception) {
+ Assert.assertEquals(409, exception.getHttpStatus());
+ Assert.assertEquals(40901, exception.getErrorCode());
+ }
+ }
+}
diff --git a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue
index 781b8584..a5c26e5b 100644
--- a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue
+++ b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue
@@ -291,23 +291,29 @@ async function executePolling(nextExecuteId: string, generation: number) {
}
}
-function resumeChain(payload: any) {
+async function resumeChain(
+ payload: any,
+ onSettled: (accepted: boolean) => void,
+) {
if (!executeId.value) {
+ onSettled(false);
return;
}
- api
- .post('/api/v1/pluginItem/testResume', {
+ try {
+ const res = await api.post('/api/v1/pluginItem/testResume', {
executeId: executeId.value,
confirmParams: payload?.confirmParams || {},
- })
- .then((res) => {
- if (res.errorCode === 0) {
- startPolling(executeId.value);
- }
- })
- .catch((error) => {
- runResultResponse.value = buildErrorResult(error);
});
+ if (res.errorCode === 0) {
+ startPolling(executeId.value);
+ onSettled(true);
+ return;
+ }
+ onSettled(false);
+ } catch (error) {
+ onSettled(false);
+ runResultResponse.value = buildErrorResult(error);
+ }
}
function showWorkflowSteps() {
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue
index b8d6055c..cc9120ec 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue
@@ -50,7 +50,7 @@ import nodeNames from './customNode/nodeNames';
import {
createInitialWorkflowData,
isWorkflowDataEmpty,
- normalizeWorkflowStartNodes,
+ normalizeWorkflowNodes,
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import '@tinyflow-ai/vue/dist/index.css';
@@ -444,7 +444,7 @@ async function handleSave(showMsg: boolean = false): Promise {
}
saveLoading.value = true;
try {
- const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
+ const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
const savedContentSignature = createWorkflowContentSignature(content);
const res = await api.post('/api/v1/workflow/update', {
id: workflowId.value,
@@ -475,11 +475,11 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
: {};
const serverContent = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
- : normalizeWorkflowStartNodes(parsedContent);
+ : normalizeWorkflowNodes(parsedContent);
serverContentSignature = createWorkflowContentSignature(serverContent);
const draft = readWorkflowDraft(workflowId, serverContent);
tinyFlowData.value = draft
- ? normalizeWorkflowStartNodes(draft.content as Record)
+ ? normalizeWorkflowNodes(draft.content as Record)
: serverContent;
lastObservedWorkflowContent = tinyFlowData.value;
}
@@ -492,7 +492,7 @@ function persistPendingWorkflowDraft() {
draftWriteTimer = undefined;
return;
}
- const content = normalizeWorkflowStartNodes(pendingDraftContent);
+ const content = normalizeWorkflowNodes(pendingDraftContent);
lastObservedWorkflowContent = content;
pendingDraftContent = null;
draftWriteTimer = undefined;
@@ -531,7 +531,7 @@ function flushWorkflowDraft() {
function captureCurrentWorkflowDraft() {
const content = tinyflowRef.value?.getData();
if (content) {
- lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
+ lastObservedWorkflowContent = normalizeWorkflowNodes(content);
pendingDraftContent = lastObservedWorkflowContent;
}
flushWorkflowDraft();
@@ -554,7 +554,7 @@ function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
clearPendingWorkflowDraft();
return;
}
- const normalizedContent = normalizeWorkflowStartNodes(currentContent);
+ const normalizedContent = normalizeWorkflowNodes(currentContent);
lastObservedWorkflowContent = normalizedContent;
if (
createWorkflowContentSignature(normalizedContent) === savedContentSignature
@@ -594,7 +594,7 @@ async function runCheck(
stage: WorkflowCheckStage,
silentPass: boolean = false,
) {
- const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
+ const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
if (!content) {
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
return false;
@@ -756,8 +756,13 @@ async function runIndependently(node: any) {
singleNode.value = node;
singleRunVisible.value = true;
}
-function resumeChain(data: any) {
- workflowForm.value?.resume(data);
+async function resumeChain(data: any, onSettled: (accepted: boolean) => void) {
+ try {
+ const accepted = await workflowForm.value?.resume(data);
+ onSettled(accepted === true);
+ } catch {
+ onSettled(false);
+ }
}
function handleChoose(nodeName: string, value: any) {
if (nodeName === nodeNames.workflowNode) {
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue
index c3e31171..1191ed72 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue
@@ -682,7 +682,7 @@ function buildResumeRequestExample() {
{
executeId: '执行ID',
confirmParams: {
- confirm: true,
+ 'selection__confirm-node-1': '审议类',
},
},
null,
@@ -936,6 +936,10 @@ const apiDocMarkdown = computed(() => {
lines.push(buildResumeRequestExample());
lines.push('```');
lines.push(``);
+ lines.push(
+ '`confirmParams` 的键请使用暂停节点 `suspendForParameters[].name` 返回的运行参数名;确认节点固定为 `selection__<节点 ID>`。单选传一个 `options[].value` 字符串,多选传由这些值组成的字符串数组;确认节点的 `label` 与 `value` 都是配置的选项内容。',
+ );
+ lines.push(``);
lines.push(`### 响应`);
lines.push(``);
lines.push('```json');
@@ -971,6 +975,9 @@ const apiDocMarkdown = computed(() => {
lines.push(
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
);
+ lines.push(
+ `| 400 | 40031 | 确认节点恢复参数缺失、类型错误或包含未配置选项 |`,
+ );
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItem.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItem.vue
deleted file mode 100644
index d491ba7d..00000000
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItem.vue
+++ /dev/null
@@ -1,211 +0,0 @@
-
-
-
-
-
-
-
- {{ item }}
-
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
- {{ item }}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItemMulti.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItemMulti.vue
deleted file mode 100644
index 46c16d23..00000000
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/ConfirmItemMulti.vue
+++ /dev/null
@@ -1,216 +0,0 @@
-
-
-
-
-
-
-
- {{ item }}
-
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
- {{ item }}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue
index b3438cf8..8e737619 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue
@@ -161,7 +161,7 @@ const extraFormRef = ref();
const waitingConfirmation = ref>();
const confirmValues = ref>({});
const confirmFormRef = ref();
-const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
+const confirmSubmittingAction = ref<'' | 'continue'>('');
const confirmError = ref('');
const detailVisible = ref(false);
const detailLoading = ref(false);
@@ -252,21 +252,9 @@ const hiddenParameterSummaryCount = computed(() =>
),
);
const confirmParameters = computed(() => {
- const parameters = Array.isArray(waitingConfirmation.value?.parameters)
+ return Array.isArray(waitingConfirmation.value?.parameters)
? waitingConfirmation.value?.parameters
: [];
- return parameters.filter(
- (parameter: any) => parameter.formType !== 'confirm',
- );
-});
-const confirmKey = computed(() => {
- const parameters = Array.isArray(waitingConfirmation.value?.parameters)
- ? waitingConfirmation.value?.parameters
- : [];
- return (
- parameters.find((parameter: any) => parameter.formType === 'confirm')
- ?.name || ''
- );
});
const composerDisabled = computed(
() =>
@@ -1012,33 +1000,25 @@ function finalizeLiveExecutionSteps(
function initializeConfirmValues(parameters: unknown) {
const values: Record = {};
for (const parameter of Array.isArray(parameters) ? parameters : []) {
- if (parameter.formType === 'confirm') {
- continue;
- }
- values[parameter.name] = parameter.defaultValue ?? '';
+ values[parameter.name] =
+ parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
}
confirmValues.value = values;
}
-async function resumeExecution(confirmed: boolean) {
- if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
+async function resumeExecution() {
+ if (!executeId.value || confirmSubmittingAction.value) {
return;
}
- confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
+ confirmSubmittingAction.value = 'continue';
confirmError.value = '';
try {
- if (
- confirmed &&
- !(await confirmFormRef.value?.validate().catch(() => false))
- ) {
+ if (!(await confirmFormRef.value?.validate().catch(() => false))) {
return;
}
await api.post(workflowChatEndpoint('resume'), {
executeId: executeId.value,
- confirmParams: {
- [confirmKey.value]: confirmed ? 'yes' : 'no',
- ...(confirmed ? confirmValues.value : {}),
- },
+ confirmParams: { ...confirmValues.value },
});
waitingConfirmation.value = undefined;
executionState.value = 'running';
@@ -1523,20 +1503,13 @@ function executionTraceText(
{{ confirmError }}
-
- 拒绝
-
- 确认
+ 继续
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue
index bee8db98..9040f518 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue
@@ -87,14 +87,22 @@ watch(
},
);
const executeId = ref('');
-function resume(data: any) {
+async function resume(data: any) {
data.executeId = executeId.value;
submitLoading.value = true;
- api.post('/api/v1/workflow/resume', data).then((res) => {
+ let accepted = false;
+ try {
+ const res = await api.post('/api/v1/workflow/resume', data);
if (res.errorCode === 0) {
+ accepted = true;
startPolling(executeId.value);
}
- });
+ return accepted;
+ } finally {
+ if (!accepted) {
+ submitLoading.value = false;
+ }
+ }
}
function submitV2() {
runForm.value?.validate((valid) => {
@@ -132,7 +140,7 @@ function startPolling(executeId: any) {
if (pollingActive) return;
pollingActive = true;
pollingGeneration += 1;
- schedulePolling(executeId, pollingGeneration);
+ void executePolling(executeId, pollingGeneration);
}
function schedulePolling(executeId: any, generation: number) {
timer.value = setTimeout(() => {
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFormItem.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFormItem.vue
index 5cb01b19..548a4ea9 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFormItem.vue
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFormItem.vue
@@ -59,6 +59,12 @@ function isWideItem(item: any) {
return item.formType === 'textarea' || contentType === 'image';
}
function getCheckboxOptions(item: any) {
+ if (Array.isArray(item.options)) {
+ return item.options.map((option: any) => ({
+ label: option.label,
+ value: option.value,
+ }));
+ }
if (item.enums) {
return (
item.enums?.map((option: 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 ecaf0099..8230c07e 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
@@ -14,14 +14,11 @@ import {
ElCollapse,
ElCollapseItem,
ElForm,
- ElFormItem,
ElIcon,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
-import { $t } from '#/locales';
-import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
-import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
+import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
export interface WorkflowStepsProps {
workflowId: any;
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
expandAll?: boolean;
}
const props = defineProps();
-const emit = defineEmits(['resume']);
+const emit = defineEmits<{
+ resume: [payload: any, onSettled: (accepted: boolean) => void];
+}>();
const nodes = ref([]);
const nodeStatusMap = ref>({});
const activeNames = ref([]);
@@ -90,7 +89,7 @@ watch(
}
const currentNodes = newVal.nodes || {};
chainErrMsg.value = newVal.status === 21 ? newVal.message : '';
- if (![20, 21].includes(newVal.status)) {
+ if (Number(newVal.status) !== 5) {
confirmBtnLoading.value = false;
}
let autoExpandNodeId: string | undefined;
@@ -106,6 +105,10 @@ watch(
continue;
}
nodeStatusMap.value[nodeId] = currentNodeState;
+ if (Number(currentStatus) === 5 && Number(previousStatus) !== 5) {
+ initializeConfirmParams(currentNodeState?.suspendForParameters);
+ confirmBtnLoading.value = false;
+ }
if (
!userControlledExpansion.value &&
!props.expandAll &&
@@ -128,6 +131,7 @@ watch(
() => props.initSignal,
() => {
nodeStatusMap.value = {};
+ confirmParams.value = {};
confirmBtnLoading.value = false;
chainErrMsg.value = '';
userControlledExpansion.value = false;
@@ -164,8 +168,13 @@ const setFormRef = (el: any, key: string) => {
formRefs.value[key] = el as FormInstance;
}
};
-function getSelectMode(ops: any) {
- return ops.formType || 'radio';
+function initializeConfirmParams(parameters: any) {
+ const values: Record = {};
+ for (const parameter of Array.isArray(parameters) ? parameters : []) {
+ values[parameter.name] =
+ parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
+ }
+ confirmParams.value = values;
}
function handleConfirm(node: any) {
const nodeKey = node.key;
@@ -176,17 +185,27 @@ function handleConfirm(node: any) {
console.warn(`Form instance for ${nodeKey} not found`);
return;
}
- const confirmKey = node.suspendForParameters[0].name;
form.validate((valid) => {
if (valid) {
const value = {
- confirmParams: {
- [confirmKey]: 'yes',
- ...confirmParams.value,
- },
+ confirmParams: { ...confirmParams.value },
};
confirmBtnLoading.value = true;
- emit('resume', value);
+ emit('resume', value, (accepted) => {
+ confirmBtnLoading.value = false;
+ if (!accepted) {
+ return;
+ }
+ confirmParams.value = {};
+ const currentState = nodeStatusMap.value[nodeKey];
+ if (currentState) {
+ nodeStatusMap.value[nodeKey] = {
+ ...currentState,
+ status: 1,
+ suspendForParameters: [],
+ };
+ }
+ });
}
});
}
@@ -234,7 +253,12 @@ function handleConfirm(node: any) {
-
+
{{ node.original.data.message }}
@@ -243,50 +267,24 @@ function handleConfirm(node: any) {
label-position="top"
:model="confirmParams"
>
-
+
-
-
- {{ ops.formDescription }}
-
-
-
-
-
-
-
-
-
- {{ $t('button.confirm') }}
-
-
-
+ 继续
+
+
@@ -318,26 +316,4 @@ function handleConfirm(node: any) {
transform: rotate(360deg);
}
}
-
-.header-container {
- display: flex;
- align-items: center;
- font-weight: bold;
- word-break: break-all;
-}
-
-.blue-bar {
- display: inline-block;
- width: 2px;
- height: 16px;
- margin-right: 16px;
- background-color: var(--el-color-primary);
- border-radius: 1px;
-}
-
-.description-container {
- margin-bottom: 16px;
- color: #969799;
- word-break: break-all;
-}
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 bef5fc15..d3470b89 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
@@ -25,6 +25,30 @@ function createWorkflowNodes() {
];
}
+function createConfirmNode() {
+ return [
+ {
+ key: 'confirm-a',
+ label: '用户确认',
+ original: {
+ data: { message: '请选择模板' },
+ type: 'confirmNode',
+ },
+ },
+ ];
+}
+
+function createConfirmNodes() {
+ return ['confirm-a', 'confirm-b'].map((key) => ({
+ key,
+ label: `用户确认 ${key}`,
+ original: {
+ data: { message: `请选择 ${key}` },
+ type: 'confirmNode',
+ },
+ }));
+}
+
function mountWorkflowSteps() {
return mount(WorkflowSteps, {
props: {
@@ -35,9 +59,8 @@ function mountWorkflowSteps() {
},
global: {
stubs: {
- ConfirmItem: true,
- ConfirmItemMulti: true,
ShowJson: true,
+ WorkflowFormItem: true,
},
},
});
@@ -159,4 +182,145 @@ describe('workflowSteps', () => {
'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token',
);
});
+
+ it('恢复请求未被受理时允许用户修正后重试', async () => {
+ const wrapper = mount(WorkflowSteps, {
+ props: {
+ initSignal: false,
+ nodeJson: createConfirmNode(),
+ pollingData: undefined,
+ workflowId: 'workflow-1',
+ },
+ global: {
+ stubs: {
+ ShowJson: true,
+ WorkflowFormItem: true,
+ },
+ },
+ });
+ await wrapper.setProps({
+ pollingData: {
+ nodes: {
+ 'confirm-a': {
+ status: 5,
+ suspendForParameters: [
+ {
+ formType: 'radio',
+ name: 'selection__confirm-a',
+ required: true,
+ },
+ ],
+ },
+ },
+ status: 5,
+ },
+ });
+
+ const button = wrapper.get('button.el-button');
+ await button.trigger('click');
+ expect(button.attributes('disabled')).toBeDefined();
+
+ const settle = wrapper.emitted('resume')?.[0]?.[1] as
+ | ((accepted: boolean) => void)
+ | undefined;
+ expect(settle).toBeTypeOf('function');
+ settle?.(false);
+ await wrapper.vm.$nextTick();
+
+ expect(button.attributes('disabled')).toBeUndefined();
+ });
+
+ it('恢复成功后同一节点再次暂停会清空旧值并允许再次确认', async () => {
+ const wrapper = mount(WorkflowSteps, {
+ props: {
+ initSignal: false,
+ nodeJson: createConfirmNode(),
+ pollingData: undefined,
+ workflowId: 'workflow-1',
+ },
+ global: {
+ stubs: {
+ ShowJson: true,
+ WorkflowFormItem: true,
+ },
+ },
+ });
+ const suspendedState = {
+ nodes: {
+ 'confirm-a': {
+ status: 5,
+ suspendForParameters: [
+ {
+ formType: 'radio',
+ name: 'selection__confirm-a',
+ required: true,
+ },
+ ],
+ },
+ },
+ status: 5,
+ };
+ await wrapper.setProps({ pollingData: suspendedState });
+
+ await wrapper.get('button.el-button').trigger('click');
+ const settle = wrapper.emitted('resume')?.[0]?.[1] as
+ | ((accepted: boolean) => void)
+ | undefined;
+ settle?.(true);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('button.el-button').exists()).toBe(false);
+
+ await wrapper.setProps({
+ pollingData: {
+ ...suspendedState,
+ nodes: {
+ 'confirm-a': {
+ ...suspendedState.nodes['confirm-a'],
+ },
+ },
+ },
+ });
+
+ const nextButton = wrapper.get('button.el-button');
+ expect(nextButton.attributes('disabled')).toBeUndefined();
+ });
+
+ it('多个确认节点只在当前暂停节点展示确认表单', async () => {
+ const wrapper = mount(WorkflowSteps, {
+ props: {
+ expandAll: true,
+ initSignal: false,
+ nodeJson: createConfirmNodes(),
+ pollingData: undefined,
+ workflowId: 'workflow-1',
+ },
+ global: {
+ stubs: {
+ ShowJson: true,
+ WorkflowFormItem: true,
+ },
+ },
+ });
+ const suspendForParameters = [
+ {
+ formType: 'radio',
+ name: 'selection__confirm-a',
+ required: true,
+ },
+ ];
+
+ await wrapper.setProps({
+ pollingData: {
+ nodes: {
+ 'confirm-a': { status: 5, suspendForParameters },
+ 'confirm-b': { status: 0, suspendForParameters },
+ },
+ status: 5,
+ },
+ });
+
+ expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
+ expect(wrapper.findAll('button.el-button')).toHaveLength(1);
+ });
});
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowPublicFormItem.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowPublicFormItem.test.ts
index aabb51bb..46162b3d 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowPublicFormItem.test.ts
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowPublicFormItem.test.ts
@@ -46,4 +46,33 @@ describe('workflow public form item', () => {
expect(input.props('allowResourcePicker')).toBe(false);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
});
+
+ it('renders confirmation option content as both label and output value', () => {
+ const wrapper = mount(WorkflowFormItem, {
+ props: {
+ parameters: [
+ {
+ contentType: 'text',
+ formLabel: '会议纪要模板',
+ formType: 'radio',
+ name: 'selection__confirm',
+ options: [
+ { label: '第一议题', value: '第一议题' },
+ { label: '审议类', value: '审议类' },
+ ],
+ required: true,
+ },
+ ],
+ runParams: {},
+ },
+ });
+
+ const group = wrapper.findComponent({ name: 'ElRadioGroup' });
+ const formItem = wrapper.findComponent({ name: 'ElFormItem' });
+ expect(formItem.props('label')).toBe('会议纪要模板');
+ expect(group.props('options')).toEqual([
+ { label: '第一议题', value: '第一议题' },
+ { label: '审议类', value: '审议类' },
+ ]);
+ });
});
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFormParameters.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFormParameters.ts
index 913ebb9a..faac61e6 100644
--- a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFormParameters.ts
+++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFormParameters.ts
@@ -64,10 +64,16 @@ function replaceDefaultParameterLabel(label: unknown, name: unknown) {
*/
export function resolveWorkflowParameterLabel(parameter: any) {
const name = String(parameter?.name || '').trim();
+ const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
+ const hasStructuredOptions =
+ ['checkbox', 'radio'].includes(String(parameter?.formType || '')) &&
+ Array.isArray(parameter?.options);
+ if (hasStructuredOptions && formLabel) {
+ return formLabel;
+ }
if (!isSystemParameter(parameter, name) && name) {
return configuredParameterName(name);
}
- const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,
diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts
index 5c63aeba..af979e3d 100644
--- a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts
+++ b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts
@@ -106,7 +106,12 @@ export class Tinyflow {
if (!flow) {
return null;
}
- return flow.toObject();
+ this.store.flushPendingEdits();
+ return {
+ ...flow.toObject(),
+ nodes: this.store.getNodes(),
+ edges: this.store.getEdges(),
+ };
}
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {
diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.svelte
index aa945bbb..6359cec1 100644
--- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.svelte
+++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.svelte
@@ -15,6 +15,8 @@
variant = 'default',
showSelectedType = true,
selectedType,
+ disabled = false,
+ disabledReason,
...rest
}: {
items: SelectItem[],
@@ -26,46 +28,55 @@
variant?: 'default' | 'reference' | 'model'
showSelectedType?: boolean
selectedType?: string
+ disabled?: boolean
+ disabledReason?: string
[key: string]: any
} = $props();
let activeItemsState = $derived.by(() => {
- const resultItems: SelectItem[] = [];
- const fillResult = (items: SelectItem[]) => {
- for (let item of items) {
- if (value.length > 0) {
- if (value.includes(item.value)) {
- resultItems.push(item);
- }
- } else {
- if (defaultValue.includes(item.value)) {
- resultItems.push(item);
- }
- }
-
+ const flattenedItems: SelectItem[] = [];
+ const flatten = (sourceItems: SelectItem[]) => {
+ for (const item of sourceItems) {
+ flattenedItems.push(item);
if (item.children && item.children.length > 0) {
- fillResult(item.children);
+ flatten(item.children);
}
}
};
- fillResult(items);
- return resultItems;
+ flatten(items);
+ const selectedValues = value.length > 0 ? value : defaultValue;
+ return selectedValues.flatMap((selectedValue) => {
+ const item = flattenedItems.find((candidate) => candidate.value === selectedValue);
+ return item ? [item] : [];
+ });
});
let triggerObject: any = $state();
+ let triggerButton: HTMLButtonElement | undefined = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
-
function closeMenu() {
triggerObject?.hide();
isOpen = false;
hoveredItem = null;
}
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key !== 'Escape' || !isOpen) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ closeMenu();
+ triggerButton?.focus();
+ }
+
function handlerOnSelect(item: SelectItem) {
if (item.selectable !== false) {
onSelect?.(item);
- closeMenu();
+ if (!multiple) {
+ closeMenu();
+ }
} else {
if (variant === 'reference') {
hoveredItem = item;
@@ -89,8 +100,23 @@
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
{#each items as item}
-