feat: 增加工作流合法性校验功能
This commit is contained in:
@@ -4,21 +4,25 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
|||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
import com.easyagents.flow.core.chain.*;
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
|
import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.service.BotWorkflowService;
|
import tech.easyflow.ai.service.BotWorkflowService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
@@ -33,7 +37,10 @@ import java.io.InputStream;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.*;
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 控制层。
|
* 控制层。
|
||||||
@@ -58,6 +65,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private CodeEngineCapabilityService codeEngineCapabilityService;
|
private CodeEngineCapabilityService codeEngineCapabilityService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowCheckService workflowCheckService;
|
||||||
|
|
||||||
public WorkflowController(WorkflowService service, ModelService modelService) {
|
public WorkflowController(WorkflowService service, ModelService modelService) {
|
||||||
super(service);
|
super(service);
|
||||||
@@ -96,6 +105,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -130,6 +140,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
InputStream is = jsonFile.getInputStream();
|
InputStream is = jsonFile.getInputStream();
|
||||||
String content = IoUtil.read(is, StandardCharsets.UTF_8);
|
String content = IoUtil.read(is, StandardCharsets.UTF_8);
|
||||||
workflow.setContent(content);
|
workflow.setContent(content);
|
||||||
|
workflowCheckService.checkOrThrow(content, WorkflowCheckStage.SAVE, workflow.getId());
|
||||||
save(workflow);
|
save(workflow);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
@@ -149,6 +160,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
|
||||||
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
||||||
if (definition == null) {
|
if (definition == null) {
|
||||||
@@ -169,6 +181,23 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
return Result.ok(codeEngineCapabilityService.listSupportedCodeEngines());
|
return Result.ok(codeEngineCapabilityService.listSupportedCodeEngines());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/check")
|
||||||
|
@SaCheckPermission("/api/v1/workflow/save")
|
||||||
|
public Result<WorkflowCheckResult> check(@JsonBody("id") BigInteger id,
|
||||||
|
@JsonBody("content") String content,
|
||||||
|
@JsonBody(value = "stage", required = true) String stage) {
|
||||||
|
WorkflowCheckStage checkStage = WorkflowCheckStage.from(stage);
|
||||||
|
WorkflowCheckResult checkResult;
|
||||||
|
if (StringUtils.hasLength(content)) {
|
||||||
|
checkResult = workflowCheckService.checkContent(content, checkStage, id);
|
||||||
|
} else if (id != null) {
|
||||||
|
checkResult = workflowCheckService.checkWorkflow(id, checkStage);
|
||||||
|
} else {
|
||||||
|
throw new BusinessException("id 与 content 不能同时为空");
|
||||||
|
}
|
||||||
|
return Result.ok(checkResult);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<Workflow> detail(String id) {
|
public Result<Workflow> detail(String id) {
|
||||||
Workflow workflow = service.getDetail(id);
|
Workflow workflow = service.getDetail(id);
|
||||||
@@ -189,6 +218,9 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
|
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
|
||||||
|
if (StringUtils.hasLength(entity.getContent())) {
|
||||||
|
workflowCheckService.checkOrThrow(entity.getContent(), WorkflowCheckStage.SAVE, entity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
String alias = entity.getAlias();
|
String alias = entity.getAlias();
|
||||||
if (StringUtils.hasLength(alias)) {
|
if (StringUtils.hasLength(alias)) {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import com.easyagents.flow.core.parser.ChainParser;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
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.TinyFlowService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ public class PublicWorkflowController {
|
|||||||
private ChainParser chainParser;
|
private ChainParser chainParser;
|
||||||
@Resource
|
@Resource
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowCheckService workflowCheckService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过id或别名获取工作流详情
|
* 通过id或别名获取工作流详情
|
||||||
@@ -83,6 +87,7 @@ public class PublicWorkflowController {
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
String executeId = chainExecutor.executeAsync(id.toString(), variables);
|
String executeId = chainExecutor.executeAsync(id.toString(), variables);
|
||||||
return Result.ok(executeId);
|
return Result.ok(executeId);
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,7 @@ public class PublicWorkflowController {
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
|
||||||
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
||||||
if (definition == null) {
|
if (definition == null) {
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ package tech.easyflow.usercenter.controller.ai;
|
|||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.easyagents.flow.core.chain.*;
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
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.TinyFlowService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
@@ -21,7 +24,10 @@ import tech.easyflow.common.web.jsonbody.JsonBody;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.*;
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流
|
* 工作流
|
||||||
@@ -37,6 +43,8 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
private ChainParser chainParser;
|
private ChainParser chainParser;
|
||||||
@Resource
|
@Resource
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowCheckService workflowCheckService;
|
||||||
|
|
||||||
public UcWorkflowController(WorkflowService service) {
|
public UcWorkflowController(WorkflowService service) {
|
||||||
super(service);
|
super(service);
|
||||||
@@ -74,6 +82,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -113,6 +122,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
|
||||||
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
ChainDefinition definition = chainParser.parse(workflow.getContent());
|
||||||
if (definition == null) {
|
if (definition == null) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.entity;
|
||||||
|
|
||||||
|
public class WorkflowCheckIssue {
|
||||||
|
private String code;
|
||||||
|
private String level;
|
||||||
|
private String message;
|
||||||
|
private String nodeId;
|
||||||
|
private String edgeId;
|
||||||
|
private String nodeName;
|
||||||
|
|
||||||
|
public WorkflowCheckIssue() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkflowCheckIssue(String code, String level, String message, String nodeId, String edgeId, String nodeName) {
|
||||||
|
this.code = code;
|
||||||
|
this.level = level;
|
||||||
|
this.message = message;
|
||||||
|
this.nodeId = nodeId;
|
||||||
|
this.edgeId = edgeId;
|
||||||
|
this.nodeName = nodeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLevel() {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLevel(String level) {
|
||||||
|
this.level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNodeId() {
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNodeId(String nodeId) {
|
||||||
|
this.nodeId = nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEdgeId() {
|
||||||
|
return edgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEdgeId(String edgeId) {
|
||||||
|
this.edgeId = edgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNodeName() {
|
||||||
|
return nodeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNodeName(String nodeName) {
|
||||||
|
this.nodeName = nodeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.entity;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class WorkflowCheckResult {
|
||||||
|
private boolean passed;
|
||||||
|
private WorkflowCheckStage stage;
|
||||||
|
private int issueCount;
|
||||||
|
private List<WorkflowCheckIssue> issues = new ArrayList<>();
|
||||||
|
|
||||||
|
public WorkflowCheckResult() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPassed() {
|
||||||
|
return passed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassed(boolean passed) {
|
||||||
|
this.passed = passed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkflowCheckStage getStage() {
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStage(WorkflowCheckStage stage) {
|
||||||
|
this.stage = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIssueCount() {
|
||||||
|
return issueCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssueCount(int issueCount) {
|
||||||
|
this.issueCount = issueCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WorkflowCheckIssue> getIssues() {
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssues(List<WorkflowCheckIssue> issues) {
|
||||||
|
this.issues = issues == null ? new ArrayList<>() : issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.entity;
|
||||||
|
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public enum WorkflowCheckStage {
|
||||||
|
SAVE,
|
||||||
|
PRE_EXECUTE;
|
||||||
|
|
||||||
|
public static WorkflowCheckStage from(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new BusinessException("校验阶段不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return WorkflowCheckStage.valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new BusinessException("不支持的校验阶段: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
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.parser.ChainParser;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WorkflowCheckService {
|
||||||
|
private static final String LEVEL_ERROR = "ERROR";
|
||||||
|
private static final String TYPE_START = "startNode";
|
||||||
|
private static final String TYPE_END = "endNode";
|
||||||
|
private static final String TYPE_LOOP = "loopNode";
|
||||||
|
private static final String TYPE_WORKFLOW = "workflow-node";
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WorkflowService workflowService;
|
||||||
|
@Resource
|
||||||
|
private ChainParser chainParser;
|
||||||
|
|
||||||
|
public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) {
|
||||||
|
if (workflowId == null) {
|
||||||
|
throw new BusinessException("工作流ID不能为空");
|
||||||
|
}
|
||||||
|
Workflow workflow = workflowService.getById(workflowId);
|
||||||
|
if (workflow == null) {
|
||||||
|
throw new BusinessException("工作流不存在: " + workflowId);
|
||||||
|
}
|
||||||
|
return checkContent(workflow.getContent(), stage, workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkflowCheckResult checkContent(String content, WorkflowCheckStage stage, BigInteger currentWorkflowId) {
|
||||||
|
if (stage == null) {
|
||||||
|
throw new BusinessException("校验阶段不能为空");
|
||||||
|
}
|
||||||
|
List<WorkflowCheckIssue> issues = new ArrayList<>();
|
||||||
|
Set<String> issueKeys = new LinkedHashSet<>();
|
||||||
|
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
||||||
|
|
||||||
|
if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) {
|
||||||
|
runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys);
|
||||||
|
}
|
||||||
|
return buildResult(stage, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkOrThrow(String content, WorkflowCheckStage stage, BigInteger currentWorkflowId) {
|
||||||
|
WorkflowCheckResult result = checkContent(content, stage, currentWorkflowId);
|
||||||
|
throwIfFailed(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkOrThrow(BigInteger workflowId, WorkflowCheckStage stage) {
|
||||||
|
WorkflowCheckResult result = checkWorkflow(workflowId, stage);
|
||||||
|
throwIfFailed(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParsedWorkflow parseAndCheckBase(String content, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
if (!StringUtils.hasText(content)) {
|
||||||
|
addIssue(issues, issueKeys, "INVALID_JSON", "工作流内容不能为空", null, null, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch (Exception e) {
|
||||||
|
addIssue(issues, issueKeys, "INVALID_JSON", "工作流内容不是合法JSON: " + shortError(e), null, null, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(parsed instanceof JSONObject)) {
|
||||||
|
addIssue(issues, issueKeys, "INVALID_JSON_OBJECT", "工作流内容必须是JSON对象", null, null, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject root = (JSONObject) parsed;
|
||||||
|
JSONArray nodesArray = getArrayField(root, "nodes", "NODES_NOT_ARRAY", "nodes 必须是数组", issues, issueKeys);
|
||||||
|
JSONArray edgesArray = getArrayField(root, "edges", "EDGES_NOT_ARRAY", "edges 必须是数组", issues, issueKeys);
|
||||||
|
|
||||||
|
List<NodeView> nodes = new ArrayList<>();
|
||||||
|
Map<String, NodeView> nodeMap = new LinkedHashMap<>();
|
||||||
|
Set<String> nodeIds = new HashSet<>();
|
||||||
|
Map<String, ?> parserMap = chainParser.getNodeParserMap() == null ? Collections.emptyMap() : chainParser.getNodeParserMap();
|
||||||
|
|
||||||
|
for (int i = 0; i < nodesArray.size(); i++) {
|
||||||
|
Object nodeObject = nodesArray.get(i);
|
||||||
|
if (!(nodeObject instanceof JSONObject)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_INVALID", "第 " + (i + 1) + " 个节点不是对象", null, null, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONObject nodeJson = (JSONObject) nodeObject;
|
||||||
|
NodeView node = new NodeView();
|
||||||
|
node.id = trimToNull(nodeJson.getString("id"));
|
||||||
|
node.type = trimToNull(nodeJson.getString("type"));
|
||||||
|
node.parentId = trimToNull(nodeJson.getString("parentId"));
|
||||||
|
node.data = nodeJson.getJSONObject("data");
|
||||||
|
node.name = extractNodeName(nodeJson, node.data, node.id);
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(node.id)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_ID_EMPTY", "存在节点缺少 id", null, null, node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nodeIds.add(node.id)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_ID_DUPLICATE", "节点ID重复: " + node.id, node.id, null, node.name);
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(node.type) || !parserMap.containsKey(node.type)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_TYPE_UNKNOWN", "节点类型无法识别: " + safe(node.type), node.id, null, node.name);
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(node.parentId) && node.parentId.equals(node.id)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_PARENT_SELF", "节点不能引用自己作为父节点", node.id, null, node.name);
|
||||||
|
}
|
||||||
|
nodes.add(node);
|
||||||
|
nodeMap.put(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NodeView node : nodes) {
|
||||||
|
if (StringUtils.hasText(node.parentId) && !nodeMap.containsKey(node.parentId)) {
|
||||||
|
addIssue(issues, issueKeys, "NODE_PARENT_NOT_FOUND",
|
||||||
|
"父节点不存在: " + node.parentId, node.id, null, node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EdgeView> edges = new ArrayList<>();
|
||||||
|
Set<String> edgeIds = new HashSet<>();
|
||||||
|
for (int i = 0; i < edgesArray.size(); i++) {
|
||||||
|
Object edgeObject = edgesArray.get(i);
|
||||||
|
if (!(edgeObject instanceof JSONObject)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_INVALID", "第 " + (i + 1) + " 条连线不是对象", null, null, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONObject edgeJson = (JSONObject) edgeObject;
|
||||||
|
EdgeView edge = new EdgeView();
|
||||||
|
edge.id = trimToNull(edgeJson.getString("id"));
|
||||||
|
edge.source = trimToNull(edgeJson.getString("source"));
|
||||||
|
edge.target = trimToNull(edgeJson.getString("target"));
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(edge.id)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!edgeIds.add(edge.id)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_ID_DUPLICATE", "连线ID重复: " + edge.id, null, edge.id, null);
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(edge.source)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_SOURCE_EMPTY", "连线 source 不能为空", null, edge.id, null);
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(edge.target)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_TARGET_EMPTY", "连线 target 不能为空", null, edge.id, null);
|
||||||
|
}
|
||||||
|
edges.add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (EdgeView edge : edges) {
|
||||||
|
if (StringUtils.hasText(edge.source) && !nodeMap.containsKey(edge.source)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_SOURCE_NOT_FOUND",
|
||||||
|
"连线 source 不存在: " + edge.source, edge.source, edge.id, null);
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(edge.target) && !nodeMap.containsKey(edge.target)) {
|
||||||
|
addIssue(issues, issueKeys, "EDGE_TARGET_NOT_FOUND",
|
||||||
|
"连线 target 不存在: " + edge.target, edge.target, edge.id, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedWorkflow parsedWorkflow = new ParsedWorkflow();
|
||||||
|
parsedWorkflow.nodes = nodes;
|
||||||
|
parsedWorkflow.edges = edges;
|
||||||
|
parsedWorkflow.nodeMap = nodeMap;
|
||||||
|
return parsedWorkflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runStrictChecks(String content, ParsedWorkflow parsed, BigInteger currentWorkflowId,
|
||||||
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
if (parsed.nodes.isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "NODES_EMPTY", "预执行校验失败:nodes 不能为空", null, null, null);
|
||||||
|
}
|
||||||
|
if (parsed.edges.isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "EDGES_EMPTY", "预执行校验失败:edges 不能为空", null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object definition = chainParser.parse(content);
|
||||||
|
if (definition == null) {
|
||||||
|
addIssue(issues, issueKeys, "PARSE_NULL", "预执行校验失败:节点配置错误,请检查", null, null, null);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
addIssue(issues, issueKeys, "PARSE_FAILED", "预执行校验失败:解析流程失败 - " + shortError(e), null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NodeView> startNodes = parsed.nodes.stream()
|
||||||
|
.filter(node -> TYPE_START.equals(node.type))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
List<NodeView> endNodes = parsed.nodes.stream()
|
||||||
|
.filter(node -> TYPE_END.equals(node.type))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (startNodes.isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "START_NODE_MISSING", "预执行校验失败:至少需要一个 startNode", null, null, null);
|
||||||
|
}
|
||||||
|
if (endNodes.isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "END_NODE_MISSING", "预执行校验失败:至少需要一个 endNode", null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NodeView> rootNodes = parsed.nodes.stream()
|
||||||
|
.filter(NodeView::isRootLevel)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
Map<String, Integer> rootInDegree = new HashMap<>();
|
||||||
|
for (NodeView rootNode : rootNodes) {
|
||||||
|
rootInDegree.put(rootNode.id, 0);
|
||||||
|
}
|
||||||
|
for (EdgeView edge : parsed.edges) {
|
||||||
|
NodeView source = parsed.nodeMap.get(edge.source);
|
||||||
|
NodeView target = parsed.nodeMap.get(edge.target);
|
||||||
|
if (source == null || target == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source.isRootLevel() && target.isRootLevel() && isSameParent(source, target)) {
|
||||||
|
rootInDegree.put(target.id, rootInDegree.getOrDefault(target.id, 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NodeView> rootEntries = new ArrayList<>();
|
||||||
|
for (NodeView rootNode : rootNodes) {
|
||||||
|
if (rootInDegree.getOrDefault(rootNode.id, 0) == 0) {
|
||||||
|
rootEntries.add(rootNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (NodeView entry : rootEntries) {
|
||||||
|
if (!TYPE_START.equals(entry.type)) {
|
||||||
|
addIssue(issues, issueKeys, "ROOT_ENTRY_NOT_START",
|
||||||
|
"根级入度为0的节点必须是 startNode", entry.id, null, entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detectExplicitCycle(parsed, issues, issueKeys);
|
||||||
|
|
||||||
|
Map<String, Set<String>> runtimeGraph = buildRuntimeGraph(parsed);
|
||||||
|
Set<String> entryNodeIds = rootEntries.stream()
|
||||||
|
.map(node -> node.id)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
if (entryNodeIds.isEmpty()) {
|
||||||
|
for (NodeView startNode : startNodes) {
|
||||||
|
entryNodeIds.add(startNode.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Set<String> reachable = bfsReachable(runtimeGraph, entryNodeIds);
|
||||||
|
|
||||||
|
for (String nodeId : reachable) {
|
||||||
|
NodeView node = parsed.nodeMap.get(nodeId);
|
||||||
|
if (node == null || TYPE_END.equals(node.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (runtimeGraph.getOrDefault(nodeId, Collections.emptySet()).isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "DEAD_END_NODE",
|
||||||
|
"存在无法继续执行的死路节点(非 end 且无后继)", node.id, null, node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endNodes.isEmpty()) {
|
||||||
|
Set<String> canReachEnd = reverseReachable(runtimeGraph, endNodes.stream()
|
||||||
|
.map(node -> node.id)
|
||||||
|
.collect(Collectors.toSet()));
|
||||||
|
for (String nodeId : reachable) {
|
||||||
|
if (!canReachEnd.contains(nodeId)) {
|
||||||
|
NodeView node = parsed.nodeMap.get(nodeId);
|
||||||
|
if (node != null) {
|
||||||
|
addIssue(issues, issueKeys, "END_UNREACHABLE",
|
||||||
|
"该节点无法到达任一 endNode", node.id, null, node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkWorkflowReferences(parsed, currentWorkflowId, content, issues, issueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkWorkflowReferences(ParsedWorkflow parsed, BigInteger currentWorkflowId, String currentContent,
|
||||||
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
Map<String, String> contentCache = new HashMap<>();
|
||||||
|
String currentWorkflowIdString = currentWorkflowId == null ? null : currentWorkflowId.toString();
|
||||||
|
if (StringUtils.hasText(currentWorkflowIdString)) {
|
||||||
|
contentCache.put(currentWorkflowIdString, currentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
if (!TYPE_WORKFLOW.equals(node.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String workflowId = getWorkflowIdInNode(node);
|
||||||
|
if (!StringUtils.hasText(workflowId)) {
|
||||||
|
addIssue(issues, issueKeys, "WORKFLOW_REF_EMPTY", "子流程节点缺少 workflowId", node.id, null, node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(currentWorkflowIdString) && currentWorkflowIdString.equals(workflowId)) {
|
||||||
|
addIssue(issues, issueKeys, "WORKFLOW_REF_CYCLE", "子流程递归引用:工作流不能引用自身", node.id, null, node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String workflowContent = loadWorkflowContent(workflowId, currentWorkflowIdString, currentContent, contentCache);
|
||||||
|
if (!StringUtils.hasText(workflowContent)) {
|
||||||
|
addIssue(issues, issueKeys, "WORKFLOW_REF_NOT_FOUND",
|
||||||
|
"子流程引用不存在: " + workflowId, node.id, null, node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(currentWorkflowIdString)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detectWorkflowReferenceCycle(currentWorkflowIdString, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void detectWorkflowReferenceCycle(String rootWorkflowId, String currentWorkflowId, String currentContent,
|
||||||
|
Map<String, String> contentCache,
|
||||||
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
Set<String> visited = new HashSet<>();
|
||||||
|
LinkedHashSet<String> visiting = new LinkedHashSet<>();
|
||||||
|
dfsWorkflowGraph(rootWorkflowId, currentWorkflowId, currentContent, contentCache, visited, visiting, issues, issueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dfsWorkflowGraph(String workflowId, String currentWorkflowId, String currentContent,
|
||||||
|
Map<String, String> contentCache,
|
||||||
|
Set<String> visited, LinkedHashSet<String> visiting,
|
||||||
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
if (visited.contains(workflowId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visiting.contains(workflowId)) {
|
||||||
|
addIssue(issues, issueKeys, "WORKFLOW_REF_CYCLE",
|
||||||
|
"检测到子流程递归引用: " + formatCyclePath(visiting, workflowId), null, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String content = loadWorkflowContent(workflowId, currentWorkflowId, currentContent, contentCache);
|
||||||
|
if (!StringUtils.hasText(content)) {
|
||||||
|
visited.add(workflowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.add(workflowId);
|
||||||
|
Set<String> referencedWorkflowIds = extractWorkflowRefIds(content);
|
||||||
|
for (String refWorkflowId : referencedWorkflowIds) {
|
||||||
|
if (!StringUtils.hasText(refWorkflowId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (visiting.contains(refWorkflowId)) {
|
||||||
|
addIssue(issues, issueKeys, "WORKFLOW_REF_CYCLE",
|
||||||
|
"检测到子流程递归引用: " + formatCyclePath(visiting, refWorkflowId), null, null, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String refContent = loadWorkflowContent(refWorkflowId, currentWorkflowId, currentContent, contentCache);
|
||||||
|
if (!StringUtils.hasText(refContent)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dfsWorkflowGraph(refWorkflowId, currentWorkflowId, currentContent, contentCache, visited, visiting, issues, issueKeys);
|
||||||
|
}
|
||||||
|
visiting.remove(workflowId);
|
||||||
|
visited.add(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadWorkflowContent(String workflowId, String currentWorkflowId, String currentContent, Map<String, String> contentCache) {
|
||||||
|
if (!StringUtils.hasText(workflowId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(currentWorkflowId) && currentWorkflowId.equals(workflowId)) {
|
||||||
|
return currentContent;
|
||||||
|
}
|
||||||
|
if (contentCache.containsKey(workflowId)) {
|
||||||
|
return contentCache.get(workflowId);
|
||||||
|
}
|
||||||
|
Workflow workflow = workflowService.getById(workflowId);
|
||||||
|
String content = workflow == null ? null : workflow.getContent();
|
||||||
|
contentCache.put(workflowId, content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> extractWorkflowRefIds(String content) {
|
||||||
|
Set<String> refs = new LinkedHashSet<>();
|
||||||
|
if (!StringUtils.hasText(content)) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object parsed = JSON.parse(content);
|
||||||
|
if (!(parsed instanceof JSONObject)) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
JSONArray nodes = ((JSONObject) parsed).getJSONArray("nodes");
|
||||||
|
if (nodes == null) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < nodes.size(); i++) {
|
||||||
|
JSONObject node = nodes.getJSONObject(i);
|
||||||
|
if (node == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TYPE_WORKFLOW.equals(trimToNull(node.getString("type")))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONObject data = node.getJSONObject("data");
|
||||||
|
String workflowId = trimToNull(data == null ? null : data.getString("workflowId"));
|
||||||
|
if (StringUtils.hasText(workflowId)) {
|
||||||
|
refs.add(workflowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatCyclePath(LinkedHashSet<String> visiting, String cycleStart) {
|
||||||
|
List<String> chain = new ArrayList<>();
|
||||||
|
boolean started = false;
|
||||||
|
for (String id : visiting) {
|
||||||
|
if (id.equals(cycleStart)) {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
if (started) {
|
||||||
|
chain.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.add(cycleStart);
|
||||||
|
return String.join(" -> ", chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getWorkflowIdInNode(NodeView node) {
|
||||||
|
if (node == null || node.data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimToNull(node.data.getString("workflowId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void detectExplicitCycle(ParsedWorkflow parsed, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
Map<String, List<String>> graph = new HashMap<>();
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
graph.put(node.id, new ArrayList<>());
|
||||||
|
}
|
||||||
|
for (EdgeView edge : parsed.edges) {
|
||||||
|
if (!StringUtils.hasText(edge.source) || !StringUtils.hasText(edge.target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsed.nodeMap.containsKey(edge.source) || !parsed.nodeMap.containsKey(edge.target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (edge.source.equals(edge.target)) {
|
||||||
|
addIssue(issues, issueKeys, "GRAPH_CYCLE", "检测到自环连线", edge.source, edge.id, parsed.nodeMap.get(edge.source).name);
|
||||||
|
}
|
||||||
|
graph.get(edge.source).add(edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> state = new HashMap<>();
|
||||||
|
Deque<String> path = new ArrayDeque<>();
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
if (state.getOrDefault(node.id, 0) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dfsCycle(node.id, graph, state, path, parsed, issues, issueKeys)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean dfsCycle(String current, Map<String, List<String>> graph, Map<String, Integer> state,
|
||||||
|
Deque<String> path, ParsedWorkflow parsed, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
state.put(current, 1);
|
||||||
|
path.addLast(current);
|
||||||
|
for (String next : graph.getOrDefault(current, Collections.emptyList())) {
|
||||||
|
int nextState = state.getOrDefault(next, 0);
|
||||||
|
if (nextState == 0) {
|
||||||
|
if (dfsCycle(next, graph, state, path, parsed, issues, issueKeys)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (nextState == 1) {
|
||||||
|
String cyclePath = formatNodeCyclePath(path, next);
|
||||||
|
NodeView node = parsed.nodeMap.get(next);
|
||||||
|
addIssue(issues, issueKeys, "GRAPH_CYCLE", "检测到环路: " + cyclePath, next, null, node == null ? null : node.name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.removeLast();
|
||||||
|
state.put(current, 2);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatNodeCyclePath(Deque<String> path, String cycleStart) {
|
||||||
|
List<String> list = new ArrayList<>(path);
|
||||||
|
int index = list.indexOf(cycleStart);
|
||||||
|
if (index < 0) {
|
||||||
|
return String.join(" -> ", list);
|
||||||
|
}
|
||||||
|
List<String> cycle = new ArrayList<>(list.subList(index, list.size()));
|
||||||
|
cycle.add(cycleStart);
|
||||||
|
return String.join(" -> ", cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Set<String>> buildRuntimeGraph(ParsedWorkflow parsed) {
|
||||||
|
Map<String, Set<String>> graph = new HashMap<>();
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
graph.put(node.id, new LinkedHashSet<>());
|
||||||
|
}
|
||||||
|
for (EdgeView edge : parsed.edges) {
|
||||||
|
if (!StringUtils.hasText(edge.source) || !StringUtils.hasText(edge.target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
NodeView source = parsed.nodeMap.get(edge.source);
|
||||||
|
NodeView target = parsed.nodeMap.get(edge.target);
|
||||||
|
if (source == null || target == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TYPE_END.equals(source.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isSameParent(source, target)
|
||||||
|
|| (TYPE_LOOP.equals(source.type) && source.id.equals(target.parentId))) {
|
||||||
|
graph.get(source.id).add(target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
if (TYPE_END.equals(node.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Set<String> nextNodes = graph.getOrDefault(node.id, new LinkedHashSet<>());
|
||||||
|
if (nextNodes.isEmpty() && StringUtils.hasText(node.parentId) && parsed.nodeMap.containsKey(node.parentId)) {
|
||||||
|
nextNodes.add(node.parentId);
|
||||||
|
}
|
||||||
|
graph.put(node.id, nextNodes);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> bfsReachable(Map<String, Set<String>> graph, Set<String> starts) {
|
||||||
|
Set<String> visited = new LinkedHashSet<>();
|
||||||
|
Deque<String> queue = new ArrayDeque<>();
|
||||||
|
for (String start : starts) {
|
||||||
|
if (StringUtils.hasText(start) && graph.containsKey(start)) {
|
||||||
|
queue.add(start);
|
||||||
|
visited.add(start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
String current = queue.pollFirst();
|
||||||
|
for (String next : graph.getOrDefault(current, Collections.emptySet())) {
|
||||||
|
if (visited.add(next)) {
|
||||||
|
queue.addLast(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> reverseReachable(Map<String, Set<String>> graph, Set<String> endNodes) {
|
||||||
|
Map<String, Set<String>> reverseGraph = new HashMap<>();
|
||||||
|
for (String source : graph.keySet()) {
|
||||||
|
reverseGraph.computeIfAbsent(source, key -> new LinkedHashSet<>());
|
||||||
|
for (String target : graph.getOrDefault(source, Collections.emptySet())) {
|
||||||
|
reverseGraph.computeIfAbsent(target, key -> new LinkedHashSet<>()).add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Set<String> visited = new LinkedHashSet<>();
|
||||||
|
Deque<String> queue = new ArrayDeque<>();
|
||||||
|
for (String endNode : endNodes) {
|
||||||
|
if (StringUtils.hasText(endNode) && reverseGraph.containsKey(endNode)) {
|
||||||
|
visited.add(endNode);
|
||||||
|
queue.add(endNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
String current = queue.pollFirst();
|
||||||
|
for (String prev : reverseGraph.getOrDefault(current, Collections.emptySet())) {
|
||||||
|
if (visited.add(prev)) {
|
||||||
|
queue.addLast(prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameParent(NodeView source, NodeView target) {
|
||||||
|
return Objects.equals(trimToNull(source.parentId), trimToNull(target.parentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONArray getArrayField(JSONObject root, String fieldName, String issueCode, String issueMessage,
|
||||||
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
Object value = root.get(fieldName);
|
||||||
|
if (value == null) {
|
||||||
|
return new JSONArray();
|
||||||
|
}
|
||||||
|
if (!(value instanceof JSONArray)) {
|
||||||
|
addIssue(issues, issueKeys, issueCode, issueMessage, null, null, null);
|
||||||
|
return new JSONArray();
|
||||||
|
}
|
||||||
|
return (JSONArray) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkflowCheckResult buildResult(WorkflowCheckStage stage, List<WorkflowCheckIssue> issues) {
|
||||||
|
WorkflowCheckResult result = new WorkflowCheckResult();
|
||||||
|
result.setStage(stage);
|
||||||
|
result.setIssues(issues);
|
||||||
|
result.setIssueCount(issues.size());
|
||||||
|
result.setPassed(issues.isEmpty());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwIfFailed(WorkflowCheckResult result) {
|
||||||
|
if (result == null || result.isPassed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String summary = result.getIssues().stream()
|
||||||
|
.limit(5)
|
||||||
|
.map(WorkflowCheckIssue::getMessage)
|
||||||
|
.collect(Collectors.joining(";"));
|
||||||
|
if (result.getIssueCount() > 5) {
|
||||||
|
summary = summary + ";等";
|
||||||
|
}
|
||||||
|
throw new BusinessException("工作流校验未通过(" + result.getStage() + "),共 " + result.getIssueCount() + " 项:" + summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addIssue(List<WorkflowCheckIssue> issues, Set<String> issueKeys, String code,
|
||||||
|
String message, String nodeId, String edgeId, String nodeName) {
|
||||||
|
String key = code + "|" + safe(nodeId) + "|" + safe(edgeId) + "|" + message;
|
||||||
|
if (!issueKeys.add(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
issues.add(new WorkflowCheckIssue(code, LEVEL_ERROR, message, nodeId, edgeId, nodeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractNodeName(JSONObject nodeJson, JSONObject data, String fallback) {
|
||||||
|
String name = null;
|
||||||
|
if (data != null) {
|
||||||
|
name = trimToNull(data.getString("title"));
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(name) && nodeJson != null) {
|
||||||
|
name = trimToNull(nodeJson.getString("label"));
|
||||||
|
}
|
||||||
|
return StringUtils.hasText(name) ? name : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String shortError(Throwable throwable) {
|
||||||
|
if (throwable == null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
String message = throwable.getMessage();
|
||||||
|
if (!StringUtils.hasText(message)) {
|
||||||
|
return throwable.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
return message.length() > 120 ? message.substring(0, 120) + "..." : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ParsedWorkflow {
|
||||||
|
private List<NodeView> nodes = new ArrayList<>();
|
||||||
|
private List<EdgeView> edges = new ArrayList<>();
|
||||||
|
private Map<String, NodeView> nodeMap = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class NodeView {
|
||||||
|
private String id;
|
||||||
|
private String type;
|
||||||
|
private String parentId;
|
||||||
|
private String name;
|
||||||
|
private JSONObject data;
|
||||||
|
|
||||||
|
private boolean isRootLevel() {
|
||||||
|
return !StringUtils.hasText(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class EdgeView {
|
||||||
|
private String id;
|
||||||
|
private String source;
|
||||||
|
private String target;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class WorkflowCheckServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldPassForValidDraft() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start-1", "startNode", null, data("开始")),
|
||||||
|
node("code-1", "codeNode", null, data("处理中"))
|
||||||
|
),
|
||||||
|
new JSONArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
|
||||||
|
Assert.assertTrue(result.isPassed());
|
||||||
|
Assert.assertEquals(0, result.getIssueCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockInvalidJson() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
WorkflowCheckResult result = service.checkContent("{invalid-json", WorkflowCheckStage.SAVE, null);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "INVALID_JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockUnknownNodeType() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node("n1", "unknownNodeType", null, data("未知节点"))),
|
||||||
|
new JSONArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "NODE_TYPE_UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockEdgeWithMissingNode() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node("n1", "startNode", null, data("开始"))),
|
||||||
|
array(edge("e1", "n1", "n2"))
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "EDGE_TARGET_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockMissingStartOrEnd() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("c1", "codeNode", null, data("处理")),
|
||||||
|
node("c2", "codeNode", null, data("处理2"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "c1", "c2"))
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "START_NODE_MISSING");
|
||||||
|
assertHasCode(result, "END_NODE_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockRootEntryNotStart() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, data("开始")),
|
||||||
|
node("x1", "codeNode", null, data("孤立入口")),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e-1", "s1", "e1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "ROOT_ENTRY_NOT_START");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockGraphCycle() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, data("开始")),
|
||||||
|
node("c1", "codeNode", null, data("处理")),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
edge("e1", "s1", "c1"),
|
||||||
|
edge("e2", "c1", "s1"),
|
||||||
|
edge("e3", "c1", "e1")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "GRAPH_CYCLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockDeadEndAndUnreachableEnd() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, data("开始")),
|
||||||
|
node("c1", "codeNode", null, data("死路节点")),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
edge("e1", "s1", "c1"),
|
||||||
|
edge("e2", "s1", "e1")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "DEAD_END_NODE");
|
||||||
|
assertHasCode(result, "END_UNREACHABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockWorkflowRecursiveReference() throws Exception {
|
||||||
|
Map<String, String> workflowStore = new HashMap<>();
|
||||||
|
workflowStore.put("2", workflowJson(
|
||||||
|
array(
|
||||||
|
node("s2", "startNode", null, data("开始2")),
|
||||||
|
workflowNode("w2", null, "1"),
|
||||||
|
node("e2", "endNode", null, data("结束2"))
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
edge("e2-1", "s2", "w2"),
|
||||||
|
edge("e2-2", "w2", "e2")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
WorkflowCheckService service = newService(workflowStore);
|
||||||
|
String rootContent = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, data("开始1")),
|
||||||
|
workflowNode("w1", null, "2"),
|
||||||
|
node("e1", "endNode", null, data("结束1"))
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
edge("e1-1", "s1", "w1"),
|
||||||
|
edge("e1-2", "w1", "e1")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(rootContent, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "WORKFLOW_REF_CYCLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldPassForExecutableWorkflow() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, data("开始")),
|
||||||
|
node("c1", "codeNode", null, data("处理")),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
edge("e1", "s1", "c1"),
|
||||||
|
edge("e2", "c1", "e1")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertTrue(result.isPassed());
|
||||||
|
Assert.assertEquals(0, result.getIssueCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
|
||||||
|
WorkflowCheckService service = new WorkflowCheckService();
|
||||||
|
ChainParser parser = ChainParser.builder()
|
||||||
|
.withDefaultParsers(true)
|
||||||
|
.build();
|
||||||
|
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||||
|
setField(service, "chainParser", parser);
|
||||||
|
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowService mockWorkflowService(Map<String, String> workflowStore) {
|
||||||
|
return (WorkflowService) Proxy.newProxyInstance(
|
||||||
|
WorkflowService.class.getClassLoader(),
|
||||||
|
new Class[]{WorkflowService.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
String methodName = method.getName();
|
||||||
|
if ("getById".equals(methodName)) {
|
||||||
|
if (args == null || args.length == 0 || args[0] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String id = String.valueOf(args[0]);
|
||||||
|
if (!workflowStore.containsKey(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
try {
|
||||||
|
workflow.setId(new BigInteger(id));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
workflow.setId(null);
|
||||||
|
}
|
||||||
|
workflow.setContent(workflowStore.get(id));
|
||||||
|
workflow.setTitle("workflow-" + id);
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
if ("equals".equals(methodName)) {
|
||||||
|
return proxy == args[0];
|
||||||
|
}
|
||||||
|
if ("hashCode".equals(methodName)) {
|
||||||
|
return System.identityHashCode(proxy);
|
||||||
|
}
|
||||||
|
if (method.getReturnType() == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (method.getReturnType() == int.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (method.getReturnType() == long.class) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = WorkflowCheckService.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertHasCode(WorkflowCheckResult result, String code) {
|
||||||
|
boolean exists = result.getIssues().stream().anyMatch(issue -> code.equals(issue.getCode()));
|
||||||
|
Assert.assertTrue("missing issue code: " + code, exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String workflowJson(JSONArray nodes, JSONArray edges) {
|
||||||
|
JSONObject root = new JSONObject();
|
||||||
|
root.put("nodes", nodes);
|
||||||
|
root.put("edges", edges);
|
||||||
|
return root.toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONArray array(JSONObject... objects) {
|
||||||
|
JSONArray array = new JSONArray();
|
||||||
|
for (JSONObject object : objects) {
|
||||||
|
array.add(object);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
|
||||||
|
JSONObject node = new JSONObject();
|
||||||
|
node.put("id", id);
|
||||||
|
node.put("type", type);
|
||||||
|
if (parentId != null) {
|
||||||
|
node.put("parentId", parentId);
|
||||||
|
}
|
||||||
|
node.put("data", data);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject workflowNode(String id, String parentId, String workflowId) {
|
||||||
|
JSONObject data = data("子流程");
|
||||||
|
data.put("workflowId", workflowId);
|
||||||
|
return node(id, "workflow-node", parentId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject data(String title) {
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("title", title);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject edge(String id, String source, String target) {
|
||||||
|
JSONObject edge = new JSONObject();
|
||||||
|
edge.put("id", id);
|
||||||
|
edge.put("source", source);
|
||||||
|
edge.put("target", target);
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,19 @@
|
|||||||
"subProcess": "SubProcess",
|
"subProcess": "SubProcess",
|
||||||
"workflowSelect": "WorkflowSelect",
|
"workflowSelect": "WorkflowSelect",
|
||||||
"bochaSearch": "BochaSearch",
|
"bochaSearch": "BochaSearch",
|
||||||
|
"check": "Check",
|
||||||
|
"checkPassed": "Workflow check passed",
|
||||||
|
"checkFailed": "Workflow check failed. Please fix the issues first",
|
||||||
|
"checkContentEmpty": "Canvas content is empty, unable to check",
|
||||||
|
"checkIssuesTitle": "Workflow Check Issues",
|
||||||
|
"checkStageLabel": "Stage",
|
||||||
|
"issueCount": "Issue Count",
|
||||||
|
"checkLevel": "Level",
|
||||||
|
"checkCode": "Rule Code",
|
||||||
|
"checkLocation": "Location",
|
||||||
|
"checkMessage": "Description",
|
||||||
|
"stageSave": "Save Check",
|
||||||
|
"stagePreExecute": "Pre-execute Check",
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"fileContentExtraction": "Extract text content from PDF or Word documents, etc",
|
"fileContentExtraction": "Extract text content from PDF or Word documents, etc",
|
||||||
"documentAddress": "Document URL address",
|
"documentAddress": "Document URL address",
|
||||||
|
|||||||
@@ -47,6 +47,19 @@
|
|||||||
"subProcess": "子流程",
|
"subProcess": "子流程",
|
||||||
"workflowSelect": "工作流选择",
|
"workflowSelect": "工作流选择",
|
||||||
"bochaSearch": "博查搜索",
|
"bochaSearch": "博查搜索",
|
||||||
|
"check": "检查",
|
||||||
|
"checkPassed": "工作流检查通过",
|
||||||
|
"checkFailed": "工作流检查未通过,请先修复问题",
|
||||||
|
"checkContentEmpty": "当前画布内容为空,无法检查",
|
||||||
|
"checkIssuesTitle": "工作流检查问题",
|
||||||
|
"checkStageLabel": "校验阶段",
|
||||||
|
"issueCount": "问题数量",
|
||||||
|
"checkLevel": "级别",
|
||||||
|
"checkCode": "规则码",
|
||||||
|
"checkLocation": "位置",
|
||||||
|
"checkMessage": "问题描述",
|
||||||
|
"stageSave": "保存校验",
|
||||||
|
"stagePreExecute": "预执行校验",
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"fileContentExtraction": "提取 PDF 或者 Word 等文件中的文字内容",
|
"fileContentExtraction": "提取 PDF 或者 Word 等文件中的文字内容",
|
||||||
"documentAddress": "文档的url地址",
|
"documentAddress": "文档的url地址",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||||
import {useRoute} from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
|
import {usePreferences} from '@easyflow/preferences';
|
||||||
import {getOptions, sortNodes} from '@easyflow/utils';
|
import {getOptions, sortNodes} from '@easyflow/utils';
|
||||||
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
|
|
||||||
import {ArrowLeft, Position} from '@element-plus/icons-vue';
|
import {ArrowLeft, CircleCheck, Close, Position} from '@element-plus/icons-vue';
|
||||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||||
import {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus';
|
import {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import nodeNames from './customNode/nodeNames';
|
|||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const {isDark} = usePreferences();
|
||||||
// vue
|
// vue
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
@@ -38,6 +40,9 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
if (focusPulseTimer) {
|
||||||
|
clearTimeout(focusPulseTimer);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// variables
|
// variables
|
||||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||||
@@ -109,6 +114,75 @@ const provider = computed(() => ({
|
|||||||
const customNode = ref();
|
const customNode = ref();
|
||||||
const showTinyFlow = ref(false);
|
const showTinyFlow = ref(false);
|
||||||
const saveLoading = ref(false);
|
const saveLoading = ref(false);
|
||||||
|
const checkLoading = ref(false);
|
||||||
|
const checkIssuesVisible = ref(false);
|
||||||
|
const checkResult = ref<any>(null);
|
||||||
|
const checkContentSnapshot = ref<any>(null);
|
||||||
|
const checkIssues = computed(() => checkResult.value?.issues || []);
|
||||||
|
const issueFocusActive = ref(false);
|
||||||
|
const focusedIssueKey = ref('');
|
||||||
|
let focusPulseTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
type WorkflowCheckStage = 'SAVE' | 'PRE_EXECUTE';
|
||||||
|
const builtInNodeIconMap: Record<string, string> = {
|
||||||
|
startNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path></svg>',
|
||||||
|
loopNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>',
|
||||||
|
conditionNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H10V10H4V4ZM14 4H20V10H14V4ZM4 14H10V20H4V14ZM14 14H20V20H14V14ZM10 7H14V9H10V7ZM7 10H9V14H7V10ZM15 10H17V14H15V10Z"></path></svg>',
|
||||||
|
llmNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
|
||||||
|
knowledgeNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path></svg>',
|
||||||
|
searchEngineNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path></svg>',
|
||||||
|
httpNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z"></path></svg>',
|
||||||
|
codeNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path></svg>',
|
||||||
|
templateNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',
|
||||||
|
confirmNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path></svg>',
|
||||||
|
endNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',
|
||||||
|
};
|
||||||
|
const builtInNodeTitleMap: Record<string, string> = {
|
||||||
|
startNode: '开始节点',
|
||||||
|
loopNode: '循环',
|
||||||
|
conditionNode: '条件判断',
|
||||||
|
llmNode: '大模型',
|
||||||
|
knowledgeNode: '知识库',
|
||||||
|
searchEngineNode: '搜索引擎',
|
||||||
|
httpNode: 'Http 请求',
|
||||||
|
codeNode: '动态代码',
|
||||||
|
templateNode: '内容模板',
|
||||||
|
confirmNode: '用户确认',
|
||||||
|
endNode: '结束节点',
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIssueDisplayList = computed(() =>
|
||||||
|
checkIssues.value.map((issue: any, index: number) => {
|
||||||
|
const node = findNodeByIssue(issue);
|
||||||
|
const nodeType = node?.type;
|
||||||
|
const icon =
|
||||||
|
node?.data?.icon ||
|
||||||
|
(nodeType ? customNode.value?.[nodeType]?.icon : undefined) ||
|
||||||
|
(nodeType ? builtInNodeIconMap[nodeType] : undefined);
|
||||||
|
const title =
|
||||||
|
issue?.nodeName ||
|
||||||
|
node?.data?.title ||
|
||||||
|
(nodeType ? builtInNodeTitleMap[nodeType] : undefined) ||
|
||||||
|
'节点';
|
||||||
|
return {
|
||||||
|
issue,
|
||||||
|
index,
|
||||||
|
key: issueKey(issue, index),
|
||||||
|
nodeDisplay: {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function findNodeByIssue(issue: any) {
|
||||||
|
if (!issue?.nodeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nodes = checkContentSnapshot.value?.nodes || [];
|
||||||
|
return nodes.find((node: any) => node.id === issue.nodeId) || null;
|
||||||
|
}
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
// 检查是否是 Ctrl+S
|
// 检查是否是 Ctrl+S
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
@@ -149,29 +223,43 @@ async function loadCustomNode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function runWorkflow() {
|
async function runWorkflow() {
|
||||||
if (!saveLoading.value) {
|
if (saveLoading.value || checkLoading.value) {
|
||||||
await handleSave().then(() => {
|
return;
|
||||||
getWorkflowInfo(workflowId.value);
|
|
||||||
getRunningParams();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const passed = await runCheck('PRE_EXECUTE');
|
||||||
|
if (!passed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = await handleSave();
|
||||||
|
if (!saved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getWorkflowInfo(workflowId.value);
|
||||||
|
await getRunningParams();
|
||||||
|
}
|
||||||
|
async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||||
|
const passed = await runCheck('SAVE', true);
|
||||||
|
if (!passed) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
async function handleSave(showMsg: boolean = false) {
|
|
||||||
saveLoading.value = true;
|
saveLoading.value = true;
|
||||||
await api
|
try {
|
||||||
.post('/api/v1/workflow/update', {
|
const res = await api.post('/api/v1/workflow/update', {
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
content: tinyflowRef.value?.getData(),
|
content: tinyflowRef.value?.getData(),
|
||||||
})
|
});
|
||||||
.then((res) => {
|
|
||||||
saveLoading.value = false;
|
|
||||||
if (res.errorCode === 0 && showMsg) {
|
if (res.errorCode === 0 && showMsg) {
|
||||||
ElMessage.success(res.message);
|
ElMessage.success(res.message);
|
||||||
}
|
}
|
||||||
});
|
return res.errorCode === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
saveLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function getWorkflowInfo(workflowId: any) {
|
async function getWorkflowInfo(workflowId: any) {
|
||||||
api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
||||||
workflowInfo.value = res.data;
|
workflowInfo.value = res.data;
|
||||||
tinyFlowData.value = workflowInfo.value.content
|
tinyFlowData.value = workflowInfo.value.content
|
||||||
? JSON.parse(workflowInfo.value.content)
|
? JSON.parse(workflowInfo.value.content)
|
||||||
@@ -179,24 +267,24 @@ async function getWorkflowInfo(workflowId: any) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function getLlmList() {
|
async function getLlmList() {
|
||||||
api.get('/api/v1/model/list').then((res) => {
|
return api.get('/api/v1/model/list').then((res) => {
|
||||||
llmList.value = res.data;
|
llmList.value = res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function getKnowledgeList() {
|
async function getKnowledgeList() {
|
||||||
api.get('/api/v1/documentCollection/list').then((res) => {
|
return api.get('/api/v1/documentCollection/list').then((res) => {
|
||||||
knowledgeList.value = res.data;
|
knowledgeList.value = res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function getCodeEngineList() {
|
async function getCodeEngineList() {
|
||||||
api.get('/api/v1/workflow/supportedCodeEngines').then((res) => {
|
return api.get('/api/v1/workflow/supportedCodeEngines').then((res) => {
|
||||||
if (res?.errorCode === 0 && Array.isArray(res.data) && res.data.length > 0) {
|
if (res?.errorCode === 0 && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
codeEngineList.value = res.data;
|
codeEngineList.value = res.data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function getRunningParams() {
|
function getRunningParams() {
|
||||||
api
|
return api
|
||||||
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
@@ -205,6 +293,102 @@ function getRunningParams() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function runCheck(stage: WorkflowCheckStage, silentPass: boolean = false) {
|
||||||
|
const content = tinyflowRef.value?.getData();
|
||||||
|
if (!content) {
|
||||||
|
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
checkContentSnapshot.value = content;
|
||||||
|
checkLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/workflow/check', {
|
||||||
|
id: workflowId.value,
|
||||||
|
content,
|
||||||
|
stage,
|
||||||
|
});
|
||||||
|
checkResult.value = res.data;
|
||||||
|
if (!res.data?.passed) {
|
||||||
|
checkIssuesVisible.value = true;
|
||||||
|
ElMessage.error($t('aiWorkflow.checkFailed'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
checkIssuesVisible.value = false;
|
||||||
|
focusedIssueKey.value = '';
|
||||||
|
issueFocusActive.value = false;
|
||||||
|
if (!silentPass) {
|
||||||
|
ElMessage.success($t('aiWorkflow.checkPassed'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
checkLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function checkStageText(stage?: string) {
|
||||||
|
if (stage === 'SAVE') {
|
||||||
|
return $t('aiWorkflow.stageSave');
|
||||||
|
}
|
||||||
|
if (stage === 'PRE_EXECUTE') {
|
||||||
|
return $t('aiWorkflow.stagePreExecute');
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
function issueKey(issue: any, index: number) {
|
||||||
|
return `${issue.code || '-'}-${issue.nodeId || '-'}-${issue.edgeId || '-'}-${index}`;
|
||||||
|
}
|
||||||
|
function canFocusIssue(issue: any) {
|
||||||
|
return Boolean(issue?.nodeId);
|
||||||
|
}
|
||||||
|
function issueLevelClass(level?: string) {
|
||||||
|
if (!level) {
|
||||||
|
return 'is-default';
|
||||||
|
}
|
||||||
|
const normalized = String(level).toLowerCase();
|
||||||
|
if (normalized === 'error') {
|
||||||
|
return 'is-error';
|
||||||
|
}
|
||||||
|
if (normalized === 'warn' || normalized === 'warning') {
|
||||||
|
return 'is-warning';
|
||||||
|
}
|
||||||
|
if (normalized === 'info') {
|
||||||
|
return 'is-info';
|
||||||
|
}
|
||||||
|
return 'is-default';
|
||||||
|
}
|
||||||
|
async function focusIssue(issue: any, index: number) {
|
||||||
|
if (!canFocusIssue(issue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusedIssueKey.value = issueKey(issue, index);
|
||||||
|
const tinyflowInstance = tinyflowRef.value?.getInstance?.();
|
||||||
|
if (!tinyflowInstance?.focusNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focused = await tinyflowInstance.focusNode(issue.nodeId, {
|
||||||
|
duration: 280,
|
||||||
|
zoom: 1,
|
||||||
|
});
|
||||||
|
if (!focused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
issueFocusActive.value = true;
|
||||||
|
if (focusPulseTimer) {
|
||||||
|
clearTimeout(focusPulseTimer);
|
||||||
|
}
|
||||||
|
focusPulseTimer = setTimeout(() => {
|
||||||
|
issueFocusActive.value = false;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
function closeCheckIssues() {
|
||||||
|
checkIssuesVisible.value = false;
|
||||||
|
focusedIssueKey.value = '';
|
||||||
|
issueFocusActive.value = false;
|
||||||
|
}
|
||||||
|
async function handleCheck() {
|
||||||
|
await runCheck('PRE_EXECUTE');
|
||||||
|
}
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
initState.value = !initState.value;
|
initState.value = !initState.value;
|
||||||
}
|
}
|
||||||
@@ -261,7 +445,11 @@ function onAsyncExecute(info: any) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="head-div h-full w-full" v-loading="pageLoading">
|
<div
|
||||||
|
class="head-div h-full w-full"
|
||||||
|
:class="{ 'workflow-issue-focus': issueFocusActive }"
|
||||||
|
v-loading="pageLoading"
|
||||||
|
>
|
||||||
<CommonSelectDataModal
|
<CommonSelectDataModal
|
||||||
ref="workflowSelectRef"
|
ref="workflowSelectRef"
|
||||||
page-url="/api/v1/workflow/page"
|
page-url="/api/v1/workflow/page"
|
||||||
@@ -325,12 +513,24 @@ function onAsyncExecute(info: any) {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ElButton :disabled="saveLoading" :icon="Position" @click="runWorkflow">
|
<ElButton
|
||||||
|
:loading="checkLoading"
|
||||||
|
:disabled="saveLoading"
|
||||||
|
:icon="CircleCheck"
|
||||||
|
@click="handleCheck"
|
||||||
|
>
|
||||||
|
{{ $t('aiWorkflow.check') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:disabled="saveLoading || checkLoading"
|
||||||
|
:icon="Position"
|
||||||
|
@click="runWorkflow"
|
||||||
|
>
|
||||||
{{ $t('button.runTest') }}
|
{{ $t('button.runTest') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="saveLoading"
|
:disabled="saveLoading || checkLoading"
|
||||||
@click="handleSave(true)"
|
@click="handleSave(true)"
|
||||||
>
|
>
|
||||||
{{ $t('button.save') }}(ctrl+s)
|
{{ $t('button.save') }}(ctrl+s)
|
||||||
@@ -342,11 +542,58 @@ function onAsyncExecute(info: any) {
|
|||||||
v-if="showTinyFlow"
|
v-if="showTinyFlow"
|
||||||
class="tiny-flow-container"
|
class="tiny-flow-container"
|
||||||
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
||||||
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:custom-nodes="customNode"
|
:custom-nodes="customNode"
|
||||||
:on-node-execute="runIndependently"
|
:on-node-execute="runIndependently"
|
||||||
/>
|
/>
|
||||||
<ElSkeleton class="load-div" v-else :rows="5" animated />
|
<transition name="checklist-slide">
|
||||||
|
<div v-if="checkIssuesVisible" class="checklist-panel">
|
||||||
|
<div class="checklist-header">
|
||||||
|
<div class="checklist-title">
|
||||||
|
{{ $t('aiWorkflow.checkIssuesTitle') }}
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
:icon="Close"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
:aria-label="$t('common.close')"
|
||||||
|
@click="closeCheckIssues"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="check-summary">
|
||||||
|
<span>{{ $t('aiWorkflow.checkStageLabel') }}:{{ checkStageText(checkResult?.stage) }}</span>
|
||||||
|
<span>{{ $t('aiWorkflow.issueCount') }}:{{ checkResult?.issueCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="checklist-body">
|
||||||
|
<button
|
||||||
|
v-for="item in checkIssueDisplayList"
|
||||||
|
:key="item.key"
|
||||||
|
class="check-item"
|
||||||
|
:class="[
|
||||||
|
{ 'is-clickable': canFocusIssue(item.issue), 'is-active': focusedIssueKey === item.key },
|
||||||
|
]"
|
||||||
|
:disabled="!canFocusIssue(item.issue)"
|
||||||
|
@click="focusIssue(item.issue, item.index)"
|
||||||
|
>
|
||||||
|
<div class="check-item-meta">
|
||||||
|
<span class="check-level" :class="issueLevelClass(item.issue.level)">{{ item.issue.level || '-' }}</span>
|
||||||
|
<span class="check-node-display">
|
||||||
|
<span
|
||||||
|
v-if="item.nodeDisplay.icon"
|
||||||
|
class="check-node-icon"
|
||||||
|
v-html="item.nodeDisplay.icon"
|
||||||
|
/>
|
||||||
|
<span v-else class="check-node-icon-fallback">N</span>
|
||||||
|
<span class="check-node-title">{{ item.nodeDisplay.title }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="check-item-message">{{ item.issue.message || '-' }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
<ElSkeleton v-if="!showTinyFlow" class="load-div" :rows="5" animated />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -361,6 +608,7 @@ function onAsyncExecute(info: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.head-div {
|
.head-div {
|
||||||
|
position: relative;
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,4 +620,186 @@ function onAsyncExecute(info: any) {
|
|||||||
.load-div {
|
.load-div {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 40;
|
||||||
|
max-height: min(320px, 42vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 0 2px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-body {
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item.is-clickable:hover {
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item.is-active {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-7) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-level {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 56px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-level.is-error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-level.is-warning {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background: var(--el-color-warning-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-level.is-info {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-node-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-node-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-node-icon :deep(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-node-icon-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-node-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item-message {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-slide-enter-active,
|
||||||
|
.checklist-slide-leave-active {
|
||||||
|
transition: all 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-slide-enter-from,
|
||||||
|
.checklist-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-issue-focus :deep(.svelte-flow__node.selected) {
|
||||||
|
border-color: var(--el-color-danger) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.26);
|
||||||
|
animation: issue-node-pulse 1.2s ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes issue-node-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.42);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 10px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,31 @@ export class Tinyflow {
|
|||||||
throw new Error('element must be a string or Element');
|
throw new Error('element must be a string or Element');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.tinyflowEl = this._createTinyflowElement();
|
||||||
|
this.rootEl.appendChild(this.tinyflowEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setOptions(options: TinyflowOptions) {
|
||||||
|
this.options = {
|
||||||
|
theme: options.theme || 'light',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getFlowInstance() {
|
||||||
|
if (!this.svelteFlowInstance) {
|
||||||
|
console.warn('Tinyflow instance is not initialized');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.svelteFlowInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _applyThemeClass(targetEl: Element, theme?: TinyflowTheme) {
|
||||||
|
targetEl.classList.remove('tf-theme-light', 'tf-theme-dark');
|
||||||
|
targetEl.classList.add(theme === 'dark' ? 'tf-theme-dark' : 'tf-theme-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createTinyflowElement() {
|
||||||
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
||||||
options: TinyflowOptions;
|
options: TinyflowOptions;
|
||||||
onInit: (svelteFlowInstance: FlowInstance) => void;
|
onInit: (svelteFlowInstance: FlowInstance) => void;
|
||||||
@@ -56,7 +81,56 @@ export class Tinyflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getData() {
|
getData() {
|
||||||
return this.svelteFlowInstance.toObject();
|
const flow = this._getFlowInstance();
|
||||||
|
if (!flow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return flow.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
async focusNode(nodeId: string, options?: { duration?: number; zoom?: number }) {
|
||||||
|
const flow = this._getFlowInstance();
|
||||||
|
if (!flow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = flow.getNode(nodeId);
|
||||||
|
if (!targetNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the target node selected so the canvas has a clear visual focus.
|
||||||
|
flow.getNodes().forEach((node) => {
|
||||||
|
flow.updateNode(node.id, { selected: node.id === nodeId });
|
||||||
|
});
|
||||||
|
|
||||||
|
const internalNode = flow.getInternalNode(nodeId) as any;
|
||||||
|
const absolutePosition =
|
||||||
|
internalNode?.internals?.positionAbsolute ||
|
||||||
|
(targetNode as any)?.positionAbsolute ||
|
||||||
|
targetNode.position ||
|
||||||
|
{ x: 0, y: 0 };
|
||||||
|
const width =
|
||||||
|
internalNode?.measured?.width ||
|
||||||
|
(targetNode as any)?.measured?.width ||
|
||||||
|
(targetNode as any)?.width ||
|
||||||
|
260;
|
||||||
|
const height =
|
||||||
|
internalNode?.measured?.height ||
|
||||||
|
(targetNode as any)?.measured?.height ||
|
||||||
|
(targetNode as any)?.height ||
|
||||||
|
120;
|
||||||
|
|
||||||
|
const centerX = absolutePosition.x + width / 2;
|
||||||
|
const centerY = absolutePosition.y + height / 2;
|
||||||
|
const nextZoom = options?.zoom ?? Math.max(flow.getZoom(), 1);
|
||||||
|
|
||||||
|
await flow.setCenter(centerX, centerY, {
|
||||||
|
zoom: nextZoom,
|
||||||
|
duration: options?.duration ?? 280
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: TinyflowTheme) {
|
setTheme(theme: TinyflowTheme) {
|
||||||
|
|||||||
Reference in New Issue
Block a user