发布 v1.10 #5
@@ -0,0 +1,353 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||||
|
import tech.easyflow.common.constant.Constants;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流管理端与分享端的对话运行接口。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/workflowChat")
|
||||||
|
public class WorkflowChatController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WorkflowService workflowService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowShareService workflowShareService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowCheckService workflowCheckService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowRunningParameterResolver parameterResolver;
|
||||||
|
@Resource
|
||||||
|
private ResourceAccessService resourceAccessService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowChatEventStream eventStream;
|
||||||
|
@Resource
|
||||||
|
private ChainExecutor chainExecutor;
|
||||||
|
@Resource
|
||||||
|
private WorkflowExecResultService execResultService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowExecStepService execStepService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流的对话运行描述和输入表单。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 对话运行描述
|
||||||
|
*/
|
||||||
|
@GetMapping("/descriptor")
|
||||||
|
public Result<Map<String, Object>> descriptor(
|
||||||
|
BigInteger workflowId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
Workflow workflow = loadRunnableWorkflow(workflowId, request);
|
||||||
|
workflowCheckService.checkOrThrow(
|
||||||
|
workflow.getContent(),
|
||||||
|
WorkflowCheckStage.PRE_EXECUTE,
|
||||||
|
workflow.getId()
|
||||||
|
);
|
||||||
|
Map<String, Object> descriptor =
|
||||||
|
parameterResolver.buildRunningParametersView(workflow);
|
||||||
|
if (descriptor == null) {
|
||||||
|
throw new BusinessException("工作流输入配置无法解析");
|
||||||
|
}
|
||||||
|
descriptor.put("workflowId", workflow.getId());
|
||||||
|
descriptor.put("publishStatus", workflow.getPublishStatus());
|
||||||
|
descriptor.put("shareable", isStrictlyPublished(workflow));
|
||||||
|
return Result.ok(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动工作流,并流式返回全部可见输出。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param variables 工作流运行变量
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 工作流 SSE 事件流
|
||||||
|
*/
|
||||||
|
@PostMapping(
|
||||||
|
value = "/run",
|
||||||
|
produces = MediaType.TEXT_EVENT_STREAM_VALUE
|
||||||
|
)
|
||||||
|
public SseEmitter run(
|
||||||
|
@JsonBody(value = "workflowId", required = true)
|
||||||
|
BigInteger workflowId,
|
||||||
|
@JsonBody("variables") Map<String, Object> variables,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
Workflow workflow = loadRunnableWorkflow(workflowId, request);
|
||||||
|
workflowCheckService.checkOrThrow(
|
||||||
|
workflow.getContent(),
|
||||||
|
WorkflowCheckStage.PRE_EXECUTE,
|
||||||
|
workflow.getId()
|
||||||
|
);
|
||||||
|
Map<String, Object> normalizedVariables =
|
||||||
|
parameterResolver.normalizeRuntimeVariables(
|
||||||
|
workflow.getContent(),
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
normalizedVariables.put(Constants.LOGIN_USER_KEY, account);
|
||||||
|
normalizedVariables.put(
|
||||||
|
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||||
|
hasChatShareKey(request)
|
||||||
|
? WorkFlowUtil.WORKFLOW_CHAT_SHARE
|
||||||
|
: WorkFlowUtil.WORKFLOW_CHAT
|
||||||
|
);
|
||||||
|
return eventStream.start(
|
||||||
|
isStrictlyPublished(workflow)
|
||||||
|
? PublishedWorkflowDefinitionIds.published(workflowId.toString())
|
||||||
|
: workflowId.toString(),
|
||||||
|
normalizedVariables
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消当前用户发起的工作流执行。
|
||||||
|
*
|
||||||
|
* @param executeId 执行实例 ID
|
||||||
|
* @return 是否完成取消状态转换
|
||||||
|
*/
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
public Result<Boolean> cancel(
|
||||||
|
@JsonBody(value = "executeId", required = true)
|
||||||
|
String executeId
|
||||||
|
) {
|
||||||
|
assertExecutionOwnership(executeId);
|
||||||
|
return Result.ok(chainExecutor.cancel(executeId, "用户已中止运行"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复当前用户发起并等待确认的工作流执行。
|
||||||
|
*
|
||||||
|
* @param executeId 执行实例 ID
|
||||||
|
* @param confirmParams 确认参数
|
||||||
|
* @return 空结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/resume")
|
||||||
|
public Result<Void> resume(
|
||||||
|
@JsonBody(value = "executeId", required = true)
|
||||||
|
String executeId,
|
||||||
|
@JsonBody("confirmParams")
|
||||||
|
Map<String, Object> confirmParams
|
||||||
|
) {
|
||||||
|
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
||||||
|
if (record.getStatus() != null
|
||||||
|
&& (record.getStatus() == ChainStatus.SUCCEEDED.getValue()
|
||||||
|
|| record.getStatus() == ChainStatus.FAILED.getValue()
|
||||||
|
|| record.getStatus() == ChainStatus.CANCELLED.getValue())) {
|
||||||
|
throw new BusinessException("当前工作流执行已结束");
|
||||||
|
}
|
||||||
|
chainExecutor.resumeAsync(
|
||||||
|
executeId,
|
||||||
|
confirmParams == null
|
||||||
|
? new LinkedHashMap<>()
|
||||||
|
: new LinkedHashMap<>(confirmParams)
|
||||||
|
);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户工作流执行的运行详情。
|
||||||
|
*
|
||||||
|
* @param executeId 执行实例 ID
|
||||||
|
* @return 执行记录和有序节点步骤
|
||||||
|
*/
|
||||||
|
@GetMapping("/execution")
|
||||||
|
public Result<Map<String, Object>> detail(String executeId) {
|
||||||
|
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
||||||
|
List<WorkflowExecStep> steps = execStepService.list(
|
||||||
|
QueryWrapper.create()
|
||||||
|
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||||
|
.orderBy(WorkflowExecStep::getStartTime, true)
|
||||||
|
);
|
||||||
|
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
|
||||||
|
for (WorkflowExecStep step : steps) {
|
||||||
|
Map<String, Object> view = new LinkedHashMap<>();
|
||||||
|
view.put("id", step.getId());
|
||||||
|
view.put("attemptKey", step.getExecKey());
|
||||||
|
view.put("nodeId", step.getNodeId());
|
||||||
|
view.put("nodeName", step.getNodeName());
|
||||||
|
view.put("input", step.getInput());
|
||||||
|
view.put("output", step.getOutput());
|
||||||
|
view.put("status", step.getStatus());
|
||||||
|
view.put("errorInfo", step.getErrorInfo());
|
||||||
|
view.put("startTime", step.getStartTime());
|
||||||
|
view.put("endTime", step.getEndTime());
|
||||||
|
view.put("execTime", step.getExecTime());
|
||||||
|
stepViews.add(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> recordView = new LinkedHashMap<>();
|
||||||
|
recordView.put("executeId", record.getExecKey());
|
||||||
|
recordView.put("workflowId", record.getWorkflowId());
|
||||||
|
recordView.put("title", record.getTitle());
|
||||||
|
recordView.put("status", record.getStatus());
|
||||||
|
recordView.put("input", record.getInput());
|
||||||
|
recordView.put("output", record.getOutput());
|
||||||
|
recordView.put("errorInfo", record.getErrorInfo());
|
||||||
|
recordView.put("startTime", record.getStartTime());
|
||||||
|
recordView.put("endTime", record.getEndTime());
|
||||||
|
recordView.put("execTime", record.getExecTime());
|
||||||
|
|
||||||
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("record", recordView);
|
||||||
|
detail.put("steps", stepViews);
|
||||||
|
return Result.ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载可运行工作流,并校验直接访问或对话分享权限。
|
||||||
|
* 管理端直接运行时,已发布工作流使用发布快照,未发布工作流使用当前内容;
|
||||||
|
* 分享运行始终要求严格发布快照。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 可运行工作流视图
|
||||||
|
*/
|
||||||
|
private Workflow loadRunnableWorkflow(
|
||||||
|
BigInteger workflowId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
if (workflowId == null) {
|
||||||
|
throw new BusinessException("工作流ID不能为空");
|
||||||
|
}
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
Workflow current = workflowService.getById(workflowId);
|
||||||
|
if (current == null) {
|
||||||
|
throw new BusinessException("工作流不存在");
|
||||||
|
}
|
||||||
|
boolean sharedRequest = hasChatShareKey(request);
|
||||||
|
if (sharedRequest) {
|
||||||
|
workflowShareService.assertChatShareAccess(
|
||||||
|
request.getHeader(
|
||||||
|
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
|
||||||
|
),
|
||||||
|
workflowId,
|
||||||
|
account.getTenantId()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resourceAccessService.assertAccess(
|
||||||
|
CategoryResourceType.WORKFLOW,
|
||||||
|
current,
|
||||||
|
ResourceAction.USE,
|
||||||
|
"无权限运行工作流"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Workflow published = workflowService.getPublishedById(workflowId);
|
||||||
|
if (isStrictlyPublished(published)) {
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
if (sharedRequest) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
409,
|
||||||
|
"工作流尚未发布或已下线"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验执行记录属于当前用户发起的工作流对话。
|
||||||
|
*
|
||||||
|
* @param executeId 执行实例 ID
|
||||||
|
* @return 执行记录
|
||||||
|
*/
|
||||||
|
private WorkflowExecResult assertExecutionOwnership(String executeId) {
|
||||||
|
if (executeId == null || executeId.isBlank()) {
|
||||||
|
throw new BusinessException("执行ID不能为空");
|
||||||
|
}
|
||||||
|
WorkflowExecResult record = execResultService.getByExecKey(executeId);
|
||||||
|
if (record == null) {
|
||||||
|
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||||
|
}
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
boolean chatSource = WorkFlowUtil.WORKFLOW_CHAT.equals(
|
||||||
|
record.getCreatedKey()
|
||||||
|
) || WorkFlowUtil.WORKFLOW_CHAT_SHARE.equals(record.getCreatedKey());
|
||||||
|
if (!chatSource
|
||||||
|
|| account.getId() == null
|
||||||
|
|| !account.getId().toString().equals(
|
||||||
|
record.getCreatedBy()
|
||||||
|
)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
403,
|
||||||
|
403,
|
||||||
|
"无权限访问当前工作流执行记录"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求是否携带对话分享密钥。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 携带非空对话分享密钥时返回 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean hasChatShareKey(HttpServletRequest request) {
|
||||||
|
String shareKey = request == null
|
||||||
|
? null
|
||||||
|
: request.getHeader(
|
||||||
|
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
|
||||||
|
);
|
||||||
|
return shareKey != null && !shareKey.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断工作流是否可按严格发布快照运行。
|
||||||
|
*
|
||||||
|
* @param workflow 工作流
|
||||||
|
* @return 已发布且存在快照时返回 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean isStrictlyPublished(Workflow workflow) {
|
||||||
|
return workflow != null
|
||||||
|
&& PublishStatus.PUBLISHED.getCode().equals(
|
||||||
|
workflow.getPublishStatus()
|
||||||
|
)
|
||||||
|
&& workflow.getPublishedSnapshotJson() != null
|
||||||
|
&& !workflow.getPublishedSnapshotJson().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import java.net.URISyntaxException;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流协作分享管理接口。
|
* 工作流分享管理接口。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/workflowShare")
|
@RequestMapping("/api/v1/workflowShare")
|
||||||
@@ -49,7 +49,7 @@ public class WorkflowShareController {
|
|||||||
private KnowledgeShareAuditService knowledgeShareAuditService;
|
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建或刷新工作流协作分享链接。
|
* 创建或刷新已发布工作流的对话分享链接。
|
||||||
*
|
*
|
||||||
* @param request HTTP 请求
|
* @param request HTTP 请求
|
||||||
* @param workflowId 工作流 ID
|
* @param workflowId 工作流 ID
|
||||||
@@ -72,7 +72,7 @@ public class WorkflowShareController {
|
|||||||
"无权限分享工作流"
|
"无权限分享工作流"
|
||||||
);
|
);
|
||||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
WorkflowShareCreateResult result = workflowShareService.createUrlShare(
|
WorkflowShareCreateResult result = workflowShareService.createChatShare(
|
||||||
workflowId,
|
workflowId,
|
||||||
loginAccount.getTenantId(),
|
loginAccount.getTenantId(),
|
||||||
loginAccount.getDeptId(),
|
loginAccount.getDeptId(),
|
||||||
@@ -81,8 +81,8 @@ public class WorkflowShareController {
|
|||||||
);
|
);
|
||||||
knowledgeShareAuditService.log(
|
knowledgeShareAuditService.log(
|
||||||
loginAccount.getId(),
|
loginAccount.getId(),
|
||||||
"创建工作流协作分享",
|
"创建工作流对话分享",
|
||||||
"WORKFLOW_SHARE_CREATE",
|
"WORKFLOW_CHAT_SHARE_CREATE",
|
||||||
request.getRequestURI(),
|
request.getRequestURI(),
|
||||||
Map.of("workflowId", workflowId, "shareId", result.getId())
|
Map.of("workflowId", workflowId, "shareId", result.getId())
|
||||||
);
|
);
|
||||||
@@ -90,7 +90,7 @@ public class WorkflowShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析当前 URL 分享指向的工作流。
|
* 解析当前对话分享指向的工作流。
|
||||||
*
|
*
|
||||||
* @param request HTTP 请求
|
* @param request HTTP 请求
|
||||||
* @return 工作流标识
|
* @return 工作流标识
|
||||||
@@ -98,8 +98,8 @@ public class WorkflowShareController {
|
|||||||
@GetMapping("/resolve")
|
@GetMapping("/resolve")
|
||||||
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
WorkflowShare share = workflowShareService.resolveUrlShare(
|
WorkflowShare share = workflowShareService.resolveChatShare(
|
||||||
request.getHeader(WorkflowSharePolicy.SHARE_KEY_HEADER),
|
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
|
||||||
loginAccount.getTenantId()
|
loginAccount.getTenantId()
|
||||||
);
|
);
|
||||||
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
|
import com.easyagents.flow.core.chain.ChainConsts;
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.Edge;
|
||||||
|
import com.easyagents.flow.core.chain.Event;
|
||||||
|
import com.easyagents.flow.core.chain.Node;
|
||||||
|
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.LlmStreamEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.NodeEndEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.NodeStartEvent;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import com.easyagents.flow.core.node.EndNode;
|
||||||
|
import com.easyagents.flow.core.node.LlmNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工作流运行事件转换为对话页可消费的 SSE 事件流。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowChatEventStream {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
||||||
|
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||||
|
|
||||||
|
private final ChainExecutor chainExecutor;
|
||||||
|
private final Map<String, StreamSession> sessions =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流对话事件流服务。
|
||||||
|
*
|
||||||
|
* @param chainExecutor 工作流执行器
|
||||||
|
*/
|
||||||
|
public WorkflowChatEventStream(ChainExecutor chainExecutor) {
|
||||||
|
this.chainExecutor = chainExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工作流全局事件监听器。
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void registerListeners() {
|
||||||
|
chainExecutor.addEventListener(this::onEvent);
|
||||||
|
chainExecutor.addOutputListener(this::onExplicitOutput);
|
||||||
|
chainExecutor.addErrorListener(this::onChainError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动工作流并返回其 SSE 连接。
|
||||||
|
*
|
||||||
|
* @param definitionId 工作流定义 ID
|
||||||
|
* @param variables 运行变量
|
||||||
|
* @return SSE 连接
|
||||||
|
*/
|
||||||
|
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||||
|
StreamSession session = new StreamSession(emitter);
|
||||||
|
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
|
||||||
|
emitter.onError(error -> disconnect(session, "运行连接已断开"));
|
||||||
|
emitter.onCompletion(() -> removeSession(session));
|
||||||
|
|
||||||
|
try {
|
||||||
|
chainExecutor.executeAsync(
|
||||||
|
definitionId,
|
||||||
|
variables,
|
||||||
|
executeId -> {
|
||||||
|
session.attach(executeId);
|
||||||
|
sessions.put(executeId, session);
|
||||||
|
session.send("execution_started", Map.of(
|
||||||
|
"executeId", executeId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (RuntimeException | Error error) {
|
||||||
|
session.fail(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工作流事件转发到对应执行流。
|
||||||
|
*
|
||||||
|
* @param event 工作流事件
|
||||||
|
* @param chain 当前工作流
|
||||||
|
*/
|
||||||
|
private void onEvent(Event event, Chain chain) {
|
||||||
|
StreamSession session = findSession(chain);
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof LlmStreamEvent streamEvent) {
|
||||||
|
session.onLlmDelta(chain, streamEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof NodeStartEvent nodeStartEvent) {
|
||||||
|
session.onNodeStarted(chain, nodeStartEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof NodeEndEvent nodeEndEvent) {
|
||||||
|
session.onNodeFinished(chain, nodeEndEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof EdgeTriggerEvent edgeTriggerEvent) {
|
||||||
|
session.onEdgeTriggered(chain, edgeTriggerEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof EdgeConditionCheckFailedEvent failedEvent) {
|
||||||
|
session.onEdgeConditionFailed(chain, failedEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof ChainStatusChangeEvent statusEvent
|
||||||
|
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||||
|
session.onStatusChanged(chain, statusEvent.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转发节点显式发布的每一条输出。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param node 输出节点
|
||||||
|
* @param outputMessage 输出内容
|
||||||
|
*/
|
||||||
|
private void onExplicitOutput(
|
||||||
|
Chain chain,
|
||||||
|
Node node,
|
||||||
|
Object outputMessage
|
||||||
|
) {
|
||||||
|
StreamSession session = findSession(chain);
|
||||||
|
if (session != null) {
|
||||||
|
session.sendNodeOutput(node, outputMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将链级异常发送到客户端。
|
||||||
|
*
|
||||||
|
* @param error 链级异常
|
||||||
|
* @param chain 当前工作流
|
||||||
|
*/
|
||||||
|
private void onChainError(Throwable error, Chain chain) {
|
||||||
|
StreamSession session = findSession(chain);
|
||||||
|
if (session != null
|
||||||
|
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||||
|
session.send("execution_error", Map.of(
|
||||||
|
"message", safeErrorMessage(error)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找顶级执行对应的事件流会话。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @return 流会话;不存在时为 {@code null}
|
||||||
|
*/
|
||||||
|
private StreamSession findSession(Chain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String auditInstanceId = chain.getAuditInstanceId();
|
||||||
|
if (auditInstanceId != null && !auditInstanceId.isBlank()) {
|
||||||
|
StreamSession session = sessions.get(auditInstanceId);
|
||||||
|
if (session != null) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessions.get(chain.getStateInstanceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 SSE 连接异常,并取消尚未结束的工作流。
|
||||||
|
*
|
||||||
|
* @param session 流会话
|
||||||
|
* @param message 取消原因
|
||||||
|
*/
|
||||||
|
private void disconnect(StreamSession session, String message) {
|
||||||
|
if (session == null || session.terminal.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String executeId = session.executeId;
|
||||||
|
removeSession(session);
|
||||||
|
if (executeId != null) {
|
||||||
|
chainExecutor.cancel(executeId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除流会话。
|
||||||
|
*
|
||||||
|
* @param session 流会话
|
||||||
|
*/
|
||||||
|
private void removeSession(StreamSession session) {
|
||||||
|
if (session != null && session.executeId != null) {
|
||||||
|
sessions.remove(session.executeId, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取适合返回给用户的异常信息。
|
||||||
|
*
|
||||||
|
* @param error 异常
|
||||||
|
* @return 非空异常信息
|
||||||
|
*/
|
||||||
|
private String safeErrorMessage(Throwable error) {
|
||||||
|
if (error == null || error.getMessage() == null
|
||||||
|
|| error.getMessage().isBlank()) {
|
||||||
|
return "工作流执行失败";
|
||||||
|
}
|
||||||
|
return error.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次工作流执行的 SSE 会话。
|
||||||
|
*/
|
||||||
|
private final class StreamSession {
|
||||||
|
|
||||||
|
private final SseEmitter emitter;
|
||||||
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
|
private final AtomicBoolean terminal = new AtomicBoolean(false);
|
||||||
|
private final Map<String, String> activeLlmStreams =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
private volatile String executeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建流会话。
|
||||||
|
*
|
||||||
|
* @param emitter SSE 发送器
|
||||||
|
*/
|
||||||
|
private StreamSession(SseEmitter emitter) {
|
||||||
|
this.emitter = emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定执行实例。
|
||||||
|
*
|
||||||
|
* @param executeId 执行实例 ID
|
||||||
|
*/
|
||||||
|
private void attach(String executeId) {
|
||||||
|
this.executeId = executeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 LLM 文本增量。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param event LLM 增量事件
|
||||||
|
*/
|
||||||
|
private void onLlmDelta(Chain chain, LlmStreamEvent event) {
|
||||||
|
String nodeRunKey = nodeRunKey(chain, event.getNode());
|
||||||
|
activeLlmStreams.put(nodeRunKey, event.getStreamId());
|
||||||
|
String eventType = event.isReasoning()
|
||||||
|
? "llm_thinking_delta"
|
||||||
|
: "llm_delta";
|
||||||
|
send(eventType, nodePayload(event.getNode(), Map.of(
|
||||||
|
"streamId", event.getStreamId(),
|
||||||
|
"delta", event.getDelta()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理节点开始事件。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param event 节点开始事件
|
||||||
|
*/
|
||||||
|
private void onNodeStarted(Chain chain, NodeStartEvent event) {
|
||||||
|
Node node = event.getNode();
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("attemptKey", event.getExecutionAttemptKey());
|
||||||
|
data.put("nodeClass", node.getClass().getSimpleName());
|
||||||
|
data.put("chainInstanceId", chain.getStateInstanceId());
|
||||||
|
data.put("startedAt", System.currentTimeMillis());
|
||||||
|
data.put("input", resolveNodeInput(chain, node));
|
||||||
|
send("node_started", nodePayload(node, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理节点完成事件并输出所有可见结果。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param event 节点完成事件
|
||||||
|
*/
|
||||||
|
private void onNodeFinished(Chain chain, NodeEndEvent event) {
|
||||||
|
Node node = event.getNode();
|
||||||
|
String streamId = activeLlmStreams.remove(nodeRunKey(chain, node));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("attemptKey", event.getExecutionAttemptKey());
|
||||||
|
data.put("status", event.getStatus() == null
|
||||||
|
? null
|
||||||
|
: event.getStatus().name());
|
||||||
|
data.put("chainInstanceId", chain.getStateInstanceId());
|
||||||
|
data.put("finishedAt", System.currentTimeMillis());
|
||||||
|
data.put("output", event.getResult() == null
|
||||||
|
? Map.of()
|
||||||
|
: event.getResult());
|
||||||
|
if (event.getError() != null) {
|
||||||
|
data.put("error", safeErrorMessage(event.getError()));
|
||||||
|
}
|
||||||
|
if (streamId != null) {
|
||||||
|
data.put("streamId", streamId);
|
||||||
|
}
|
||||||
|
send("node_finished", nodePayload(node, data));
|
||||||
|
|
||||||
|
if (node instanceof EndNode) {
|
||||||
|
sendNodeOutput(node, visibleEndOutput(event.getResult()));
|
||||||
|
} else if (node instanceof LlmNode && streamId == null) {
|
||||||
|
// 少数模型只在流结束时返回完整文本,仍需向对话区展示结果。
|
||||||
|
sendNodeOutput(node, event.getResult());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录命中条件的流转分支。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param event 边触发事件
|
||||||
|
*/
|
||||||
|
private void onEdgeTriggered(Chain chain, EdgeTriggerEvent event) {
|
||||||
|
if (event.getTrigger() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Edge edge = chain.getDefinition().getEdgeById(
|
||||||
|
event.getTrigger().getEdgeId());
|
||||||
|
if (edge == null || edge.getCondition() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Node sourceNode = chain.getDefinition().getNodeById(
|
||||||
|
edge.getSource());
|
||||||
|
sendEdgeTrace(chain, sourceNode, edge, "matched");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录未命中条件的流转分支。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param event 条件未命中事件
|
||||||
|
*/
|
||||||
|
private void onEdgeConditionFailed(
|
||||||
|
Chain chain,
|
||||||
|
EdgeConditionCheckFailedEvent event
|
||||||
|
) {
|
||||||
|
sendEdgeTrace(chain, event.getNode(), event.getEdge(), "skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送节点的条件判断轨迹。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param sourceNode 条件来源节点
|
||||||
|
* @param edge 被判断的边
|
||||||
|
* @param outcome 判断结果
|
||||||
|
*/
|
||||||
|
private void sendEdgeTrace(
|
||||||
|
Chain chain,
|
||||||
|
Node sourceNode,
|
||||||
|
Edge edge,
|
||||||
|
String outcome
|
||||||
|
) {
|
||||||
|
if (sourceNode == null || edge == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Node targetNode = chain.getDefinition().getNodeById(
|
||||||
|
edge.getTarget());
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put(
|
||||||
|
"attemptKey",
|
||||||
|
chain.currentExecutionAttemptKey(sourceNode.getId()));
|
||||||
|
data.put("kind", "condition");
|
||||||
|
data.put("outcome", outcome);
|
||||||
|
data.put("edgeId", edge.getId());
|
||||||
|
data.put("targetNodeId", edge.getTarget());
|
||||||
|
data.put(
|
||||||
|
"targetNodeName",
|
||||||
|
targetNode == null ? edge.getTarget() : targetNode.getName());
|
||||||
|
send("node_trace", nodePayload(sourceNode, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析节点本次执行实际使用的输入。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param node 当前节点
|
||||||
|
* @return 可序列化的节点输入
|
||||||
|
*/
|
||||||
|
private Map<String, Object> resolveNodeInput(Chain chain, Node node) {
|
||||||
|
try {
|
||||||
|
return chain.getExecutionState()
|
||||||
|
.resolveParametersPreservingReferences(node);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
log.warn(
|
||||||
|
"Failed to resolve workflow node input, "
|
||||||
|
+ "executeId={}, nodeId={}",
|
||||||
|
executeId,
|
||||||
|
node == null ? null : node.getId(),
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理工作流状态变化。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param status 新状态
|
||||||
|
*/
|
||||||
|
private void onStatusChanged(Chain chain, ChainStatus status) {
|
||||||
|
if (status == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status == ChainStatus.SUSPEND) {
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("message", chain.getState().getMessage());
|
||||||
|
data.put(
|
||||||
|
"parameters",
|
||||||
|
chain.getState().getSuspendForParameters()
|
||||||
|
);
|
||||||
|
send("execution_waiting", data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status.isTerminal()) {
|
||||||
|
send("execution_status", Map.of("status", status.name()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!terminal.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String eventType = switch (status) {
|
||||||
|
case SUCCEEDED -> "execution_finished";
|
||||||
|
case CANCELLED -> "execution_cancelled";
|
||||||
|
default -> "execution_failed";
|
||||||
|
};
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("status", status.name());
|
||||||
|
data.put("message", chain.getState().getMessage());
|
||||||
|
send(eventType, data);
|
||||||
|
removeSession(this);
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一条用户可见节点输出。
|
||||||
|
*
|
||||||
|
* @param node 输出节点
|
||||||
|
* @param outputMessage 输出内容
|
||||||
|
*/
|
||||||
|
private void sendNodeOutput(Node node, Object outputMessage) {
|
||||||
|
send("output", nodePayload(node, Map.of(
|
||||||
|
"output", outputMessage == null ? Map.of() : outputMessage
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 SSE 事件。
|
||||||
|
*
|
||||||
|
* @param type 事件类型
|
||||||
|
* @param data 事件数据
|
||||||
|
*/
|
||||||
|
private void send(String type, Map<String, ?> data) {
|
||||||
|
long nextSequence = sequence.incrementAndGet();
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("eventId", executeId + ":" + nextSequence);
|
||||||
|
payload.put("sequence", nextSequence);
|
||||||
|
payload.put("executeId", executeId);
|
||||||
|
payload.put("type", type);
|
||||||
|
payload.put("data", data);
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.id(String.valueOf(nextSequence))
|
||||||
|
.name("workflow")
|
||||||
|
// 显式发送 JSON 文本,避免全局 CBOR 转换器将 SSE data 编码为二进制。
|
||||||
|
.data(JSON.toJSONString(payload)));
|
||||||
|
} catch (IOException | IllegalStateException error) {
|
||||||
|
log.debug(
|
||||||
|
"workflow chat stream disconnected, executeId={}",
|
||||||
|
executeId,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
disconnect(this, "运行连接已断开");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在启动失败时关闭 SSE 会话。
|
||||||
|
*
|
||||||
|
* @param error 启动异常
|
||||||
|
*/
|
||||||
|
private void fail(Throwable error) {
|
||||||
|
if (terminal.compareAndSet(false, true)) {
|
||||||
|
send("execution_failed", Map.of(
|
||||||
|
"message", safeErrorMessage(error)
|
||||||
|
));
|
||||||
|
removeSession(this);
|
||||||
|
emitter.completeWithError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建节点本次执行的关联键。
|
||||||
|
*
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param node 当前节点
|
||||||
|
* @return 节点运行键
|
||||||
|
*/
|
||||||
|
private String nodeRunKey(Chain chain, Node node) {
|
||||||
|
return chain.getStateInstanceId() + ":" + node.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建带节点信息的事件数据。
|
||||||
|
*
|
||||||
|
* @param node 当前节点
|
||||||
|
* @param values 业务数据
|
||||||
|
* @return 事件数据
|
||||||
|
*/
|
||||||
|
private Map<String, Object> nodePayload(
|
||||||
|
Node node,
|
||||||
|
Map<String, ?> values
|
||||||
|
) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("nodeId", node == null ? null : node.getId());
|
||||||
|
payload.put("nodeName", node == null ? null : node.getName());
|
||||||
|
if (values != null) {
|
||||||
|
payload.putAll(values);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉结束节点内部状态控制字段。
|
||||||
|
*
|
||||||
|
* @param result 节点结果
|
||||||
|
* @return 用户可见输出
|
||||||
|
*/
|
||||||
|
private Map<String, Object> visibleEndOutput(
|
||||||
|
Map<String, Object> result
|
||||||
|
) {
|
||||||
|
Map<String, Object> visible = new LinkedHashMap<>();
|
||||||
|
if (result != null) {
|
||||||
|
visible.putAll(result);
|
||||||
|
}
|
||||||
|
visible.remove(ChainConsts.CHAIN_STATE_STATUS_KEY);
|
||||||
|
visible.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY);
|
||||||
|
visible.remove(ChainConsts.NODE_STATE_STATUS_KEY);
|
||||||
|
visible.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY);
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowChatController} 运行来源与分享边界测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowChatControllerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证登录用户可以进入并运行未发布工作流,同时描述信息禁止分享。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRunDraftForAuthenticatedRequestWithoutSharing() {
|
||||||
|
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
|
||||||
|
when(fixture.parameterResolver.buildRunningParametersView(fixture.current))
|
||||||
|
.thenReturn(new LinkedHashMap<>());
|
||||||
|
when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
eq("draft-content"),
|
||||||
|
anyMap()
|
||||||
|
)).thenReturn(new LinkedHashMap<>());
|
||||||
|
when(fixture.eventStream.start(eq("1"), anyMap())).thenReturn(new SseEmitter());
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
|
||||||
|
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
|
||||||
|
BigInteger.ONE,
|
||||||
|
request(Map.of())
|
||||||
|
);
|
||||||
|
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
|
||||||
|
|
||||||
|
Assert.assertEquals(descriptor.getData().get("shareable"), false);
|
||||||
|
Assert.assertEquals(
|
||||||
|
descriptor.getData().get("publishStatus"),
|
||||||
|
PublishStatus.DRAFT.getCode()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(fixture.parameterResolver).buildRunningParametersView(fixture.current);
|
||||||
|
verify(fixture.eventStream).start(eq("1"), anyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证已发布工作流的管理端运行继续读取发布快照。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRunPublishedSnapshotForAuthenticatedRequest() {
|
||||||
|
Workflow current = workflow(PublishStatus.PUBLISHED, "draft-content", true);
|
||||||
|
Workflow published = workflow(PublishStatus.PUBLISHED, "published-content", true);
|
||||||
|
ControllerFixture fixture = fixture(current, published);
|
||||||
|
when(fixture.parameterResolver.buildRunningParametersView(published))
|
||||||
|
.thenReturn(new LinkedHashMap<>());
|
||||||
|
when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
eq("published-content"),
|
||||||
|
anyMap()
|
||||||
|
)).thenReturn(new LinkedHashMap<>());
|
||||||
|
when(fixture.eventStream.start(
|
||||||
|
eq(PublishedWorkflowDefinitionIds.published("1")),
|
||||||
|
anyMap()
|
||||||
|
)).thenReturn(new SseEmitter());
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
|
||||||
|
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
|
||||||
|
BigInteger.ONE,
|
||||||
|
request(Map.of())
|
||||||
|
);
|
||||||
|
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
|
||||||
|
|
||||||
|
Assert.assertEquals(descriptor.getData().get("shareable"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(fixture.parameterResolver).buildRunningParametersView(published);
|
||||||
|
verify(fixture.eventStream).start(
|
||||||
|
eq(PublishedWorkflowDefinitionIds.published("1")),
|
||||||
|
anyMap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证分享访问仍拒绝未发布工作流。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectDraftWorkflowFromShareRequest() {
|
||||||
|
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
|
||||||
|
HttpServletRequest request = request(Map.of(
|
||||||
|
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER.toLowerCase(Locale.ROOT),
|
||||||
|
"share-key"
|
||||||
|
));
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
|
||||||
|
Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.controller.descriptor(BigInteger.ONE, request)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(fixture.workflowShareService).assertChatShareAccess(
|
||||||
|
"share-key",
|
||||||
|
BigInteger.ONE,
|
||||||
|
BigInteger.ONE
|
||||||
|
);
|
||||||
|
verify(fixture.resourceAccessService, never()).assertAccess(
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.anyString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用同一当前视图和发布视图的测试夹具。
|
||||||
|
*
|
||||||
|
* @param current 当前工作流
|
||||||
|
* @return 控制器测试夹具
|
||||||
|
*/
|
||||||
|
private ControllerFixture fixture(Workflow current) {
|
||||||
|
return fixture(current, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建控制器测试夹具。
|
||||||
|
*
|
||||||
|
* @param current 当前工作流
|
||||||
|
* @param published 发布工作流视图
|
||||||
|
* @return 控制器测试夹具
|
||||||
|
*/
|
||||||
|
private ControllerFixture fixture(Workflow current, Workflow published) {
|
||||||
|
WorkflowService workflowService = mock(WorkflowService.class);
|
||||||
|
WorkflowShareService workflowShareService = mock(WorkflowShareService.class);
|
||||||
|
WorkflowCheckService workflowCheckService = mock(WorkflowCheckService.class);
|
||||||
|
WorkflowRunningParameterResolver parameterResolver =
|
||||||
|
mock(WorkflowRunningParameterResolver.class);
|
||||||
|
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
||||||
|
WorkflowChatEventStream eventStream = mock(WorkflowChatEventStream.class);
|
||||||
|
WorkflowChatController controller = new WorkflowChatController();
|
||||||
|
setField(controller, "workflowService", workflowService);
|
||||||
|
setField(controller, "workflowShareService", workflowShareService);
|
||||||
|
setField(controller, "workflowCheckService", workflowCheckService);
|
||||||
|
setField(controller, "parameterResolver", parameterResolver);
|
||||||
|
setField(controller, "resourceAccessService", resourceAccessService);
|
||||||
|
setField(controller, "eventStream", eventStream);
|
||||||
|
setField(controller, "chainExecutor", mock(ChainExecutor.class));
|
||||||
|
setField(controller, "execResultService", mock(WorkflowExecResultService.class));
|
||||||
|
setField(controller, "execStepService", mock(WorkflowExecStepService.class));
|
||||||
|
when(workflowService.getById(BigInteger.ONE)).thenReturn(current);
|
||||||
|
when(workflowService.getPublishedById(BigInteger.ONE)).thenReturn(published);
|
||||||
|
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.ONE);
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
return new ControllerFixture(
|
||||||
|
controller,
|
||||||
|
current,
|
||||||
|
workflowService,
|
||||||
|
workflowShareService,
|
||||||
|
parameterResolver,
|
||||||
|
resourceAccessService,
|
||||||
|
eventStream,
|
||||||
|
account
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流测试视图。
|
||||||
|
*
|
||||||
|
* @param publishStatus 发布状态
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @param withSnapshot 是否包含发布快照
|
||||||
|
* @return 工作流测试视图
|
||||||
|
*/
|
||||||
|
private Workflow workflow(
|
||||||
|
PublishStatus publishStatus,
|
||||||
|
String content,
|
||||||
|
boolean withSnapshot
|
||||||
|
) {
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.ONE);
|
||||||
|
workflow.setContent(content);
|
||||||
|
workflow.setPublishStatus(publishStatus.getCode());
|
||||||
|
if (withSnapshot) {
|
||||||
|
workflow.setPublishedSnapshotJson(Map.of("content", content));
|
||||||
|
}
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建登录账号静态模拟。
|
||||||
|
*
|
||||||
|
* @param account 登录账号
|
||||||
|
* @return 静态模拟句柄
|
||||||
|
*/
|
||||||
|
private MockedStatic<SaTokenUtil> login(LoginAccount account) {
|
||||||
|
MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class);
|
||||||
|
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅提供请求头能力的轻量 Servlet 请求代理。
|
||||||
|
*
|
||||||
|
* @param headers 小写请求头映射
|
||||||
|
* @return HTTP 请求代理
|
||||||
|
*/
|
||||||
|
private HttpServletRequest request(Map<String, String> headers) {
|
||||||
|
return (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance(
|
||||||
|
getClass().getClassLoader(),
|
||||||
|
new Class<?>[]{HttpServletRequest.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("getHeader".equals(method.getName())) {
|
||||||
|
String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT);
|
||||||
|
return headers.get(name);
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过反射设置控制器依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param fieldName 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String fieldName, Object value) {
|
||||||
|
try {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("设置测试字段失败: " + fieldName, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回代理方法所需的基础类型默认值。
|
||||||
|
*
|
||||||
|
* @param returnType 返回类型
|
||||||
|
* @return 默认值
|
||||||
|
*/
|
||||||
|
private Object defaultValue(Class<?> returnType) {
|
||||||
|
if (!returnType.isPrimitive()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (boolean.class == returnType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (char.class == returnType) {
|
||||||
|
return '\0';
|
||||||
|
}
|
||||||
|
if (byte.class == returnType) {
|
||||||
|
return (byte) 0;
|
||||||
|
}
|
||||||
|
if (short.class == returnType) {
|
||||||
|
return (short) 0;
|
||||||
|
}
|
||||||
|
if (int.class == returnType) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (long.class == returnType) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
if (float.class == returnType) {
|
||||||
|
return 0F;
|
||||||
|
}
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制器及其测试依赖夹具。
|
||||||
|
*
|
||||||
|
* @param controller 控制器
|
||||||
|
* @param current 当前工作流
|
||||||
|
* @param workflowService 工作流服务
|
||||||
|
* @param workflowShareService 工作流分享服务
|
||||||
|
* @param parameterResolver 参数解析器
|
||||||
|
* @param resourceAccessService 资源权限服务
|
||||||
|
* @param eventStream 事件流服务
|
||||||
|
* @param account 登录账号
|
||||||
|
*/
|
||||||
|
private record ControllerFixture(
|
||||||
|
WorkflowChatController controller,
|
||||||
|
Workflow current,
|
||||||
|
WorkflowService workflowService,
|
||||||
|
WorkflowShareService workflowShareService,
|
||||||
|
WorkflowRunningParameterResolver parameterResolver,
|
||||||
|
ResourceAccessService resourceAccessService,
|
||||||
|
WorkflowChatEventStream eventStream,
|
||||||
|
LoginAccount account
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,15 @@ public class LlmProviderImpl implements LlmProvider {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(LlmProviderImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(LlmProviderImpl.class);
|
||||||
@Resource
|
@Resource
|
||||||
private ModelService modelService;
|
private ModelService modelService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowImageSourceResolver workflowImageSourceResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型标识创建工作流聊天模型适配器。
|
||||||
|
*
|
||||||
|
* @param modelId 模型标识
|
||||||
|
* @return 工作流 LLM;模型不存在时返回 {@code null}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Llm getChatModel(Object modelId) {
|
public Llm getChatModel(Object modelId) {
|
||||||
Model model = modelService.getModelInstance(new BigInteger(modelId.toString()));
|
Model model = modelService.getModelInstance(new BigInteger(modelId.toString()));
|
||||||
@@ -28,6 +36,7 @@ public class LlmProviderImpl implements LlmProvider {
|
|||||||
}
|
}
|
||||||
EasyAgentsLlm llm = new EasyAgentsLlm();
|
EasyAgentsLlm llm = new EasyAgentsLlm();
|
||||||
llm.setChatModel(model.toChatModel());
|
llm.setChatModel(model.toChatModel());
|
||||||
|
llm.setImageInputResolver(workflowImageSourceResolver);
|
||||||
return llm;
|
return llm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.llm;
|
||||||
|
|
||||||
|
import com.easyagents.core.util.ImageUtil;
|
||||||
|
import com.easyagents.flow.support.provider.ImageInputResolver;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageReader;
|
||||||
|
import javax.imageio.stream.ImageInputStream;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.Inet6Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在模型调用前读取、校验并转换工作流图片输入。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowImageSourceResolver implements ImageInputResolver {
|
||||||
|
|
||||||
|
static final long MAX_IMAGE_BYTES = 10L * 1024 * 1024;
|
||||||
|
static final long MAX_IMAGE_PIXELS = 40_000_000L;
|
||||||
|
private static final int MAX_REDIRECTS = 3;
|
||||||
|
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(8);
|
||||||
|
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
private static final String DATA_URI_PREFIX = "data:image/";
|
||||||
|
private static final String BASE64_MARKER = ";base64,";
|
||||||
|
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流图片解析器。
|
||||||
|
*
|
||||||
|
* @param fileStorageService 文件存储服务
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public WorkflowImageSourceResolver(
|
||||||
|
@Qualifier("default") FileStorageService fileStorageService) {
|
||||||
|
this(fileStorageService, HttpClient.newBuilder()
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT)
|
||||||
|
.followRedirects(HttpClient.Redirect.NEVER)
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用指定 HTTP 客户端的图片解析器,供隔离测试使用。
|
||||||
|
*
|
||||||
|
* @param fileStorageService 文件存储服务
|
||||||
|
* @param httpClient HTTP 客户端
|
||||||
|
*/
|
||||||
|
WorkflowImageSourceResolver(FileStorageService fileStorageService, HttpClient httpClient) {
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析图片输入并返回带 MIME 的 Data URI。
|
||||||
|
*
|
||||||
|
* @param imageInput 图片 URL、文件或结构化图片描述
|
||||||
|
* @return 可供模型消费的 Data URI
|
||||||
|
* @throws BusinessException 图片不可读取、格式不支持或超过限制时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String resolve(Object imageInput) {
|
||||||
|
if (imageInput instanceof File file) {
|
||||||
|
return process(readLocalFile(file));
|
||||||
|
}
|
||||||
|
if (imageInput instanceof String value) {
|
||||||
|
return resolveString(value);
|
||||||
|
}
|
||||||
|
if (imageInput instanceof Map<?, ?> imageMap) {
|
||||||
|
return resolveMap(imageMap);
|
||||||
|
}
|
||||||
|
throw new BusinessException("图片输入格式不受支持");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析字符串形式的旧版图片输入。
|
||||||
|
*
|
||||||
|
* @param value 图片 URL 或 Data URI
|
||||||
|
* @return 规范化 Data URI
|
||||||
|
*/
|
||||||
|
private String resolveString(String value) {
|
||||||
|
String normalized = trimToNull(value);
|
||||||
|
if (!StringUtils.hasText(normalized)) {
|
||||||
|
throw new BusinessException("图片输入不能为空");
|
||||||
|
}
|
||||||
|
if (normalized.startsWith(DATA_URI_PREFIX)) {
|
||||||
|
return process(decodeDataUri(normalized));
|
||||||
|
}
|
||||||
|
return process(download(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结构化图片描述。
|
||||||
|
*
|
||||||
|
* @param imageMap 图片描述
|
||||||
|
* @return 规范化 Data URI
|
||||||
|
*/
|
||||||
|
private String resolveMap(Map<?, ?> imageMap) {
|
||||||
|
String sourceType = trimObjectToNull(imageMap.get("sourceType"));
|
||||||
|
String filePath = trimObjectToNull(imageMap.get("filePath"));
|
||||||
|
if (!StringUtils.hasText(sourceType)) {
|
||||||
|
sourceType = StringUtils.hasText(filePath) ? "upload" : "url";
|
||||||
|
}
|
||||||
|
if ("url".equals(sourceType)) {
|
||||||
|
return process(download(trimObjectToNull(imageMap.get("url"))));
|
||||||
|
}
|
||||||
|
if (!"upload".equals(sourceType) && !"resource".equals(sourceType)) {
|
||||||
|
throw new BusinessException("图片 sourceType 不受支持");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(filePath)) {
|
||||||
|
throw new BusinessException("图片缺少 filePath");
|
||||||
|
}
|
||||||
|
try (InputStream input = fileStorageService.readStream(filePath)) {
|
||||||
|
return process(readBounded(input));
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片读取失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全下载外部图片,并在每次重定向后重新校验目标地址。
|
||||||
|
*
|
||||||
|
* @param value 外部图片 URL
|
||||||
|
* @return 图片字节
|
||||||
|
*/
|
||||||
|
private byte[] download(String value) {
|
||||||
|
URI current = parseRemoteUri(value);
|
||||||
|
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||||
|
validateRemoteUri(current);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(current)
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.header("Accept", "image/png,image/jpeg,image/webp,image/gif,image/bmp")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
HttpResponse<InputStream> response =
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
int status = response.statusCode();
|
||||||
|
if (status >= 300 && status < 400) {
|
||||||
|
closeQuietly(response.body());
|
||||||
|
if (redirectCount == MAX_REDIRECTS) {
|
||||||
|
throw new BusinessException("图片 URL 重定向次数超过限制");
|
||||||
|
}
|
||||||
|
String location = response.headers().firstValue("location")
|
||||||
|
.orElseThrow(() -> new BusinessException("图片 URL 重定向缺少目标地址"));
|
||||||
|
current = current.resolve(location);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
closeQuietly(response.body());
|
||||||
|
throw new BusinessException("图片 URL 请求失败,HTTP 状态码: " + status);
|
||||||
|
}
|
||||||
|
try (InputStream input = response.body()) {
|
||||||
|
return readBounded(input);
|
||||||
|
}
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new BusinessException(400, 1, "图片 URL 请求被中断", exception);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片 URL 请求失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BusinessException("图片 URL 请求失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验远程图片 URI,阻止访问本机、内网和云元数据地址。
|
||||||
|
*
|
||||||
|
* @param uri 待访问 URI
|
||||||
|
* @throws BusinessException URI 不安全时抛出
|
||||||
|
*/
|
||||||
|
void validateRemoteUri(URI uri) {
|
||||||
|
if (uri == null
|
||||||
|
|| (!"http".equalsIgnoreCase(uri.getScheme())
|
||||||
|
&& !"https".equalsIgnoreCase(uri.getScheme()))) {
|
||||||
|
throw new BusinessException("图片 URL 仅支持 HTTP/HTTPS");
|
||||||
|
}
|
||||||
|
if (uri.getUserInfo() != null) {
|
||||||
|
throw new BusinessException("图片 URL 不能包含用户信息");
|
||||||
|
}
|
||||||
|
String host = trimToNull(uri.getHost());
|
||||||
|
if (!StringUtils.hasText(host)) {
|
||||||
|
throw new BusinessException("图片 URL 缺少有效主机");
|
||||||
|
}
|
||||||
|
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||||
|
if ("localhost".equals(normalizedHost)
|
||||||
|
|| normalizedHost.endsWith(".localhost")
|
||||||
|
|| "metadata.google.internal".equals(normalizedHost)) {
|
||||||
|
throw new BusinessException("图片 URL 不能访问本机或云元数据地址");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
InetAddress[] addresses = InetAddress.getAllByName(host);
|
||||||
|
if (addresses.length == 0) {
|
||||||
|
throw new BusinessException("图片 URL 主机无法解析");
|
||||||
|
}
|
||||||
|
for (InetAddress address : addresses) {
|
||||||
|
if (!isPublicAddress(address)) {
|
||||||
|
throw new BusinessException("图片 URL 不能访问内网或保留地址");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片 URL 主机解析失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断解析后的地址是否为允许访问的公网地址。
|
||||||
|
*
|
||||||
|
* @param address IP 地址
|
||||||
|
* @return 是否为公网地址
|
||||||
|
*/
|
||||||
|
private boolean isPublicAddress(InetAddress address) {
|
||||||
|
if (address.isAnyLocalAddress()
|
||||||
|
|| address.isLoopbackAddress()
|
||||||
|
|| address.isLinkLocalAddress()
|
||||||
|
|| address.isSiteLocalAddress()
|
||||||
|
|| address.isMulticastAddress()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
byte[] bytes = address.getAddress();
|
||||||
|
if (address instanceof Inet4Address && bytes.length == 4) {
|
||||||
|
int first = bytes[0] & 0xff;
|
||||||
|
int second = bytes[1] & 0xff;
|
||||||
|
int third = bytes[2] & 0xff;
|
||||||
|
int fourth = bytes[3] & 0xff;
|
||||||
|
return first != 0
|
||||||
|
&& first != 10
|
||||||
|
&& first != 127
|
||||||
|
&& !(first == 168 && second == 63 && third == 129 && fourth == 16)
|
||||||
|
&& !(first == 169 && second == 254)
|
||||||
|
&& !(first == 100 && second >= 64 && second <= 127)
|
||||||
|
&& !(first == 172 && second >= 16 && second <= 31)
|
||||||
|
&& !(first == 192 && (second == 0 || second == 168))
|
||||||
|
&& !(first == 198 && (second == 18 || second == 19))
|
||||||
|
&& first < 224;
|
||||||
|
}
|
||||||
|
if (address instanceof Inet6Address && bytes.length == 16) {
|
||||||
|
int first = bytes[0] & 0xff;
|
||||||
|
return (first & 0xfe) != 0xfc;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验图片格式、尺寸并规范化不兼容格式。
|
||||||
|
*
|
||||||
|
* @param source 原始图片字节
|
||||||
|
* @return 带 MIME 的 Data URI
|
||||||
|
*/
|
||||||
|
private String process(byte[] source) {
|
||||||
|
if (source.length == 0) {
|
||||||
|
throw new BusinessException("图片内容为空");
|
||||||
|
}
|
||||||
|
ImageFormat format = detectFormat(source);
|
||||||
|
try {
|
||||||
|
Dimensions dimensions = format == ImageFormat.WEBP
|
||||||
|
? webpDimensions(source)
|
||||||
|
: imageIoDimensions(source);
|
||||||
|
validateDimensions(dimensions);
|
||||||
|
|
||||||
|
byte[] normalized = source;
|
||||||
|
String mimeType = format.mimeType;
|
||||||
|
if (format == ImageFormat.GIF || format == ImageFormat.BMP) {
|
||||||
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(source));
|
||||||
|
if (image == null) {
|
||||||
|
throw new BusinessException("图片内容无法解析");
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
if (!ImageIO.write(image, "png", output)) {
|
||||||
|
throw new BusinessException("图片格式转换失败");
|
||||||
|
}
|
||||||
|
normalized = output.toByteArray();
|
||||||
|
mimeType = "image/png";
|
||||||
|
}
|
||||||
|
if (normalized.length > MAX_IMAGE_BYTES) {
|
||||||
|
throw new BusinessException("处理后的图片不能超过 10 MiB");
|
||||||
|
}
|
||||||
|
return ImageUtil.imageBytesToDataUri(normalized, mimeType);
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片处理失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件签名识别真实图片格式。
|
||||||
|
*
|
||||||
|
* @param bytes 图片字节
|
||||||
|
* @return 图片格式
|
||||||
|
*/
|
||||||
|
private ImageFormat detectFormat(byte[] bytes) {
|
||||||
|
if (bytes.length >= 8
|
||||||
|
&& bytes[0] == (byte) 0x89 && bytes[1] == 0x50
|
||||||
|
&& bytes[2] == 0x4e && bytes[3] == 0x47) {
|
||||||
|
return ImageFormat.PNG;
|
||||||
|
}
|
||||||
|
if (bytes.length >= 3
|
||||||
|
&& bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8
|
||||||
|
&& bytes[2] == (byte) 0xff) {
|
||||||
|
return ImageFormat.JPEG;
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6) {
|
||||||
|
String header = ascii(bytes, 0, 6);
|
||||||
|
if ("GIF87a".equals(header) || "GIF89a".equals(header)) {
|
||||||
|
return ImageFormat.GIF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes.length >= 2 && bytes[0] == 'B' && bytes[1] == 'M') {
|
||||||
|
return ImageFormat.BMP;
|
||||||
|
}
|
||||||
|
if (bytes.length >= 12
|
||||||
|
&& "RIFF".equals(ascii(bytes, 0, 4))
|
||||||
|
&& "WEBP".equals(ascii(bytes, 8, 4))) {
|
||||||
|
return ImageFormat.WEBP;
|
||||||
|
}
|
||||||
|
throw new BusinessException("仅支持 PNG、JPG、JPEG、WebP、GIF、BMP 图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 ImageIO 读取图片尺寸。
|
||||||
|
*
|
||||||
|
* @param bytes 图片字节
|
||||||
|
* @return 图片尺寸
|
||||||
|
* @throws IOException 无法读取图片时抛出
|
||||||
|
*/
|
||||||
|
private Dimensions imageIoDimensions(byte[] bytes) throws IOException {
|
||||||
|
try (ImageInputStream input =
|
||||||
|
ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
|
||||||
|
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
|
||||||
|
if (!readers.hasNext()) {
|
||||||
|
throw new BusinessException("图片内容无法解析");
|
||||||
|
}
|
||||||
|
ImageReader reader = readers.next();
|
||||||
|
try {
|
||||||
|
reader.setInput(input, true, true);
|
||||||
|
return new Dimensions(reader.getWidth(0), reader.getHeight(0));
|
||||||
|
} finally {
|
||||||
|
reader.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 WebP 图片尺寸。
|
||||||
|
*
|
||||||
|
* @param bytes 图片字节
|
||||||
|
* @return 图片尺寸
|
||||||
|
*/
|
||||||
|
private Dimensions webpDimensions(byte[] bytes) {
|
||||||
|
int offset = 12;
|
||||||
|
while (offset + 8 <= bytes.length) {
|
||||||
|
String chunk = ascii(bytes, offset, 4);
|
||||||
|
long size = Integer.toUnsignedLong(littleEndianInt(bytes, offset + 4));
|
||||||
|
int data = offset + 8;
|
||||||
|
if (size > bytes.length - data) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int chunkSize = (int) size;
|
||||||
|
if ("VP8X".equals(chunk) && chunkSize >= 10) {
|
||||||
|
return new Dimensions(
|
||||||
|
1 + littleEndian24(bytes, data + 4),
|
||||||
|
1 + littleEndian24(bytes, data + 7));
|
||||||
|
}
|
||||||
|
if ("VP8 ".equals(chunk) && chunkSize >= 10
|
||||||
|
&& bytes[data + 3] == (byte) 0x9d
|
||||||
|
&& bytes[data + 4] == 0x01 && bytes[data + 5] == 0x2a) {
|
||||||
|
return new Dimensions(
|
||||||
|
littleEndian16(bytes, data + 6) & 0x3fff,
|
||||||
|
littleEndian16(bytes, data + 8) & 0x3fff);
|
||||||
|
}
|
||||||
|
if ("VP8L".equals(chunk) && chunkSize >= 5 && bytes[data] == 0x2f) {
|
||||||
|
int b1 = unsigned(bytes[data + 1]);
|
||||||
|
int b2 = unsigned(bytes[data + 2]);
|
||||||
|
int b3 = unsigned(bytes[data + 3]);
|
||||||
|
int b4 = unsigned(bytes[data + 4]);
|
||||||
|
return new Dimensions(
|
||||||
|
1 + ((b1 | b2 << 8) & 0x3fff),
|
||||||
|
1 + (((b2 >> 6) | b3 << 2 | b4 << 10) & 0x3fff));
|
||||||
|
}
|
||||||
|
long nextOffset = (long) data + chunkSize + (chunkSize & 1);
|
||||||
|
if (nextOffset > Integer.MAX_VALUE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset = (int) nextOffset;
|
||||||
|
}
|
||||||
|
throw new BusinessException("WebP 图片内容无法解析");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验图片像素数量。
|
||||||
|
*
|
||||||
|
* @param dimensions 图片尺寸
|
||||||
|
*/
|
||||||
|
private void validateDimensions(Dimensions dimensions) {
|
||||||
|
if (dimensions.width <= 0 || dimensions.height <= 0) {
|
||||||
|
throw new BusinessException("图片尺寸无效");
|
||||||
|
}
|
||||||
|
long pixels = (long) dimensions.width * dimensions.height;
|
||||||
|
if (pixels > MAX_IMAGE_PIXELS) {
|
||||||
|
throw new BusinessException("图片不能超过 4000 万像素");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取文件并应用大小上限。
|
||||||
|
*
|
||||||
|
* @param file 本地文件
|
||||||
|
* @return 文件字节
|
||||||
|
*/
|
||||||
|
private byte[] readLocalFile(File file) {
|
||||||
|
if (file == null || !file.isFile()) {
|
||||||
|
throw new BusinessException("图片文件不存在");
|
||||||
|
}
|
||||||
|
try (InputStream input = java.nio.file.Files.newInputStream(file.toPath())) {
|
||||||
|
return readBounded(input);
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片文件读取失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式读取图片,并拒绝超过 10 MiB 的内容。
|
||||||
|
*
|
||||||
|
* @param input 图片输入流
|
||||||
|
* @return 图片字节
|
||||||
|
* @throws IOException 读取失败时抛出
|
||||||
|
*/
|
||||||
|
private byte[] readBounded(InputStream input) throws IOException {
|
||||||
|
if (input == null) {
|
||||||
|
throw new BusinessException("图片读取失败");
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
long total = 0L;
|
||||||
|
int read;
|
||||||
|
while ((read = input.read(buffer)) != -1) {
|
||||||
|
total += read;
|
||||||
|
if (total > MAX_IMAGE_BYTES) {
|
||||||
|
throw new BusinessException("单张图片不能超过 10 MiB");
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码并校验图片 Data URI。
|
||||||
|
*
|
||||||
|
* @param dataUri 图片 Data URI
|
||||||
|
* @return 图片字节
|
||||||
|
*/
|
||||||
|
private byte[] decodeDataUri(String dataUri) {
|
||||||
|
int markerIndex = dataUri.indexOf(BASE64_MARKER);
|
||||||
|
if (markerIndex <= DATA_URI_PREFIX.length()) {
|
||||||
|
throw new BusinessException("图片 Data URI 格式不正确");
|
||||||
|
}
|
||||||
|
String encoded = dataUri.substring(markerIndex + BASE64_MARKER.length());
|
||||||
|
long maxEncodedLength = (MAX_IMAGE_BYTES + 2L) / 3L * 4L;
|
||||||
|
if (encoded.length() > maxEncodedLength + 2L) {
|
||||||
|
throw new BusinessException("单张图片不能超过 10 MiB");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] bytes = Base64.getDecoder().decode(encoded);
|
||||||
|
if (bytes.length > MAX_IMAGE_BYTES) {
|
||||||
|
throw new BusinessException("单张图片不能超过 10 MiB");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片 Data URI 编码无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析远程 URI。
|
||||||
|
*
|
||||||
|
* @param value 原始 URL
|
||||||
|
* @return URI
|
||||||
|
*/
|
||||||
|
private URI parseRemoteUri(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
throw new BusinessException("图片 URL 不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URI(value.trim());
|
||||||
|
} catch (URISyntaxException exception) {
|
||||||
|
throw new BusinessException(400, 1, "图片 URL 格式不正确", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimObjectToNull(Object value) {
|
||||||
|
return trimToNull(value == null ? null : String.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
return StringUtils.hasText(value) ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String ascii(byte[] bytes, int offset, int length) {
|
||||||
|
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int littleEndian16(byte[] bytes, int offset) {
|
||||||
|
return unsigned(bytes[offset]) | unsigned(bytes[offset + 1]) << 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int littleEndian24(byte[] bytes, int offset) {
|
||||||
|
return unsigned(bytes[offset])
|
||||||
|
| unsigned(bytes[offset + 1]) << 8
|
||||||
|
| unsigned(bytes[offset + 2]) << 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int littleEndianInt(byte[] bytes, int offset) {
|
||||||
|
return unsigned(bytes[offset])
|
||||||
|
| unsigned(bytes[offset + 1]) << 8
|
||||||
|
| unsigned(bytes[offset + 2]) << 16
|
||||||
|
| unsigned(bytes[offset + 3]) << 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int unsigned(byte value) {
|
||||||
|
return value & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeQuietly(InputStream input) {
|
||||||
|
if (input == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
input.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 响应已失败,关闭异常不覆盖原始业务错误。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Dimensions(int width, int height) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ImageFormat {
|
||||||
|
PNG("image/png"),
|
||||||
|
JPEG("image/jpeg"),
|
||||||
|
WEBP("image/webp"),
|
||||||
|
GIF("image/gif"),
|
||||||
|
BMP("image/bmp");
|
||||||
|
|
||||||
|
private final String mimeType;
|
||||||
|
|
||||||
|
ImageFormat(String mimeType) {
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import java.util.Collection;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ public class WorkflowRunningParameterResolver {
|
|||||||
private static final int FILE_MAX_COUNT = 10;
|
private static final int FILE_MAX_COUNT = 10;
|
||||||
private static final long FILE_MAX_SINGLE_SIZE = 20L * 1024 * 1024;
|
private static final long FILE_MAX_SINGLE_SIZE = 20L * 1024 * 1024;
|
||||||
private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024;
|
private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024;
|
||||||
|
private static final long IMAGE_MAX_SINGLE_SIZE = 10L * 1024 * 1024;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ChainParser chainParser;
|
private ChainParser chainParser;
|
||||||
@@ -103,14 +105,15 @@ public class WorkflowRunningParameterResolver {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
for (Parameter parameter : startParameters) {
|
for (Parameter parameter : startParameters) {
|
||||||
if (!isFileParameter(parameter)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String name = trimToNull(parameter.getName());
|
String name = trimToNull(parameter.getName());
|
||||||
if (!StringUtils.hasText(name) || !normalized.containsKey(name)) {
|
if (!StringUtils.hasText(name) || !normalized.containsKey(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isFileParameter(parameter)) {
|
||||||
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
||||||
|
} else if (isImageParameter(parameter)) {
|
||||||
|
normalized.put(name, normalizeImageVariableValue(normalized.get(name), name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -146,13 +149,21 @@ public class WorkflowRunningParameterResolver {
|
|||||||
List<Map<String, Object>> schema = new ArrayList<>();
|
List<Map<String, Object>> schema = new ArrayList<>();
|
||||||
Set<String> seenKeys = new LinkedHashSet<>();
|
Set<String> seenKeys = new LinkedHashSet<>();
|
||||||
boolean hasExplicitSchema = rawSchema != null;
|
boolean hasExplicitSchema = rawSchema != null;
|
||||||
|
Map<String, Parameter> parameterByName = new LinkedHashMap<>();
|
||||||
|
for (Parameter parameter : parameters) {
|
||||||
|
String parameterName = trimToNull(parameter == null ? null : parameter.getName());
|
||||||
|
if (StringUtils.hasText(parameterName)) {
|
||||||
|
parameterByName.put(parameterName, parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
boolean hasSystemParameter = parameters.stream().anyMatch(parameter ->
|
boolean hasSystemParameter = parameters.stream().anyMatch(parameter ->
|
||||||
SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter == null ? null : parameter.getName()))
|
SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter == null ? null : parameter.getName()))
|
||||||
);
|
);
|
||||||
if (rawSchema != null && !rawSchema.isEmpty()) {
|
if (rawSchema != null && !rawSchema.isEmpty()) {
|
||||||
for (int i = 0; i < rawSchema.size(); i++) {
|
for (int i = 0; i < rawSchema.size(); i++) {
|
||||||
JSONObject field = rawSchema.getJSONObject(i);
|
JSONObject field = rawSchema.getJSONObject(i);
|
||||||
Map<String, Object> normalized = normalizeStartFormField(field, null);
|
String fieldKey = trimToNull(field == null ? null : field.getString("key"));
|
||||||
|
Map<String, Object> normalized = normalizeStartFormField(field, parameterByName.get(fieldKey));
|
||||||
if (normalized == null) {
|
if (normalized == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -202,6 +213,10 @@ public class WorkflowRunningParameterResolver {
|
|||||||
boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key)
|
boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key)
|
||||||
|| (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved")));
|
|| (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved")));
|
||||||
String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved);
|
String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved);
|
||||||
|
String contentType = resolveStartFormContentType(field, parameter, type, systemReserved);
|
||||||
|
if ("file".equals(contentType)) {
|
||||||
|
type = "file";
|
||||||
|
}
|
||||||
List<String> options = resolveFieldOptions(field, parameter, type);
|
List<String> options = resolveFieldOptions(field, parameter, type);
|
||||||
|
|
||||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
@@ -212,6 +227,7 @@ public class WorkflowRunningParameterResolver {
|
|||||||
SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key
|
SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key
|
||||||
));
|
));
|
||||||
normalized.put("type", type);
|
normalized.put("type", type);
|
||||||
|
normalized.put("contentType", contentType);
|
||||||
normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required")))
|
normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required")))
|
||||||
|| (parameter != null && parameter.isRequired()));
|
|| (parameter != null && parameter.isRequired()));
|
||||||
normalized.put("placeholder", trimToDefault(
|
normalized.put("placeholder", trimToDefault(
|
||||||
@@ -230,6 +246,40 @@ public class WorkflowRunningParameterResolver {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析开始表单字段的数据内容类型,并兼容旧版仅通过字段类型表达文件输入的配置。
|
||||||
|
*
|
||||||
|
* @param field 字段 Schema
|
||||||
|
* @param parameter 旧版参数定义
|
||||||
|
* @param fieldType 表单字段类型
|
||||||
|
* @param systemReserved 是否系统入口字段
|
||||||
|
* @return 归一化后的数据内容类型
|
||||||
|
*/
|
||||||
|
private String resolveStartFormContentType(JSONObject field,
|
||||||
|
Parameter parameter,
|
||||||
|
String fieldType,
|
||||||
|
boolean systemReserved) {
|
||||||
|
if (systemReserved) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
if ("file".equals(fieldType)) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
String requested = trimToNull(field == null ? null : field.getString("contentType"));
|
||||||
|
String parameterContentType = parameter == null ? null : trimToNull(parameter.getContentType());
|
||||||
|
if ("image".equals(parameterContentType)
|
||||||
|
&& (!StringUtils.hasText(requested) || "text".equals(requested))) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(requested)) {
|
||||||
|
requested = parameterContentType;
|
||||||
|
}
|
||||||
|
return switch (requested == null ? "" : requested) {
|
||||||
|
case "image", "video", "audio", "file", "other" -> requested;
|
||||||
|
default -> "text";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) {
|
private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) {
|
||||||
Object rawDefaultValue = field == null ? null : field.get("defaultValue");
|
Object rawDefaultValue = field == null ? null : field.get("defaultValue");
|
||||||
if (rawDefaultValue != null) {
|
if (rawDefaultValue != null) {
|
||||||
@@ -329,6 +379,117 @@ public class WorkflowRunningParameterResolver {
|
|||||||
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())));
|
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断参数是否为图片输入参数。
|
||||||
|
*
|
||||||
|
* @param parameter 参数定义
|
||||||
|
* @return 是否图片参数
|
||||||
|
*/
|
||||||
|
private boolean isImageParameter(Parameter parameter) {
|
||||||
|
return parameter != null && "image".equals(trimToNull(parameter.getContentType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图片运行值归一化为单图描述对象。
|
||||||
|
*
|
||||||
|
* @param value 原始图片值
|
||||||
|
* @param parameterName 参数名
|
||||||
|
* @return 归一化后的图片描述;空值返回 {@code null}
|
||||||
|
*/
|
||||||
|
private Object normalizeImageVariableValue(Object value, String parameterName) {
|
||||||
|
if (value == null
|
||||||
|
|| (value instanceof String stringValue && !StringUtils.hasText(stringValue))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof Collection<?>) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 仅支持单张图片");
|
||||||
|
}
|
||||||
|
if (value instanceof String stringValue) {
|
||||||
|
String normalized = stringValue.trim();
|
||||||
|
if (isHttpUrl(normalized)) {
|
||||||
|
Map<String, Object> image = new LinkedHashMap<>();
|
||||||
|
image.put("sourceType", "url");
|
||||||
|
image.put("url", normalized);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 仅支持 HTTP/HTTPS 图片 URL");
|
||||||
|
}
|
||||||
|
if (!(value instanceof Map<?, ?> imageMap)) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 的输入格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sourceType = trimObjectToNull(imageMap.get("sourceType"));
|
||||||
|
String filePath = trimObjectToNull(imageMap.get("filePath"));
|
||||||
|
String url = trimObjectToNull(imageMap.get("url"));
|
||||||
|
// 兼容旧版没有 sourceType 的文件对象和 URL 对象。
|
||||||
|
if (!StringUtils.hasText(sourceType)) {
|
||||||
|
sourceType = StringUtils.hasText(filePath) ? "upload" : "url";
|
||||||
|
}
|
||||||
|
if ("url".equals(sourceType)) {
|
||||||
|
if (!isHttpUrl(url)) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 缺少有效的 HTTP/HTTPS URL");
|
||||||
|
}
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
|
normalized.put("sourceType", "url");
|
||||||
|
normalized.put("url", url);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (!"upload".equals(sourceType) && !"resource".equals(sourceType)) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 的 sourceType 不受支持");
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = trimObjectToNull(imageMap.get("fileName"));
|
||||||
|
if (!StringUtils.hasText(fileName)) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 缺少 fileName");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(filePath)) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 缺少 filePath");
|
||||||
|
}
|
||||||
|
Long size = parseLong(imageMap.get("size"));
|
||||||
|
if (size != null && size > IMAGE_MAX_SINGLE_SIZE) {
|
||||||
|
throw new BusinessException("图片参数 " + parameterName + " 中图片不能超过 10 MiB");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
|
normalized.put("sourceType", sourceType);
|
||||||
|
normalized.put("fileName", fileName);
|
||||||
|
normalized.put("filePath", filePath);
|
||||||
|
copyOptionalImageField(imageMap, normalized, "contentType");
|
||||||
|
if (size != null) {
|
||||||
|
normalized.put("size", size);
|
||||||
|
}
|
||||||
|
copyOptionalImageField(imageMap, normalized, "url");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制图片描述中的可选非空字段。
|
||||||
|
*
|
||||||
|
* @param source 原始图片描述
|
||||||
|
* @param target 归一化图片描述
|
||||||
|
* @param key 字段名
|
||||||
|
*/
|
||||||
|
private void copyOptionalImageField(Map<?, ?> source, Map<String, Object> target, String key) {
|
||||||
|
String value = trimObjectToNull(source.get(key));
|
||||||
|
if (StringUtils.hasText(value)) {
|
||||||
|
target.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否为 HTTP 或 HTTPS URL。
|
||||||
|
*
|
||||||
|
* @param value 待判断值
|
||||||
|
* @return 是否为受支持 URL
|
||||||
|
*/
|
||||||
|
private boolean isHttpUrl(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lowerValue = value.toLowerCase(Locale.ROOT);
|
||||||
|
return lowerValue.startsWith("http://") || lowerValue.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将单文件或多文件运行值归一化为文件对象数组。
|
* 将单文件或多文件运行值归一化为文件对象数组。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public class WorkflowShare implements Serializable {
|
|||||||
@Column(comment = "工作流ID")
|
@Column(comment = "工作流ID")
|
||||||
private BigInteger workflowId;
|
private BigInteger workflowId;
|
||||||
|
|
||||||
|
@Column(comment = "分享用途")
|
||||||
|
private String sharePurpose;
|
||||||
|
|
||||||
@Column(comment = "分享密钥哈希")
|
@Column(comment = "分享密钥哈希")
|
||||||
private String shareKeyHash;
|
private String shareKeyHash;
|
||||||
|
|
||||||
@@ -86,6 +89,24 @@ public class WorkflowShare implements Serializable {
|
|||||||
this.workflowId = workflowId;
|
this.workflowId = workflowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享用途。
|
||||||
|
*
|
||||||
|
* @return 分享用途
|
||||||
|
*/
|
||||||
|
public String getSharePurpose() {
|
||||||
|
return sharePurpose;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分享用途。
|
||||||
|
*
|
||||||
|
* @param sharePurpose 分享用途
|
||||||
|
*/
|
||||||
|
public void setSharePurpose(String sharePurpose) {
|
||||||
|
this.sharePurpose = sharePurpose;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分享密钥哈希。
|
* 获取分享密钥哈希。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流分享用途。
|
||||||
|
*/
|
||||||
|
public enum WorkflowSharePurpose {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史协作编辑分享。
|
||||||
|
*/
|
||||||
|
COLLABORATION,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已发布工作流对话运行分享。
|
||||||
|
*/
|
||||||
|
CHAT
|
||||||
|
}
|
||||||
@@ -29,6 +29,24 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
|
|||||||
String baseUrl
|
String baseUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或刷新工作流的唯一对话分享链接。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param operatorId 操作人账号 ID
|
||||||
|
* @param baseUrl 工作流对话分享基础 URL
|
||||||
|
* @return 创建结果
|
||||||
|
*/
|
||||||
|
WorkflowShareCreateResult createChatShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验分享密钥是否可访问指定工作流。
|
* 校验分享密钥是否可访问指定工作流。
|
||||||
*
|
*
|
||||||
@@ -51,4 +69,27 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
|
|||||||
* @return 有效分享记录
|
* @return 有效分享记录
|
||||||
*/
|
*/
|
||||||
WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId);
|
WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验对话分享密钥是否可访问指定工作流。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 当前登录租户 ID
|
||||||
|
* @return 有效对话分享记录
|
||||||
|
*/
|
||||||
|
WorkflowShare assertChatShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验对话分享密钥并解析目标工作流。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @param tenantId 当前登录租户 ID
|
||||||
|
* @return 有效对话分享记录
|
||||||
|
*/
|
||||||
|
WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import org.springframework.transaction.support.TransactionTemplate;
|
|||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowShare;
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
import tech.easyflow.ai.enums.KnowledgeShareStatus;
|
import tech.easyflow.ai.enums.KnowledgeShareStatus;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.enums.WorkflowSharePurpose;
|
||||||
import tech.easyflow.ai.mapper.WorkflowShareMapper;
|
import tech.easyflow.ai.mapper.WorkflowShareMapper;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.service.WorkflowShareService;
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
@@ -53,9 +55,63 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
BigInteger deptId,
|
BigInteger deptId,
|
||||||
BigInteger operatorId,
|
BigInteger operatorId,
|
||||||
String baseUrl
|
String baseUrl
|
||||||
|
) {
|
||||||
|
return createShare(
|
||||||
|
workflowId,
|
||||||
|
tenantId,
|
||||||
|
deptId,
|
||||||
|
operatorId,
|
||||||
|
baseUrl,
|
||||||
|
WorkflowSharePurpose.COLLABORATION,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShareCreateResult createChatShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl
|
||||||
|
) {
|
||||||
|
return createShare(
|
||||||
|
workflowId,
|
||||||
|
tenantId,
|
||||||
|
deptId,
|
||||||
|
operatorId,
|
||||||
|
baseUrl,
|
||||||
|
WorkflowSharePurpose.CHAT,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在用途级分布式锁内创建分享。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param operatorId 操作人账号 ID
|
||||||
|
* @param baseUrl 分享基础 URL
|
||||||
|
* @param purpose 分享用途
|
||||||
|
* @param requirePublished 是否要求严格发布态
|
||||||
|
* @return 创建结果
|
||||||
|
*/
|
||||||
|
private WorkflowShareCreateResult createShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl,
|
||||||
|
WorkflowSharePurpose purpose,
|
||||||
|
boolean requirePublished
|
||||||
) {
|
) {
|
||||||
return redisLockExecutor.executeWithLock(
|
return redisLockExecutor.executeWithLock(
|
||||||
LOCK_KEY_PREFIX + workflowId,
|
LOCK_KEY_PREFIX + workflowId + ":" + purpose.name(),
|
||||||
LOCK_WAIT_TIMEOUT,
|
LOCK_WAIT_TIMEOUT,
|
||||||
LOCK_LEASE_TIMEOUT,
|
LOCK_LEASE_TIMEOUT,
|
||||||
() -> {
|
() -> {
|
||||||
@@ -66,7 +122,9 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
tenantId,
|
tenantId,
|
||||||
deptId,
|
deptId,
|
||||||
operatorId,
|
operatorId,
|
||||||
baseUrl
|
baseUrl,
|
||||||
|
purpose,
|
||||||
|
requirePublished
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -96,11 +154,55 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId) {
|
public WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId) {
|
||||||
|
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.COLLABORATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShare assertChatShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId
|
||||||
|
) {
|
||||||
|
if (workflowId == null) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
WorkflowShare share = resolveChatShare(shareKey, tenantId);
|
||||||
|
if (!workflowId.equals(share.getWorkflowId())) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId) {
|
||||||
|
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按用途校验并解析分享。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @param tenantId 当前租户 ID
|
||||||
|
* @param purpose 分享用途
|
||||||
|
* @return 有效分享记录
|
||||||
|
*/
|
||||||
|
private WorkflowShare resolveShare(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger tenantId,
|
||||||
|
WorkflowSharePurpose purpose
|
||||||
|
) {
|
||||||
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
|
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
|
||||||
throw invalidShare();
|
throw invalidShare();
|
||||||
}
|
}
|
||||||
WorkflowShare share = getOne(QueryWrapper.create()
|
WorkflowShare share = getOne(QueryWrapper.create()
|
||||||
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
|
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
|
||||||
|
.eq(WorkflowShare::getSharePurpose, purpose.name())
|
||||||
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
||||||
if (share == null || !tenantId.equals(share.getTenantId())) {
|
if (share == null || !tenantId.equals(share.getTenantId())) {
|
||||||
throw invalidShare();
|
throw invalidShare();
|
||||||
@@ -112,6 +214,9 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
if (workflow == null || !tenantId.equals(workflow.getTenantId())) {
|
if (workflow == null || !tenantId.equals(workflow.getTenantId())) {
|
||||||
throw invalidShare();
|
throw invalidShare();
|
||||||
}
|
}
|
||||||
|
if (purpose == WorkflowSharePurpose.CHAT && !isStrictlyPublished(workflow)) {
|
||||||
|
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
|
||||||
|
}
|
||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +228,8 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
* @param deptId 部门 ID
|
* @param deptId 部门 ID
|
||||||
* @param operatorId 操作人账号 ID
|
* @param operatorId 操作人账号 ID
|
||||||
* @param baseUrl 工作流分享基础 URL
|
* @param baseUrl 工作流分享基础 URL
|
||||||
|
* @param purpose 分享用途
|
||||||
|
* @param requirePublished 是否要求严格发布态
|
||||||
* @return 创建结果
|
* @return 创建结果
|
||||||
*/
|
*/
|
||||||
private WorkflowShareCreateResult createOrReplaceShare(
|
private WorkflowShareCreateResult createOrReplaceShare(
|
||||||
@@ -130,19 +237,27 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
BigInteger deptId,
|
BigInteger deptId,
|
||||||
BigInteger operatorId,
|
BigInteger operatorId,
|
||||||
String baseUrl
|
String baseUrl,
|
||||||
|
WorkflowSharePurpose purpose,
|
||||||
|
boolean requirePublished
|
||||||
) {
|
) {
|
||||||
Workflow workflow = workflowService.getById(workflowId);
|
Workflow workflow = workflowService.getById(workflowId);
|
||||||
if (workflow == null || tenantId == null || !tenantId.equals(workflow.getTenantId())) {
|
if (workflow == null || tenantId == null || !tenantId.equals(workflow.getTenantId())) {
|
||||||
throw new BusinessException("工作流不存在");
|
throw new BusinessException("工作流不存在");
|
||||||
}
|
}
|
||||||
|
if (requirePublished && !isStrictlyPublished(workflow)) {
|
||||||
|
throw new BusinessException(409, 409, "仅已发布工作流可创建对话分享");
|
||||||
|
}
|
||||||
|
|
||||||
String shareKey = UUID.randomUUID().toString().replace("-", "");
|
String shareKey = UUID.randomUUID().toString().replace("-", "");
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiresAt = WorkflowSharePolicy.defaultExpiresAt(now);
|
Date expiresAt = purpose == WorkflowSharePurpose.CHAT
|
||||||
invalidateExistingShares(workflowId, operatorId, now);
|
? WorkflowSharePolicy.defaultChatExpiresAt(now)
|
||||||
|
: WorkflowSharePolicy.defaultExpiresAt(now);
|
||||||
|
invalidateExistingShares(workflowId, purpose, operatorId, now);
|
||||||
WorkflowShare share = new WorkflowShare();
|
WorkflowShare share = new WorkflowShare();
|
||||||
share.setWorkflowId(workflowId);
|
share.setWorkflowId(workflowId);
|
||||||
|
share.setSharePurpose(purpose.name());
|
||||||
share.setTenantId(tenantId);
|
share.setTenantId(tenantId);
|
||||||
share.setDeptId(deptId);
|
share.setDeptId(deptId);
|
||||||
share.setShareKeyHash(WorkflowSharePolicy.hashShareKey(shareKey));
|
share.setShareKeyHash(WorkflowSharePolicy.hashShareKey(shareKey));
|
||||||
@@ -166,12 +281,19 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
* 使工作流已有的有效分享记录失效。
|
* 使工作流已有的有效分享记录失效。
|
||||||
*
|
*
|
||||||
* @param workflowId 工作流 ID
|
* @param workflowId 工作流 ID
|
||||||
|
* @param purpose 分享用途
|
||||||
* @param operatorId 操作人账号 ID
|
* @param operatorId 操作人账号 ID
|
||||||
* @param now 当前时间
|
* @param now 当前时间
|
||||||
*/
|
*/
|
||||||
private void invalidateExistingShares(BigInteger workflowId, BigInteger operatorId, Date now) {
|
private void invalidateExistingShares(
|
||||||
|
BigInteger workflowId,
|
||||||
|
WorkflowSharePurpose purpose,
|
||||||
|
BigInteger operatorId,
|
||||||
|
Date now
|
||||||
|
) {
|
||||||
List<WorkflowShare> activeShares = list(QueryWrapper.create()
|
List<WorkflowShare> activeShares = list(QueryWrapper.create()
|
||||||
.eq(WorkflowShare::getWorkflowId, workflowId)
|
.eq(WorkflowShare::getWorkflowId, workflowId)
|
||||||
|
.eq(WorkflowShare::getSharePurpose, purpose.name())
|
||||||
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
||||||
for (WorkflowShare activeShare : activeShares) {
|
for (WorkflowShare activeShare : activeShares) {
|
||||||
WorkflowShare update = new WorkflowShare();
|
WorkflowShare update = new WorkflowShare();
|
||||||
@@ -205,4 +327,17 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
|
|||||||
private BusinessException invalidShare() {
|
private BusinessException invalidShare() {
|
||||||
return new BusinessException(403, 403, "工作流分享链接无效");
|
return new BusinessException(403, 403, "工作流分享链接无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断工作流是否处于严格发布态且存在发布快照。
|
||||||
|
*
|
||||||
|
* @param workflow 工作流
|
||||||
|
* @return 可按发布快照运行时返回 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean isStrictlyPublished(Workflow workflow) {
|
||||||
|
return workflow != null
|
||||||
|
&& PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||||
|
&& workflow.getPublishedSnapshotJson() != null
|
||||||
|
&& !workflow.getPublishedSnapshotJson().isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ public final class WorkflowSharePolicy {
|
|||||||
*/
|
*/
|
||||||
public static final String SHARE_KEY_HEADER = "X-Workflow-Share-Key";
|
public static final String SHARE_KEY_HEADER = "X-Workflow-Share-Key";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流对话分享请求头。
|
||||||
|
*/
|
||||||
|
public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key";
|
||||||
|
|
||||||
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
|
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
|
||||||
|
private static final Duration DEFAULT_CHAT_EXPIRE_DURATION = Duration.ofDays(7);
|
||||||
private static final Set<String> ALLOWED_REQUESTS = Set.of(
|
private static final Set<String> ALLOWED_REQUESTS = Set.of(
|
||||||
permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ),
|
permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ),
|
||||||
permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ),
|
permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ),
|
||||||
@@ -66,6 +72,19 @@ public final class WorkflowSharePolicy {
|
|||||||
return new Date(createdAt.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
|
return new Date(createdAt.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算对话分享默认过期时间。
|
||||||
|
*
|
||||||
|
* @param createdAt 创建时间
|
||||||
|
* @return 创建后 7 天的时间
|
||||||
|
*/
|
||||||
|
public static Date defaultChatExpiresAt(Date createdAt) {
|
||||||
|
if (createdAt == null) {
|
||||||
|
throw new IllegalArgumentException("创建时间不能为空");
|
||||||
|
}
|
||||||
|
return new Date(createdAt.getTime() + DEFAULT_CHAT_EXPIRE_DURATION.toMillis());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断 HTTP 请求是否位于工作流协作授权白名单。
|
* 判断 HTTP 请求是否位于工作流协作授权白名单。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ public class WorkFlowUtil {
|
|||||||
|
|
||||||
public final static String USER_KEY = "user";
|
public final static String USER_KEY = "user";
|
||||||
public final static String API_KEY = "API_KEY";
|
public final static String API_KEY = "API_KEY";
|
||||||
|
/** 管理端工作流对话执行来源。 */
|
||||||
|
public final static String WORKFLOW_CHAT = "WORKFLOW_CHAT";
|
||||||
|
/** 工作流对话分享执行来源。 */
|
||||||
|
public final static String WORKFLOW_CHAT_SHARE = "WORKFLOW_CHAT_SHARE";
|
||||||
public final static String WORKFLOW_KEY = "workflow";
|
public final static String WORKFLOW_KEY = "workflow";
|
||||||
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
|
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.llm;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流图片源解析器测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowImageSourceResolverTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证存储中的 PNG 图片转换为完整 Data URI。
|
||||||
|
*
|
||||||
|
* @throws Exception 图片构造失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveStoredPngToDataUri() throws Exception {
|
||||||
|
byte[] png = imageBytes("png");
|
||||||
|
WorkflowImageSourceResolver resolver = resolver(Map.of("/images/a.png", png));
|
||||||
|
|
||||||
|
String dataUri = resolver.resolve(Map.of(
|
||||||
|
"sourceType", "upload",
|
||||||
|
"fileName", "a.png",
|
||||||
|
"filePath", "/images/a.png"));
|
||||||
|
|
||||||
|
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
|
||||||
|
Assert.assertArrayEquals(
|
||||||
|
png,
|
||||||
|
java.util.Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(',') + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 BMP 图片会在模型调用前规范化为 PNG。
|
||||||
|
*
|
||||||
|
* @throws Exception 图片构造失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNormalizeBmpToPng() throws Exception {
|
||||||
|
WorkflowImageSourceResolver resolver =
|
||||||
|
resolver(Map.of("/images/a.bmp", imageBytes("bmp")));
|
||||||
|
|
||||||
|
String dataUri = resolver.resolve(Map.of(
|
||||||
|
"sourceType", "resource",
|
||||||
|
"fileName", "a.bmp",
|
||||||
|
"filePath", "/images/a.bmp"));
|
||||||
|
|
||||||
|
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证旧版 Data URI 会经过真实图片校验后继续使用。
|
||||||
|
*
|
||||||
|
* @throws Exception 图片构造失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldValidateLegacyDataUri() throws Exception {
|
||||||
|
byte[] png = imageBytes("png");
|
||||||
|
String input = "data:image/png;base64,"
|
||||||
|
+ java.util.Base64.getEncoder().encodeToString(png);
|
||||||
|
|
||||||
|
String dataUri = resolver(Map.of()).resolve(input);
|
||||||
|
|
||||||
|
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证本机、内网和云元数据地址会被拦截。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectUnsafeRemoteAddresses() {
|
||||||
|
WorkflowImageSourceResolver resolver = resolver(Map.of());
|
||||||
|
|
||||||
|
assertUnsafe(resolver, "http://127.0.0.1/image.png");
|
||||||
|
assertUnsafe(resolver, "http://192.168.1.2/image.png");
|
||||||
|
assertUnsafe(resolver, "http://168.63.129.16/metadata/instance");
|
||||||
|
assertUnsafe(resolver, "http://169.254.169.254/latest/meta-data");
|
||||||
|
assertUnsafe(resolver, "http://metadata.google.internal/image.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertUnsafe(WorkflowImageSourceResolver resolver, String value) {
|
||||||
|
try {
|
||||||
|
resolver.validateRemoteUri(URI.create(value));
|
||||||
|
Assert.fail("expected BusinessException for " + value);
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("不能访问"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowImageSourceResolver resolver(Map<String, byte[]> files) {
|
||||||
|
return new WorkflowImageSourceResolver(
|
||||||
|
new InMemoryStorage(files),
|
||||||
|
HttpClient.newHttpClient());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] imageBytes(String format) throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Assert.assertTrue(ImageIO.write(image, format, output));
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用内存文件存储。
|
||||||
|
*/
|
||||||
|
private static final class InMemoryStorage implements FileStorageService {
|
||||||
|
|
||||||
|
private final Map<String, byte[]> files;
|
||||||
|
|
||||||
|
private InMemoryStorage(Map<String, byte[]> files) {
|
||||||
|
this.files = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String save(MultipartFile file) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String path) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String save(File file, String prePath) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputStream readStream(String path) {
|
||||||
|
byte[] bytes = files.get(path);
|
||||||
|
if (bytes == null) {
|
||||||
|
throw new IllegalArgumentException("missing file: " + path);
|
||||||
|
}
|
||||||
|
return new ByteArrayInputStream(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getFileSize(String path) {
|
||||||
|
byte[] bytes = files.get(path);
|
||||||
|
return bytes == null ? 0 : bytes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,16 +43,34 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
fileField.put("key", "attachments");
|
fileField.put("key", "attachments");
|
||||||
fileField.put("label", "附件");
|
fileField.put("label", "附件");
|
||||||
fileField.put("type", "file");
|
fileField.put("type", "file");
|
||||||
|
fileField.put("contentType", "file");
|
||||||
fileField.put("required", false);
|
fileField.put("required", false);
|
||||||
schema.add(fileField);
|
schema.add(fileField);
|
||||||
|
|
||||||
|
JSONObject imageField = new JSONObject();
|
||||||
|
imageField.put("key", "preview_image");
|
||||||
|
imageField.put("label", "预览图");
|
||||||
|
imageField.put("type", "text");
|
||||||
|
imageField.put("contentType", "text");
|
||||||
|
imageField.put("required", false);
|
||||||
|
schema.add(imageField);
|
||||||
|
|
||||||
JSONObject meta = new JSONObject();
|
JSONObject meta = new JSONObject();
|
||||||
meta.put("title", "问答入口");
|
meta.put("title", "问答入口");
|
||||||
meta.put("description", "请先填写信息");
|
meta.put("description", "请先填写信息");
|
||||||
meta.put("submitText", "立即开始");
|
meta.put("submitText", "立即开始");
|
||||||
startData.put("startFormMeta", meta);
|
startData.put("startFormMeta", meta);
|
||||||
startData.put("startFormSchema", schema);
|
startData.put("startFormSchema", schema);
|
||||||
startData.put("parameters", startParameters());
|
JSONArray parameters = startParameters();
|
||||||
|
JSONObject imageParameter = new JSONObject();
|
||||||
|
imageParameter.put("name", "preview_image");
|
||||||
|
imageParameter.put("dataType", "Object");
|
||||||
|
imageParameter.put("refType", "input");
|
||||||
|
imageParameter.put("contentType", "image");
|
||||||
|
imageParameter.put("formType", "input");
|
||||||
|
imageParameter.put("formLabel", "预览图");
|
||||||
|
parameters.add(imageParameter);
|
||||||
|
startData.put("parameters", parameters);
|
||||||
|
|
||||||
Workflow workflow = workflow(
|
Workflow workflow = workflow(
|
||||||
workflowJson(
|
workflowJson(
|
||||||
@@ -68,11 +86,16 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
Assert.assertNotNull(result);
|
Assert.assertNotNull(result);
|
||||||
Assert.assertEquals("问答入口", ((Map<?, ?>) result.get("startFormMeta")).get("title"));
|
Assert.assertEquals("问答入口", ((Map<?, ?>) result.get("startFormMeta")).get("title"));
|
||||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||||
Assert.assertEquals(2, fields.size());
|
Assert.assertEquals(3, fields.size());
|
||||||
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
||||||
Assert.assertEquals("text", fields.get(0).get("type"));
|
Assert.assertEquals("text", fields.get(0).get("type"));
|
||||||
|
Assert.assertEquals("text", fields.get(0).get("contentType"));
|
||||||
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
||||||
Assert.assertEquals("file", fields.get(1).get("type"));
|
Assert.assertEquals("file", fields.get(1).get("type"));
|
||||||
|
Assert.assertEquals("file", fields.get(1).get("contentType"));
|
||||||
|
Assert.assertEquals("preview_image", fields.get(2).get("key"));
|
||||||
|
Assert.assertEquals("text", fields.get(2).get("type"));
|
||||||
|
Assert.assertEquals("image", fields.get(2).get("contentType"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,6 +236,77 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧版图片 URL 应归一化为 URL 图片描述。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldNormalizeLegacyImageUrl() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("image_input", "https://example.com/image.png");
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithImageStartParameter(),
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertTrue(normalized.get("image_input") instanceof Map<?, ?>);
|
||||||
|
Map<?, ?> image = (Map<?, ?>) normalized.get("image_input");
|
||||||
|
Assert.assertEquals("url", image.get("sourceType"));
|
||||||
|
Assert.assertEquals("https://example.com/image.png", image.get("url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行入口不应接收 Data URI,避免 Base64 写入工作流状态和审计参数。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldRejectImageDataUri() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("image_input", "data:image/png;base64,AQID");
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolver.normalizeRuntimeVariables(workflowContentWithImageStartParameter(), variables);
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"图片参数 image_input 仅支持 HTTP/HTTPS 图片 URL",
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片参数应允许 10 MiB 边界并拒绝更大的声明值。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldEnforceImageLimit() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("image_input", imageValue(10L * 1024L * 1024L));
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithImageStartParameter(),
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
Assert.assertEquals("upload", ((Map<?, ?>) normalized.get("image_input")).get("sourceType"));
|
||||||
|
|
||||||
|
variables.put("image_input", imageValue(10L * 1024L * 1024L + 1L));
|
||||||
|
try {
|
||||||
|
resolver.normalizeRuntimeVariables(workflowContentWithImageStartParameter(), variables);
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"图片参数 image_input 中图片不能超过 10 MiB",
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkflowRunningParameterResolver newResolver() throws Exception {
|
private static WorkflowRunningParameterResolver newResolver() throws Exception {
|
||||||
WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver();
|
WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver();
|
||||||
ChainParser parser = ChainParser.builder()
|
ChainParser parser = ChainParser.builder()
|
||||||
@@ -247,6 +341,26 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String workflowContentWithImageStartParameter() {
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray parameters = new JSONArray();
|
||||||
|
JSONObject imageField = new JSONObject();
|
||||||
|
imageField.put("name", "image_input");
|
||||||
|
imageField.put("dataType", "Object");
|
||||||
|
imageField.put("refType", "input");
|
||||||
|
imageField.put("contentType", "image");
|
||||||
|
imageField.put("formType", "input");
|
||||||
|
parameters.add(imageField);
|
||||||
|
startData.put("parameters", parameters);
|
||||||
|
return workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static JSONArray startParameters() {
|
private static JSONArray startParameters() {
|
||||||
JSONArray parameters = new JSONArray();
|
JSONArray parameters = new JSONArray();
|
||||||
|
|
||||||
@@ -298,6 +412,16 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> imageValue(long size) {
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("sourceType", "upload");
|
||||||
|
value.put("fileName", "image.png");
|
||||||
|
value.put("filePath", "/files/image.png");
|
||||||
|
value.put("size", size);
|
||||||
|
value.put("contentType", "image/png");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName);
|
Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName);
|
||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public class WorkflowShareMigrationContractTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void migrationShouldCreateWorkflowShareContracts() throws Exception {
|
public void migrationShouldCreateWorkflowShareContracts() throws Exception {
|
||||||
String sql = migrationSql();
|
String sql = migrationSql(
|
||||||
|
"V34__mysql_workflow_share_and_approval_reason.sql"
|
||||||
|
);
|
||||||
|
|
||||||
assertTrue(sql.contains("ADD COLUMN `application_reason` VARCHAR(500)"));
|
assertTrue(sql.contains("ADD COLUMN `application_reason` VARCHAR(500)"));
|
||||||
assertTrue(sql.contains("ADD COLUMN `revision` INT NOT NULL DEFAULT 0"));
|
assertTrue(sql.contains("ADD COLUMN `revision` INT NOT NULL DEFAULT 0"));
|
||||||
@@ -34,22 +36,41 @@ public class WorkflowShareMigrationContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取工作区中的 V34 MySQL 迁移。
|
* 验证 V39 为协作分享和对话分享建立用途隔离。
|
||||||
*
|
*
|
||||||
|
* @throws Exception 迁移文件不可读时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void migrationShouldSeparateChatSharePurpose() throws Exception {
|
||||||
|
String sql = migrationSql("V39__mysql_workflow_chat_share.sql");
|
||||||
|
|
||||||
|
assertTrue(sql.contains(
|
||||||
|
"ADD COLUMN `share_purpose` VARCHAR(32) NOT NULL DEFAULT 'COLLABORATION'"
|
||||||
|
));
|
||||||
|
assertTrue(sql.contains("`idx_workflow_share_purpose_status`"));
|
||||||
|
assertTrue(sql.contains(
|
||||||
|
"(`workflow_id`, `share_purpose`, `status`)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取工作区中的指定 MySQL 迁移。
|
||||||
|
*
|
||||||
|
* @param fileName 迁移文件名
|
||||||
* @return 迁移 SQL
|
* @return 迁移 SQL
|
||||||
* @throws Exception 迁移文件不存在或不可读时抛出
|
* @throws Exception 迁移文件不存在或不可读时抛出
|
||||||
*/
|
*/
|
||||||
private String migrationSql() throws Exception {
|
private String migrationSql(String fileName) throws Exception {
|
||||||
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
|
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
|
||||||
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
|
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
|
||||||
while (root != null) {
|
while (root != null) {
|
||||||
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
|
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
|
||||||
+ "db/migration/mysql/V34__mysql_workflow_share_and_approval_reason.sql");
|
+ "db/migration/mysql/" + fileName);
|
||||||
if (Files.isRegularFile(migration)) {
|
if (Files.isRegularFile(migration)) {
|
||||||
return Files.readString(migration, StandardCharsets.UTF_8);
|
return Files.readString(migration, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
root = root.getParent();
|
root = root.getParent();
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("找不到 V34 工作流分享与审批说明迁移");
|
throw new IllegalStateException("找不到工作流分享迁移: " + fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ public class WorkflowSharePolicyTest {
|
|||||||
Assert.assertEquals(30 * 60 * 1_000L, expiresAt.getTime() - createdAt.getTime());
|
Assert.assertEquals(30 * 60 * 1_000L, expiresAt.getTime() - createdAt.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证对话分享默认在创建七天后过期。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExpireChatShareSevenDaysAfterCreation() {
|
||||||
|
Date createdAt = new Date(1_000L);
|
||||||
|
|
||||||
|
Date expiresAt = WorkflowSharePolicy.defaultChatExpiresAt(createdAt);
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
7L * 24L * 60L * 60L * 1_000L,
|
||||||
|
expiresAt.getTime() - createdAt.getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
|
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `tb_workflow_share`
|
||||||
|
ADD COLUMN `share_purpose` VARCHAR(32) NOT NULL DEFAULT 'COLLABORATION'
|
||||||
|
COMMENT '分享用途:COLLABORATION/CHAT' AFTER `workflow_id`;
|
||||||
|
|
||||||
|
DROP INDEX `idx_workflow_share_status` ON `tb_workflow_share`;
|
||||||
|
|
||||||
|
CREATE INDEX `idx_workflow_share_purpose_status`
|
||||||
|
ON `tb_workflow_share` (`workflow_id`, `share_purpose`, `status`);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { readScopedRouteQueryParam } from './share-route-context';
|
import { readScopedRouteQueryParam } from './share-route-context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流协作分享请求头。
|
* 工作流对话分享请求头。
|
||||||
*/
|
*/
|
||||||
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Share-Key';
|
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key';
|
||||||
|
|
||||||
interface WorkflowShareResolutionOptions<T> {
|
interface WorkflowShareResolutionOptions<T> {
|
||||||
currentWorkflowId?: null | T;
|
currentWorkflowId?: null | T;
|
||||||
@@ -18,19 +18,14 @@ interface WorkflowShareHeaderOptions {
|
|||||||
requestUrl?: string;
|
requestUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKFLOW_SHARE_ROUTES = ['/share/workflow', '/ai/workflow/design'];
|
const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
|
||||||
const WORKFLOW_SHARE_REQUESTS = [
|
const WORKFLOW_SHARE_REQUESTS = [
|
||||||
['GET', '/api/v1/workflow/detail'],
|
['GET', '/api/v1/workflowChat/descriptor'],
|
||||||
['GET', '/api/v1/workflow/getRunningParameters'],
|
['GET', '/api/v1/workflowChat/execution'],
|
||||||
['GET', '/api/v1/workflow/publishApprovalRequirement'],
|
|
||||||
['GET', '/api/v1/workflowShare/resolve'],
|
['GET', '/api/v1/workflowShare/resolve'],
|
||||||
['POST', '/api/v1/workflow/check'],
|
['POST', '/api/v1/workflowChat/cancel'],
|
||||||
['POST', '/api/v1/workflow/getChainStatus'],
|
['POST', '/api/v1/workflowChat/resume'],
|
||||||
['POST', '/api/v1/workflow/resume'],
|
['POST', '/api/v1/workflowChat/run'],
|
||||||
['POST', '/api/v1/workflow/runAsync'],
|
|
||||||
['POST', '/api/v1/workflow/singleRun'],
|
|
||||||
['POST', '/api/v1/workflow/submitPublishApproval'],
|
|
||||||
['POST', '/api/v1/workflow/update'],
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,134 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import WorkflowChatPage from './components/WorkflowChatPage.vue';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
|
|
||||||
import { sortNodes } from '@easyflow/utils';
|
|
||||||
|
|
||||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
|
||||||
import { ElAvatar, ElButton, ElCard, ElCol, ElRow } from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
|
||||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
|
||||||
import { $t } from '#/locales';
|
|
||||||
import { router } from '#/router';
|
|
||||||
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
|
|
||||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
|
||||||
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
pageLoading.value = true;
|
|
||||||
await Promise.all([getWorkflowInfo(workflowId.value), getRunningParams()]);
|
|
||||||
pageLoading.value = false;
|
|
||||||
});
|
|
||||||
const pageLoading = ref(false);
|
|
||||||
const route = useRoute();
|
|
||||||
const workflowId = ref(route.query.id);
|
|
||||||
const workflowInfo = ref<any>({});
|
|
||||||
const runParams = ref<any>(null);
|
|
||||||
const initState = ref(false);
|
|
||||||
const tinyFlowData = ref<any>(null);
|
|
||||||
const workflowForm = ref();
|
|
||||||
async function getWorkflowInfo(workflowId: any) {
|
|
||||||
api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
|
||||||
workflowInfo.value = res.data;
|
|
||||||
tinyFlowData.value = workflowInfo.value.content
|
|
||||||
? JSON.parse(workflowInfo.value.content)
|
|
||||||
: {};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function getRunningParams() {
|
|
||||||
api
|
|
||||||
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
|
||||||
.then((res) => {
|
|
||||||
runParams.value = res.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onSubmit() {
|
|
||||||
initState.value = !initState.value;
|
|
||||||
}
|
|
||||||
function resumeChain(data: any) {
|
|
||||||
workflowForm.value?.resume(data);
|
|
||||||
}
|
|
||||||
const chainInfo = ref<any>(null);
|
|
||||||
function onAsyncExecute(info: any) {
|
|
||||||
chainInfo.value = info;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<WorkflowChatPage />
|
||||||
v-loading="pageLoading"
|
|
||||||
class="bg-background-deep flex h-full max-h-[calc(100vh-90px)] w-full flex-col gap-6 overflow-hidden p-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<ElButton
|
|
||||||
:icon="ArrowLeft"
|
|
||||||
@click="router.replace({ path: '/ai/workflow' })"
|
|
||||||
>
|
|
||||||
{{ $t('button.back') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex h-[150px] shrink-0 items-center gap-6 rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] pl-11"
|
|
||||||
>
|
|
||||||
<ElAvatar
|
|
||||||
class="shrink-0"
|
|
||||||
:src="workflowInfo.icon ?? workflowIcon"
|
|
||||||
:size="72"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<span class="text-2xl font-medium">{{ workflowInfo.title }}</span>
|
|
||||||
<span class="text-base text-[#75808d]">{{
|
|
||||||
workflowInfo.description
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ElRow class="h-full overflow-hidden" :gutter="10">
|
|
||||||
<ElCol :span="10" class="h-full overflow-hidden">
|
|
||||||
<div class="grid h-full grid-rows-2 gap-2.5">
|
|
||||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
|
||||||
<div class="mb-2.5 font-semibold">
|
|
||||||
{{ $t('aiWorkflow.params') }}:
|
|
||||||
</div>
|
|
||||||
<WorkflowForm
|
|
||||||
v-if="runParams && tinyFlowData"
|
|
||||||
ref="workflowForm"
|
|
||||||
:workflow-id="workflowId"
|
|
||||||
:workflow-params="runParams"
|
|
||||||
:on-submit="onSubmit"
|
|
||||||
:on-async-execute="onAsyncExecute"
|
|
||||||
:tiny-flow-data="tinyFlowData"
|
|
||||||
/>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
|
||||||
<div class="mb-2.5 font-semibold">
|
|
||||||
{{ $t('aiWorkflow.steps') }}:
|
|
||||||
</div>
|
|
||||||
<WorkflowSteps
|
|
||||||
v-if="tinyFlowData"
|
|
||||||
:workflow-id="workflowId"
|
|
||||||
:node-json="sortNodes(tinyFlowData)"
|
|
||||||
:init-signal="initState"
|
|
||||||
:polling-data="chainInfo"
|
|
||||||
@resume="resumeChain"
|
|
||||||
/>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="14">
|
|
||||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
|
||||||
<div class="mb-2.5 mt-2.5 font-semibold">
|
|
||||||
{{ $t('aiWorkflow.result') }}:
|
|
||||||
</div>
|
|
||||||
<ExecResult
|
|
||||||
v-if="tinyFlowData"
|
|
||||||
:workflow-id="workflowId"
|
|
||||||
:node-json="sortNodes(tinyFlowData)"
|
|
||||||
:init-signal="initState"
|
|
||||||
:polling-data="chainInfo"
|
|
||||||
/>
|
|
||||||
</ElCard>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -202,7 +202,9 @@ const actions: ActionButton[] = [
|
|||||||
text: $t('button.share'),
|
text: $t('button.share'),
|
||||||
permission: '/api/v1/workflow/save',
|
permission: '/api/v1/workflow/save',
|
||||||
placement: 'menu',
|
placement: 'menu',
|
||||||
disabled: (row: any) => sharingWorkflowId.value === row.id,
|
disabled: (row: any) =>
|
||||||
|
row.publishStatus !== 'PUBLISHED' ||
|
||||||
|
sharingWorkflowId.value === row.id,
|
||||||
loading: (row: any) => sharingWorkflowId.value === row.id,
|
loading: (row: any) => sharingWorkflowId.value === row.id,
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
shareWorkflow(row);
|
shareWorkflow(row);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkflowDesign from './WorkflowDesign.vue';
|
import WorkflowChatPage from './components/WorkflowChatPage.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<WorkflowDesign share-mode />
|
<WorkflowChatPage share-mode />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { api } from '#/api/request';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
import WorkflowFormItem from './WorkflowFormItem.vue';
|
import WorkflowFormItem from './WorkflowFormItem.vue';
|
||||||
|
import { resolveWorkflowFormParameters } from './workflowFormParameters';
|
||||||
|
|
||||||
export type WorkflowFormProps = {
|
export type WorkflowFormProps = {
|
||||||
onAsyncExecute?: (values: any) => void;
|
onAsyncExecute?: (values: any) => void;
|
||||||
@@ -49,30 +50,9 @@ const startFormMeta = computed(() => {
|
|||||||
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const parameters = computed(() => {
|
const parameters = computed(() =>
|
||||||
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
resolveWorkflowFormParameters(props.workflowParams),
|
||||||
? props.workflowParams.startFormSchema
|
);
|
||||||
: [];
|
|
||||||
if (schema.length === 0) {
|
|
||||||
return props.workflowParams.parameters || [];
|
|
||||||
}
|
|
||||||
return schema.map((field: any) => {
|
|
||||||
const type = String(field.type || '').trim() || 'text';
|
|
||||||
return {
|
|
||||||
name: field.key,
|
|
||||||
formLabel: field.label || field.key,
|
|
||||||
formDescription: field.description || '',
|
|
||||||
formPlaceholder: field.placeholder || '',
|
|
||||||
required: Boolean(field.required),
|
|
||||||
defaultValue: field.defaultValue,
|
|
||||||
enums: Array.isArray(field.options) ? field.options : [],
|
|
||||||
contentType: type === 'file' ? 'file' : 'text',
|
|
||||||
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
|
||||||
dataType:
|
|
||||||
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
watch(
|
watch(
|
||||||
parameters,
|
parameters,
|
||||||
(items) => {
|
(items) => {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||||
|
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
|
||||||
|
|
||||||
|
import { hasWorkflowImageValue } from './workflowImageValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -37,7 +40,7 @@ function getContentType(item: any) {
|
|||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
function isResource(contentType: any) {
|
function isResource(contentType: any) {
|
||||||
return ['audio', 'image', 'video'].includes(contentType);
|
return ['audio', 'video'].includes(contentType);
|
||||||
}
|
}
|
||||||
function isFileContentType(contentType: any) {
|
function isFileContentType(contentType: any) {
|
||||||
return contentType === 'file';
|
return contentType === 'file';
|
||||||
@@ -61,6 +64,14 @@ function buildRules(item: any) {
|
|||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
validator: (_rule: any, value: any, callback: any) => {
|
validator: (_rule: any, value: any, callback: any) => {
|
||||||
|
if (getContentType(item) === 'image') {
|
||||||
|
callback(
|
||||||
|
hasWorkflowImageValue(value)
|
||||||
|
? undefined
|
||||||
|
: new Error($t('message.required')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||||
return;
|
return;
|
||||||
@@ -144,6 +155,12 @@ function choose(data: any, propName: string) {
|
|||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="getContentType(item) === 'image'">
|
||||||
|
<WorkflowImageInput
|
||||||
|
:model-value="runParams[item.name]"
|
||||||
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-if="isResource(getContentType(item))">
|
<template v-if="isResource(getContentType(item))">
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElButton, ElImage, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowImageValueFromResource,
|
||||||
|
buildWorkflowImageValueFromUpload,
|
||||||
|
buildWorkflowImageValueFromUrl,
|
||||||
|
formatWorkflowImageSize,
|
||||||
|
getWorkflowImagePreviewUrl,
|
||||||
|
normalizeWorkflowImageValue,
|
||||||
|
validateWorkflowImageFile,
|
||||||
|
WORKFLOW_IMAGE_LIMITS,
|
||||||
|
} from './workflowImageValue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const uploadLoading = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const urlInput = ref('');
|
||||||
|
|
||||||
|
const currentImage = computed(() =>
|
||||||
|
normalizeWorkflowImageValue(props.modelValue),
|
||||||
|
);
|
||||||
|
const previewUrl = computed(() =>
|
||||||
|
getWorkflowImagePreviewUrl(props.modelValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
const image = normalizeWorkflowImageValue(value);
|
||||||
|
urlInput.value = image?.sourceType === 'url' ? image.url : '';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function applyUrl() {
|
||||||
|
try {
|
||||||
|
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '图片 URL 无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSelectFile() {
|
||||||
|
if (!uploadLoading.value) {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNativeFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadLoading.value = true;
|
||||||
|
try {
|
||||||
|
validateWorkflowImageFile(file);
|
||||||
|
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '图片上传失败');
|
||||||
|
console.error('工作流图片上传失败', error);
|
||||||
|
} finally {
|
||||||
|
uploadLoading.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChooseResource(resource: any) {
|
||||||
|
try {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
buildWorkflowImageValueFromResource(resource || {}),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '图片素材选择失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
urlInput.value = '';
|
||||||
|
emit('update:modelValue', undefined);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="workflow-image-input">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="workflow-image-input__native"
|
||||||
|
type="file"
|
||||||
|
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
||||||
|
@change="handleNativeFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="workflow-image-input__hint">
|
||||||
|
支持 PNG、JPEG、WebP、GIF、BMP,单张不超过 10 MiB
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentImage" class="workflow-image-input__preview">
|
||||||
|
<ElImage
|
||||||
|
class="workflow-image-input__thumbnail"
|
||||||
|
:src="previewUrl"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="previewUrl ? [previewUrl] : []"
|
||||||
|
preview-teleported
|
||||||
|
/>
|
||||||
|
<div class="workflow-image-input__summary">
|
||||||
|
<div class="workflow-image-input__name">
|
||||||
|
{{
|
||||||
|
currentImage.sourceType === 'url'
|
||||||
|
? currentImage.url
|
||||||
|
: currentImage.fileName
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="workflow-image-input__meta">
|
||||||
|
{{
|
||||||
|
currentImage.sourceType === 'url'
|
||||||
|
? '图片 URL'
|
||||||
|
: formatWorkflowImageSize(currentImage.size) || '图片文件'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElInput
|
||||||
|
v-model="urlInput"
|
||||||
|
clearable
|
||||||
|
placeholder="输入 HTTP/HTTPS 图片 URL"
|
||||||
|
@keyup.enter="applyUrl"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<ElButton @click="applyUrl">使用 URL</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<div class="workflow-image-input__actions">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:loading="uploadLoading"
|
||||||
|
@click="triggerSelectFile"
|
||||||
|
>
|
||||||
|
{{ currentImage ? '替换图片' : $t('button.upload') }}
|
||||||
|
</ElButton>
|
||||||
|
<ChooseResource
|
||||||
|
attr-name="image"
|
||||||
|
:resource-type="0"
|
||||||
|
@choose="handleChooseResource"
|
||||||
|
/>
|
||||||
|
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
|
||||||
|
清空
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-image-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__native {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__hint,
|
||||||
|
.workflow-image-input__meta {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__thumbnail {
|
||||||
|
width: 96px;
|
||||||
|
height: 72px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--el-border-radius-small);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__summary {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-image-input__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveWorkflowFormParameters } from '../workflowFormParameters';
|
||||||
|
|
||||||
|
describe('resolveWorkflowFormParameters', () => {
|
||||||
|
it('uses the image parameter when a legacy schema still declares text', () => {
|
||||||
|
const parameters = resolveWorkflowFormParameters({
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'image_input',
|
||||||
|
contentType: 'image',
|
||||||
|
dataType: 'Object',
|
||||||
|
formType: 'input',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
startFormSchema: [
|
||||||
|
{
|
||||||
|
key: 'image_input',
|
||||||
|
label: '图片',
|
||||||
|
type: 'text',
|
||||||
|
contentType: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parameters).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'image_input',
|
||||||
|
contentType: 'image',
|
||||||
|
dataType: 'Object',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a custom parameter name when the schema keeps a default label', () => {
|
||||||
|
const parameters = resolveWorkflowFormParameters({
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: '补充信息',
|
||||||
|
formLabel: '文本字段',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
startFormSchema: [
|
||||||
|
{
|
||||||
|
key: '补充信息',
|
||||||
|
label: '文本字段',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parameters[0]).toMatchObject({
|
||||||
|
name: '补充信息',
|
||||||
|
formLabel: '补充信息',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicitly customized field label', () => {
|
||||||
|
const parameters = resolveWorkflowFormParameters({
|
||||||
|
startFormSchema: [
|
||||||
|
{
|
||||||
|
key: 'context',
|
||||||
|
label: '背景资料',
|
||||||
|
type: 'textarea',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parameters[0]).toMatchObject({
|
||||||
|
name: 'context',
|
||||||
|
formLabel: '背景资料',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowFormSubmissionImages,
|
||||||
|
buildWorkflowFormSubmissionText,
|
||||||
|
hasRequiredWorkflowFormParameters,
|
||||||
|
} from '../workflowFormPresentation';
|
||||||
|
|
||||||
|
describe('workflowFormPresentation', () => {
|
||||||
|
it('only requires the opening form when a required parameter exists', () => {
|
||||||
|
expect(
|
||||||
|
hasRequiredWorkflowFormParameters([
|
||||||
|
{ name: 'optional', required: false },
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasRequiredWorkflowFormParameters([
|
||||||
|
{ name: 'optional', required: false },
|
||||||
|
{ name: 'required', required: true },
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows entered form values using their configured parameter labels', () => {
|
||||||
|
expect(
|
||||||
|
buildWorkflowFormSubmissionText(
|
||||||
|
[
|
||||||
|
{ name: '补充信息', formLabel: '补充信息' },
|
||||||
|
{ name: '附件', formLabel: '附件' },
|
||||||
|
{ name: '未填写', formLabel: '未填写' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
补充信息: '项目背景',
|
||||||
|
附件: [{ fileName: '需求说明.pdf' }, { fileName: '接口定义.docx' }],
|
||||||
|
未填写: '',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe('补充信息:项目背景\n附件:需求说明.pdf、接口定义.docx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders image fields as image attachments instead of filename text', () => {
|
||||||
|
const parameters = [
|
||||||
|
{ name: '图片', formLabel: '图片', contentType: 'image' },
|
||||||
|
{ name: '说明', formLabel: '说明', contentType: 'text' },
|
||||||
|
];
|
||||||
|
const values = {
|
||||||
|
图片: {
|
||||||
|
sourceType: 'upload',
|
||||||
|
fileName: '界面截图.png',
|
||||||
|
filePath: '/uploads/interface.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
说明: '请检查布局',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildWorkflowFormSubmissionText(parameters, values)).toBe(
|
||||||
|
'说明:请检查布局',
|
||||||
|
);
|
||||||
|
expect(buildWorkflowFormSubmissionImages(parameters, values)).toEqual([
|
||||||
|
{
|
||||||
|
mimeType: 'image/png',
|
||||||
|
name: '界面截图.png',
|
||||||
|
previewUrl: '/uploads/interface.png',
|
||||||
|
size: 1024,
|
||||||
|
status: 'ready',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowImageValueFromResource,
|
||||||
|
buildWorkflowImageValueFromUpload,
|
||||||
|
buildWorkflowImageValueFromUrl,
|
||||||
|
hasWorkflowImageValue,
|
||||||
|
normalizeWorkflowImageValue,
|
||||||
|
validateWorkflowImageFile,
|
||||||
|
} from '../workflowImageValue';
|
||||||
|
|
||||||
|
describe('workflowImageValue', () => {
|
||||||
|
it('构建并归一化 URL 图片值', () => {
|
||||||
|
expect(buildWorkflowImageValueFromUrl('https://example.com/a.png')).toEqual({
|
||||||
|
sourceType: 'url',
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
});
|
||||||
|
expect(normalizeWorkflowImageValue('https://example.com/a.png')).toEqual({
|
||||||
|
sourceType: 'url',
|
||||||
|
url: 'https://example.com/a.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('构建上传和素材图片值', () => {
|
||||||
|
const file = new File(['image'], 'a.png', { type: 'image/png' });
|
||||||
|
expect(buildWorkflowImageValueFromUpload(file, '/files/a.png')).toMatchObject({
|
||||||
|
sourceType: 'upload',
|
||||||
|
fileName: 'a.png',
|
||||||
|
filePath: '/files/a.png',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
buildWorkflowImageValueFromResource({
|
||||||
|
fileSize: '128',
|
||||||
|
resourceName: 'asset',
|
||||||
|
resourceType: 0,
|
||||||
|
resourceUrl: '/files/asset.webp',
|
||||||
|
suffix: 'webp',
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
sourceType: 'resource',
|
||||||
|
fileName: 'asset.webp',
|
||||||
|
filePath: '/files/asset.webp',
|
||||||
|
size: 128,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('兼容旧文件对象并执行内容感知必填判断', () => {
|
||||||
|
const legacy = {
|
||||||
|
fileName: 'legacy.jpg',
|
||||||
|
filePath: '/files/legacy.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
expect(normalizeWorkflowImageValue(legacy)).toMatchObject({
|
||||||
|
sourceType: 'upload',
|
||||||
|
fileName: 'legacy.jpg',
|
||||||
|
});
|
||||||
|
expect(hasWorkflowImageValue(legacy)).toBe(true);
|
||||||
|
expect(hasWorkflowImageValue({})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('校验图片格式与 10 MiB 边界', () => {
|
||||||
|
const accepted = new File(
|
||||||
|
[new Uint8Array(10 * 1024 * 1024)],
|
||||||
|
'accepted.png',
|
||||||
|
{ type: 'image/png' },
|
||||||
|
);
|
||||||
|
const oversized = new File(
|
||||||
|
[new Uint8Array(10 * 1024 * 1024 + 1)],
|
||||||
|
'oversized.png',
|
||||||
|
{ type: 'image/png' },
|
||||||
|
);
|
||||||
|
expect(() => validateWorkflowImageFile(accepted)).not.toThrow();
|
||||||
|
expect(() => validateWorkflowImageFile(oversized)).toThrow(
|
||||||
|
'单张图片不能超过 10 MiB',
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
validateWorkflowImageFile(
|
||||||
|
new File(['text'], 'note.txt', { type: 'text/plain' }),
|
||||||
|
),
|
||||||
|
).toThrow('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendWorkflowStreamDelta,
|
||||||
|
appendWorkflowThinkingDelta,
|
||||||
|
createWorkflowStreamMessage,
|
||||||
|
updateWorkflowStreamStatus,
|
||||||
|
} from './workflowChatStreamMessage';
|
||||||
|
|
||||||
|
function createMessage(): ChatTimelineMessageItem {
|
||||||
|
return appendWorkflowStreamDelta(
|
||||||
|
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||||
|
'首包',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('workflowChatStreamMessage', () => {
|
||||||
|
it('appends later chunks with a new message and text-part reference', () => {
|
||||||
|
const message = createMessage();
|
||||||
|
const nextMessage = appendWorkflowStreamDelta(message, '后续内容');
|
||||||
|
const answerPart = nextMessage.parts.find(
|
||||||
|
(part) => part.id === 'llm-stream-1-answer',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nextMessage).not.toBe(message);
|
||||||
|
expect(answerPart).not.toBe(message.parts[0]);
|
||||||
|
expect(answerPart?.content).toBe('**大模型**\n\n首包后续内容');
|
||||||
|
expect(message.parts[0]?.content).toBe('**大模型**\n\n首包');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams thinking separately and ends it when answer starts', () => {
|
||||||
|
const message = createWorkflowStreamMessage('llm-stream-1', '大模型');
|
||||||
|
const thinkingMessage = appendWorkflowThinkingDelta(message, '先分析');
|
||||||
|
const nextThinkingMessage = appendWorkflowThinkingDelta(
|
||||||
|
thinkingMessage,
|
||||||
|
'再作答',
|
||||||
|
);
|
||||||
|
const answerMessage = appendWorkflowStreamDelta(
|
||||||
|
nextThinkingMessage,
|
||||||
|
'结论',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nextThinkingMessage.parts).toEqual([
|
||||||
|
{
|
||||||
|
content: '先分析再作答',
|
||||||
|
id: 'llm-stream-1-thinking',
|
||||||
|
status: 'thinking',
|
||||||
|
type: 'thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '**大模型**',
|
||||||
|
id: 'llm-stream-1-answer',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(answerMessage.parts).toEqual([
|
||||||
|
{
|
||||||
|
content: '先分析再作答',
|
||||||
|
id: 'llm-stream-1-thinking',
|
||||||
|
status: 'end',
|
||||||
|
type: 'thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '**大模型**\n\n结论',
|
||||||
|
id: 'llm-stream-1-answer',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores late thinking after answer output has started', () => {
|
||||||
|
const message = createMessage();
|
||||||
|
|
||||||
|
expect(appendWorkflowThinkingDelta(message, '迟到内容')).toBe(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes thinking with a new message reference', () => {
|
||||||
|
const message = appendWorkflowThinkingDelta(
|
||||||
|
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||||
|
'思考内容',
|
||||||
|
);
|
||||||
|
const nextMessage = updateWorkflowStreamStatus(message, 'done');
|
||||||
|
|
||||||
|
expect(nextMessage).not.toBe(message);
|
||||||
|
expect(nextMessage.status).toBe('done');
|
||||||
|
expect(nextMessage.parts[0]).toMatchObject({
|
||||||
|
status: 'end',
|
||||||
|
type: 'thinking',
|
||||||
|
});
|
||||||
|
expect(message.status).toBe('streaming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks thinking as error when the stream fails', () => {
|
||||||
|
const message = appendWorkflowThinkingDelta(
|
||||||
|
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||||
|
'思考内容',
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextMessage = updateWorkflowStreamStatus(message, 'error');
|
||||||
|
|
||||||
|
expect(nextMessage.parts[0]).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
type: 'thinking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
const ANSWER_SEPARATOR = '\n\n';
|
||||||
|
const ANSWER_PART_SUFFIX = '-answer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流 LLM 流式消息。
|
||||||
|
*
|
||||||
|
* @param id 消息 ID
|
||||||
|
* @param nodeName 节点名称
|
||||||
|
* @returns 待接收思考或回答增量的消息
|
||||||
|
*/
|
||||||
|
export function createWorkflowStreamMessage(
|
||||||
|
id: string,
|
||||||
|
nodeName: string,
|
||||||
|
): ChatTimelineMessageItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
content: `**${nodeName}**`,
|
||||||
|
id: `${id}${ANSWER_PART_SUFFIX}`,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'streaming',
|
||||||
|
type: 'message',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以不可变方式追加流式文本,确保时间线子组件能够观察到消息引用变化。
|
||||||
|
*
|
||||||
|
* @param message 当前流式消息
|
||||||
|
* @param delta 本次新增文本
|
||||||
|
* @returns 追加文本后的新消息
|
||||||
|
*/
|
||||||
|
export function appendWorkflowStreamDelta(
|
||||||
|
message: ChatTimelineMessageItem,
|
||||||
|
delta: string,
|
||||||
|
): ChatTimelineMessageItem {
|
||||||
|
const answerPartId = `${message.id}${ANSWER_PART_SUFFIX}`;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: message.parts.map((part) => {
|
||||||
|
if (part.type === 'thinking' && part.status === 'thinking') {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
status: 'end' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (part.id === answerPartId && part.type === 'text') {
|
||||||
|
const separator = part.content.includes(ANSWER_SEPARATOR)
|
||||||
|
? ''
|
||||||
|
: ANSWER_SEPARATOR;
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
content: `${part.content}${separator}${delta}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以不可变方式追加模型思考增量。
|
||||||
|
*
|
||||||
|
* @param message 当前流式消息
|
||||||
|
* @param delta 本次新增思考内容
|
||||||
|
* @returns 追加思考后的新消息;正式回答已开始时忽略迟到的思考片段
|
||||||
|
*/
|
||||||
|
export function appendWorkflowThinkingDelta(
|
||||||
|
message: ChatTimelineMessageItem,
|
||||||
|
delta: string,
|
||||||
|
): ChatTimelineMessageItem {
|
||||||
|
const answerPartId = `${message.id}${ANSWER_PART_SUFFIX}`;
|
||||||
|
const answerPart = message.parts.find(
|
||||||
|
(part) => part.id === answerPartId && part.type === 'text',
|
||||||
|
);
|
||||||
|
if (answerPart?.content.includes(ANSWER_SEPARATOR)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const thinkingPart = message.parts.find((part) => part.type === 'thinking');
|
||||||
|
if (!thinkingPart) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
content: delta,
|
||||||
|
id: `${message.id}-thinking`,
|
||||||
|
status: 'thinking',
|
||||||
|
type: 'thinking',
|
||||||
|
},
|
||||||
|
...message.parts,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: message.parts.map((part) =>
|
||||||
|
part.id === thinkingPart.id && part.type === 'thinking'
|
||||||
|
? {
|
||||||
|
...part,
|
||||||
|
content: `${part.content}${delta}`,
|
||||||
|
status: 'thinking',
|
||||||
|
}
|
||||||
|
: part,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以不可变方式更新流式消息状态。
|
||||||
|
*
|
||||||
|
* @param message 当前流式消息
|
||||||
|
* @param status 新消息状态
|
||||||
|
* @returns 更新状态后的新消息
|
||||||
|
*/
|
||||||
|
export function updateWorkflowStreamStatus(
|
||||||
|
message: ChatTimelineMessageItem,
|
||||||
|
status: ChatTimelineMessageItem['status'],
|
||||||
|
): ChatTimelineMessageItem {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: message.parts.map((part) =>
|
||||||
|
part.type === 'thinking'
|
||||||
|
? {
|
||||||
|
...part,
|
||||||
|
status: status === 'error' ? 'error' : 'end',
|
||||||
|
}
|
||||||
|
: part,
|
||||||
|
),
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
finalizeWorkflowExecutionSteps,
|
||||||
|
hydrateWorkflowExecutionSteps,
|
||||||
|
reduceWorkflowExecutionSteps,
|
||||||
|
} from './workflowExecutionDetails';
|
||||||
|
|
||||||
|
describe('workflowExecutionDetails', () => {
|
||||||
|
it('keeps loop attempts separate and completes each output', () => {
|
||||||
|
const first = reduceWorkflowExecutionSteps([], {
|
||||||
|
data: {
|
||||||
|
attemptKey: 'loop:1',
|
||||||
|
input: { index: 0 },
|
||||||
|
nodeId: 'llm',
|
||||||
|
nodeName: '大模型',
|
||||||
|
startedAt: 100,
|
||||||
|
},
|
||||||
|
eventId: '1',
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
const firstDone = reduceWorkflowExecutionSteps(
|
||||||
|
first,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
attemptKey: 'loop:1',
|
||||||
|
finishedAt: 150,
|
||||||
|
nodeId: 'llm',
|
||||||
|
nodeName: '大模型',
|
||||||
|
output: { text: '第一轮完整输出' },
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
},
|
||||||
|
eventId: '2',
|
||||||
|
type: 'node_finished',
|
||||||
|
},
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
const second = reduceWorkflowExecutionSteps(firstDone, {
|
||||||
|
data: {
|
||||||
|
attemptKey: 'loop:2',
|
||||||
|
input: { index: 1 },
|
||||||
|
nodeId: 'llm',
|
||||||
|
nodeName: '大模型',
|
||||||
|
startedAt: 200,
|
||||||
|
},
|
||||||
|
eventId: '3',
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toHaveLength(2);
|
||||||
|
expect(second[0]).toMatchObject({
|
||||||
|
duration: 50,
|
||||||
|
output: { text: '第一轮完整输出' },
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
expect(second[1]).toMatchObject({
|
||||||
|
attemptKey: 'loop:2',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends condition decisions to the source attempt', () => {
|
||||||
|
const started = reduceWorkflowExecutionSteps([], {
|
||||||
|
data: {
|
||||||
|
attemptKey: 'condition:1',
|
||||||
|
nodeId: 'condition',
|
||||||
|
nodeName: '条件判断',
|
||||||
|
},
|
||||||
|
eventId: '1',
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
const traced = reduceWorkflowExecutionSteps(started, {
|
||||||
|
data: {
|
||||||
|
attemptKey: 'condition:1',
|
||||||
|
nodeId: 'condition',
|
||||||
|
outcome: 'skipped',
|
||||||
|
targetNodeName: '拒绝分支',
|
||||||
|
},
|
||||||
|
eventId: '2',
|
||||||
|
type: 'node_trace',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(traced[0]?.traces).toEqual([
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
outcome: 'skipped',
|
||||||
|
targetNodeName: '拒绝分支',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates persisted JSON values and finalizes active steps', () => {
|
||||||
|
const hydrated = hydrateWorkflowExecutionSteps([
|
||||||
|
{
|
||||||
|
attemptKey: 'node:1',
|
||||||
|
input: '{"question":"你好"}',
|
||||||
|
nodeId: 'node',
|
||||||
|
nodeName: '节点',
|
||||||
|
output: '{"answer":"你好"}',
|
||||||
|
status: 20,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const running = reduceWorkflowExecutionSteps(hydrated, {
|
||||||
|
data: {
|
||||||
|
attemptKey: 'node:2',
|
||||||
|
nodeId: 'node',
|
||||||
|
nodeName: '节点',
|
||||||
|
startedAt: 100,
|
||||||
|
},
|
||||||
|
eventId: '2',
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
const finalized = finalizeWorkflowExecutionSteps(running, 'cancelled', 160);
|
||||||
|
|
||||||
|
expect(finalized[0]).toMatchObject({
|
||||||
|
input: { question: '你好' },
|
||||||
|
output: { answer: '你好' },
|
||||||
|
});
|
||||||
|
expect(finalized[1]).toMatchObject({
|
||||||
|
duration: 60,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
export interface WorkflowExecutionTrace {
|
||||||
|
id: string;
|
||||||
|
outcome: 'matched' | 'skipped';
|
||||||
|
targetNodeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowExecutionStepStatus =
|
||||||
|
| 'cancelled'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'running'
|
||||||
|
| 'waiting';
|
||||||
|
|
||||||
|
export interface WorkflowExecutionStepView {
|
||||||
|
attemptKey?: string;
|
||||||
|
duration?: number;
|
||||||
|
endTime?: number;
|
||||||
|
error?: string;
|
||||||
|
hasInput: boolean;
|
||||||
|
hasOutput: boolean;
|
||||||
|
input?: unknown;
|
||||||
|
key: string;
|
||||||
|
nodeClass?: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
output?: unknown;
|
||||||
|
startTime?: number;
|
||||||
|
status: WorkflowExecutionStepStatus;
|
||||||
|
traces: WorkflowExecutionTrace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowExecutionEvent {
|
||||||
|
data?: Record<string, any>;
|
||||||
|
eventId: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将实时工作流事件归并为稳定的节点执行步骤。
|
||||||
|
*/
|
||||||
|
export function reduceWorkflowExecutionSteps(
|
||||||
|
current: WorkflowExecutionStepView[],
|
||||||
|
event: WorkflowExecutionEvent,
|
||||||
|
now = Date.now(),
|
||||||
|
): WorkflowExecutionStepView[] {
|
||||||
|
if (
|
||||||
|
event.type !== 'node_started' &&
|
||||||
|
event.type !== 'node_finished' &&
|
||||||
|
event.type !== 'node_trace'
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const data = event.data || {};
|
||||||
|
const attemptKey = textValue(data.attemptKey);
|
||||||
|
const nodeId = textValue(data.nodeId);
|
||||||
|
const stepIndex = findStepIndex(current, attemptKey, nodeId);
|
||||||
|
|
||||||
|
if (event.type === 'node_started') {
|
||||||
|
const startTime = numberValue(data.startedAt) ?? now;
|
||||||
|
const nextStep: WorkflowExecutionStepView = {
|
||||||
|
attemptKey: attemptKey || undefined,
|
||||||
|
hasInput: hasOwn(data, 'input'),
|
||||||
|
hasOutput: false,
|
||||||
|
input: data.input,
|
||||||
|
key: attemptKey || `${nodeId || 'node'}:${event.eventId}`,
|
||||||
|
nodeClass: textValue(data.nodeClass) || undefined,
|
||||||
|
nodeId,
|
||||||
|
nodeName: textValue(data.nodeName) || nodeId || '工作流节点',
|
||||||
|
startTime,
|
||||||
|
status: 'running',
|
||||||
|
traces: [],
|
||||||
|
};
|
||||||
|
if (stepIndex === -1) {
|
||||||
|
return [...current, nextStep];
|
||||||
|
}
|
||||||
|
const existingStep = current[stepIndex];
|
||||||
|
if (!existingStep) {
|
||||||
|
return [...current, nextStep];
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
next[stepIndex] = {
|
||||||
|
...existingStep,
|
||||||
|
...nextStep,
|
||||||
|
traces: existingStep.traces,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStep = stepIndex === -1 ? undefined : current[stepIndex];
|
||||||
|
const baseStep =
|
||||||
|
existingStep === undefined
|
||||||
|
? createFallbackStep(event, data, attemptKey, nodeId, now)
|
||||||
|
: existingStep;
|
||||||
|
let updated: WorkflowExecutionStepView;
|
||||||
|
if (event.type === 'node_trace') {
|
||||||
|
const targetNodeName =
|
||||||
|
textValue(data.targetNodeName) ||
|
||||||
|
textValue(data.targetNodeId) ||
|
||||||
|
'后续节点';
|
||||||
|
const outcome = data.outcome === 'matched' ? 'matched' : 'skipped';
|
||||||
|
const trace: WorkflowExecutionTrace = {
|
||||||
|
id: event.eventId,
|
||||||
|
outcome,
|
||||||
|
targetNodeName,
|
||||||
|
};
|
||||||
|
updated = {
|
||||||
|
...baseStep,
|
||||||
|
traces: baseStep.traces.some((item) => item.id === trace.id)
|
||||||
|
? baseStep.traces
|
||||||
|
: [...baseStep.traces, trace],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const endTime = numberValue(data.finishedAt) ?? now;
|
||||||
|
const startTime = baseStep.startTime;
|
||||||
|
updated = {
|
||||||
|
...baseStep,
|
||||||
|
duration:
|
||||||
|
startTime === undefined ? undefined : Math.max(0, endTime - startTime),
|
||||||
|
endTime,
|
||||||
|
error: textValue(data.error) || undefined,
|
||||||
|
hasOutput: hasOwn(data, 'output'),
|
||||||
|
output: data.output,
|
||||||
|
status: resolveLiveStatus(data.status, data.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex === -1) {
|
||||||
|
return [...current, updated];
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
next[stepIndex] = updated;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将持久化步骤转换为运行详情统一视图。
|
||||||
|
*/
|
||||||
|
export function hydrateWorkflowExecutionSteps(
|
||||||
|
steps: unknown,
|
||||||
|
): WorkflowExecutionStepView[] {
|
||||||
|
if (!Array.isArray(steps)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return steps.map((step: Record<string, any>, index) => ({
|
||||||
|
attemptKey: textValue(step.attemptKey) || undefined,
|
||||||
|
duration: numberValue(step.execTime),
|
||||||
|
endTime: timeValue(step.endTime),
|
||||||
|
error: textValue(step.errorInfo) || undefined,
|
||||||
|
hasInput: step.input !== undefined && step.input !== null,
|
||||||
|
hasOutput: step.output !== undefined && step.output !== null,
|
||||||
|
input: parseExecutionValue(step.input),
|
||||||
|
key:
|
||||||
|
textValue(step.attemptKey) ||
|
||||||
|
textValue(step.id) ||
|
||||||
|
`${textValue(step.nodeId) || 'node'}:${index}`,
|
||||||
|
nodeId: textValue(step.nodeId),
|
||||||
|
nodeName:
|
||||||
|
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
|
||||||
|
output: parseExecutionValue(step.output),
|
||||||
|
startTime: timeValue(step.startTime),
|
||||||
|
status: resolvePersistedStatus(step.status),
|
||||||
|
traces: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将仍在执行的步骤收口为工作流终态。
|
||||||
|
*/
|
||||||
|
export function finalizeWorkflowExecutionSteps(
|
||||||
|
steps: WorkflowExecutionStepView[],
|
||||||
|
status: 'cancelled' | 'completed' | 'failed',
|
||||||
|
now = Date.now(),
|
||||||
|
): WorkflowExecutionStepView[] {
|
||||||
|
let changed = false;
|
||||||
|
const next = steps.map((step) => {
|
||||||
|
if (step.status !== 'running' && step.status !== 'waiting') {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
duration:
|
||||||
|
step.startTime === undefined
|
||||||
|
? step.duration
|
||||||
|
: Math.max(0, now - step.startTime),
|
||||||
|
endTime: now,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? next : steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化节点输入或输出。
|
||||||
|
*/
|
||||||
|
export function formatExecutionValue(value: unknown) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value ?? null, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackStep(
|
||||||
|
event: WorkflowExecutionEvent,
|
||||||
|
data: Record<string, any>,
|
||||||
|
attemptKey: string,
|
||||||
|
nodeId: string,
|
||||||
|
now: number,
|
||||||
|
): WorkflowExecutionStepView {
|
||||||
|
return {
|
||||||
|
attemptKey: attemptKey || undefined,
|
||||||
|
hasInput: false,
|
||||||
|
hasOutput: false,
|
||||||
|
key: attemptKey || `${nodeId || 'node'}:${event.eventId}`,
|
||||||
|
nodeId,
|
||||||
|
nodeName: textValue(data.nodeName) || nodeId || '工作流节点',
|
||||||
|
startTime: now,
|
||||||
|
status: 'running',
|
||||||
|
traces: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStepIndex(
|
||||||
|
steps: WorkflowExecutionStepView[],
|
||||||
|
attemptKey: string,
|
||||||
|
nodeId: string,
|
||||||
|
) {
|
||||||
|
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attemptKey && step.attemptKey === attemptKey) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
if (!attemptKey && nodeId && step.nodeId === nodeId) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExecutionValue(value: unknown) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLiveStatus(
|
||||||
|
status: unknown,
|
||||||
|
error: unknown,
|
||||||
|
): WorkflowExecutionStepStatus {
|
||||||
|
const normalized = textValue(status).toUpperCase();
|
||||||
|
if (error || normalized === 'ERROR' || normalized === 'FAILED') {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
if (normalized === 'SUSPEND') {
|
||||||
|
return 'waiting';
|
||||||
|
}
|
||||||
|
return normalized === 'RUNNING' ? 'running' : 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePersistedStatus(status: unknown): WorkflowExecutionStepStatus {
|
||||||
|
switch (String(status)) {
|
||||||
|
case '1': {
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
|
case '5': {
|
||||||
|
return 'waiting';
|
||||||
|
}
|
||||||
|
case '10':
|
||||||
|
case '21': {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwn(value: object, key: string) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(value, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValue(value: unknown) {
|
||||||
|
const direct = numberValue(value);
|
||||||
|
if (direct !== undefined) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp) ? undefined : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(value: unknown) {
|
||||||
|
return value === undefined || value === null ? '' : String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const SUPPORTED_CONTENT_TYPES = new Set([
|
||||||
|
'audio',
|
||||||
|
'image',
|
||||||
|
'other',
|
||||||
|
'text',
|
||||||
|
'video',
|
||||||
|
]);
|
||||||
|
const DEFAULT_FIELD_LABELS = new Set([
|
||||||
|
'下拉字段',
|
||||||
|
'单选字段',
|
||||||
|
'多选字段',
|
||||||
|
'文件字段',
|
||||||
|
'文本字段',
|
||||||
|
'新字段',
|
||||||
|
'长文本字段',
|
||||||
|
]);
|
||||||
|
const GENERATED_FIELD_KEY_PATTERN =
|
||||||
|
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
|
||||||
|
|
||||||
|
function resolveFieldLabel(field: any) {
|
||||||
|
const key = String(field?.key || '').trim();
|
||||||
|
const label = String(field?.label || '').trim();
|
||||||
|
if (
|
||||||
|
key &&
|
||||||
|
DEFAULT_FIELD_LABELS.has(label) &&
|
||||||
|
!GENERATED_FIELD_KEY_PATTERN.test(key)
|
||||||
|
) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return label || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContentType(
|
||||||
|
type: string,
|
||||||
|
rawContentType: string,
|
||||||
|
parameterContentType: string,
|
||||||
|
) {
|
||||||
|
if (type === 'file') {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
if (parameterContentType === 'image') {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
return SUPPORTED_CONTENT_TYPES.has(rawContentType) ? rawContentType : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataType(type: string, contentType: string) {
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return 'Array';
|
||||||
|
}
|
||||||
|
if (contentType === 'file') {
|
||||||
|
return 'File';
|
||||||
|
}
|
||||||
|
if (contentType === 'image') {
|
||||||
|
return 'Object';
|
||||||
|
}
|
||||||
|
return 'String';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkflowFormParameters(workflowParams: any) {
|
||||||
|
const schema = Array.isArray(workflowParams?.startFormSchema)
|
||||||
|
? workflowParams.startFormSchema
|
||||||
|
: [];
|
||||||
|
if (schema.length === 0) {
|
||||||
|
return workflowParams?.parameters || [];
|
||||||
|
}
|
||||||
|
const parameterMap = new Map(
|
||||||
|
(Array.isArray(workflowParams?.parameters)
|
||||||
|
? workflowParams.parameters
|
||||||
|
: []
|
||||||
|
).map((parameter: any) => [
|
||||||
|
String(parameter?.name || '').trim(),
|
||||||
|
parameter,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return schema.map((field: any) => {
|
||||||
|
const type = String(field.type || '').trim() || 'text';
|
||||||
|
const rawContentType = String(field.contentType || '').trim();
|
||||||
|
const parameter = parameterMap.get(String(field.key || '').trim()) as
|
||||||
|
| any
|
||||||
|
| undefined;
|
||||||
|
const parameterContentType = String(parameter?.contentType || '').trim();
|
||||||
|
const contentType = resolveContentType(
|
||||||
|
type,
|
||||||
|
rawContentType,
|
||||||
|
parameterContentType,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: field.key,
|
||||||
|
formLabel: resolveFieldLabel(field),
|
||||||
|
formDescription: field.description || '',
|
||||||
|
formPlaceholder: field.placeholder || '',
|
||||||
|
required: Boolean(field.required),
|
||||||
|
defaultValue: field.defaultValue,
|
||||||
|
enums: Array.isArray(field.options) ? field.options : [],
|
||||||
|
contentType,
|
||||||
|
formType: type === 'text' || contentType === 'file' ? 'input' : type,
|
||||||
|
dataType: resolveDataType(type, contentType),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getWorkflowImagePreviewUrl,
|
||||||
|
normalizeWorkflowImageValue,
|
||||||
|
} from './workflowImageValue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断附加表单是否存在必填参数。
|
||||||
|
*
|
||||||
|
* @param parameters 附加表单参数
|
||||||
|
* @returns 存在必填参数时返回 true
|
||||||
|
*/
|
||||||
|
export function hasRequiredWorkflowFormParameters(parameters: any[]) {
|
||||||
|
return parameters.some((parameter) => parameter?.required === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建用户可读的表单填写内容。
|
||||||
|
*
|
||||||
|
* @param parameters 附加表单参数
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 非空字段组成的多行文本
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFormSubmissionText(
|
||||||
|
parameters: any[],
|
||||||
|
values: Record<string, any>,
|
||||||
|
) {
|
||||||
|
return parameters
|
||||||
|
.map((parameter) => {
|
||||||
|
if (parameter?.contentType === 'image') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const value = formatWorkflowFormValue(values[parameter?.name]);
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const label = String(
|
||||||
|
parameter?.formLabel || parameter?.name || '补充信息',
|
||||||
|
).trim();
|
||||||
|
return `${label}:${value}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图片表单字段转换为聊天图片附件。
|
||||||
|
*
|
||||||
|
* @param parameters 附加表单参数
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 可直接渲染的图片附件
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFormSubmissionImages(
|
||||||
|
parameters: any[],
|
||||||
|
values: Record<string, any>,
|
||||||
|
): ChatImageAttachment[] {
|
||||||
|
return parameters.flatMap((parameter) => {
|
||||||
|
if (parameter?.contentType !== 'image') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const value = values[parameter?.name];
|
||||||
|
const image = normalizeWorkflowImageValue(value);
|
||||||
|
const previewUrl = getWorkflowImagePreviewUrl(value);
|
||||||
|
if (!image || !previewUrl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
mimeType: image.sourceType === 'url' ? undefined : image.contentType,
|
||||||
|
name:
|
||||||
|
image.sourceType === 'url'
|
||||||
|
? resolveWorkflowImageUrlName(image.url)
|
||||||
|
: image.fileName,
|
||||||
|
previewUrl,
|
||||||
|
size: image.sourceType === 'url' ? undefined : image.size,
|
||||||
|
status: 'ready' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将表单字段值转换为适合会话展示的文本。
|
||||||
|
*
|
||||||
|
* @param value 表单字段值
|
||||||
|
* @returns 用户可读文本;空值返回空字符串
|
||||||
|
*/
|
||||||
|
function formatWorkflowFormValue(value: any): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => formatWorkflowFormValue(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、');
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? '是' : '否';
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
for (const key of ['fileName', 'resourceName', 'name', 'url', 'text']) {
|
||||||
|
const displayValue = formatWorkflowFormValue(value[key]);
|
||||||
|
if (displayValue) {
|
||||||
|
return displayValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(value).length > 0 ? '已填写' : '';
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图片 URL 中提取用于预览的文件名。
|
||||||
|
*
|
||||||
|
* @param url 图片 URL
|
||||||
|
* @returns 文件名;无法提取时返回“图片”
|
||||||
|
*/
|
||||||
|
function resolveWorkflowImageUrlName(url: string): string {
|
||||||
|
try {
|
||||||
|
const segments = new URL(url).pathname.split('/');
|
||||||
|
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||||
|
const segment = segments[index];
|
||||||
|
if (segment) {
|
||||||
|
return decodeURIComponent(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '图片';
|
||||||
|
} catch {
|
||||||
|
return '图片';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
export type WorkflowImageSourceType = 'resource' | 'upload' | 'url';
|
||||||
|
|
||||||
|
export type WorkflowImageValue =
|
||||||
|
| {
|
||||||
|
sourceType: 'url';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
sourceType: 'resource' | 'upload';
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
contentType?: string;
|
||||||
|
size?: number;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkflowImageResourceLike {
|
||||||
|
fileSize?: number | string;
|
||||||
|
resourceName?: string;
|
||||||
|
resourceType?: number | string;
|
||||||
|
resourceUrl?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_IMAGE_LIMITS = {
|
||||||
|
maxSize: 10 * 1024 * 1024,
|
||||||
|
accept: '.png,.jpg,.jpeg,.webp,.gif,.bmp',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/bmp',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
const ALLOWED_EXTENSIONS = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从上传结果构建单图运行值。
|
||||||
|
*/
|
||||||
|
export function buildWorkflowImageValueFromUpload(
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
): WorkflowImageValue {
|
||||||
|
validateWorkflowImageFile(file);
|
||||||
|
const filePath = String(path || '').trim();
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error('上传结果缺少图片路径');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceType: 'upload',
|
||||||
|
fileName: file.name,
|
||||||
|
filePath,
|
||||||
|
contentType: file.type || undefined,
|
||||||
|
size: file.size,
|
||||||
|
url: filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从素材库对象构建单图运行值。
|
||||||
|
*/
|
||||||
|
export function buildWorkflowImageValueFromResource(
|
||||||
|
resource: WorkflowImageResourceLike,
|
||||||
|
): WorkflowImageValue {
|
||||||
|
const filePath = String(resource?.resourceUrl || '').trim();
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error('图片素材缺少 resourceUrl');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resource.resourceType !== undefined &&
|
||||||
|
Number(resource.resourceType) !== 0
|
||||||
|
) {
|
||||||
|
throw new Error('请选择图片素材');
|
||||||
|
}
|
||||||
|
const suffix = String(resource?.suffix || '').trim().toLowerCase();
|
||||||
|
if (suffix && !ALLOWED_EXTENSIONS.has(suffix)) {
|
||||||
|
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||||
|
}
|
||||||
|
const size = toNumber(resource?.fileSize);
|
||||||
|
validateWorkflowImageSize(size);
|
||||||
|
const resourceName = String(resource?.resourceName || '').trim();
|
||||||
|
const fallbackName = filePath.split('/').pop()?.split('?')[0] || 'image';
|
||||||
|
return {
|
||||||
|
sourceType: 'resource',
|
||||||
|
fileName:
|
||||||
|
resourceName && suffix
|
||||||
|
? `${resourceName}.${suffix}`
|
||||||
|
: resourceName || fallbackName,
|
||||||
|
filePath,
|
||||||
|
contentType: suffixToMimeType(suffix),
|
||||||
|
size,
|
||||||
|
url: filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 HTTP/HTTPS URL 构建图片运行值。
|
||||||
|
*/
|
||||||
|
export function buildWorkflowImageValueFromUrl(url: string): WorkflowImageValue {
|
||||||
|
const normalized = String(url || '').trim();
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(normalized);
|
||||||
|
} catch {
|
||||||
|
throw new Error('请输入有效的图片 URL');
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('图片 URL 仅支持 HTTP/HTTPS');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceType: 'url',
|
||||||
|
url: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化新旧工作流图片值。
|
||||||
|
*/
|
||||||
|
export function normalizeWorkflowImageValue(
|
||||||
|
value: unknown,
|
||||||
|
): WorkflowImageValue | undefined {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
try {
|
||||||
|
return buildWorkflowImageValueFromUrl(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
const filePath = String(candidate.filePath || '').trim();
|
||||||
|
const sourceType = String(
|
||||||
|
candidate.sourceType || (filePath ? 'upload' : 'url'),
|
||||||
|
) as WorkflowImageSourceType;
|
||||||
|
if (sourceType === 'url') {
|
||||||
|
try {
|
||||||
|
return buildWorkflowImageValueFromUrl(String(candidate.url || ''));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!['resource', 'upload'].includes(sourceType)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const fileName = String(candidate.fileName || '').trim();
|
||||||
|
if (!fileName || !filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const size = toNumber(candidate.size as number | string | undefined);
|
||||||
|
try {
|
||||||
|
validateWorkflowImageSize(size);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceType,
|
||||||
|
fileName,
|
||||||
|
filePath,
|
||||||
|
contentType: String(candidate.contentType || '').trim() || undefined,
|
||||||
|
size,
|
||||||
|
url: String(candidate.url || '').trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断值中是否存在可提交的图片。
|
||||||
|
*/
|
||||||
|
export function hasWorkflowImageValue(value: unknown): boolean {
|
||||||
|
return normalizeWorkflowImageValue(value) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缩略图 URL。
|
||||||
|
*/
|
||||||
|
export function getWorkflowImagePreviewUrl(value: unknown): string {
|
||||||
|
const image = normalizeWorkflowImageValue(value);
|
||||||
|
return image?.sourceType === 'url'
|
||||||
|
? image.url
|
||||||
|
: image?.url || image?.filePath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验本地图片格式和大小。
|
||||||
|
*/
|
||||||
|
export function validateWorkflowImageFile(file: File) {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.has(file.type.toLowerCase()) &&
|
||||||
|
!ALLOWED_EXTENSIONS.has(extension)
|
||||||
|
) {
|
||||||
|
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||||
|
}
|
||||||
|
validateWorkflowImageSize(file.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化图片大小。
|
||||||
|
*/
|
||||||
|
export function formatWorkflowImageSize(size?: number): string {
|
||||||
|
if (!size || size <= 0 || Number.isNaN(size)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWorkflowImageSize(size?: number) {
|
||||||
|
if (size !== undefined && size > WORKFLOW_IMAGE_LIMITS.maxSize) {
|
||||||
|
throw new Error('单张图片不能超过 10 MiB');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suffixToMimeType(suffix: string): string | undefined {
|
||||||
|
if (!suffix) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return suffix === 'jpg' || suffix === 'jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: `image/${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value?: number | string): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ describe('workflow share context', () => {
|
|||||||
it('reads the share key from a history-mode URL', () => {
|
it('reads the share key from a history-mode URL', () => {
|
||||||
expect(
|
expect(
|
||||||
readWorkflowShareKey(
|
readWorkflowShareKey(
|
||||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
'https://example.test/share/workflow?shareKey=abc123',
|
||||||
),
|
),
|
||||||
).toBe('abc123');
|
).toBe('abc123');
|
||||||
});
|
});
|
||||||
@@ -21,7 +21,7 @@ describe('workflow share context', () => {
|
|||||||
it('reads the share key from a hash-mode URL', () => {
|
it('reads the share key from a hash-mode URL', () => {
|
||||||
expect(
|
expect(
|
||||||
readWorkflowShareKey(
|
readWorkflowShareKey(
|
||||||
'https://example.test/#/ai/workflow/design?id=1&shareKey=hash-key',
|
'https://example.test/#/share/workflow?shareKey=hash-key',
|
||||||
),
|
),
|
||||||
).toBe('hash-key');
|
).toBe('hash-key');
|
||||||
});
|
});
|
||||||
@@ -39,10 +39,9 @@ describe('workflow share context', () => {
|
|||||||
withWorkflowShareHeader(
|
withWorkflowShareHeader(
|
||||||
{ 'Accept-Language': 'zh-CN' },
|
{ 'Accept-Language': 'zh-CN' },
|
||||||
{
|
{
|
||||||
pageUrl:
|
pageUrl: 'https://example.test/share/workflow?shareKey=abc123',
|
||||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
|
||||||
requestMethod: 'GET',
|
requestMethod: 'GET',
|
||||||
requestUrl: '/api/v1/workflow/detail?id=1',
|
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -56,9 +55,9 @@ describe('workflow share context', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
withWorkflowShareHeader(headers, {
|
withWorkflowShareHeader(headers, {
|
||||||
pageUrl: 'https://example.test/ai/workflow/design?id=1',
|
pageUrl: 'https://example.test/share/workflow',
|
||||||
requestMethod: 'GET',
|
requestMethod: 'GET',
|
||||||
requestUrl: '/api/v1/workflow/detail?id=1',
|
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
|
||||||
}),
|
}),
|
||||||
).toEqual(headers);
|
).toEqual(headers);
|
||||||
});
|
});
|
||||||
@@ -80,17 +79,17 @@ describe('workflow share context', () => {
|
|||||||
).toEqual({ 'Accept-Language': 'zh-CN' });
|
).toEqual({ 'Accept-Language': 'zh-CN' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not reuse an outer share key after entering workflow design', () => {
|
it('does not attach chat sharing capabilities to workflow design', () => {
|
||||||
expect(
|
expect(
|
||||||
readWorkflowShareKey(
|
readWorkflowShareKey(
|
||||||
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow/design?id=1',
|
'https://example.test/flow/#/ai/workflow/design?id=1&shareKey=workflow-key',
|
||||||
),
|
),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only attaches the share key to explicitly allowed workflow requests', () => {
|
it('only attaches the share key to explicitly allowed workflow requests', () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
'https://example.test/flow/#/ai/workflow/design?shareKey=workflow-key';
|
'https://example.test/flow/#/share/workflow?shareKey=workflow-key';
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
withWorkflowShareHeader(
|
withWorkflowShareHeader(
|
||||||
@@ -118,7 +117,7 @@ describe('workflow share context', () => {
|
|||||||
{
|
{
|
||||||
pageUrl,
|
pageUrl,
|
||||||
requestMethod: 'POST',
|
requestMethod: 'POST',
|
||||||
requestUrl: '/api/v1/workflow/update',
|
requestUrl: '/api/v1/workflowChat/run',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -128,11 +127,14 @@ describe('workflow share context', () => {
|
|||||||
|
|
||||||
it('matches only the workflow sharing endpoint whitelist', () => {
|
it('matches only the workflow sharing endpoint whitelist', () => {
|
||||||
expect(
|
expect(
|
||||||
isWorkflowShareRequest(
|
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
|
||||||
'/flow/api/v1/workflow/submitPublishApproval',
|
|
||||||
'post',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isWorkflowShareRequest('/flow/api/v1/workflowChat/execution', 'get'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
expect(isWorkflowShareRequest('/flow/api/v1/workflow/page', 'get')).toBe(
|
expect(isWorkflowShareRequest('/flow/api/v1/workflow/page', 'get')).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ function getMessageParts(item: ChatTimelineMessageItem) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMessageBubble(item: ChatTimelineMessageItem) {
|
||||||
|
return (
|
||||||
|
getMessageParts(item).length > 0 || Boolean(item.knowledgeItems?.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateThinkingExpanded(partId: string, expanded: boolean) {
|
function updateThinkingExpanded(partId: string, expanded: boolean) {
|
||||||
const item = messageItem.value;
|
const item = messageItem.value;
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -170,6 +176,11 @@ function handleCopyAction() {
|
|||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="hasMessageBubble(messageItem)"
|
||||||
|
class="chat-timeline-item__bubble"
|
||||||
|
:class="`is-${messageItem.role}`"
|
||||||
|
>
|
||||||
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
||||||
<ChatThinkingBlock
|
<ChatThinkingBlock
|
||||||
v-if="part.type === 'thinking'"
|
v-if="part.type === 'thinking'"
|
||||||
@@ -190,6 +201,7 @@ function handleCopyAction() {
|
|||||||
v-if="messageItem.knowledgeItems?.length"
|
v-if="messageItem.knowledgeItems?.length"
|
||||||
:items="messageItem.knowledgeItems"
|
:items="messageItem.knowledgeItems"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<ChatMessageToolbar
|
<ChatMessageToolbar
|
||||||
v-if="showToolbar && messageItem"
|
v-if="showToolbar && messageItem"
|
||||||
:align="messageItem.role === 'user' ? 'end' : 'start'"
|
:align="messageItem.role === 'user' ? 'end' : 'start'"
|
||||||
@@ -246,18 +258,29 @@ function handleCopyAction() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: min(78%, 680px);
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-timeline-item__message {
|
.chat-timeline-item__message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: min(78%, 680px);
|
max-width: min(78%, 680px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-timeline-item__message.is-user {
|
||||||
|
align-items: flex-end;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-timeline-item__message.is-assistant,
|
||||||
|
.chat-timeline-item__message.is-system {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-timeline-item__message :deep(.chat-text-block),
|
.chat-timeline-item__message :deep(.chat-text-block),
|
||||||
.chat-timeline-item__message :deep(.chat-thinking-block) {
|
.chat-timeline-item__message :deep(.chat-thinking-block) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -271,10 +294,6 @@ function handleCopyAction() {
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-timeline-item__message.is-user {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-timeline-item__message.is-assistant.has-variant-navigator {
|
.chat-timeline-item__message.is-assistant.has-variant-navigator {
|
||||||
width: min(78%, 680px);
|
width: min(78%, 680px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<BookOpenText
|
<BookOpenText
|
||||||
|
v-if="item.icon !== 'none'"
|
||||||
class="chat-timeline-status-row__icon"
|
class="chat-timeline-status-row__icon"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,4 +73,22 @@ describe('ChatTimelineStatusRow', () => {
|
|||||||
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
|
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
|
||||||
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
|
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can render a plain inline status without the context icon', () => {
|
||||||
|
const item: ChatTimelineStatusItem = {
|
||||||
|
icon: 'none',
|
||||||
|
id: 'workflow-run',
|
||||||
|
label: '运行完成',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'workflow-run',
|
||||||
|
type: 'status',
|
||||||
|
};
|
||||||
|
const wrapper = mount(ChatTimelineStatusRow, {
|
||||||
|
props: { item },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('运行完成');
|
||||||
|
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {ChatTimelineItem} from '../types';
|
import type { ChatTimelineItem } from '../types';
|
||||||
|
|
||||||
import {flushPromises, mount} from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import ChatTimeline from '../ChatTimeline.vue';
|
import ChatTimeline from '../ChatTimeline.vue';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ function textMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ChatTimeline toolbar', () => {
|
describe('chat timeline toolbar', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -49,6 +49,86 @@ describe('ChatTimeline toolbar', () => {
|
|||||||
expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage);
|
expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('places the user toolbar outside the message bubble', () => {
|
||||||
|
const wrapper = mount(ChatTimeline, {
|
||||||
|
props: {
|
||||||
|
copyable: () => true,
|
||||||
|
items: [textMessage('user', '用户问题')],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = wrapper.get('.chat-timeline-item__message');
|
||||||
|
const bubble = message.get('.chat-timeline-item__bubble');
|
||||||
|
const toolbar = message.get('.chat-message-toolbar');
|
||||||
|
|
||||||
|
expect(bubble.find('[aria-label="复制消息"]').exists()).toBe(false);
|
||||||
|
expect(toolbar.element.parentElement).toBe(message.element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders image and document attachments outside the message bubble', () => {
|
||||||
|
const wrapper = mount(ChatTimeline, {
|
||||||
|
props: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
name: '需求说明.pdf',
|
||||||
|
status: 'ready',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 'user-attachments',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
name: '示例图片.png',
|
||||||
|
previewUrl: 'data:image/png;base64,AA==',
|
||||||
|
status: 'ready',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parts: [],
|
||||||
|
role: 'user',
|
||||||
|
status: 'done',
|
||||||
|
type: 'message',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = wrapper.get('.chat-timeline-item__message');
|
||||||
|
expect(message.find('.chat-timeline-item__bubble').exists()).toBe(false);
|
||||||
|
expect(message.get('.chat-image-attachments').element.parentElement).toBe(
|
||||||
|
message.element,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
message.get('.chat-document-attachments').element.parentElement,
|
||||||
|
).toBe(message.element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only text inside the bubble when attachments accompany text', () => {
|
||||||
|
const wrapper = mount(ChatTimeline, {
|
||||||
|
props: {
|
||||||
|
items: [
|
||||||
|
textMessage('user', '请识别这张图片', {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
name: '示例图片.png',
|
||||||
|
previewUrl: 'data:image/png;base64,AA==',
|
||||||
|
status: 'ready',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = wrapper.get('.chat-timeline-item__message');
|
||||||
|
const bubble = message.get('.chat-timeline-item__bubble');
|
||||||
|
const images = message.get('.chat-image-attachments');
|
||||||
|
|
||||||
|
expect(bubble.text()).toContain('请识别这张图片');
|
||||||
|
expect(bubble.find('.chat-image-attachments').exists()).toBe(false);
|
||||||
|
expect(images.element.parentElement).toBe(message.element);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows copy and regenerate buttons for assistant messages', async () => {
|
it('shows copy and regenerate buttons for assistant messages', async () => {
|
||||||
const assistantMessage = textMessage('assistant', '助手回答', {
|
const assistantMessage = textMessage('assistant', '助手回答', {
|
||||||
regenerable: true,
|
regenerable: true,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
|
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
|
||||||
|
icon?: 'book' | 'none';
|
||||||
label: string;
|
label: string;
|
||||||
presentation?: 'inline' | 'separator';
|
presentation?: 'inline' | 'separator';
|
||||||
status: ChatTimelineStatusStatus;
|
status: ChatTimelineStatusStatus;
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Heading level={3}>图片识别</Heading>
|
<Heading level={3}>图片输入</Heading>
|
||||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||||
addParameter(currentNodeId, "images")
|
addParameter(currentNodeId, "images")
|
||||||
}}>
|
}}>
|
||||||
@@ -219,7 +219,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RefParameterList dataKeyName="images" noneParameterText="无图片参数" />
|
<RefParameterList
|
||||||
|
dataKeyName="images"
|
||||||
|
noneParameterText="无图片参数"
|
||||||
|
acceptedContentTypes={["image"]}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if queryContextOptions.length > 0}
|
{#if queryContextOptions.length > 0}
|
||||||
<Heading level={3} mt="10px">查询数据信息</Heading>
|
<Heading level={3} mt="10px">查询数据信息</Heading>
|
||||||
|
|||||||
Reference in New Issue
Block a user