feat(XL13): 归档工作流对话运行界面

- 接入发布快照优先与未发布草稿受控运行

- 支持文本和思考流式输出、循环多输出及实时运行详情

- 完成聊天分享、图片输入、中止与清空重来

- 补充后端与前端定向回归测试
This commit is contained in:
2026-07-31 09:40:54 +08:00
parent 048aa9bc1e
commit 615092f4f7
43 changed files with 6128 additions and 242 deletions

View File

@@ -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();
}
}

View File

@@ -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()));

View File

@@ -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;
}
}
}

View File

@@ -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
) {
}
}

View File

@@ -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;
} }
} }

View File

@@ -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;
}
}
}

View File

@@ -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://");
}
/** /**
* 将单文件或多文件运行值归一化为文件对象数组。 * 将单文件或多文件运行值归一化为文件对象数组。
* *

View File

@@ -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;
}
/** /**
* 获取分享密钥哈希。 * 获取分享密钥哈希。
* *

View File

@@ -0,0 +1,17 @@
package tech.easyflow.ai.enums;
/**
* 工作流分享用途。
*/
public enum WorkflowSharePurpose {
/**
* 历史协作编辑分享。
*/
COLLABORATION,
/**
* 已发布工作流对话运行分享。
*/
CHAT
}

View File

@@ -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);
} }

View File

@@ -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();
}
} }

View File

@@ -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 请求是否位于工作流协作授权白名单。
* *

View File

@@ -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";

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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()
);
}
/** /**
* 验证分享授权仅覆盖编辑、运行和发布所需接口。 * 验证分享授权仅覆盖编辑、运行和发布所需接口。
*/ */

View File

@@ -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`);

View File

@@ -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;
/** /**

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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]"

View File

@@ -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">
支持 PNGJPEGWebPGIFBMP单张不超过 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>

View File

@@ -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: '背景资料',
});
});
});

View File

@@ -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',
},
]);
});
});

View File

@@ -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 图片');
});
});

View File

@@ -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',
});
});
});

View File

@@ -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,
};
}

View File

@@ -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',
});
});
});

View File

@@ -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);
}

View File

@@ -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),
};
});
}

View File

@@ -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 '图片';
}
}

View File

@@ -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;
}

View File

@@ -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,
); );

View File

@@ -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);
} }

View File

@@ -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"
/> />

View File

@@ -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);
});
}); });

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>