diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java index e489c21..85efc2c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java @@ -4,21 +4,25 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.IdUtil; -import com.mybatisflex.core.query.QueryWrapper; -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.parser.ChainParser; +import com.mybatisflex.core.query.QueryWrapper; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; 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.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.ModelService; 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.domain.Result; import tech.easyflow.common.entity.LoginAccount; @@ -33,7 +37,10 @@ import java.io.InputStream; import java.io.Serializable; import java.math.BigInteger; 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 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 public Result detail(String id) { Workflow workflow = service.getDetail(id); @@ -189,6 +218,9 @@ public class WorkflowController extends BaseCurdController 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 getIssues() { + return issues; + } + + public void setIssues(List issues) { + this.issues = issues == null ? new ArrayList<>() : issues; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/entity/WorkflowCheckStage.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/entity/WorkflowCheckStage.java new file mode 100644 index 0000000..449540c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/entity/WorkflowCheckStage.java @@ -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); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java new file mode 100644 index 0000000..f548b08 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java @@ -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 issues = new ArrayList<>(); + Set 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 issues, Set 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 nodes = new ArrayList<>(); + Map nodeMap = new LinkedHashMap<>(); + Set nodeIds = new HashSet<>(); + Map 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 edges = new ArrayList<>(); + Set 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 issues, Set 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 startNodes = parsed.nodes.stream() + .filter(node -> TYPE_START.equals(node.type)) + .collect(Collectors.toList()); + List 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 rootNodes = parsed.nodes.stream() + .filter(NodeView::isRootLevel) + .collect(Collectors.toList()); + Map 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 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> runtimeGraph = buildRuntimeGraph(parsed); + Set entryNodeIds = rootEntries.stream() + .map(node -> node.id) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (entryNodeIds.isEmpty()) { + for (NodeView startNode : startNodes) { + entryNodeIds.add(startNode.id); + } + } + Set 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 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 issues, Set issueKeys) { + Map 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 contentCache, + List issues, Set issueKeys) { + Set visited = new HashSet<>(); + LinkedHashSet visiting = new LinkedHashSet<>(); + dfsWorkflowGraph(rootWorkflowId, currentWorkflowId, currentContent, contentCache, visited, visiting, issues, issueKeys); + } + + private void dfsWorkflowGraph(String workflowId, String currentWorkflowId, String currentContent, + Map contentCache, + Set visited, LinkedHashSet visiting, + List issues, Set 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 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 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 extractWorkflowRefIds(String content) { + Set 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 visiting, String cycleStart) { + List 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 issues, Set issueKeys) { + Map> 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 state = new HashMap<>(); + Deque 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> graph, Map state, + Deque path, ParsedWorkflow parsed, List issues, Set 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 path, String cycleStart) { + List list = new ArrayList<>(path); + int index = list.indexOf(cycleStart); + if (index < 0) { + return String.join(" -> ", list); + } + List cycle = new ArrayList<>(list.subList(index, list.size())); + cycle.add(cycleStart); + return String.join(" -> ", cycle); + } + + private Map> buildRuntimeGraph(ParsedWorkflow parsed) { + Map> 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 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 bfsReachable(Map> graph, Set starts) { + Set visited = new LinkedHashSet<>(); + Deque 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 reverseReachable(Map> graph, Set endNodes) { + Map> 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 visited = new LinkedHashSet<>(); + Deque 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 issues, Set 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 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 issues, Set 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 nodes = new ArrayList<>(); + private List edges = new ArrayList<>(); + private Map 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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java new file mode 100644 index 0000000..e824b25 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java @@ -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 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 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 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; + } +} diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index f4e1c69..764394f 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -47,6 +47,19 @@ "subProcess": "SubProcess", "workflowSelect": "WorkflowSelect", "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": { "fileContentExtraction": "Extract text content from PDF or Word documents, etc", "documentAddress": "Document URL address", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index c79fd68..a386268 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -47,6 +47,19 @@ "subProcess": "子流程", "workflowSelect": "工作流选择", "bochaSearch": "博查搜索", + "check": "检查", + "checkPassed": "工作流检查通过", + "checkFailed": "工作流检查未通过,请先修复问题", + "checkContentEmpty": "当前画布内容为空,无法检查", + "checkIssuesTitle": "工作流检查问题", + "checkStageLabel": "校验阶段", + "issueCount": "问题数量", + "checkLevel": "级别", + "checkCode": "规则码", + "checkLocation": "位置", + "checkMessage": "问题描述", + "stageSave": "保存校验", + "stagePreExecute": "预执行校验", "descriptions": { "fileContentExtraction": "提取 PDF 或者 Word 等文件中的文字内容", "documentAddress": "文档的url地址", diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue index 6020ee8..da280d0 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue @@ -2,10 +2,11 @@ import {computed, onMounted, onUnmounted, ref} from 'vue'; import {useRoute} from 'vue-router'; +import {usePreferences} from '@easyflow/preferences'; import {getOptions, sortNodes} from '@easyflow/utils'; 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 {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus'; @@ -24,6 +25,7 @@ import nodeNames from './customNode/nodeNames'; import '@tinyflow-ai/vue/dist/index.css'; const route = useRoute(); +const {isDark} = usePreferences(); // vue onMounted(async () => { document.addEventListener('keydown', handleKeydown); @@ -38,6 +40,9 @@ onMounted(async () => { }); onUnmounted(() => { document.removeEventListener('keydown', handleKeydown); + if (focusPulseTimer) { + clearTimeout(focusPulseTimer); + } }); // variables const tinyflowRef = ref | null>(null); @@ -109,6 +114,75 @@ const provider = computed(() => ({ const customNode = ref(); const showTinyFlow = ref(false); const saveLoading = ref(false); +const checkLoading = ref(false); +const checkIssuesVisible = ref(false); +const checkResult = ref(null); +const checkContentSnapshot = ref(null); +const checkIssues = computed(() => checkResult.value?.issues || []); +const issueFocusActive = ref(false); +const focusedIssueKey = ref(''); +let focusPulseTimer: ReturnType | undefined; + +type WorkflowCheckStage = 'SAVE' | 'PRE_EXECUTE'; +const builtInNodeIconMap: Record = { + startNode: '', + loopNode: '', + conditionNode: '', + llmNode: '', + knowledgeNode: '', + searchEngineNode: '', + httpNode: '', + codeNode: '', + templateNode: '', + confirmNode: '', + endNode: '', +}; +const builtInNodeTitleMap: Record = { + 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) => { // 检查是否是 Ctrl+S if ((event.ctrlKey || event.metaKey) && event.key === 's') { @@ -149,29 +223,43 @@ async function loadCustomNode() { }); } async function runWorkflow() { - if (!saveLoading.value) { - await handleSave().then(() => { - getWorkflowInfo(workflowId.value); - getRunningParams(); - }); + if (saveLoading.value || checkLoading.value) { + return; } + 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) { +async function handleSave(showMsg: boolean = false): Promise { + const passed = await runCheck('SAVE', true); + if (!passed) { + return false; + } saveLoading.value = true; - await api - .post('/api/v1/workflow/update', { + try { + const res = await api.post('/api/v1/workflow/update', { id: workflowId.value, content: tinyflowRef.value?.getData(), - }) - .then((res) => { - saveLoading.value = false; - if (res.errorCode === 0 && showMsg) { - ElMessage.success(res.message); - } }); + if (res.errorCode === 0 && showMsg) { + ElMessage.success(res.message); + } + return res.errorCode === 0; + } catch { + return false; + } finally { + saveLoading.value = false; + } } 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; tinyFlowData.value = workflowInfo.value.content ? JSON.parse(workflowInfo.value.content) @@ -179,24 +267,24 @@ async function getWorkflowInfo(workflowId: any) { }); } async function getLlmList() { - api.get('/api/v1/model/list').then((res) => { + return api.get('/api/v1/model/list').then((res) => { llmList.value = res.data; }); } async function getKnowledgeList() { - api.get('/api/v1/documentCollection/list').then((res) => { + return api.get('/api/v1/documentCollection/list').then((res) => { knowledgeList.value = res.data; }); } 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) { codeEngineList.value = res.data; } }); } function getRunningParams() { - api + return api .get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`) .then((res) => { 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() { initState.value = !initState.value; } @@ -261,7 +445,11 @@ function onAsyncExecute(info: any) { @@ -361,6 +608,7 @@ function onAsyncExecute(info: any) { } .head-div { + position: relative; background-color: var(--el-bg-color); } @@ -372,4 +620,186 @@ function onAsyncExecute(info: any) { .load-div { 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; + } +} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts index 8d0527e..f1c75ae 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts @@ -35,6 +35,31 @@ export class Tinyflow { 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 & { options: TinyflowOptions; onInit: (svelteFlowInstance: FlowInstance) => void; @@ -56,7 +81,56 @@ export class Tinyflow { } 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) {