From 9feb8896379948ef387d768a48d50ab9fdf37dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sun, 19 Apr 2026 13:57:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=BC=80=E5=A7=8B=E8=8A=82=E7=82=B9=E5=BC=80=E5=9C=BA?= =?UTF-8?q?=E8=A1=A8=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析 - 统一 Admin/UserCenter 开场表单渲染与文件集合输入 - 补充开始表单校验、引用迁移和前端工具测试 --- .../controller/ai/WorkflowController.java | 21 +- .../controller/PublicWorkflowController.java | 22 +- .../controller/ai/UcWorkflowController.java | 22 +- .../service/WorkflowCheckService.java | 68 ++ .../WorkflowRunningParameterResolver.java | 446 ++++++++++ .../service/WorkflowCheckServiceTest.java | 92 ++ .../WorkflowRunningParameterResolverTest.java | 267 ++++++ .../src/views/ai/resource/ChooseResource.vue | 12 +- .../src/views/ai/workflow/WorkflowDesign.vue | 2 +- .../workflow/components/WorkflowFileInput.vue | 130 ++- .../ai/workflow/components/WorkflowForm.vue | 106 ++- .../workflow/components/WorkflowFormItem.vue | 29 +- .../__tests__/workflowFileValue.test.ts | 99 +++ .../workflow/components/workflowFileValue.ts | 87 ++ .../src/components/TinyflowCore.svelte | 68 +- .../core/DefinedParameterItem.svelte | 133 ++- .../src/components/core/NodeWrapper.svelte | 35 +- .../src/components/nodes/StartNode.svelte | 108 ++- .../packages/tinyflow-ui/src/consts.ts | 1 + .../src/utils/workflowNodeFields.test.ts | 442 ++++++++++ .../src/utils/workflowNodeFields.ts | 791 +++++++++++++++++- .../src/views/ai/resource/ChooseResource.vue | 12 +- .../workflow/components/WorkflowFileInput.vue | 219 +++++ .../ai/workflow/components/WorkflowForm.vue | 106 ++- .../workflow/components/WorkflowFormItem.vue | 49 +- .../workflow/components/workflowFileValue.ts | 196 +++++ 26 files changed, 3391 insertions(+), 172 deletions(-) create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/WorkflowFileInput.vue create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowFileValue.ts 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 640e01c..62f3969 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 @@ -5,10 +5,7 @@ import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.IdUtil; import com.mybatisflex.core.paginate.Page; -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.context.request.RequestContextHolder; @@ -25,6 +22,7 @@ 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.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.publish.WorkflowPublishAppService; @@ -77,8 +75,6 @@ public class WorkflowController extends BaseCurdController(); } + variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); if (StpUtil.isLogin()) { variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); } @@ -155,6 +154,7 @@ public class WorkflowController extends BaseCurdController res = workflowRunningParameterResolver.buildRunningParametersView(workflow); + if (res == null) { return Result.fail(2, "节点配置错误,请检查! "); } - List chainParameters = definition.getStartParameters(); - Map res = new HashMap<>(); - res.put("parameters", chainParameters); - res.put("title", workflow.getTitle()); - res.put("description", workflow.getDescription()); - res.put("icon", workflow.getIcon()); return Result.ok(res); } diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java index 0b09f71..4cd5045 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java @@ -2,10 +2,7 @@ package tech.easyflow.publicapi.controller; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; -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 jakarta.annotation.Resource; import jakarta.validation.constraints.NotBlank; import org.springframework.web.bind.annotation.*; @@ -16,7 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; -import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.constant.Constants; @@ -41,13 +38,11 @@ public class PublicWorkflowController { @Resource private ChainExecutor chainExecutor; @Resource - private ChainParser chainParser; - @Resource private TinyFlowService tinyFlowService; @Resource private WorkflowCheckService workflowCheckService; @Resource - private WorkflowDatacenterContentService workflowDatacenterContentService; + private WorkflowRunningParameterResolver workflowRunningParameterResolver; /** * 通过id或别名获取工作流详情 @@ -81,6 +76,7 @@ public class PublicWorkflowController { if (variables == null) { variables = new HashMap<>(); } + variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); if (StpUtil.isLogin()) { variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); } @@ -104,6 +100,7 @@ public class PublicWorkflowController { throw new RuntimeException("工作流不存在"); } workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); + variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables); return Result.ok(executeId); } @@ -139,17 +136,10 @@ public class PublicWorkflowController { 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(workflowDatacenterContentService.prepareContent(workflow.getContent())); - if (definition == null) { + Map res = workflowRunningParameterResolver.buildRunningParametersView(workflow); + if (res == null) { return Result.fail(2, "节点配置错误,请检查! "); } - List chainParameters = definition.getStartParameters(); - Map res = new HashMap<>(); - res.put("parameters", chainParameters); - res.put("title", workflow.getTitle()); - res.put("description", workflow.getDescription()); - res.put("icon", workflow.getIcon()); return Result.ok(res); } } diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java index b45c960..edd4097 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java @@ -4,18 +4,15 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; -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 org.springframework.web.bind.annotation.*; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; -import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.annotation.UsePermission; @@ -48,13 +45,11 @@ public class UcWorkflowController extends BaseCurdController(); } + variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); if (StpUtil.isLogin()) { variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); } @@ -115,6 +111,7 @@ public class UcWorkflowController extends BaseCurdController res = workflowRunningParameterResolver.buildRunningParametersView(workflow); + if (res == null) { return Result.fail(2, "节点配置错误,请检查! "); } - List chainParameters = definition.getStartParameters(); - Map res = new HashMap<>(); - res.put("parameters", chainParameters); - res.put("title", workflow.getTitle()); - res.put("description", workflow.getDescription()); - res.put("icon", workflow.getIcon()); return Result.ok(res); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java index bf8a8b0..0c14e2c 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java @@ -45,6 +45,7 @@ public class WorkflowCheckService { private static final String TYPE_LOOP = "loopNode"; private static final String TYPE_WORKFLOW = "workflow-node"; private static final String TYPE_PLUGIN = "plugin-node"; + private static final String SYSTEM_START_PARAM_NAME = "user_input"; @Resource private WorkflowService workflowService; @@ -78,6 +79,10 @@ public class WorkflowCheckService { Set issueKeys = new LinkedHashSet<>(); ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys); if (parsedWorkflow != null) { + List startNodes = parsedWorkflow.nodes.stream() + .filter(node -> TYPE_START.equals(node.type)) + .collect(Collectors.toList()); + checkStartFormSchema(startNodes, issues, issueKeys); checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys); } @@ -436,6 +441,69 @@ public class WorkflowCheckService { detectWorkflowReferenceCycle(currentWorkflowIdString, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys); } + /** + * 校验开始节点开场表单 Schema 的最小约束。 + * + * @param startNodes 开始节点列表 + * @param issues 问题收集 + * @param issueKeys 去重键集合 + */ + private void checkStartFormSchema(List startNodes, + List issues, + Set issueKeys) { + for (NodeView startNode : startNodes) { + if (startNode == null || startNode.data == null) { + continue; + } + JSONArray schema = startNode.data.getJSONArray("startFormSchema"); + if (schema == null || schema.isEmpty()) { + continue; + } + + JSONObject userInputField = null; + for (int i = 0; i < schema.size(); i++) { + JSONObject field = schema.getJSONObject(i); + if (field == null) { + continue; + } + String key = trimToNull(field.getString("key")); + if (!StringUtils.hasText(key)) { + addIssue(issues, issueKeys, "START_FORM_FIELD_KEY_EMPTY", + "开始表单字段缺少 key", startNode.id, null, startNode.name); + continue; + } + String type = trimToNull(field.getString("type")); + if (Arrays.asList("radio", "checkbox", "select").contains(type)) { + JSONArray options = field.getJSONArray("options"); + if (options == null || options.isEmpty()) { + addIssue(issues, issueKeys, "START_FORM_OPTIONS_EMPTY", + "选择类开始表单字段必须配置至少一个选项", startNode.id, null, startNode.name); + } + } + if (SYSTEM_START_PARAM_NAME.equals(key)) { + userInputField = field; + } + } + + if (userInputField == null) { + addIssue(issues, issueKeys, "START_FORM_USER_INPUT_MISSING", + "开始表单必须包含 user_input 主问题字段", startNode.id, null, startNode.name); + continue; + } + + if (!Boolean.TRUE.equals(userInputField.getBoolean("required"))) { + addIssue(issues, issueKeys, "START_FORM_USER_INPUT_REQUIRED", + "开始表单的 user_input 字段必须为必填", startNode.id, null, startNode.name); + } + + String userInputType = trimToNull(userInputField.getString("type")); + if (!Arrays.asList("text", "textarea").contains(userInputType)) { + addIssue(issues, issueKeys, "START_FORM_USER_INPUT_TYPE_INVALID", + "开始表单的 user_input 字段只能使用 text 或 textarea", startNode.id, null, startNode.name); + } + } + } + private void detectWorkflowReferenceCycle(String rootWorkflowId, String currentWorkflowId, String currentContent, Map contentCache, List issues, Set issueKeys) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java new file mode 100644 index 0000000..5db7df1 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java @@ -0,0 +1,446 @@ +package tech.easyflow.ai.easyagentsflow.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.parser.ChainParser; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.ai.entity.Workflow; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流运行参数解析器。 + * + *

负责统一构建运行页所需的开始节点参数、开场表单元信息和字段 Schema, + * 避免 Admin / Public / UserCenter 三端各自维护一套解析逻辑。

+ */ +@Service +public class WorkflowRunningParameterResolver { + + private static final String START_NODE_TYPE = "startNode"; + private static final String SYSTEM_START_PARAM_NAME = "user_input"; + private static final String DEFAULT_START_FORM_TITLE = "开始问答"; + private static final String DEFAULT_START_FORM_DESCRIPTION = "请先补充必要信息,再开始执行工作流。"; + private static final String DEFAULT_START_FORM_SUBMIT_TEXT = "开始"; + private static final int FILE_MAX_COUNT = 10; + private static final long FILE_MAX_SINGLE_SIZE = 5L * 1024 * 1024; + private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024; + + @Resource + private ChainParser chainParser; + @Resource + private WorkflowDatacenterContentService workflowDatacenterContentService; + + /** + * 构建工作流运行参数视图。 + * + * @param workflow 工作流实体 + * @return 运行参数视图;当流程内容无法解析时返回 {@code null} + */ + public Map buildRunningParametersView(Workflow workflow) { + if (workflow == null) { + return null; + } + List startParameters = resolveStartParameters(workflow.getContent()); + if (startParameters == null) { + return null; + } + + JSONObject startNodeData = findStartNodeData(workflow.getContent()); + List> startFormSchema = resolveStartFormSchema(startNodeData, startParameters); + Map result = new LinkedHashMap<>(); + result.put("parameters", startParameters); + result.put("title", workflow.getTitle()); + result.put("description", workflow.getDescription()); + result.put("icon", workflow.getIcon()); + result.put("startFormMeta", resolveStartFormMeta(startNodeData)); + result.put("startFormSchema", startFormSchema); + return result; + } + + /** + * 解析开始节点运行参数。 + * + * @param content 工作流内容 + * @return 开始节点参数;解析失败时返回 {@code null} + */ + public List resolveStartParameters(String content) { + try { + String preparedContent = workflowDatacenterContentService.prepareContent(content); + ChainDefinition definition = chainParser.parse(preparedContent); + return definition == null ? null : definition.getStartParameters(); + } catch (Exception ignored) { + return null; + } + } + + /** + * 归一化工作流运行时变量,确保文件参数统一为文件对象数组。 + * + * @param content 工作流内容 + * @param variables 原始运行变量 + * @return 归一化后的变量副本 + */ + public Map normalizeRuntimeVariables(String content, Map variables) { + Map normalized = new LinkedHashMap<>(); + if (variables != null) { + normalized.putAll(variables); + } + List startParameters = resolveStartParameters(content); + if (startParameters == null || startParameters.isEmpty()) { + return normalized; + } + for (Parameter parameter : startParameters) { + if (!isFileParameter(parameter)) { + continue; + } + String name = trimToNull(parameter.getName()); + if (!StringUtils.hasText(name) || !normalized.containsKey(name)) { + continue; + } + normalized.put(name, normalizeFileVariableValue(normalized.get(name), name)); + } + return normalized; + } + + private Map resolveStartFormMeta(JSONObject startNodeData) { + JSONObject rawMeta = startNodeData == null ? null : startNodeData.getJSONObject("startFormMeta"); + Map meta = new LinkedHashMap<>(); + meta.put("title", trimToDefault(rawMeta == null ? null : rawMeta.getString("title"), DEFAULT_START_FORM_TITLE)); + meta.put("description", trimToDefault(rawMeta == null ? null : rawMeta.getString("description"), DEFAULT_START_FORM_DESCRIPTION)); + meta.put("submitText", trimToDefault(rawMeta == null ? null : rawMeta.getString("submitText"), DEFAULT_START_FORM_SUBMIT_TEXT)); + return meta; + } + + private List> resolveStartFormSchema(JSONObject startNodeData, List parameters) { + JSONArray rawSchema = startNodeData == null ? null : startNodeData.getJSONArray("startFormSchema"); + List> schema = new ArrayList<>(); + Set seenKeys = new LinkedHashSet<>(); + if (rawSchema != null && !rawSchema.isEmpty()) { + for (int i = 0; i < rawSchema.size(); i++) { + JSONObject field = rawSchema.getJSONObject(i); + Map normalized = normalizeStartFormField(field, null); + if (normalized == null) { + continue; + } + String key = String.valueOf(normalized.get("key")); + if (!seenKeys.add(key)) { + continue; + } + if (SYSTEM_START_PARAM_NAME.equals(key)) { + schema.add(0, normalized); + } else { + schema.add(normalized); + } + } + } + if (schema.isEmpty()) { + for (Parameter parameter : parameters) { + Map normalized = normalizeStartFormField(null, parameter); + if (normalized == null) { + continue; + } + String key = String.valueOf(normalized.get("key")); + if (!seenKeys.add(key)) { + continue; + } + if (SYSTEM_START_PARAM_NAME.equals(key)) { + schema.add(0, normalized); + } else { + schema.add(normalized); + } + } + } + if (!seenKeys.contains(SYSTEM_START_PARAM_NAME)) { + schema.add(0, normalizeStartFormField(new JSONObject(), null)); + } + return schema; + } + + private Map normalizeStartFormField(JSONObject field, Parameter parameter) { + String parameterName = parameter == null ? null : trimToNull(parameter.getName()); + String key = trimToNull(field == null ? null : field.getString("key")); + if (!StringUtils.hasText(key)) { + key = parameterName; + } + if (!StringUtils.hasText(key)) { + key = SYSTEM_START_PARAM_NAME; + } + boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key) + || (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved"))); + String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved); + List options = resolveFieldOptions(field, parameter, type); + + Map normalized = new LinkedHashMap<>(); + normalized.put("key", key); + normalized.put("label", trimToDefault( + field == null ? null : field.getString("label"), + parameter == null ? null : parameter.getFormLabel(), + SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key + )); + normalized.put("type", type); + normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required"))) + || (parameter != null && parameter.isRequired())); + normalized.put("placeholder", trimToDefault( + field == null ? null : field.getString("placeholder"), + parameter == null ? null : parameter.getFormPlaceholder(), + SYSTEM_START_PARAM_NAME.equals(key) ? "请输入用户问题" : "" + )); + normalized.put("description", trimToDefault( + field == null ? null : field.getString("description"), + parameter == null ? null : parameter.getFormDescription(), + "" + )); + normalized.put("defaultValue", resolveDefaultValue(field, parameter, type)); + normalized.put("options", options); + normalized.put("systemReserved", systemReserved); + return normalized; + } + + private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) { + Object rawDefaultValue = field == null ? null : field.get("defaultValue"); + if (rawDefaultValue != null) { + if ("checkbox".equals(type) && rawDefaultValue instanceof List) { + return rawDefaultValue; + } + return rawDefaultValue; + } + return parameter == null ? "" : parameter.getDefaultValue(); + } + + private List resolveFieldOptions(JSONObject field, Parameter parameter, String type) { + if (!"radio".equals(type) && !"checkbox".equals(type) && !"select".equals(type)) { + return new ArrayList<>(); + } + List options = new ArrayList<>(); + if (field != null) { + JSONArray rawOptions = field.getJSONArray("options"); + if (rawOptions != null) { + for (int i = 0; i < rawOptions.size(); i++) { + String option = trimToNull(rawOptions.getString(i)); + if (StringUtils.hasText(option)) { + options.add(option); + } + } + } + } + if (!options.isEmpty()) { + return options; + } + if (parameter != null && parameter.getEnums() != null) { + for (Object option : parameter.getEnums()) { + String normalized = trimToNull(option == null ? null : String.valueOf(option)); + if (StringUtils.hasText(normalized)) { + options.add(normalized); + } + } + } + return options; + } + + private String resolveStartFormFieldType(String rawType, Parameter parameter, boolean systemReserved) { + String parameterType = parameterType(parameter); + String requested = trimToNull(rawType); + String normalized; + if (StringUtils.hasText(requested)) { + normalized = requested; + } else if (StringUtils.hasText(parameterType)) { + normalized = parameterType; + } else { + normalized = systemReserved ? "textarea" : "text"; + } + if (systemReserved) { + return "text".equals(normalized) ? "text" : "textarea"; + } + return switch (normalized) { + case "textarea", "radio", "checkbox", "select", "file" -> normalized; + default -> "text"; + }; + } + + private String parameterType(Parameter parameter) { + if (parameter == null) { + return null; + } + if ("file".equals(trimToNull(parameter.getContentType())) + || "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())))) { + return "file"; + } + String formType = trimToNull(parameter.getFormType()); + if ("textarea".equals(formType)) { + return "textarea"; + } + if ("radio".equals(formType)) { + return "radio"; + } + if ("checkbox".equals(formType)) { + return "checkbox"; + } + if ("select".equals(formType)) { + return "select"; + } + return SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter.getName())) ? "textarea" : "text"; + } + + /** + * 判断参数是否为文件输入参数。 + * + * @param parameter 参数定义 + * @return 是否文件参数 + */ + private boolean isFileParameter(Parameter parameter) { + if (parameter == null) { + return false; + } + return "file".equals(trimToNull(parameter.getContentType())) + || "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType()))); + } + + /** + * 将单文件或多文件运行值归一化为文件对象数组。 + * + * @param value 原始变量值 + * @param parameterName 参数名 + * @return 归一化后的文件对象数组 + */ + private List normalizeFileVariableValue(Object value, String parameterName) { + List candidates = new ArrayList<>(); + collectFileValues(value, candidates); + if (candidates.isEmpty()) { + return new ArrayList<>(); + } + + List normalized = new ArrayList<>(); + Set seenFilePaths = new LinkedHashSet<>(); + long totalSize = 0L; + for (Object candidate : candidates) { + if (!(candidate instanceof Map fileMap)) { + throw new BusinessException("文件参数 " + parameterName + " 的输入格式不正确,必须为文件对象或文件对象数组"); + } + String fileName = trimObjectToNull(fileMap.get("fileName")); + String filePath = trimObjectToNull(fileMap.get("filePath")); + if (!StringUtils.hasText(fileName)) { + throw new BusinessException("文件参数 " + parameterName + " 缺少 fileName"); + } + if (!StringUtils.hasText(filePath)) { + throw new BusinessException("文件参数 " + parameterName + " 缺少 filePath"); + } + if (!seenFilePaths.add(filePath)) { + continue; + } + Long size = parseLong(fileMap.get("size")); + if (size != null && size > FILE_MAX_SINGLE_SIZE) { + throw new BusinessException("文件参数 " + parameterName + " 中单个文件不能超过 5MB"); + } + if (size != null && size > 0) { + totalSize += size; + } + normalized.add(new LinkedHashMap<>(fileMap)); + } + + if (normalized.size() > FILE_MAX_COUNT) { + throw new BusinessException("文件参数 " + parameterName + " 最多上传 10 个文件"); + } + if (totalSize > FILE_MAX_TOTAL_SIZE) { + throw new BusinessException("文件参数 " + parameterName + " 的文件总大小不能超过 50MB"); + } + return normalized; + } + + private void collectFileValues(Object value, List result) { + if (value == null) { + return; + } + if (value instanceof Collection collection) { + for (Object item : collection) { + collectFileValues(item, result); + } + return; + } + result.add(value); + } + + private String trimObjectToNull(Object value) { + return trimToNull(value == null ? null : String.valueOf(value)); + } + + private Long parseLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.longValue(); + } + String text = trimObjectToNull(value); + if (!StringUtils.hasText(text)) { + return null; + } + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + throw new BusinessException("文件大小格式不正确: " + text); + } + } + + private JSONObject findStartNodeData(String content) { + if (!StringUtils.hasText(content)) { + return null; + } + try { + Object parsed = JSON.parse(content); + if (!(parsed instanceof JSONObject root)) { + return null; + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null) { + return null; + } + for (int i = 0; i < nodes.size(); i++) { + JSONObject node = nodes.getJSONObject(i); + if (node == null) { + continue; + } + if (!START_NODE_TYPE.equals(trimToNull(node.getString("type")))) { + continue; + } + return node.getJSONObject("data"); + } + return null; + } catch (Exception ignored) { + return null; + } + } + + private String trimToDefault(String value, String fallback) { + String normalized = trimToNull(value); + return StringUtils.hasText(normalized) ? normalized : fallback; + } + + private String trimToDefault(String primary, String secondary, String fallback) { + String normalizedPrimary = trimToNull(primary); + if (StringUtils.hasText(normalizedPrimary)) { + return normalizedPrimary; + } + String normalizedSecondary = trimToNull(secondary); + return StringUtils.hasText(normalizedSecondary) ? normalizedSecondary : fallback; + } + + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java index b1f6882..bfa57bf 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java @@ -236,6 +236,98 @@ public class WorkflowCheckServiceTest { Assert.assertEquals(0, result.getIssueCount()); } + @Test + public void testPreExecuteShouldBlockStartFormWithoutUserInput() throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject startData = data("开始"); + JSONArray schema = new JSONArray(); + JSONObject field = new JSONObject(); + field.put("key", "scene"); + field.put("type", "select"); + field.put("required", true); + JSONArray options = new JSONArray(); + options.add("售前"); + field.put("options", options); + schema.add(field); + startData.put("startFormSchema", schema); + String content = workflowJson( + array( + node("s1", "startNode", null, startData), + node("e1", "endNode", null, data("结束")) + ), + array(edge("e1", "s1", "e1")) + ); + + WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE); + Assert.assertFalse(result.isPassed()); + assertHasCode(result, "START_FORM_USER_INPUT_MISSING"); + } + + @Test + public void testPreExecuteShouldBlockInvalidStartFormUserInputTypeAndOptions() throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject startData = data("开始"); + JSONArray schema = new JSONArray(); + + JSONObject userInputField = new JSONObject(); + userInputField.put("key", "user_input"); + userInputField.put("type", "radio"); + userInputField.put("required", false); + schema.add(userInputField); + + JSONObject selectField = new JSONObject(); + selectField.put("key", "scene"); + selectField.put("type", "select"); + selectField.put("required", true); + schema.add(selectField); + + startData.put("startFormSchema", schema); + String content = workflowJson( + array( + node("s1", "startNode", null, startData), + node("e1", "endNode", null, data("结束")) + ), + array(edge("e1", "s1", "e1")) + ); + + WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE); + Assert.assertFalse(result.isPassed()); + assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED"); + assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID"); + assertHasCode(result, "START_FORM_OPTIONS_EMPTY"); + } + + @Test + public void testSaveShouldBlockInvalidStartFormSchema() throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject startData = data("开始"); + JSONArray schema = new JSONArray(); + + JSONObject userInputField = new JSONObject(); + userInputField.put("key", "user_input"); + userInputField.put("type", "radio"); + userInputField.put("required", false); + schema.add(userInputField); + + JSONObject checkboxField = new JSONObject(); + checkboxField.put("key", "labels"); + checkboxField.put("type", "checkbox"); + checkboxField.put("required", true); + schema.add(checkboxField); + + startData.put("startFormSchema", schema); + String content = workflowJson( + array(node("s1", "startNode", null, startData)), + new JSONArray() + ); + + WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null); + Assert.assertFalse(result.isPassed()); + assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED"); + assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID"); + assertHasCode(result, "START_FORM_OPTIONS_EMPTY"); + } + private static WorkflowCheckService newService(Map workflowStore) throws Exception { WorkflowCheckService service = new WorkflowCheckService(); ChainParser parser = ChainParser.builder() diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java new file mode 100644 index 0000000..d2ba88e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java @@ -0,0 +1,267 @@ +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.entity.Workflow; +import tech.easyflow.ai.node.SearchDatasetNodeParser; +import tech.easyflow.ai.node.WorkflowNodeParser; + +import java.lang.reflect.Field; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link WorkflowRunningParameterResolver} 测试。 + */ +public class WorkflowRunningParameterResolverTest { + + /** + * 应返回显式配置的开场表单元信息与字段 Schema。 + * + * @throws Exception 反射注入失败 + */ + @Test + public void testBuildRunningParametersViewShouldKeepStartFormSchema() throws Exception { + WorkflowRunningParameterResolver resolver = newResolver(); + JSONObject startData = data("开始"); + JSONArray schema = new JSONArray(); + + JSONObject userInputField = new JSONObject(); + userInputField.put("key", "user_input"); + userInputField.put("label", "主问题"); + userInputField.put("type", "text"); + userInputField.put("required", true); + schema.add(userInputField); + + JSONObject fileField = new JSONObject(); + fileField.put("key", "attachments"); + fileField.put("label", "附件"); + fileField.put("type", "file"); + fileField.put("required", false); + schema.add(fileField); + + JSONObject meta = new JSONObject(); + meta.put("title", "问答入口"); + meta.put("description", "请先填写信息"); + meta.put("submitText", "立即开始"); + startData.put("startFormMeta", meta); + startData.put("startFormSchema", schema); + startData.put("parameters", startParameters()); + + Workflow workflow = workflow( + workflowJson( + array( + node("s1", "startNode", null, startData), + node("e1", "endNode", null, data("结束")) + ), + array(edge("e1", "s1", "e1")) + ) + ); + + Map result = resolver.buildRunningParametersView(workflow); + Assert.assertNotNull(result); + Assert.assertEquals("问答入口", ((Map) result.get("startFormMeta")).get("title")); + List> fields = (List>) result.get("startFormSchema"); + Assert.assertEquals(2, fields.size()); + Assert.assertEquals("user_input", fields.get(0).get("key")); + Assert.assertEquals("text", fields.get(0).get("type")); + Assert.assertEquals("attachments", fields.get(1).get("key")); + Assert.assertEquals("file", fields.get(1).get("type")); + } + + /** + * 旧工作流仅保留 parameters 时,也应补出最小开场表单 Schema。 + * + * @throws Exception 反射注入失败 + */ + @Test + public void testBuildRunningParametersViewShouldFallbackToParameters() throws Exception { + WorkflowRunningParameterResolver resolver = newResolver(); + JSONObject startData = data("开始"); + startData.put("parameters", startParameters()); + + Workflow workflow = workflow( + workflowJson( + array( + node("s1", "startNode", null, startData), + node("e1", "endNode", null, data("结束")) + ), + array(edge("e1", "s1", "e1")) + ) + ); + + Map result = resolver.buildRunningParametersView(workflow); + Assert.assertNotNull(result); + List> fields = (List>) result.get("startFormSchema"); + Assert.assertEquals(2, fields.size()); + Assert.assertEquals("user_input", fields.get(0).get("key")); + Assert.assertEquals("textarea", fields.get(0).get("type")); + Assert.assertEquals(Boolean.TRUE, fields.get(0).get("required")); + Assert.assertEquals("attachments", fields.get(1).get("key")); + Assert.assertEquals("file", fields.get(1).get("type")); + } + + /** + * 文件参数运行值应统一归一化为数组并按 filePath 去重。 + * + * @throws Exception 反射注入失败 + */ + @Test + public void testNormalizeRuntimeVariablesShouldWrapFileValueIntoArray() throws Exception { + WorkflowRunningParameterResolver resolver = newResolver(); + Map variables = new LinkedHashMap<>(); + variables.put("attachments", fileValue("demo.pdf", "/files/demo.pdf", 1024L)); + + Map normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables); + + Object attachments = normalized.get("attachments"); + Assert.assertTrue(attachments instanceof List); + Assert.assertEquals(1, ((List) attachments).size()); + Assert.assertTrue(((List) attachments).get(0) instanceof Map); + } + + /** + * 多文件参数应按 filePath 去重并保留已有非文件变量。 + * + * @throws Exception 反射注入失败 + */ + @Test + public void testNormalizeRuntimeVariablesShouldDedupeFileArray() throws Exception { + WorkflowRunningParameterResolver resolver = newResolver(); + Map variables = new LinkedHashMap<>(); + variables.put("user_input", "hello"); + variables.put("attachments", List.of( + fileValue("a.pdf", "/files/a.pdf", 1024L), + fileValue("a-copy.pdf", "/files/a.pdf", 1024L), + fileValue("b.pdf", "/files/b.pdf", 1024L) + )); + + Map normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables); + + Assert.assertEquals("hello", normalized.get("user_input")); + Object attachments = normalized.get("attachments"); + Assert.assertTrue(attachments instanceof List); + Assert.assertEquals(2, ((List) attachments).size()); + } + + private static WorkflowRunningParameterResolver newResolver() throws Exception { + WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver(); + ChainParser parser = ChainParser.builder() + .withDefaultParsers(true) + .build(); + parser.addNodeParser("workflow-node", new WorkflowNodeParser()); + parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser()); + setField(resolver, "chainParser", parser); + setField(resolver, "workflowDatacenterContentService", new WorkflowDatacenterContentService()); + return resolver; + } + + private static Workflow workflow(String content) { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.ONE); + workflow.setTitle("测试工作流"); + workflow.setDescription("用于测试运行参数"); + workflow.setIcon("icon.png"); + workflow.setContent(content); + return workflow; + } + + private static String workflowContentWithStartParameters() { + JSONObject startData = data("开始"); + startData.put("parameters", startParameters()); + return workflowJson( + array( + node("s1", "startNode", null, startData), + node("e1", "endNode", null, data("结束")) + ), + array(edge("e1", "s1", "e1")) + ); + } + + private static JSONArray startParameters() { + JSONArray parameters = new JSONArray(); + + JSONObject userInput = new JSONObject(); + userInput.put("name", "user_input"); + userInput.put("dataType", "String"); + userInput.put("refType", "input"); + userInput.put("required", true); + userInput.put("contentType", "text"); + userInput.put("formType", "textarea"); + userInput.put("formLabel", "用户问题"); + userInput.put("formPlaceholder", "请输入用户问题"); + parameters.add(userInput); + + JSONObject fileField = new JSONObject(); + fileField.put("name", "attachments"); + fileField.put("dataType", "File"); + fileField.put("refType", "input"); + fileField.put("required", false); + fileField.put("contentType", "file"); + fileField.put("formType", "input"); + fileField.put("formLabel", "附件"); + parameters.add(fileField); + return parameters; + } + + private static Map fileValue(String fileName, String filePath, Long size) { + Map value = new LinkedHashMap<>(); + value.put("fileName", fileName); + value.put("filePath", filePath); + value.put("size", size); + value.put("contentType", "application/pdf"); + value.put("url", filePath); + return value; + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + 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 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/views/ai/resource/ChooseResource.vue b/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue index c08efa4..f093e8f 100644 --- a/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue +++ b/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue @@ -14,6 +14,10 @@ const props = defineProps({ type: String, required: true, }, + multiple: { + type: Boolean, + default: false, + }, }); const emit = defineEmits(['choose']); @@ -29,7 +33,7 @@ function closeDialog() { dialogVisible.value = false; } function confirm() { - emit('choose', currentChoose.value, props.attrName); + emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName); closeDialog(); } watch( @@ -55,7 +59,11 @@ watch( :page-sizes="[8, 12, 16, 20]" >