Compare commits

..

6 Commits

Author SHA1 Message Date
1bd9810518 feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:48 +08:00
fa174f6c16 feat: M28 增加工作流汇聚可视化配置 2026-08-31 15:56:07 +08:00
e40bd9dc82 feat: M28 增加工作流汇聚安全校验 2026-08-31 15:56:06 +08:00
38078741f2 fix: 修复智能体聊天图片能力判断 2026-08-31 15:11:07 +08:00
1d3147e7bf fix: 统一工作流空媒体参数处理 2026-08-31 15:03:05 +08:00
a771affc5d fix: 补全前端容器公共 API 代理
- 将 /flow/public-api/ 请求转发到后端 /public-api/

- 保持工作流等公共接口的同源访问路径
2026-08-31 13:47:15 +08:00
64 changed files with 5096 additions and 232 deletions

View File

@@ -0,0 +1,117 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaIgnore;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.util.Map;
/**
* 工作流对话匿名分享接口。
*/
@SaIgnore
@RestController
@RequestMapping("/api/v1/workflowChat/public")
public class WorkflowPublicChatController {
private final WorkflowPublicChatService publicChatService;
public WorkflowPublicChatController(
WorkflowPublicChatService publicChatService
) {
this.publicChatService = publicChatService;
}
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
return Result.ok(publicChatService.descriptor(
shareKey(request),
visitorId(request)
));
}
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
return publicChatService.run(
shareKey(request),
visitorId(request),
variables
);
}
@GetMapping("/execution")
public Result<Map<String, Object>> execution(
@RequestParam String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.detail(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true) String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.cancel(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request
) {
publicChatService.resume(
shareKey(request),
visitorId(request),
executeId,
confirmParams
);
return Result.ok();
}
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("parameterName") String parameterName,
HttpServletRequest request
) {
return Result.ok(publicChatService.upload(
shareKey(request),
visitorId(request),
parameterName,
file
));
}
private String shareKey(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
}
private String visitorId(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -96,11 +97,10 @@ public class WorkflowShareController {
* @return 工作流标识 * @return 工作流标识
*/ */
@GetMapping("/resolve") @GetMapping("/resolve")
@SaIgnore
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) { public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); WorkflowShare share = workflowShareService.resolvePublicChatShare(
WorkflowShare share = workflowShareService.resolveChatShare( request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
loginAccount.getTenantId()
); );
return Result.ok(Map.of("workflowId", share.getWorkflowId())); return Result.ok(Map.of("workflowId", share.getWorkflowId()));
} }

View File

@@ -19,11 +19,17 @@ import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException; import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -40,6 +46,15 @@ public class WorkflowChatEventStream {
private final ChainExecutor chainExecutor; private final ChainExecutor chainExecutor;
private final Map<String, StreamSession> sessions = private final Map<String, StreamSession> sessions =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
private final ScheduledExecutorService detachedSessionCleaner =
Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(
task,
"workflow-chat-detached-session-cleaner"
);
thread.setDaemon(true);
return thread;
});
/** /**
* 创建工作流对话事件流服务。 * 创建工作流对话事件流服务。
@@ -59,6 +74,15 @@ public class WorkflowChatEventStream {
chainExecutor.addErrorListener(this::onChainError); chainExecutor.addErrorListener(this::onChainError);
} }
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/** /**
* 启动工作流并返回其 SSE 连接。 * 启动工作流并返回其 SSE 连接。
* *
@@ -67,11 +91,53 @@ public class WorkflowChatEventStream {
* @return SSE 连接 * @return SSE 连接
*/ */
public SseEmitter start(String definitionId, Map<String, Object> variables) { public SseEmitter start(String definitionId, Map<String, Object> variables) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); return start(definitionId, variables, () -> {
StreamSession session = new StreamSession(emitter); });
emitter.onTimeout(() -> disconnect(session, "运行连接超时")); }
emitter.onError(error -> disconnect(session, "运行连接已断开"));
emitter.onCompletion(() -> removeSession(session)); /**
* 启动工作流并在流会话结束时执行清理回调。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup
) {
return start(definitionId, variables, cleanup, Duration.ZERO);
}
/**
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
*
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
* 超过保留期时由租约兜底释放资源。</p>
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
* @param detachedRetention 浏览器断开后的监听保留时长
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup,
Duration detachedRetention
) {
SseEmitter emitter = createEmitter();
StreamSession session = new StreamSession(
emitter,
cleanup,
detachedRetention
);
emitter.onTimeout(() -> detach(session));
emitter.onError(error -> detach(session));
emitter.onCompletion(() -> detach(session));
try { try {
chainExecutor.executeAsync( chainExecutor.executeAsync(
@@ -79,6 +145,9 @@ public class WorkflowChatEventStream {
variables, variables,
executeId -> { executeId -> {
session.attach(executeId); session.attach(executeId);
if (session.cleaned.get()) {
return;
}
sessions.put(executeId, session); sessions.put(executeId, session);
session.send("execution_started", Map.of( session.send("execution_started", Map.of(
"executeId", executeId "executeId", executeId
@@ -92,6 +161,13 @@ public class WorkflowChatEventStream {
return emitter; return emitter;
} }
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/** /**
* 将工作流事件转发到对应执行流。 * 将工作流事件转发到对应执行流。
* *
@@ -162,20 +238,21 @@ public class WorkflowChatEventStream {
} }
/** /**
* 处理 SSE 连接异常,并取消尚未结束的工作流 * 分离已经断开的浏览器传输,不影响工作流 Runtime
* *
* @param session 流会话 * @param session 流会话
* @param message 取消原因
*/ */
private void disconnect(StreamSession session, String message) { private void detach(StreamSession session) {
if (session == null || session.terminal.get()) { if (session == null || session.terminal.get()) {
return; return;
} }
String executeId = session.executeId; session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
removeSession(session); removeSession(session);
if (executeId != null) { return;
chainExecutor.cancel(executeId, message);
} }
session.scheduleDetachedCleanup();
} }
/** /**
@@ -187,6 +264,9 @@ public class WorkflowChatEventStream {
if (session != null && session.executeId != null) { if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session); sessions.remove(session.executeId, session);
} }
if (session != null) {
session.cleanup();
}
} }
/** /**
@@ -231,6 +311,11 @@ public class WorkflowChatEventStream {
private final SseEmitter emitter; private final SseEmitter emitter;
private final AtomicLong sequence = new AtomicLong(); private final AtomicLong sequence = new AtomicLong();
private final AtomicBoolean terminal = new AtomicBoolean(false); private final AtomicBoolean terminal = new AtomicBoolean(false);
private final AtomicBoolean cleaned = new AtomicBoolean(false);
private final AtomicBoolean connected = new AtomicBoolean(true);
private final Runnable cleanup;
private final Duration detachedRetention;
private volatile ScheduledFuture<?> detachedCleanup;
private volatile String executeId; private volatile String executeId;
/** /**
@@ -238,8 +323,36 @@ public class WorkflowChatEventStream {
* *
* @param emitter SSE 发送器 * @param emitter SSE 发送器
*/ */
private StreamSession(SseEmitter emitter) { private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
this.emitter = emitter; this.emitter = emitter;
this.cleanup = cleanup == null ? () -> {
} : cleanup;
this.detachedRetention = detachedRetention == null
? Duration.ZERO
: detachedRetention;
}
/**
* 幂等释放当前流持有的外部资源。
*/
private void cleanup() {
if (!cleaned.compareAndSet(false, true)) {
return;
}
cancelDetachedCleanup();
try {
cleanup.run();
} catch (RuntimeException error) {
log.warn(
"workflow chat stream cleanup failed, executeId={}",
executeId,
error
);
}
} }
/** /**
@@ -251,6 +364,38 @@ public class WorkflowChatEventStream {
this.executeId = executeId; this.executeId = executeId;
} }
/**
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
*/
private void detachTransport() {
connected.set(false);
}
/**
* 浏览器断开后按活动租约安排会话兜底清理。
*/
private synchronized void scheduleDetachedCleanup() {
if (detachedCleanup != null || cleaned.get()) {
return;
}
detachedCleanup = detachedSessionCleaner.schedule(
() -> removeSession(this),
Math.max(1L, detachedRetention.toMillis()),
TimeUnit.MILLISECONDS
);
}
/**
* 取消尚未触发的断开会话兜底任务。
*/
private synchronized void cancelDetachedCleanup() {
if (detachedCleanup == null) {
return;
}
detachedCleanup.cancel(false);
detachedCleanup = null;
}
/** /**
* 处理节点开始事件。 * 处理节点开始事件。
* *
@@ -424,8 +569,10 @@ public class WorkflowChatEventStream {
} }
send(eventType, data); send(eventType, data);
removeSession(this); removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.complete(); emitter.complete();
} }
}
/** /**
* 发送 SSE 事件。 * 发送 SSE 事件。
@@ -434,6 +581,9 @@ public class WorkflowChatEventStream {
* @param data 事件数据 * @param data 事件数据
*/ */
private void send(String type, Map<String, ?> data) { private void send(String type, Map<String, ?> data) {
if (!connected.get()) {
return;
}
long nextSequence = sequence.incrementAndGet(); long nextSequence = sequence.incrementAndGet();
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", executeId + ":" + nextSequence); payload.put("eventId", executeId + ":" + nextSequence);
@@ -453,7 +603,7 @@ public class WorkflowChatEventStream {
executeId, executeId,
error error
); );
disconnect(this, "运行连接已断开"); detach(this);
} }
} }
@@ -468,9 +618,11 @@ public class WorkflowChatEventStream {
"message", safeErrorMessage(error) "message", safeErrorMessage(error)
)); ));
removeSession(this); removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.completeWithError(error); emitter.completeWithError(error);
} }
} }
}
/** /**
* 构建带节点信息的事件数据。 * 构建带节点信息的事件数据。

View File

@@ -0,0 +1,165 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
/**
* 工作流匿名分享的限流与活动执行互斥保护。
*/
@Component
public class WorkflowPublicChatAccessGuard {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatAccessGuard.class);
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(
"local visitor = redis.call('incr', KEYS[1]); "
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
+ "local share = redis.call('incr', KEYS[2]); "
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
+ "then return 0 else return 1 end"
);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
private final StringRedisTemplate redisTemplate;
private final RedisLockExecutor redisLockExecutor;
private final WorkflowPublicShareProperties properties;
public WorkflowPublicChatAccessGuard(
StringRedisTemplate redisTemplate,
RedisLockExecutor redisLockExecutor,
WorkflowPublicShareProperties properties
) {
this.redisTemplate = redisTemplate;
this.redisLockExecutor = redisLockExecutor;
this.properties = properties;
}
/**
* 检查匿名运行固定窗口限流。
*/
public void checkRun(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"run",
properties.getRunVisitorLimit(),
properties.getRunShareLimit()
);
}
/**
* 检查匿名上传固定窗口限流。
*/
public void checkUpload(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"upload",
properties.getUploadVisitorLimit(),
properties.getUploadShareLimit()
);
}
/**
* 获取同一分享访客的活动执行锁。
*
* @return 由 SSE 生命周期显式释放的锁句柄
*/
public RedisLockExecutor.LockHandle acquireActivity(
BigInteger shareId,
String visitorDigest
) {
try {
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
Duration.ZERO,
properties.getActiveLease()
);
if (handle == null) {
throw new BusinessException(
409,
40931,
"当前分享访客已有工作流正在运行"
);
}
return handle;
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流活动锁暂不可用shareId={}", shareId, exception);
throw unavailable(exception);
}
}
/**
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
*/
public Duration activityLease() {
return properties.getActiveLease();
}
private void checkRate(
BigInteger shareId,
String visitorDigest,
String action,
int visitorLimit,
int shareLimit
) {
String slot = "{" + shareId + "}";
List<String> keys = List.of(
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
KEY_PREFIX + slot + ":rate:" + action + ":share"
);
try {
Long allowed = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
keys,
String.valueOf(visitorLimit),
String.valueOf(shareLimit),
String.valueOf(properties.getRateWindow().toMillis())
);
if (allowed == null) {
throw unavailable(new IllegalStateException(
"Redis 未返回匿名工作流限流结果"));
}
if (!Long.valueOf(1L).equals(allowed)) {
throw new BusinessException(
429,
42931,
"匿名工作流请求过于频繁,请稍后重试"
);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流限流暂不可用shareId={}, action={}",
shareId, action, exception);
throw unavailable(exception);
}
}
private BusinessException unavailable(RuntimeException cause) {
return new BusinessException(
503,
50331,
"匿名工作流保护服务暂不可用,请稍后重试",
cause
);
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.admin.service.ai;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.common.entity.LoginAccount;
/**
* 完成匿名分享边界校验后的运行上下文。
*/
public record WorkflowPublicChatContext(
WorkflowShare share,
Workflow workflow,
LoginAccount creator,
String shareKey,
String visitorDigest
) {
}

View File

@@ -0,0 +1,121 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 解析并校验工作流匿名分享上下文。
*/
@Service
public class WorkflowPublicChatContextResolver {
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
private final WorkflowShareService shareService;
private final WorkflowService workflowService;
private final SysAccountService accountService;
public WorkflowPublicChatContextResolver(
WorkflowShareService shareService,
WorkflowService workflowService,
SysAccountService accountService
) {
this.shareService = shareService;
this.workflowService = workflowService;
this.accountService = accountService;
}
/**
* 解析新运行、恢复与上传所需的当前有效上下文。
*/
public WorkflowPublicChatContext resolveActive(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
Workflow workflow = TenantManager.withoutTenantCondition(
() -> workflowService.getPublishedById(share.getWorkflowId()));
if (!isStrictlyPublished(workflow)
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
SysAccount account = TenantManager.withoutTenantCondition(
() -> accountService.getById(share.getCreatedBy()));
if (account == null
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
throw new BusinessException(
403,
40331,
"工作流分享创建者账号当前不可用"
);
}
LoginAccount creator = account.toLoginAccount();
return new WorkflowPublicChatContext(
share,
workflow,
creator,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
/**
* 解析已发起执行的详情与取消所需历史上下文。
*/
public WorkflowPublicChatContext resolveHistorical(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
return new WorkflowPublicChatContext(
share,
null,
null,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
private String requireVisitor(String visitorId) {
String normalized = visitorId == null ? "" : visitorId.trim();
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
throw new BusinessException(
400,
40031,
"工作流分享访客标识无效"
);
}
return normalized;
}
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -0,0 +1,338 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
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.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 工作流匿名分享对话应用服务。
*/
@Service
public class WorkflowPublicChatService {
private static final Logger log =
LoggerFactory.getLogger(WorkflowPublicChatService.class);
private final WorkflowPublicChatContextResolver contextResolver;
private final WorkflowCheckService workflowCheckService;
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatUploadService uploadService;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
public WorkflowPublicChatService(
WorkflowPublicChatContextResolver contextResolver,
WorkflowCheckService workflowCheckService,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatUploadService uploadService,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
this.contextResolver = contextResolver;
this.workflowCheckService = workflowCheckService;
this.parameterResolver = parameterResolver;
this.uploadService = uploadService;
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
/**
* 获取匿名分享的发布工作流描述。
*/
public Map<String, Object> descriptor(String shareKey, String visitorId) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
checkWorkflow(context);
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", context.workflow().getId());
descriptor.put("publishStatus", context.workflow().getPublishStatus());
descriptor.put("shareable", false);
return descriptor;
}
/**
* 启动匿名分享工作流并返回 SSE。
*/
public SseEmitter run(
String shareKey,
String visitorId,
Map<String, Object> variables
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
accessGuard.checkRun(
context.share().getId(),
context.visitorDigest()
);
checkWorkflow(context);
Map<String, Object> normalized = parameterResolver
.normalizeRuntimeVariables(
context.workflow().getContent(),
variables
);
uploadService.assertOwnedUploads(context, normalized);
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
normalized.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId())
);
normalized.put(
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
context.visitorDigest()
);
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
context.share().getId(),
context.visitorDigest()
);
try {
return eventStream.start(
PublishedWorkflowDefinitionIds.published(
context.workflow().getId().toString()),
normalized,
activity::release,
accessGuard.activityLease()
);
} catch (RuntimeException | Error error) {
activity.release();
throw error;
}
}
/**
* 获取当前匿名访客发起的执行详情。
*/
public Map<String, Object> detail(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
() -> execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
));
return buildExecutionDetail(record, steps, runtimeView(executeId));
}
/**
* 取消当前匿名访客发起的执行。
*/
public boolean cancel(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
}
/**
* 恢复当前有效分享访客等待确认的执行。
*/
public void resume(
String shareKey,
String visitorId,
String executeId,
Map<String, Object> confirmParams
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
if (isTerminal(record.getStatus())) {
throw new BusinessException("当前工作流执行已结束");
}
chainExecutor.resumeAsync(
executeId,
confirmParams == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(confirmParams)
);
}
/**
* 上传当前发布快照声明的匿名输入文件。
*/
public UploadResVo upload(
String shareKey,
String visitorId,
String parameterName,
MultipartFile file
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
return uploadService.upload(context, parameterName, file);
}
private void checkWorkflow(WorkflowPublicChatContext context) {
TenantManager.withoutTenantCondition(() -> {
workflowCheckService.checkOrThrow(
context.workflow().getContent(),
WorkflowCheckStage.PRE_EXECUTE,
context.workflow().getId()
);
return null;
});
}
private WorkflowExecResult assertExecutionOwnership(
WorkflowPublicChatContext context,
String executeId
) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = TenantManager.withoutTenantCondition(
() -> execResultService.getByExecKey(executeId));
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId());
if (!Objects.equals(expectedSource, record.getCreatedKey())
|| !Objects.equals(
context.visitorDigest(),
record.getCreatedBy())
|| !Objects.equals(
context.share().getWorkflowId(),
record.getWorkflowId())) {
throw new BusinessException(
403,
40333,
"无权限访问当前工作流执行记录"
);
}
return record;
}
private boolean isTerminal(Integer status) {
return status != null
&& (status == ChainStatus.SUCCEEDED.getValue()
|| status == ChainStatus.FAILED.getValue()
|| status == ChainStatus.CANCELLED.getValue());
}
private Map<String, Object> buildExecutionDetail(
WorkflowExecResult record,
List<WorkflowExecStep> steps,
Map<String, Object> runtime
) {
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);
detail.put("runtime", runtime);
return detail;
}
/**
* 构建刷新恢复所需的最小 Runtime 视图。
*/
private Map<String, Object> runtimeView(String executeId) {
try {
ChainState state = chainExecutor.getChainStateRepository()
.load(executeId);
if (state == null || state.getStatus() == null) {
return Map.of();
}
Map<String, Object> view = new LinkedHashMap<>();
view.put("status", state.getStatus().name());
view.put("statusValue", state.getStatus().getValue());
view.put("message", state.getMessage());
if (state.getStatus() == ChainStatus.SUSPEND) {
view.put("parameters", state.getSuspendForParameters());
}
if (state.getStatus() == ChainStatus.SUCCEEDED) {
view.put(
"output",
WorkflowChatEventStream.visibleFinalOutput(
state.getExecuteResult())
);
}
return view;
} catch (RuntimeException error) {
log.warn(
"failed to load public workflow runtime state, executeId={}",
executeId,
error
);
return Map.of();
}
}
}

View File

@@ -0,0 +1,325 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 工作流匿名分享的隔离上传与运行引用校验。
*/
@Service
public class WorkflowPublicChatUploadService {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatUploadService.class);
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"bmp", "gif", "jpeg", "jpg", "png", "webp");
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowPublicShareProperties properties;
private final StringRedisTemplate redisTemplate;
private final FileStorageService storageService;
public WorkflowPublicChatUploadService(
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowPublicShareProperties properties,
StringRedisTemplate redisTemplate,
@Qualifier("default") FileStorageService storageService
) {
this.parameterResolver = parameterResolver;
this.accessGuard = accessGuard;
this.properties = properties;
this.redisTemplate = redisTemplate;
this.storageService = storageService;
}
/**
* 上传发布快照声明的文件或图片参数。
*/
public UploadResVo upload(
WorkflowPublicChatContext context,
String parameterName,
MultipartFile file
) {
String normalizedName = requireParameterName(parameterName);
String contentType = resolveUploadContentType(context, normalizedName);
validateFile(file, contentType);
accessGuard.checkUpload(
context.share().getId(),
context.visitorDigest()
);
String path = storageService.save(
file,
"workflow-chat-share/" + context.share().getId()
+ "/" + context.visitorDigest()
);
if (!StringUtils.hasText(path)) {
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
}
try {
redisTemplate.opsForValue().set(
grantKey(context, normalizedName, path),
contentType,
grantTtl(context).toMillis(),
TimeUnit.MILLISECONDS
);
} catch (RuntimeException exception) {
try {
storageService.delete(path);
} catch (RuntimeException cleanupError) {
log.warn("匿名上传授权写入失败后清理文件失败path={}",
path, cleanupError);
}
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
UploadResVo response = new UploadResVo();
response.setPath(path);
return response;
}
/**
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
*/
public void assertOwnedUploads(
WorkflowPublicChatContext context,
Map<String, Object> variables
) {
Map<String, String> uploadFields = resolveUploadFields(context);
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
Object value = variables.get(entry.getKey());
if (value == null) {
continue;
}
if ("image".equals(entry.getValue())) {
assertOwnedImage(context, entry.getKey(), value);
} else {
assertOwnedFiles(context, entry.getKey(), value);
}
}
}
private void assertOwnedImage(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Map<?, ?> image)) {
throw invalidUploadReference(parameterName);
}
String sourceType = trim(image.get("sourceType"));
if ("url".equals(sourceType)) {
String url = trim(image.get("url"));
if (isHttpUrl(url)) {
return;
}
throw invalidUploadReference(parameterName);
}
if (!"upload".equals(sourceType)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(image.get("filePath")),
"image"
);
}
private void assertOwnedFiles(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Collection<?> files)) {
throw invalidUploadReference(parameterName);
}
for (Object item : files) {
if (!(item instanceof Map<?, ?> file)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(file.get("filePath")),
"file"
);
}
}
private void assertGrant(
WorkflowPublicChatContext context,
String parameterName,
String path,
String expectedContentType
) {
if (!StringUtils.hasText(path)) {
throw invalidUploadReference(parameterName);
}
try {
String grantedContentType = redisTemplate.opsForValue().get(
grantKey(context, parameterName, path));
if (!expectedContentType.equals(grantedContentType)) {
throw invalidUploadReference(parameterName);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
}
private String resolveUploadContentType(
WorkflowPublicChatContext context,
String parameterName
) {
String contentType = resolveUploadFields(context).get(parameterName);
if (contentType == null) {
throw new BusinessException(
400,
40032,
"当前发布工作流未声明该上传参数"
);
}
return contentType;
}
@SuppressWarnings("unchecked")
private Map<String, String> resolveUploadFields(
WorkflowPublicChatContext context
) {
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
Map<String, String> fields = new java.util.LinkedHashMap<>();
Object rawSchema = descriptor.get("startFormSchema");
if (!(rawSchema instanceof Collection<?> schema)) {
return fields;
}
for (Object item : schema) {
if (!(item instanceof Map<?, ?> field)) {
continue;
}
String name = trim(field.get("key"));
String contentType = trim(field.get("contentType"));
if (StringUtils.hasText(name)
&& ("file".equals(contentType)
|| "image".equals(contentType))) {
fields.put(name, contentType);
}
}
return fields;
}
private void validateFile(MultipartFile file, String contentType) {
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
long maxSize = "image".equals(contentType)
? IMAGE_MAX_SIZE
: FILE_MAX_SIZE;
if (file.getSize() > maxSize) {
throw new BusinessException(
"image".equals(contentType)
? "单张图片不能超过 10 MiB"
: "单个文件不能超过 100 MiB"
);
}
if (!"image".equals(contentType)) {
return;
}
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
String filename = trim(file.getOriginalFilename());
int dot = filename.lastIndexOf('.');
String extension = dot < 0
? ""
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!IMAGE_MIME_TYPES.contains(mimeType)
&& !IMAGE_EXTENSIONS.contains(extension)) {
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
}
}
private Duration grantTtl(WorkflowPublicChatContext context) {
long expiresIn = context.share().getExpiresAt().getTime()
- System.currentTimeMillis();
long ttl = Math.min(
properties.getUploadGrantTtl().toMillis(),
expiresIn
);
return Duration.ofMillis(Math.max(1L, ttl));
}
private String grantKey(
WorkflowPublicChatContext context,
String parameterName,
String path
) {
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
+ context.visitorDigest() + ":"
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
+ WorkflowSharePolicy.hashShareKey(path);
}
private String requireParameterName(String value) {
String normalized = value == null ? "" : value.trim();
if (!StringUtils.hasText(normalized)) {
throw new BusinessException("上传参数名不能为空");
}
return normalized;
}
private String trim(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private boolean isHttpUrl(String value) {
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
return normalized.startsWith("http://")
|| normalized.startsWith("https://");
}
private BusinessException invalidUploadReference(String parameterName) {
return new BusinessException(
403,
40332,
"上传参数 " + parameterName + " 不属于当前分享访客"
);
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.admin.service.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 工作流匿名分享运行保护参数。
*/
@Component
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
public class WorkflowPublicShareProperties {
private Duration rateWindow = Duration.ofMinutes(1);
private int runVisitorLimit = 5;
private int runShareLimit = 60;
private int uploadVisitorLimit = 10;
private int uploadShareLimit = 60;
private Duration activeLease = Duration.ofMinutes(35);
private Duration uploadGrantTtl = Duration.ofDays(7);
public Duration getRateWindow() {
return rateWindow;
}
public void setRateWindow(Duration rateWindow) {
this.rateWindow = requirePositive(rateWindow, "rateWindow");
}
public int getRunVisitorLimit() {
return runVisitorLimit;
}
public void setRunVisitorLimit(int runVisitorLimit) {
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
}
public int getRunShareLimit() {
return runShareLimit;
}
public void setRunShareLimit(int runShareLimit) {
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
}
public int getUploadVisitorLimit() {
return uploadVisitorLimit;
}
public void setUploadVisitorLimit(int uploadVisitorLimit) {
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
}
public int getUploadShareLimit() {
return uploadShareLimit;
}
public void setUploadShareLimit(int uploadShareLimit) {
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
}
public Duration getActiveLease() {
return activeLease;
}
public void setActiveLease(Duration activeLease) {
this.activeLease = requirePositive(activeLease, "activeLease");
}
public Duration getUploadGrantTtl() {
return uploadGrantTtl;
}
public void setUploadGrantTtl(Duration uploadGrantTtl) {
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
}
private static int requirePositive(int value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
private static Duration requirePositive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
}

View File

@@ -3,17 +3,50 @@ package tech.easyflow.admin.controller.ai;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.testng.Assert; import org.testng.Assert;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** /**
* {@link WorkflowShareController} 分享地址构建测试。 * {@link WorkflowShareController} 分享地址构建测试。
*/ */
public class WorkflowShareControllerTest { public class WorkflowShareControllerTest {
/**
* 验证分享解析仅依赖分享密钥,不读取当前浏览器登录租户。
*/
@Test
public void shouldResolvePublicChatShareWithoutLoginContext()
throws Exception {
WorkflowShareService shareService = mock(WorkflowShareService.class);
WorkflowShare share = new WorkflowShare();
share.setWorkflowId(BigInteger.valueOf(11));
when(shareService.resolvePublicChatShare("share-key"))
.thenReturn(share);
WorkflowShareController controller = new WorkflowShareController();
setField(controller, "workflowShareService", shareService);
BigInteger workflowId = controller.resolveUrlShare(request(Map.of(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
.toLowerCase(Locale.ROOT),
"share-key"
))).getData().get("workflowId");
Assert.assertEquals(workflowId, BigInteger.valueOf(11));
verify(shareService).resolvePublicChatShare("share-key");
}
/** /**
* 验证分享地址保留前端部署基路径。 * 验证分享地址保留前端部署基路径。
* *
@@ -120,4 +153,11 @@ public class WorkflowShareControllerTest {
} }
return 0D; return 0D;
} }
private void setField(Object target, String name, Object value)
throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
} }

View File

@@ -4,13 +4,19 @@ import com.easyagents.flow.core.chain.ChainConsts;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.testng.Assert; import org.testng.Assert;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -69,4 +75,81 @@ public class WorkflowChatEventStreamTest {
WorkflowChatEventStream.visibleFinalOutput(null).isEmpty() WorkflowChatEventStream.visibleFinalOutput(null).isEmpty()
); );
} }
/**
* 验证工作流启动异常也会释放匿名活动执行租约。
*/
@Test
public void shouldCleanupExternalResourceWhenStartFails() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
doThrow(new IllegalStateException("start failed"))
.when(chainExecutor)
.executeAsync(any(), any(), any());
WorkflowChatEventStream eventStream =
new WorkflowChatEventStream(chainExecutor);
AtomicInteger cleanupCount = new AtomicInteger();
Assert.expectThrows(
IllegalStateException.class,
() -> eventStream.start(
"definition",
Map.of(),
cleanupCount::incrementAndGet
)
);
Assert.assertEquals(cleanupCount.get(), 1);
}
/**
* 验证浏览器断开只分离 SSE不取消仍在运行的工作流。
*/
@Test
public void shouldKeepRuntimeRunningWhenBrowserDisconnects() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
Consumer<String> beforeStart = invocation.getArgument(2);
beforeStart.accept("execution-1");
return "execution-1";
}).when(chainExecutor).executeAsync(any(), any(), any());
CapturingSseEmitter emitter = new CapturingSseEmitter();
WorkflowChatEventStream eventStream =
new WorkflowChatEventStream(chainExecutor) {
@Override
SseEmitter createEmitter() {
return emitter;
}
};
AtomicInteger cleanupCount = new AtomicInteger();
eventStream.start(
"definition",
Map.of(),
cleanupCount::incrementAndGet,
Duration.ofMinutes(35)
);
emitter.disconnect();
verify(chainExecutor, never()).cancel(any(), any());
Assert.assertEquals(cleanupCount.get(), 0);
eventStream.shutdown();
Assert.assertEquals(cleanupCount.get(), 1);
}
private static final class CapturingSseEmitter extends SseEmitter {
private Runnable completion;
@Override
public synchronized void onCompletion(Runnable callback) {
this.completion = callback;
}
private void disconnect() {
Assert.assertNotNull(completion);
completion.run();
}
}
} }

View File

@@ -0,0 +1,86 @@
package tech.easyflow.admin.service.ai;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatAccessGuard} Redis 失败关闭测试。
*/
public class WorkflowPublicChatAccessGuardTest {
@Test
public void shouldExposeDocumentedProtectionDefaults() {
WorkflowPublicShareProperties properties =
new WorkflowPublicShareProperties();
Assert.assertEquals(properties.getRunVisitorLimit(), 5);
Assert.assertEquals(properties.getRunShareLimit(), 60);
Assert.assertEquals(properties.getUploadVisitorLimit(), 10);
Assert.assertEquals(properties.getUploadShareLimit(), 60);
Assert.assertEquals(properties.getRateWindow(), Duration.ofMinutes(1));
Assert.assertEquals(properties.getActiveLease(), Duration.ofMinutes(35));
}
@Test
public void shouldReturn429WhenFixedWindowIsExceeded() {
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
when(redisTemplate.execute(
any(DefaultRedisScript.class),
anyList(),
anyString(),
anyString(),
anyString()
)).thenReturn(0L);
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
redisTemplate,
lockExecutor,
new WorkflowPublicShareProperties()
);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> guard.checkRun(BigInteger.ONE, "visitor")
);
Assert.assertEquals(error.getHttpStatus(), 429);
}
@Test
public void shouldReturn503WhenRedisRateLimitIsUnavailable() {
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
when(redisTemplate.execute(
any(DefaultRedisScript.class),
anyList(),
anyString(),
anyString(),
anyString()
)).thenThrow(new IllegalStateException("redis unavailable"));
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
redisTemplate,
lockExecutor,
new WorkflowPublicShareProperties()
);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> guard.checkUpload(BigInteger.ONE, "visitor")
);
Assert.assertEquals(error.getHttpStatus(), 503);
}
}

View File

@@ -0,0 +1,136 @@
package tech.easyflow.admin.service.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatContextResolver} 匿名主体边界测试。
*/
public class WorkflowPublicChatContextResolverTest {
@Test
public void shouldUseCurrentShareCreatorAsPermissionSubject() {
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
WorkflowPublicChatContext context = fixture.resolver.resolveActive(
"share-key",
"00112233445566778899aabbccddeeff"
);
Assert.assertEquals(context.creator().getId(), BigInteger.TEN);
Assert.assertEquals(context.creator().getTenantId(), BigInteger.ONE);
Assert.assertEquals(
context.visitorDigest(),
WorkflowSharePolicy.hashChatVisitor(
"share-key",
"00112233445566778899aabbccddeeff"
)
);
}
@Test
public void shouldRejectDisabledShareCreator() {
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.resolver.resolveActive(
"share-key",
"00112233445566778899aabbccddeeff"
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertTrue(error.getMessage().contains("创建者账号"));
}
@Test
public void shouldResolveHistoricalShareWithoutCurrentCreatorCheck() {
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
WorkflowPublicChatContext context = fixture.resolver.resolveHistorical(
"share-key",
"00112233445566778899aabbccddeeff"
);
Assert.assertNull(context.creator());
Assert.assertNull(context.workflow());
verify(fixture.accountService, never()).getById(BigInteger.TEN);
}
@Test
public void shouldRejectMalformedVisitorIdentity() {
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.resolver.resolveActive("share-key", "short")
);
Assert.assertEquals(error.getErrorCode(), 40031);
}
private Fixture fixture(Integer accountStatus) {
WorkflowShareService shareService = mock(WorkflowShareService.class);
WorkflowService workflowService = mock(WorkflowService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setWorkflowId(BigInteger.valueOf(11));
share.setTenantId(BigInteger.ONE);
share.setCreatedBy(BigInteger.TEN);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
workflow.setTenantId(BigInteger.ONE);
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
workflow.setPublishedSnapshotJson(Map.of("content", "{}"));
SysAccount account = new SysAccount();
account.setId(BigInteger.TEN);
account.setTenantId(BigInteger.ONE);
account.setStatus(accountStatus);
when(shareService.resolvePublicChatShare("share-key"))
.thenReturn(share);
when(shareService.resolveHistoricalChatShare("share-key"))
.thenReturn(share);
when(workflowService.getPublishedById(BigInteger.valueOf(11)))
.thenReturn(workflow);
when(accountService.getById(BigInteger.TEN)).thenReturn(account);
return new Fixture(
new WorkflowPublicChatContextResolver(
shareService,
workflowService,
accountService
),
accountService
);
}
private record Fixture(
WorkflowPublicChatContextResolver resolver,
SysAccountService accountService
) {
}
}

View File

@@ -0,0 +1,229 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.testng.Assert;
import org.testng.annotations.Test;
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.WorkflowShare;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatService} 匿名执行归属测试。
*/
public class WorkflowPublicChatServiceTest {
@Test
public void shouldSeparatePermissionSubjectFromExecutionOwner() {
Fixture fixture = fixture();
RedisLockExecutor.LockHandle activity = mock(
RedisLockExecutor.LockHandle.class);
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("{}"), anyMap())).thenReturn(new LinkedHashMap<>());
when(fixture.accessGuard.acquireActivity(
BigInteger.valueOf(7), "visitor-digest"))
.thenReturn(activity);
when(fixture.accessGuard.activityLease())
.thenReturn(Duration.ofMinutes(35));
when(fixture.eventStream.start(
eq(PublishedWorkflowDefinitionIds.published("11")),
anyMap(),
any(Runnable.class),
eq(Duration.ofMinutes(35))
)).thenReturn(new SseEmitter());
fixture.service.run("share-key", visitorId(), Map.of());
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> variables = ArgumentCaptor
.forClass((Class) Map.class);
verify(fixture.eventStream).start(
eq(PublishedWorkflowDefinitionIds.published("11")),
variables.capture(),
any(Runnable.class),
eq(Duration.ofMinutes(35))
);
Assert.assertSame(
variables.getValue().get(Constants.LOGIN_USER_KEY),
fixture.context.creator()
);
Assert.assertEquals(
variables.getValue().get(WorkFlowUtil.CREATED_KEY_MEMORY_KEY),
"WORKFLOW_CHAT_SHARE:7"
);
Assert.assertEquals(
variables.getValue().get(WorkFlowUtil.CREATED_BY_MEMORY_KEY),
"visitor-digest"
);
}
@Test
public void shouldRejectExecutionOwnedByAnotherVisitor() {
Fixture fixture = fixture();
WorkflowExecResult record = new WorkflowExecResult();
record.setWorkflowId(BigInteger.valueOf(11));
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
record.setCreatedBy("another-visitor");
when(fixture.execResultService.getByExecKey("execution-1"))
.thenReturn(record);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.detail(
"share-key", visitorId(), "execution-1")
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40333);
}
@Test
public void shouldExposeMinimalRuntimeStateForRefreshRecovery() {
Fixture fixture = fixture();
WorkflowExecResult record = ownedRecord();
when(fixture.execResultService.getByExecKey("execution-1"))
.thenReturn(record);
when(fixture.execStepService.list(any(QueryWrapper.class)))
.thenReturn(List.of());
ChainStateRepository repository = mock(ChainStateRepository.class);
ChainState state = new ChainState();
state.setStatus(ChainStatus.SUSPEND);
state.setMessage("请确认是否继续");
state.setSuspendForParameters(List.of(new Parameter("approved")));
when(fixture.chainExecutor.getChainStateRepository())
.thenReturn(repository);
when(repository.load("execution-1")).thenReturn(state);
Map<String, Object> detail = fixture.service.detail(
"share-key", visitorId(), "execution-1");
@SuppressWarnings("unchecked")
Map<String, Object> runtime =
(Map<String, Object>) detail.get("runtime");
Assert.assertEquals(runtime.get("status"), "SUSPEND");
Assert.assertEquals(runtime.get("statusValue"), 5);
Assert.assertEquals(runtime.get("message"), "请确认是否继续");
Assert.assertEquals(
((List<?>) runtime.get("parameters")).size(),
1
);
}
private WorkflowExecResult ownedRecord() {
WorkflowExecResult record = new WorkflowExecResult();
record.setId(BigInteger.valueOf(31));
record.setWorkflowId(BigInteger.valueOf(11));
record.setExecKey("execution-1");
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
record.setCreatedBy("visitor-digest");
return record;
}
private Fixture fixture() {
WorkflowPublicChatContextResolver contextResolver = mock(
WorkflowPublicChatContextResolver.class);
WorkflowCheckService workflowCheckService = mock(
WorkflowCheckService.class);
WorkflowRunningParameterResolver parameterResolver = mock(
WorkflowRunningParameterResolver.class);
WorkflowPublicChatUploadService uploadService = mock(
WorkflowPublicChatUploadService.class);
WorkflowPublicChatAccessGuard accessGuard = mock(
WorkflowPublicChatAccessGuard.class);
WorkflowChatEventStream eventStream = mock(
WorkflowChatEventStream.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
WorkflowExecResultService execResultService = mock(
WorkflowExecResultService.class);
WorkflowExecStepService execStepService = mock(
WorkflowExecStepService.class);
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setWorkflowId(BigInteger.valueOf(11));
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
workflow.setContent("{}");
LoginAccount creator = new LoginAccount();
creator.setId(BigInteger.TEN);
creator.setTenantId(BigInteger.ONE);
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
share,
workflow,
creator,
"share-key",
"visitor-digest"
);
when(contextResolver.resolveActive("share-key", visitorId()))
.thenReturn(context);
when(contextResolver.resolveHistorical("share-key", visitorId()))
.thenReturn(context);
WorkflowPublicChatService service = new WorkflowPublicChatService(
contextResolver,
workflowCheckService,
parameterResolver,
uploadService,
accessGuard,
eventStream,
chainExecutor,
execResultService,
execStepService
);
return new Fixture(
service,
context,
parameterResolver,
accessGuard,
eventStream,
chainExecutor,
execResultService,
execStepService
);
}
private String visitorId() {
return "00112233445566778899aabbccddeeff";
}
private record Fixture(
WorkflowPublicChatService service,
WorkflowPublicChatContext context,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
}
}

View File

@@ -0,0 +1,168 @@
package tech.easyflow.admin.service.ai;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.multipart.MultipartFile;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowPublicChatUploadService} 上传边界测试。
*/
public class WorkflowPublicChatUploadServiceTest {
@Test
public void shouldStoreDeclaredFileUnderVisitorScope() {
Fixture fixture = fixture("file");
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getOriginalFilename()).thenReturn("input.pdf");
when(fixture.storageService.save(
eq(file), anyString())).thenReturn("/files/input.pdf");
fixture.service.upload(fixture.context, "attachment", file);
verify(fixture.accessGuard).checkUpload(
BigInteger.valueOf(7), "visitor-digest");
verify(fixture.storageService).save(
file,
"workflow-chat-share/7/visitor-digest"
);
}
@Test
public void shouldRejectReferenceWithoutCurrentVisitorGrant() {
Fixture fixture = fixture("file");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.assertOwnedUploads(
fixture.context,
Map.of("attachment", List.of(Map.of(
"fileName", "input.pdf",
"filePath", "/files/other.pdf"
)))
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40332);
}
@Test
public void shouldRejectGrantCreatedForDifferentParameterType() {
Fixture fixture = fixture("image");
when(fixture.valueOperations.get(anyString())).thenReturn("file");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.assertOwnedUploads(
fixture.context,
Map.of("attachment", Map.of(
"sourceType", "upload",
"filePath", "/files/input.png"
))
)
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 40332);
}
@Test
public void shouldRejectUnsupportedImageType() {
Fixture fixture = fixture("image");
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn("image/svg+xml");
when(file.getOriginalFilename()).thenReturn("input.svg");
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> fixture.service.upload(
fixture.context, "attachment", file)
);
Assert.assertTrue(error.getMessage().contains("PNG"));
}
@SuppressWarnings("unchecked")
private Fixture fixture(String contentType) {
WorkflowRunningParameterResolver parameterResolver = mock(
WorkflowRunningParameterResolver.class);
WorkflowPublicChatAccessGuard accessGuard = mock(
WorkflowPublicChatAccessGuard.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(
ValueOperations.class);
FileStorageService storageService = mock(FileStorageService.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(11));
when(parameterResolver.buildRunningParametersView(workflow))
.thenReturn(Map.of(
"startFormSchema",
List.of(Map.of(
"key", "attachment",
"contentType", contentType
))
));
WorkflowShare share = new WorkflowShare();
share.setId(BigInteger.valueOf(7));
share.setExpiresAt(new Date(
System.currentTimeMillis() + 60_000L));
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
share,
workflow,
null,
"share-key",
"visitor-digest"
);
WorkflowPublicChatUploadService service =
new WorkflowPublicChatUploadService(
parameterResolver,
accessGuard,
new WorkflowPublicShareProperties(),
redisTemplate,
storageService
);
return new Fixture(
service,
context,
accessGuard,
redisTemplate,
valueOperations,
storageService
);
}
private record Fixture(
WorkflowPublicChatUploadService service,
WorkflowPublicChatContext context,
WorkflowPublicChatAccessGuard accessGuard,
StringRedisTemplate redisTemplate,
ValueOperations<String, String> valueOperations,
FileStorageService storageService
) {
}
}

View File

@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setStartTime(new Date()); record.setStartTime(new Date());
record.setStatus(state.getStatus().getValue()); record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain)); record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString()); record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。 // 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
try { try {
workflowExecResultService.save(record); workflowExecResultService.save(record);

View File

@@ -59,6 +59,8 @@ public class WorkflowCheckService {
private static final String SYSTEM_START_PARAM_NAME = "user_input"; private static final String SYSTEM_START_PARAM_NAME = "user_input";
private static final int MIN_LOOP_COUNT = 1; private static final int MIN_LOOP_COUNT = 1;
private static final int MAX_LOOP_COUNT = 300; private static final int MAX_LOOP_COUNT = 300;
private static final String JOIN_MODE_ANY = "any";
private static final String JOIN_MODE_ALL = "all";
@Resource @Resource
private WorkflowService workflowService; private WorkflowService workflowService;
@@ -196,6 +198,10 @@ public class WorkflowCheckService {
edge.id = trimToNull(edgeJson.getString("id")); edge.id = trimToNull(edgeJson.getString("id"));
edge.source = trimToNull(edgeJson.getString("source")); edge.source = trimToNull(edgeJson.getString("source"));
edge.target = trimToNull(edgeJson.getString("target")); edge.target = trimToNull(edgeJson.getString("target"));
JSONObject edgeData = edgeJson.getJSONObject("data");
edge.condition = edgeData == null
? null
: trimToNull(edgeData.getString("condition"));
if (!StringUtils.hasText(edge.id)) { if (!StringUtils.hasText(edge.id)) {
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null); addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
@@ -228,10 +234,162 @@ public class WorkflowCheckService {
parsedWorkflow.nodes = nodes; parsedWorkflow.nodes = nodes;
parsedWorkflow.edges = edges; parsedWorkflow.edges = edges;
parsedWorkflow.nodeMap = nodeMap; parsedWorkflow.nodeMap = nodeMap;
checkJoinModes(parsedWorkflow, issues, issueKeys);
checkDatacenterNodes(parsedWorkflow, issues, issueKeys); checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
return parsedWorkflow; return parsedWorkflow;
} }
/**
* 校验节点汇聚模式及其静态可证明的到达安全性。
*
* @param parsed 工作流视图
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkJoinModes(
ParsedWorkflow parsed,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
Map<String, List<EdgeView>> inwardEdges = new LinkedHashMap<>();
for (EdgeView edge : parsed.edges) {
if (edge == null || !StringUtils.hasText(edge.target)) {
continue;
}
inwardEdges.computeIfAbsent(
edge.target, ignored -> new ArrayList<>()).add(edge);
}
for (NodeView node : parsed.nodes) {
String joinMode = resolveJoinMode(node);
if (joinMode == null) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_INVALID",
"执行时机配置无效joinMode 仅支持 any 或 all",
node.id,
null,
node.name);
continue;
}
if (JOIN_MODE_ALL.equals(joinMode)
&& StringUtils.hasText(node.parentId)) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_LOOP_CHILD_UNSUPPORTED",
"显式循环子图暂不支持“全部上游完成”,请改为“任一上游完成”",
node.id,
null,
node.name);
}
}
Set<String> guaranteedNodes = findGuaranteedNodes(
parsed, inwardEdges);
for (NodeView node : parsed.nodes) {
if (!JOIN_MODE_ALL.equals(resolveJoinMode(node))
|| StringUtils.hasText(node.parentId)) {
continue;
}
List<EdgeView> directInward = inwardEdges.getOrDefault(
node.id, Collections.emptyList());
if (directInward.size() <= 1) {
continue;
}
boolean allGuaranteed = directInward.stream().allMatch(edge ->
!edge.hasCondition()
&& guaranteedNodes.contains(edge.source));
if (!allGuaranteed) {
addIssue(
issues,
issueKeys,
"JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED",
"“全部上游完成”可能永久等待:存在条件、互斥或无法证明必达的上游路径。"
+ "请改为“任一上游完成”或调整连线,确保所有直接入边都会到达",
node.id,
null,
node.name);
}
}
}
/**
* 使用保守固定点传播计算能够保证执行的根级节点。
*
* @param parsed 工作流视图
* @param inwardEdges 直接入边索引
* @return 保证执行的节点 ID
*/
private Set<String> findGuaranteedNodes(
ParsedWorkflow parsed,
Map<String, List<EdgeView>> inwardEdges) {
Set<String> guaranteed = parsed.nodes.stream()
.filter(NodeView::isRootLevel)
.filter(node -> TYPE_START.equals(node.type))
.map(node -> node.id)
.filter(StringUtils::hasText)
.collect(Collectors.toCollection(LinkedHashSet::new));
boolean changed;
do {
changed = false;
for (NodeView node : parsed.nodes) {
if (!node.isRootLevel()
|| guaranteed.contains(node.id)
|| hasAdvancedCondition(node)) {
continue;
}
String joinMode = resolveJoinMode(node);
if (joinMode == null) {
continue;
}
List<EdgeView> directInward = inwardEdges.getOrDefault(
node.id, Collections.emptyList());
boolean isGuaranteed;
if (JOIN_MODE_ALL.equals(joinMode)) {
isGuaranteed = !directInward.isEmpty()
&& directInward.stream().allMatch(edge ->
!edge.hasCondition()
&& guaranteed.contains(edge.source));
} else {
isGuaranteed = directInward.stream().anyMatch(edge ->
!edge.hasCondition()
&& guaranteed.contains(edge.source));
}
if (isGuaranteed && guaranteed.add(node.id)) {
changed = true;
}
}
} while (changed);
return guaranteed;
}
/**
* 读取节点汇聚模式。字段缺失时兼容为 any显式非法值返回 null。
*/
private String resolveJoinMode(NodeView node) {
if (node == null || node.data == null
|| !node.data.containsKey("joinMode")) {
return JOIN_MODE_ANY;
}
String value = trimToNull(node.data.getString("joinMode"));
if (JOIN_MODE_ANY.equalsIgnoreCase(value)) {
return JOIN_MODE_ANY;
}
if (JOIN_MODE_ALL.equalsIgnoreCase(value)) {
return JOIN_MODE_ALL;
}
return null;
}
private boolean hasAdvancedCondition(NodeView node) {
return node != null
&& node.data != null
&& StringUtils.hasText(
trimToNull(node.data.getString("condition")));
}
/** /**
* 校验普通循环、显式循环和循环父子层级。 * 校验普通循环、显式循环和循环父子层级。
* *
@@ -1610,5 +1768,10 @@ public class WorkflowCheckService {
private String id; private String id;
private String source; private String source;
private String target; private String target;
private String condition;
private boolean hasCondition() {
return StringUtils.hasText(condition);
}
} }
} }

View File

@@ -139,7 +139,7 @@ public class WorkflowRunningParameterResolver {
} }
/** /**
* 归一化工作流运行时变量,确保文件参数统一文件对象数组 * 归一化工作流运行时变量,统一文件结构并移除空图片值
* *
* @param content 工作流内容 * @param content 工作流内容
* @param variables 原始运行变量 * @param variables 原始运行变量
@@ -162,7 +162,13 @@ public class WorkflowRunningParameterResolver {
if (isFileParameter(parameter)) { if (isFileParameter(parameter)) {
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name)); normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
} else if (isImageParameter(parameter)) { } else if (isImageParameter(parameter)) {
normalized.put(name, normalizeImageVariableValue(normalized.get(name), name)); Object imageValue = normalizeImageVariableValue(
normalized.get(name), name);
if (imageValue == null) {
normalized.remove(name);
} else {
normalized.put(name, imageValue);
}
} }
} }
return normalized; return normalized;
@@ -683,6 +689,10 @@ public class WorkflowRunningParameterResolver {
if (value == null) { if (value == null) {
return; return;
} }
if (value instanceof String stringValue
&& !StringUtils.hasText(stringValue)) {
return;
}
if (value instanceof Collection<?> collection) { if (value instanceof Collection<?> collection) {
for (Object item : collection) { for (Object item : collection) {
collectFileValues(item, result); collectFileValues(item, result);

View File

@@ -92,4 +92,22 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
* @return 有效对话分享记录 * @return 有效对话分享记录
*/ */
WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId); WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId);
/**
* 跨租户解析当前有效的匿名对话分享。
*
* @param shareKey 原始分享密钥
* @return 有效且指向严格发布工作流的分享记录
*/
WorkflowShare resolvePublicChatShare(String shareKey);
/**
* 跨租户解析匿名对话分享的历史记录。
*
* <p>仅用于详情和取消已发起执行,不校验分享状态、有效期与当前发布态。</p>
*
* @param shareKey 原始分享密钥
* @return 对话分享记录
*/
WorkflowShare resolveHistoricalChatShare(String shareKey);
} }

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.service.impl; package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import com.mybatisflex.spring.service.impl.ServiceImpl; import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
@@ -23,6 +24,7 @@ import java.math.BigInteger;
import java.time.Duration; import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -184,6 +186,61 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT); return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT);
} }
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare resolvePublicChatShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
return TenantManager.withoutTenantCondition(() -> {
WorkflowShare share = findShare(
shareKey,
WorkflowSharePurpose.CHAT,
true
);
if (share == null) {
throw invalidShare();
}
if (share.getExpiresAt() == null
|| !share.getExpiresAt().after(new Date())) {
throw new BusinessException(403, 403, "工作流分享链接已过期");
}
Workflow workflow = workflowService.getPublishedById(
share.getWorkflowId());
if (workflow == null
|| !Objects.equals(
share.getTenantId(), workflow.getTenantId())) {
throw invalidShare();
}
if (!isStrictlyPublished(workflow)) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
return share;
});
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare resolveHistoricalChatShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
WorkflowShare share = TenantManager.withoutTenantCondition(
() -> findShare(
shareKey,
WorkflowSharePurpose.CHAT,
false
));
if (share == null) {
throw invalidShare();
}
return share;
}
/** /**
* 按用途校验并解析分享。 * 按用途校验并解析分享。
* *
@@ -200,10 +257,7 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
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 = findShare(shareKey, purpose, true);
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
.eq(WorkflowShare::getSharePurpose, purpose.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();
} }
@@ -220,6 +274,34 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
return share; return share;
} }
/**
* 按密钥与用途查询分享记录。
*
* @param shareKey 原始分享密钥
* @param purpose 分享用途
* @param activeOnly 是否仅查询启用记录
* @return 分享记录
*/
private WorkflowShare findShare(
String shareKey,
WorkflowSharePurpose purpose,
boolean activeOnly
) {
QueryWrapper query = QueryWrapper.create()
.eq(
WorkflowShare::getShareKeyHash,
WorkflowSharePolicy.hashShareKey(shareKey)
)
.eq(WorkflowShare::getSharePurpose, purpose.name());
if (activeOnly) {
query.eq(
WorkflowShare::getStatus,
KnowledgeShareStatus.ENABLED.name()
);
}
return getOne(query);
}
/** /**
* 在锁保护下创建或替换工作流的唯一分享记录。 * 在锁保护下创建或替换工作流的唯一分享记录。
* *

View File

@@ -9,6 +9,8 @@ import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Set; import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** /**
* 工作流协作分享的密钥、时效与接口授权策略。 * 工作流协作分享的密钥、时效与接口授权策略。
@@ -25,6 +27,11 @@ public final class WorkflowSharePolicy {
*/ */
public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key"; public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key";
/**
* 工作流对话分享访客标识请求头。
*/
public static final String CHAT_VISITOR_HEADER = "X-Workflow-Chat-Visitor";
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 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(
@@ -67,6 +74,28 @@ public final class WorkflowSharePolicy {
} }
} }
/**
* 计算匿名访客的不可逆执行归属摘要。
*
* @param shareKey 原始分享密钥
* @param visitorId 当前标签页访客标识
* @return HMAC-SHA256 前 16 字节的小写十六进制摘要
*/
public static String hashChatVisitor(String shareKey, String visitorId) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
shareKey.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
));
byte[] digest = mac.doFinal(
visitorId.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 16);
} catch (Exception exception) {
throw new IllegalStateException("HmacSHA256 unavailable", exception);
}
}
/** /**
* 计算默认过期时间。 * 计算默认过期时间。
* *

View File

@@ -19,6 +19,7 @@ public class WorkFlowUtil {
public final static String WORKFLOW_CHAT_SHARE = "WORKFLOW_CHAT_SHARE"; 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";
public final static String CREATED_BY_MEMORY_KEY = "workflowCreatedBy";
public static String removeSensitiveInfo(String originJson) { public static String removeSensitiveInfo(String originJson) {
JSONObject workflowInfo = JSON.parseObject(originJson); JSONObject workflowInfo = JSON.parseObject(originJson);
@@ -56,6 +57,35 @@ public class WorkFlowUtil {
return value == null ? USER_KEY : String.valueOf(value); return value == null ? USER_KEY : String.valueOf(value);
} }
/**
* 获取工作流执行记录的归属主体。
*
* <p>匿名分享可覆盖为访客摘要;其他入口继续使用权限主体账号 ID。</p>
*
* @param chain 当前工作流执行链
* @return 执行归属主体
*/
public static String getCreatedBy(Chain chain) {
Object value = chain.getExecutionState()
.getMemory()
.get(CREATED_BY_MEMORY_KEY);
if (value != null) {
return String.valueOf(value);
}
LoginAccount operator = getOperator(chain);
return operator.getId() == null ? "0" : operator.getId().toString();
}
/**
* 构建工作流匿名分享的执行来源标识。
*
* @param shareId 分享记录 ID
* @return 执行来源标识
*/
public static String publicChatShareCreatedKey(BigInteger shareId) {
return WORKFLOW_CHAT_SHARE + ":" + shareId;
}
public static LoginAccount defaultAccount() { public static LoginAccount defaultAccount() {
LoginAccount account = new LoginAccount(); LoginAccount account = new LoginAccount();
account.setId(new BigInteger("0")); account.setId(new BigInteger("0"));

View File

@@ -22,6 +22,163 @@ import java.util.Map;
public class WorkflowCheckServiceTest { public class WorkflowCheckServiceTest {
@Test
public void testSaveAndPreExecuteShouldPassGuaranteedAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("分支 A")),
node("b", "codeNode", null, data("分支 B")),
node("join", "codeNode", null, joinData),
node("end", "endNode", null, data("结束"))),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join"),
edge("join-end", "join", "end")));
Assert.assertTrue(service.checkContent(
content, WorkflowCheckStage.SAVE, null).isPassed());
Assert.assertTrue(service.checkContent(
content, WorkflowCheckStage.PRE_EXECUTE, null).isPassed());
}
@Test
public void testSaveAndPreExecuteShouldBlockConditionalAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("条件来源")),
node("b", "codeNode", null, data("普通来源")),
node("join", "codeNode", null, joinData),
node("end", "endNode", null, data("结束"))),
array(
conditionalEdge("start-a", "start", "a", "enabled === true"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join"),
edge("join-end", "join", "end")));
WorkflowCheckResult save = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
WorkflowCheckResult preExecute = service.checkContent(
content, WorkflowCheckStage.PRE_EXECUTE, null);
Assert.assertFalse(save.isPassed());
Assert.assertFalse(preExecute.isPassed());
assertHasCode(save, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
assertHasCode(preExecute, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
Assert.assertTrue(save.getIssues().stream().anyMatch(issue ->
"join".equals(issue.getNodeId())
&& issue.getMessage().contains("永久等待")
&& issue.getMessage().contains("任一上游完成")));
}
@Test
public void testSaveShouldBlockAllJoinWithDirectConditionalEdge() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, data("分支 A")),
node("b", "codeNode", null, data("分支 B")),
node("join", "codeNode", null, joinData)),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
conditionalEdge("a-join", "a", "join", "matched === true"),
edge("b-join", "b", "join")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
}
@Test
public void testSaveShouldBlockAllJoinFromCustomConditionSource() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject conditionalSource = data("高级条件来源");
conditionalSource.put("condition", "score > 0");
JSONObject joinData = data("汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("a", "codeNode", null, conditionalSource),
node("b", "codeNode", null, data("普通来源")),
node("join", "codeNode", null, joinData)),
array(
edge("start-a", "start", "a"),
edge("start-b", "start", "b"),
edge("a-join", "a", "join"),
edge("b-join", "b", "join")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
}
@Test
public void testSaveShouldBlockInvalidAndLoopChildJoinModes() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject invalidData = data("非法汇聚");
invalidData.put("joinMode", "first");
JSONObject loopData = loopData(
fixedParameter("count", "2", "Number"), null);
JSONObject childData = data("循环子节点");
childData.put("joinMode", "all");
String content = workflowJson(
array(
node("invalid", "codeNode", null, invalidData),
node("loop", "loopNode", null, loopData),
node("child", "codeNode", "loop", childData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "JOIN_MODE_INVALID");
assertHasCode(result, "JOIN_MODE_LOOP_CHILD_UNSUPPORTED");
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
"invalid".equals(issue.getNodeId())
&& "JOIN_MODE_INVALID".equals(issue.getCode())));
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
"child".equals(issue.getNodeId())
&& "JOIN_MODE_LOOP_CHILD_UNSUPPORTED".equals(issue.getCode())));
}
@Test
public void testSaveShouldAllowSingleConditionalInboundAllJoin() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject joinData = data("单入边汇聚");
joinData.put("joinMode", "all");
String content = workflowJson(
array(
node("start", "startNode", null, data("开始")),
node("join", "codeNode", null, joinData)),
array(conditionalEdge(
"start-join", "start", "join", "enabled === true")));
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
/** /**
* 验证保存阶段接受合法的正则条件规则。 * 验证保存阶段接受合法的正则条件规则。
*/ */
@@ -992,4 +1149,13 @@ public class WorkflowCheckServiceTest {
edge.put("target", target); edge.put("target", target);
return edge; return edge;
} }
private static JSONObject conditionalEdge(
String id, String source, String target, String condition) {
JSONObject edge = edge(id, source, target);
JSONObject data = new JSONObject();
data.put("condition", condition);
edge.put("data", data);
return edge;
}
} }

View File

@@ -219,6 +219,38 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>); Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
} }
/**
* 空文件参数应统一归一化为空数组,避免旧客户端空字符串触发格式错误。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldTreatBlankFileValuesAsEmptyList()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Object[] emptyValues = {null, "", " ", List.of()};
for (Object emptyValue : emptyValues) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", emptyValue);
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables);
Assert.assertEquals(List.of(), normalized.get("attachments"));
}
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", List.of(
" ",
"https://files.example.com/contracts/contract.docx"));
List<?> normalizedFiles = (List<?>) resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables).get("attachments");
Assert.assertEquals(1, normalizedFiles.size());
}
/** /**
* 文件参数应接受远程 URL 字符串数组并自动提取文件名。 * 文件参数应接受远程 URL 字符串数组并自动提取文件名。
* *
@@ -446,6 +478,30 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertEquals("https://example.com/image.png", image.get("url")); Assert.assertEquals("https://example.com/image.png", image.get("url"));
} }
/**
* 空图片参数应从运行变量中移除,避免向执行引擎的并发 Map 写入 null。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldRemoveBlankImageValues()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Object[] emptyValues = {null, "", " "};
for (Object emptyValue : emptyValues) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("image_input", emptyValue);
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithImageStartParameter(),
variables);
Assert.assertFalse(normalized.containsKey("image_input"));
Assert.assertFalse(normalized.containsValue(null));
}
}
/** /**
* 运行入口不应接收 Data URI避免 Base64 写入工作流状态和审计参数。 * 运行入口不应接收 Data URI避免 Base64 写入工作流状态和审计参数。
* *

View File

@@ -24,6 +24,26 @@ public class WorkflowSharePolicyTest {
Assert.assertNotEquals("share-key", first); Assert.assertNotEquals("share-key", first);
} }
/**
* 验证匿名访客归属摘要稳定、定长且按分享密钥隔离。
*/
@Test
public void shouldHashChatVisitorPerShareWithoutLeakingIdentity() {
String visitorId = "00112233445566778899aabbccddeeff";
String first = WorkflowSharePolicy.hashChatVisitor(
"share-key-a", visitorId);
String second = WorkflowSharePolicy.hashChatVisitor(
"share-key-a", visitorId);
String otherShare = WorkflowSharePolicy.hashChatVisitor(
"share-key-b", visitorId);
Assert.assertEquals(first, second);
Assert.assertEquals(32, first.length());
Assert.assertNotEquals(first, otherShare);
Assert.assertFalse(first.contains(visitorId));
}
/** /**
* 验证默认过期时间为创建时间后 30 分钟。 * 验证默认过期时间为创建时间后 30 分钟。
*/ */

View File

@@ -21,12 +21,11 @@ import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import { import {
isWorkflowShareRequest, isWorkflowShareRequest,
readWorkflowShareKey, withWorkflowShareHeaders,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
} from '#/utils/workflow-share-context'; } from '#/utils/workflow-share-context';
import { refreshTokenApi } from './core'; import { refreshTokenApi } from './core';
import { isInactiveSseRequest } from './sseRequestLifecycle';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const ERROR_MESSAGE_DEDUP_WINDOW = 800; const ERROR_MESSAGE_DEDUP_WINDOW = 800;
@@ -101,12 +100,15 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers['easyflow-token'] = formatToken(accessStore.accessToken); config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale; config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey(); const workflowShareHeaders = withWorkflowShareHeaders(
if ( {},
workflowShareKey && {
isWorkflowShareRequest(config.url, config.method) requestMethod: config.method,
) { requestUrl: config.url,
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey; },
);
for (const [name, value] of Object.entries(workflowShareHeaders)) {
config.headers[name] = value;
} }
return config; return config;
}, },
@@ -132,6 +134,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
doRefreshToken, doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken ?? false, enableRefreshToken: preferences.app.enableRefreshToken ?? false,
formatToken, formatToken,
shouldHandleUnauthorized: (config) =>
!isWorkflowShareRequest(config?.url, config?.method),
}), }),
); );
@@ -186,7 +190,7 @@ export function createEventStreamHeaders(
headers[key] = value; headers[key] = value;
}); });
} }
return withWorkflowShareHeader(headers, { return withWorkflowShareHeaders(headers, {
requestMethod: 'POST', requestMethod: 'POST',
requestUrl, requestUrl,
}); });
@@ -274,15 +278,25 @@ export class SseClient {
options?.onMessage?.(event); options?.onMessage?.(event);
} }
} catch (innerError) { } catch (innerError) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
options?.onError?.(innerError); options?.onError?.(innerError);
return;
} }
// 只有在还是同一个请求的情况下才调用 onFinished // 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) { if (
!isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
options?.onFinished?.(); options?.onFinished?.();
} }
} catch (error) { } catch (error) {
if (this.currentRequestId !== currentRequestId) { if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return; return;
} }
console.error('SSE错误:', error); console.error('SSE错误:', error);

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isInactiveSseRequest } from './sseRequestLifecycle';
describe('sseRequestLifecycle', () => {
it('treats an explicit abort as an inactive request', () => {
const controller = new AbortController();
controller.abort();
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(true);
});
it('treats a superseded request as inactive', () => {
const controller = new AbortController();
expect(isInactiveSseRequest(controller.signal, 2, 1)).toBe(true);
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/**
* 判断 SSE 请求是否已被主动中止或被后续请求替换。
*/
export function isInactiveSseRequest(
signal: AbortSignal,
currentRequestId: number,
requestId: number,
) {
return signal.aborted || currentRequestId !== requestId;
}

View File

@@ -0,0 +1,42 @@
import { createMemoryHistory, createRouter } from 'vue-router';
import { useAccessStore } from '@easyflow/stores';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { createRouterGuard } from '../guard';
describe('public route guard', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('bypasses stale login state for an anonymous workflow share', async () => {
const accessStore = useAccessStore();
accessStore.setAccessToken('stale-token');
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
component: { template: '<div>login</div>' },
name: 'Login',
path: '/auth/login',
},
{
component: { template: '<div>workflow share</div>' },
meta: { ignoreAccess: true, title: 'Workflow Share' },
name: 'WorkflowShare',
path: '/share/workflow',
},
],
});
createRouterGuard(router);
await router.push('/share/workflow?shareKey=share-key');
await router.isReady();
expect(router.currentRoute.value.name).toBe('WorkflowShare');
expect(router.currentRoute.value.query.shareKey).toBe('share-key');
});
});

View File

@@ -11,6 +11,7 @@ describe('external share routes', () => {
hideInBreadcrumb: true, hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
ignoreAccess: true,
noBasicLayout: true, noBasicLayout: true,
}); });
}); });
@@ -19,6 +20,7 @@ describe('external share routes', () => {
const route = routes.find((item) => item.name === 'WorkflowShareExpired'); const route = routes.find((item) => item.name === 'WorkflowShareExpired');
expect(route?.path).toBe('/share/workflow/expired'); expect(route?.path).toBe('/share/workflow/expired');
expect(route?.meta?.ignoreAccess).toBe(true);
expect(route?.meta?.noBasicLayout).toBe(true); expect(route?.meta?.noBasicLayout).toBe(true);
}); });
}); });

View File

@@ -152,6 +152,12 @@ function setupAccessGuard(router: Router) {
let devLoginPromise: null | Promise<void> = null; let devLoginPromise: null | Promise<void> = null;
router.beforeEach(async (to, from) => { router.beforeEach(async (to, from) => {
// 公开路由必须在读取或刷新登录态之前短路,避免浏览器残留的过期
// token 把匿名分享页重定向到登录页。
if (to.meta.ignoreAccess) {
return true;
}
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -227,11 +233,6 @@ function setupAccessGuard(router: Router) {
// accessToken 检查 // accessToken 检查
if (!accessStore.accessToken) { if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
}
// 没有访问权限,跳转登录页面 // 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) { if (to.fullPath !== LOGIN_PATH) {
const cleanFullPath = const cleanFullPath =

View File

@@ -33,6 +33,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/ai/workflow/WorkflowShareView.vue'), component: () => import('#/views/ai/workflow/WorkflowShareView.vue'),
meta: { meta: {
title: 'Workflow Share', title: 'Workflow Share',
ignoreAccess: true,
noBasicLayout: true, noBasicLayout: true,
hideInMenu: true, hideInMenu: true,
hideInBreadcrumb: true, hideInBreadcrumb: true,
@@ -46,6 +47,7 @@ const routes: RouteRecordRaw[] = [
import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'), import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'),
meta: { meta: {
title: 'Workflow Share Expired', title: 'Workflow Share Expired',
ignoreAccess: true,
noBasicLayout: true, noBasicLayout: true,
hideInMenu: true, hideInMenu: true,
hideInBreadcrumb: true, hideInBreadcrumb: true,

View File

@@ -5,6 +5,14 @@ import { readScopedRouteQueryParam } from './share-route-context';
*/ */
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key'; export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key';
/**
* 当前标签页的工作流对话分享访客标识请求头。
*/
export const WORKFLOW_SHARE_VISITOR_HEADER = 'X-Workflow-Chat-Visitor';
const WORKFLOW_SHARE_VISITOR_STORAGE_KEY =
'easyflow.workflow-chat-share.visitor';
interface WorkflowShareResolutionOptions<T> { interface WorkflowShareResolutionOptions<T> {
currentWorkflowId?: null | T; currentWorkflowId?: null | T;
onFailure: (error: unknown) => Promise<void> | void; onFailure: (error: unknown) => Promise<void> | void;
@@ -16,16 +24,19 @@ interface WorkflowShareHeaderOptions {
pageUrl?: string; pageUrl?: string;
requestMethod?: string; requestMethod?: string;
requestUrl?: string; requestUrl?: string;
storage?: Pick<Storage, 'getItem' | 'setItem'>;
visitorId?: string;
} }
const WORKFLOW_SHARE_ROUTES = ['/share/workflow']; const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
const WORKFLOW_SHARE_REQUESTS = [ const WORKFLOW_SHARE_REQUESTS = [
['GET', '/api/v1/workflowChat/descriptor'], ['GET', '/api/v1/workflowChat/public/descriptor'],
['GET', '/api/v1/workflowChat/execution'], ['GET', '/api/v1/workflowChat/public/execution'],
['GET', '/api/v1/workflowShare/resolve'], ['GET', '/api/v1/workflowShare/resolve'],
['POST', '/api/v1/workflowChat/cancel'], ['POST', '/api/v1/workflowChat/public/cancel'],
['POST', '/api/v1/workflowChat/resume'], ['POST', '/api/v1/workflowChat/public/resume'],
['POST', '/api/v1/workflowChat/run'], ['POST', '/api/v1/workflowChat/public/run'],
['POST', '/api/v1/workflowChat/public/upload'],
] as const; ] as const;
/** /**
@@ -91,12 +102,42 @@ export function isWorkflowShareRequest(
} }
} }
/**
* 读取或创建当前标签页稳定的 128-bit 匿名访客标识。
*/
export function resolveWorkflowShareVisitorId(
storage:
| Pick<Storage, 'getItem' | 'setItem'>
| undefined = resolveSessionStorage(),
randomBytes: (size: number) => Uint8Array = createRandomBytes,
): string {
const existing = storage?.getItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY)?.trim();
if (existing && /^[a-f0-9]{32}$/.test(existing)) {
return existing;
}
const visitorId = [...randomBytes(16)]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
storage?.setItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY, visitorId);
return visitorId;
}
/** /**
* 在保留现有请求头的基础上附加工作流分享密钥。 * 在保留现有请求头的基础上附加工作流分享密钥。
*/ */
export function withWorkflowShareHeader( export function withWorkflowShareHeader(
headers: Record<string, string>, headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {}, options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
return withWorkflowShareHeaders(headers, options);
}
/**
* 在保留通用请求头的基础上附加匿名分享密钥与当前标签页访客标识。
*/
export function withWorkflowShareHeaders(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> { ): Record<string, string> {
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) { if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
return headers; return headers;
@@ -105,12 +146,32 @@ export function withWorkflowShareHeader(
if (!shareKey) { if (!shareKey) {
return headers; return headers;
} }
const visitorId =
options.visitorId || resolveWorkflowShareVisitorId(options.storage);
return { return {
...headers, ...headers,
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: shareKey, [WORKFLOW_SHARE_HEADER]: shareKey,
[WORKFLOW_SHARE_VISITOR_HEADER]: visitorId,
}; };
} }
function resolveSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function createRandomBytes(size: number) {
const bytes = new Uint8Array(size);
if (!globalThis.crypto?.getRandomValues) {
throw new Error('当前浏览器不支持安全的匿名访客标识');
}
return globalThis.crypto.getRandomValues(bytes);
}
/** /**
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。 * 解析分享地址对应的工作流,并在链接失效时统一收口异常。
*/ */

View File

@@ -2,6 +2,13 @@ import type { AgentInfo } from '../agents/types';
import { api } from '#/api/request'; import { api } from '#/api/request';
export type AgentChatAgentOption = Pick<
AgentInfo,
'avatar' | 'description' | 'id' | 'interactionConfigJson' | 'name'
> & {
supportImage?: boolean | null;
};
export interface RequestResult<T = any> { export interface RequestResult<T = any> {
data: T; data: T;
errorCode: number; errorCode: number;
@@ -74,9 +81,12 @@ export interface AgentChatCapabilityPayload {
} }
export function getPublishedAgents() { export function getPublishedAgents() {
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/session/options', { return api.get<RequestResult<AgentChatAgentOption[]>>(
'/api/v1/agent/session/options',
{
params: { publishedOnly: true }, params: { publishedOnly: true },
}); },
);
} }
export function generateAgentSessionId() { export function generateAgentSessionId() {

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
AGENT_CHAT_ATTACHMENT_ACCEPT,
AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE,
AGENT_IMAGE_UNSUPPORTED_MESSAGE,
supportsAgentImageInput,
} from './imageCapability';
describe('agent chat image capability', () => {
it('仅允许明确启用图片能力的发布模型', () => {
expect(supportsAgentImageInput(true)).toBe(true);
expect(supportsAgentImageInput(false)).toBe(false);
expect(supportsAgentImageInput(null)).toBe(false);
expect(supportsAgentImageInput(undefined)).toBe(false);
});
it('附件选择器同时展示文档和图片格式', () => {
expect(AGENT_CHAT_ATTACHMENT_ACCEPT).toContain('.pdf');
expect(AGENT_CHAT_ATTACHMENT_ACCEPT).toContain('.png');
expect(AGENT_CHAT_ATTACHMENT_ACCEPT).toContain('image/png');
});
it('为视觉能力限制提供明确反馈', () => {
expect(AGENT_IMAGE_UNSUPPORTED_MESSAGE).toBe(
'当前大模型不支持视觉,无法上传图片',
);
expect(AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE).toBe(
'当前大模型不支持视觉,已添加的图片无法发送,请先移除',
);
});
});

View File

@@ -0,0 +1,14 @@
export const AGENT_CHAT_ATTACHMENT_ACCEPT =
'.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp';
export const AGENT_IMAGE_UNSUPPORTED_MESSAGE =
'当前大模型不支持视觉,无法上传图片';
export const AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE =
'当前大模型不支持视觉,已添加的图片无法发送,请先移除';
export function supportsAgentImageInput(
supportImage?: boolean | null,
): boolean {
return supportImage === true;
}

View File

@@ -8,8 +8,7 @@ import type {
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui'; } from '@easyflow/common-ui';
import type { AgentInfo } from '../agents/types'; import type { AgentChatAgentOption, AgentChatSessionView } from './api';
import type { AgentChatSessionView } from './api';
import type { import type {
ChatInputTriggerGroup, ChatInputTriggerGroup,
@@ -78,6 +77,12 @@ import {
renameAgentSession, renameAgentSession,
saveAgentSessionExtraKnowledges, saveAgentSessionExtraKnowledges,
} from './api'; } from './api';
import {
AGENT_CHAT_ATTACHMENT_ACCEPT,
AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE,
AGENT_IMAGE_UNSUPPORTED_MESSAGE,
supportsAgentImageInput,
} from './imageCapability';
import { import {
isMissingAgentSessionError, isMissingAgentSessionError,
resolveAgentSessionErrorMessage, resolveAgentSessionErrorMessage,
@@ -86,7 +91,7 @@ import {
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const agents = ref<AgentInfo[]>([]); const agents = ref<AgentChatAgentOption[]>([]);
const sessions = ref<AgentChatSessionView[]>([]); const sessions = ref<AgentChatSessionView[]>([]);
const timelineItems = ref<ChatTimelineItem[]>([]); const timelineItems = ref<ChatTimelineItem[]>([]);
const selectedAgentId = ref(''); const selectedAgentId = ref('');
@@ -122,9 +127,7 @@ const selectedAgent = computed(() =>
agents.value.find((agent) => String(agent.id) === selectedAgentId.value), agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
); );
const selectedAgentImageSupport = computed(() => const selectedAgentImageSupport = computed(() =>
Boolean( supportsAgentImageInput(selectedAgent.value?.supportImage),
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
),
); );
const supportedAttachmentFormats = computed(() => const supportedAttachmentFormats = computed(() =>
selectedAgentImageSupport.value === false selectedAgentImageSupport.value === false
@@ -614,7 +617,7 @@ async function handleAgentChange() {
selectedAgentImageSupport.value === false && selectedAgentImageSupport.value === false &&
composer.images.items.value.length > 0 composer.images.items.value.length > 0
) { ) {
ElMessage.warning('当前智能体不支持图片,请先移除图片'); ElMessage.warning(AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE);
} }
} }
@@ -822,7 +825,7 @@ async function addImageFiles(files: File[]) {
if (selectedAgentImageSupport.value === false) { if (selectedAgentImageSupport.value === false) {
ElMessage.warning({ ElMessage.warning({
grouping: true, grouping: true,
message: `当前智能体不支持图片,支持:${CHAT_DOCUMENT_SUPPORTED_FORMATS}`, message: AGENT_IMAGE_UNSUPPORTED_MESSAGE,
}); });
return; return;
} }
@@ -1395,11 +1398,7 @@ onBeforeUnmount(() => {
ref="attachmentFileInputRef" ref="attachmentFileInputRef"
class="agent-chat__image-file-input" class="agent-chat__image-file-input"
type="file" type="file"
:accept=" :accept="AGENT_CHAT_ATTACHMENT_ACCEPT"
selectedAgentImageSupport === false
? '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md'
: '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp'
"
multiple multiple
@change="handleAttachmentFiles" @change="handleAttachmentFiles"
/> />

View File

@@ -49,7 +49,9 @@ export function buildInteractionConfigPayload(
}; };
} }
export function resolveInteractionDisplay(agent?: AgentInfo) { export function resolveInteractionDisplay(
agent?: Pick<AgentInfo, 'interactionConfigJson' | 'name'>,
) {
const config = buildInteractionConfigPayload(agent?.interactionConfigJson); const config = buildInteractionConfigPayload(agent?.interactionConfigJson);
const agentName = String(agent?.name || '').trim() || '智能体'; const agentName = String(agent?.name || '').trim() || '智能体';
return { return {

View File

@@ -57,7 +57,11 @@ import { navigateBackToList } from '#/router/list-return-context';
import { resolveAgentChatIdentity } from '#/utils/agent-chat-cache'; import { resolveAgentChatIdentity } from '#/utils/agent-chat-cache';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context'; import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context'; import {
readWorkflowShareKey,
resolveWorkflowShareFailureReason,
resolveWorkflowShareVisitorId,
} from '#/utils/workflow-share-context';
import { import {
finalizeWorkflowExecutionSteps, finalizeWorkflowExecutionSteps,
@@ -65,9 +69,16 @@ import {
hydrateWorkflowExecutionSteps, hydrateWorkflowExecutionSteps,
reduceWorkflowExecutionSteps, reduceWorkflowExecutionSteps,
} from './workflowExecutionDetails'; } from './workflowExecutionDetails';
import {
resolveWorkflowExecutionRecoveryOutput,
resolveWorkflowExecutionRecoveryStatus,
} from './workflowExecutionRecovery';
import WorkflowFinalOutput from './WorkflowFinalOutput.vue'; import WorkflowFinalOutput from './WorkflowFinalOutput.vue';
import WorkflowFormItem from './WorkflowFormItem.vue'; import WorkflowFormItem from './WorkflowFormItem.vue';
import { resolveWorkflowFormParameters } from './workflowFormParameters'; import {
buildWorkflowFormInitialValues,
resolveWorkflowFormParameters,
} from './workflowFormParameters';
import { import {
buildWorkflowFormParameterSummaries, buildWorkflowFormParameterSummaries,
buildWorkflowFormSubmissionImages, buildWorkflowFormSubmissionImages,
@@ -85,6 +96,14 @@ import {
formatWorkflowProgressLabel, formatWorkflowProgressLabel,
summarizeWorkflowActiveNodes, summarizeWorkflowActiveNodes,
} from './workflowRunProgress'; } from './workflowRunProgress';
import {
buildWorkflowShareConversationKey,
buildWorkflowShareStorageScope,
getWorkflowShareConversationStorage,
readWorkflowShareConversation,
removeWorkflowShareConversation,
writeWorkflowShareConversation,
} from './workflowShareConversationStorage';
interface WorkflowStreamEnvelope { interface WorkflowStreamEnvelope {
data?: Record<string, any>; data?: Record<string, any>;
@@ -94,6 +113,10 @@ interface WorkflowStreamEnvelope {
type: string; type: string;
} }
interface ChatTimelineHandle {
scrollToBottom: () => void;
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
shareMode?: boolean; shareMode?: boolean;
@@ -106,14 +129,21 @@ const props = withDefaults(
const MAX_COLLAPSED_PARAMETER_COUNT = 4; const MAX_COLLAPSED_PARAMETER_COUNT = 4;
const PRIMARY_PARAMETER_COUNT = 4; const PRIMARY_PARAMETER_COUNT = 4;
const DRAFT_SAVE_DELAY_MS = 300; const DRAFT_SAVE_DELAY_MS = 300;
const CONVERSATION_SAVE_DELAY_MS = 300;
const EXECUTION_RECOVERY_POLL_MS = 1500;
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const streamClient = new SseClient(); const streamClient = new SseClient();
const shareStorageScope = resolveShareStorageScope();
const loading = ref(true); const loading = ref(true);
const loadError = ref(''); const loadError = ref('');
const descriptor = ref<Record<string, any>>({}); const descriptor = ref<Record<string, any>>({});
const workflowId = ref<string>(); const workflowId = ref<string>();
const timelineItems = ref<ChatTimelineItem[]>([]); const timelineItems = ref<ChatTimelineItem[]>([]);
const timelineRef = ref<ChatTimelineHandle>();
const timelinePinnedToBottom = ref(true);
const composerRef = ref<HTMLElement>();
const composerHeight = ref(152);
const question = ref(''); const question = ref('');
const running = ref(false); const running = ref(false);
const stopping = ref(false); const stopping = ref(false);
@@ -150,7 +180,12 @@ const manualAbort = ref(false);
const lastRunningNodeName = ref(''); const lastRunningNodeName = ref('');
let progressStatusTimer = 0; let progressStatusTimer = 0;
let draftSaveTimer = 0; let draftSaveTimer = 0;
let conversationSaveTimer = 0;
let executionRecoveryTimer = 0;
let executionRecoveryActive = false;
let executionRecoveryLoading = false;
let draftReady = false; let draftReady = false;
let conversationReady = false;
let userMessageSequence = 0; let userMessageSequence = 0;
const formParameters = computed(() => const formParameters = computed(() =>
@@ -286,22 +321,46 @@ const detailDurationText = computed(() => {
: persistedDuration; : persistedDuration;
return duration === undefined ? '—' : `${duration} ms`; return duration === undefined ? '—' : `${duration} ms`;
}); });
const timelineStyle = computed<Record<string, string>>(() => ({
'--workflow-chat-composer-height': `${composerHeight.value}px`,
}));
useResizeObserver(parameterChipsRef, ([entry]) => { useResizeObserver(parameterChipsRef, ([entry]) => {
parameterChipsWidth.value = entry?.contentRect.width || 0; parameterChipsWidth.value = entry?.contentRect.width || 0;
}); });
useResizeObserver(composerRef, () => {
const nextHeight = composerRef.value?.getBoundingClientRect().height || 0;
if (nextHeight > 0) {
composerHeight.value = Math.ceil(nextHeight);
}
});
watch([question, extraValues], scheduleDraftSave, { deep: true }); watch([question, extraValues], scheduleDraftSave, { deep: true });
watch(
[
timelineItems,
executeId,
runStatusKey,
parametersLocked,
executionState,
executionStartedAt,
executionElapsed,
],
scheduleConversationSave,
{ deep: true },
);
onMounted(() => { onMounted(() => {
window.addEventListener('pagehide', persistDraft); window.addEventListener('pagehide', handlePageHide);
void loadPage(); void loadPage();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('pagehide', persistDraft); window.removeEventListener('pagehide', handlePageHide);
clearDraftSaveTimer(); clearDraftSaveTimer();
persistDraft(); clearConversationSaveTimer();
clearExecutionRecoveryTimer();
persistPageState();
manualAbort.value = true; manualAbort.value = true;
streamClient.abort(); streamClient.abort();
clearProgressStatusTimer(); clearProgressStatusTimer();
@@ -309,6 +368,7 @@ onBeforeUnmount(() => {
async function loadPage() { async function loadPage() {
draftReady = false; draftReady = false;
conversationReady = false;
loading.value = true; loading.value = true;
loadError.value = ''; loadError.value = '';
try { try {
@@ -318,14 +378,23 @@ async function loadPage() {
if (!workflowId.value) { if (!workflowId.value) {
throw new Error('工作流不存在'); throw new Error('工作流不存在');
} }
const response = await api.get('/api/v1/workflowChat/descriptor', { const response = await api.get(workflowChatEndpoint('descriptor'), {
params: { workflowId: workflowId.value }, params: { workflowId: workflowId.value },
}); });
descriptor.value = response.data || {}; descriptor.value = response.data || {};
initializeAdditionalValues(); initializeAdditionalValues();
restoreDraft(); restoreDraft();
restoreShareConversation();
await nextTick(); await nextTick();
draftReady = true; draftReady = true;
conversationReady = true;
if (props.shareMode && executeId.value) {
if (executionRecoveryActive) {
void recoverExecutionAfterRefresh();
} else {
void loadExecutionDetail();
}
}
} catch (error: any) { } catch (error: any) {
loadError.value = error?.message || '工作流加载失败'; loadError.value = error?.message || '工作流加载失败';
} finally { } finally {
@@ -349,24 +418,7 @@ async function resolveSharedWorkflowId() {
} }
function initializeAdditionalValues() { function initializeAdditionalValues() {
const values: Record<string, any> = {}; const values = buildWorkflowFormInitialValues(additionalParameters.value);
for (const parameter of additionalParameters.value) {
const defaultValue = parameter.defaultValue;
if (
parameter.contentType === 'file' &&
(defaultValue === null ||
defaultValue === undefined ||
defaultValue === '')
) {
values[parameter.name] = [];
} else if (Array.isArray(defaultValue)) {
values[parameter.name] = [...defaultValue];
} else if (defaultValue && typeof defaultValue === 'object') {
values[parameter.name] = { ...defaultValue };
} else {
values[parameter.name] = defaultValue ?? '';
}
}
defaultExtraValues.value = values; defaultExtraValues.value = values;
extraValues.value = values; extraValues.value = values;
parametersLocked.value = false; parametersLocked.value = false;
@@ -384,14 +436,29 @@ function draftKey() {
} }
return buildWorkflowRunDraftKey( return buildWorkflowRunDraftKey(
workflowId.value, workflowId.value,
resolveAgentChatIdentity(userStore.userInfo), props.shareMode
? shareStorageScope
: resolveAgentChatIdentity(userStore.userInfo),
props.shareMode, props.shareMode,
); );
} }
function shareConversationKey() {
return buildWorkflowShareConversationKey(
workflowId.value || '',
shareStorageScope,
);
}
function workflowChatEndpoint(action: string) {
return props.shareMode
? `/api/v1/workflowChat/public/${action}`
: `/api/v1/workflowChat/${action}`;
}
function restoreDraft() { function restoreDraft() {
const draft = readWorkflowRunDraft( const draft = readWorkflowRunDraft(
getWorkflowRunDraftStorage(), getWorkflowRunDraftStorage(props.shareMode),
draftKey(), draftKey(),
additionalParameters.value, additionalParameters.value,
); );
@@ -433,7 +500,7 @@ function persistDraft() {
if (!draftReady) { if (!draftReady) {
return; return;
} }
const storage = getWorkflowRunDraftStorage(); const storage = getWorkflowRunDraftStorage(props.shareMode);
const key = draftKey(); const key = draftKey();
if ( if (
!hasWorkflowRunDraftContent( !hasWorkflowRunDraftContent(
@@ -451,6 +518,99 @@ function persistDraft() {
}); });
} }
function resolveShareStorageScope() {
if (!props.shareMode) {
return '';
}
try {
return buildWorkflowShareStorageScope(
resolveWorkflowShareVisitorId(),
readWorkflowShareKey() || '',
);
} catch {
return '';
}
}
function restoreShareConversation() {
if (!props.shareMode) {
return false;
}
const snapshot = readWorkflowShareConversation(
getWorkflowShareConversationStorage(),
shareConversationKey(),
);
if (!snapshot) {
return false;
}
timelineItems.value = snapshot.timelineItems;
executeId.value = snapshot.executeId;
runStatusKey.value = snapshot.runStatusKey;
parametersLocked.value = snapshot.parametersLocked;
executionState.value = snapshot.executionState;
executionStartedAt.value = snapshot.executionStartedAt;
executionElapsed.value = snapshot.executionElapsed;
executionRecoveryActive =
Boolean(snapshot.executeId) &&
(snapshot.executionState === 'running' ||
snapshot.executionState === 'waiting');
running.value = executionRecoveryActive;
stopping.value = false;
waitingConfirmation.value = undefined;
confirmSubmittingAction.value = '';
confirmError.value = '';
return true;
}
function clearConversationSaveTimer() {
if (conversationSaveTimer) {
window.clearTimeout(conversationSaveTimer);
conversationSaveTimer = 0;
}
}
function scheduleConversationSave() {
if (!props.shareMode || !conversationReady) {
return;
}
clearConversationSaveTimer();
conversationSaveTimer = window.setTimeout(() => {
conversationSaveTimer = 0;
persistShareConversation();
}, CONVERSATION_SAVE_DELAY_MS);
}
function persistShareConversation() {
if (!props.shareMode || !conversationReady) {
return;
}
const storage = getWorkflowShareConversationStorage();
const key = shareConversationKey();
if (timelineItems.value.length === 0 && !executeId.value) {
removeWorkflowShareConversation(storage, key);
return;
}
writeWorkflowShareConversation(storage, key, {
executeId: executeId.value,
executionElapsed: executionElapsed.value,
executionStartedAt: executionStartedAt.value,
executionState: executionState.value,
parametersLocked: parametersLocked.value,
runStatusKey: runStatusKey.value,
timelineItems: timelineItems.value,
});
}
function persistPageState() {
persistDraft();
persistShareConversation();
}
function handlePageHide() {
manualAbort.value = true;
persistPageState();
}
async function backToWorkflowList() { async function backToWorkflowList() {
await navigateBackToList( await navigateBackToList(
router, router,
@@ -543,10 +703,23 @@ function appendError(message: string, id = `error-${Date.now()}`) {
} }
function appendFinalOutput(output: unknown, eventId: string) { function appendFinalOutput(output: unknown, eventId: string) {
const finalOutputId = `final-output-${eventId}`;
const executionPrefix = executeId.value
? `final-output-${executeId.value}`
: finalOutputId;
if (
timelineItems.value.some(
(item) =>
item.type === 'custom' &&
(item.id === finalOutputId || item.id.startsWith(executionPrefix)),
)
) {
return;
}
timelineItems.value.push({ timelineItems.value.push({
customType: 'workflow-final-output', customType: 'workflow-final-output',
data: output, data: output,
id: `final-output-${eventId}`, id: finalOutputId,
type: 'custom', type: 'custom',
}); });
} }
@@ -618,6 +791,8 @@ async function handleSend() {
extraValues.value, extraValues.value,
); );
parametersLocked.value = true; parametersLocked.value = true;
executionRecoveryActive = false;
clearExecutionRecoveryTimer();
appendUserMessage(content, images); appendUserMessage(content, images);
question.value = ''; question.value = '';
parametersExpanded.value = false; parametersExpanded.value = false;
@@ -637,11 +812,13 @@ async function handleSend() {
confirmError.value = ''; confirmError.value = '';
lastRunningNodeName.value = ''; lastRunningNodeName.value = '';
manualAbort.value = false; manualAbort.value = false;
await nextTick();
scrollToLatest();
void streamClient.post( void streamClient.post(
'/api/v1/workflowChat/run', workflowChatEndpoint('run'),
{ {
workflowId: workflowId.value, ...(props.shareMode ? {} : { workflowId: workflowId.value }),
variables: { variables: {
...extraValues.value, ...extraValues.value,
user_input: content, user_input: content,
@@ -652,6 +829,9 @@ async function handleSend() {
if (manualAbort.value) { if (manualAbort.value) {
return; return;
} }
if (beginExecutionRecovery()) {
return;
}
finishExecution( finishExecution(
'failed', 'failed',
error?.message || '工作流执行失败', error?.message || '工作流执行失败',
@@ -661,6 +841,9 @@ async function handleSend() {
}, },
onFinished: () => { onFinished: () => {
if (running.value && !manualAbort.value) { if (running.value && !manualAbort.value) {
if (beginExecutionRecovery()) {
return;
}
finishExecution( finishExecution(
'failed', 'failed',
'运行连接已结束,请重试', '运行连接已结束,请重试',
@@ -761,6 +944,8 @@ function finishExecution(
eventId = executeId.value || String(Date.now()), eventId = executeId.value || String(Date.now()),
) { ) {
const failedNodeName = activeNodeSummary(); const failedNodeName = activeNodeSummary();
executionRecoveryActive = false;
clearExecutionRecoveryTimer();
clearProgressStatusTimer(); clearProgressStatusTimer();
running.value = false; running.value = false;
stopping.value = false; stopping.value = false;
@@ -820,7 +1005,7 @@ function finalizeLiveExecutionSteps(
); );
executionElapsed.value = executionElapsed.value =
executionStartedAt.value === undefined executionStartedAt.value === undefined
? undefined ? executionElapsed.value
: Math.max(0, finishedAt - executionStartedAt.value); : Math.max(0, finishedAt - executionStartedAt.value);
} }
@@ -848,7 +1033,7 @@ async function resumeExecution(confirmed: boolean) {
) { ) {
return; return;
} }
await api.post('/api/v1/workflowChat/resume', { await api.post(workflowChatEndpoint('resume'), {
executeId: executeId.value, executeId: executeId.value,
confirmParams: { confirmParams: {
[confirmKey.value]: confirmed ? 'yes' : 'no', [confirmKey.value]: confirmed ? 'yes' : 'no',
@@ -859,6 +1044,9 @@ async function resumeExecution(confirmed: boolean) {
executionState.value = 'running'; executionState.value = 'running';
markConfirmationStep('running'); markConfirmationStep('running');
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value); appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
if (executionRecoveryActive) {
scheduleExecutionRecovery();
}
} catch (error: any) { } catch (error: any) {
confirmError.value = error?.message || '提交失败,请重试'; confirmError.value = error?.message || '提交失败,请重试';
} finally { } finally {
@@ -875,7 +1063,7 @@ async function stopExecution() {
appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value); appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value);
try { try {
if (executeId.value) { if (executeId.value) {
await api.post('/api/v1/workflowChat/cancel', { await api.post(workflowChatEndpoint('cancel'), {
executeId: executeId.value, executeId: executeId.value,
}); });
} }
@@ -900,8 +1088,19 @@ async function resetConversation() {
} }
} }
draftReady = false; draftReady = false;
conversationReady = false;
clearDraftSaveTimer(); clearDraftSaveTimer();
removeWorkflowRunDraft(getWorkflowRunDraftStorage(), draftKey()); clearConversationSaveTimer();
clearExecutionRecoveryTimer();
executionRecoveryActive = false;
removeWorkflowRunDraft(
getWorkflowRunDraftStorage(props.shareMode),
draftKey(),
);
removeWorkflowShareConversation(
getWorkflowShareConversationStorage(),
shareConversationKey(),
);
manualAbort.value = true; manualAbort.value = true;
streamClient.abort(); streamClient.abort();
timelineItems.value = []; timelineItems.value = [];
@@ -924,25 +1123,180 @@ async function resetConversation() {
initializeAdditionalValues(); initializeAdditionalValues();
await nextTick(); await nextTick();
draftReady = true; draftReady = true;
conversationReady = true;
} }
async function loadExecutionDetail() { function clearExecutionRecoveryTimer() {
if (!executionRecoveryTimer) {
return;
}
window.clearTimeout(executionRecoveryTimer);
executionRecoveryTimer = 0;
}
function beginExecutionRecovery() {
if (!props.shareMode || !executeId.value) {
return false;
}
executionRecoveryActive = true;
running.value = true;
stopping.value = false;
if (executionState.value !== 'waiting') {
executionState.value = 'running';
appendStatus(
'连接已断开,正在恢复运行状态…',
'running',
runStatusKey.value,
);
}
clearExecutionRecoveryTimer();
void recoverExecutionAfterRefresh();
return true;
}
function scheduleExecutionRecovery() {
if (
!executionRecoveryActive ||
!props.shareMode ||
!executeId.value ||
executionState.value === 'waiting'
) {
return;
}
clearExecutionRecoveryTimer();
executionRecoveryTimer = window.setTimeout(() => {
executionRecoveryTimer = 0;
void recoverExecutionAfterRefresh();
}, EXECUTION_RECOVERY_POLL_MS);
}
async function recoverExecutionAfterRefresh() {
if (
!executionRecoveryActive ||
executionRecoveryLoading ||
!executeId.value
) {
return;
}
executionRecoveryLoading = true;
try {
const detail = await loadExecutionDetail({ background: true });
if (!detail || !executionRecoveryActive) {
scheduleExecutionRecovery();
return;
}
syncRecoveredExecution(detail);
} finally {
executionRecoveryLoading = false;
}
}
function syncRecoveredExecution(detail: Record<string, any>) {
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(detail.steps);
const activeStep = [...liveExecutionSteps.value]
.reverse()
.find((step) => step.status === 'running' || step.status === 'waiting');
lastRunningNodeName.value = activeStep?.nodeName || '';
const status = resolveWorkflowExecutionRecoveryStatus(detail);
if (status === 'SUSPEND') {
removeStaleConnectionFailure();
running.value = true;
stopping.value = false;
executionState.value = 'waiting';
waitingConfirmation.value = {
message: detail.runtime?.message || '请确认',
parameters: Array.isArray(detail.runtime?.parameters)
? detail.runtime.parameters
: [],
};
initializeConfirmValues(waitingConfirmation.value.parameters);
markConfirmationStep('waiting');
appendStatus(progressLabel('等待确认'), 'running', runStatusKey.value);
return;
}
if (status === 'READY' || status === 'RUNNING' || status === 'ERROR') {
removeStaleConnectionFailure();
running.value = true;
stopping.value = false;
waitingConfirmation.value = undefined;
executionState.value = 'running';
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
scheduleExecutionRecovery();
return;
}
const persistedDuration = Number(detail.record?.execTime);
if (Number.isFinite(persistedDuration)) {
executionElapsed.value = persistedDuration;
executionStartedAt.value = undefined;
}
if (status === 'SUCCEEDED') {
removeStaleConnectionFailure();
const output = resolveWorkflowExecutionRecoveryOutput(detail);
finishExecution('completed', undefined, output, executeId.value);
return;
}
if (status === 'CANCELLED') {
finishExecution('cancelled', undefined, undefined, executeId.value);
return;
}
if (status === 'FAILED') {
finishExecution(
'failed',
detail.runtime?.message || detail.record?.errorInfo,
undefined,
executeId.value,
);
return;
}
scheduleExecutionRecovery();
}
function removeStaleConnectionFailure() {
if (!executeId.value) { if (!executeId.value) {
return; return;
} }
const staleErrorId = `terminal-error-${executeId.value}`;
const next = timelineItems.value.filter(
(item) => item.type !== 'error' || item.id !== staleErrorId,
);
if (next.length !== timelineItems.value.length) {
timelineItems.value = next;
}
}
async function loadExecutionDetail(options: { background?: boolean } = {}) {
if (!executeId.value) {
return;
}
const targetExecuteId = executeId.value;
if (!options.background) {
detailLoading.value = true; detailLoading.value = true;
detailLoadError.value = ''; detailLoadError.value = '';
}
try { try {
const response = await api.get('/api/v1/workflowChat/execution', { const response = await api.get(workflowChatEndpoint('execution'), {
params: { executeId: executeId.value }, params: { executeId: targetExecuteId },
}); });
if (targetExecuteId !== executeId.value) {
return;
}
executionDetail.value = response.data; executionDetail.value = response.data;
return response.data as Record<string, any>;
} catch (error: any) { } catch (error: any) {
if (!options.background) {
detailLoadError.value = error?.message || '运行详情加载失败'; detailLoadError.value = error?.message || '运行详情加载失败';
}
} finally { } finally {
if (!options.background) {
detailLoading.value = false; detailLoading.value = false;
} }
} }
}
function scrollToLatest() {
timelineRef.value?.scrollToBottom();
}
async function openExecutionDetail() { async function openExecutionDetail() {
detailVisible.value = true; detailVisible.value = true;
@@ -1065,6 +1419,7 @@ function executionTraceText(
<div class="workflow-chat__title-row"> <div class="workflow-chat__title-row">
<h1>{{ descriptor.title || '工作流' }}</h1> <h1>{{ descriptor.title || '工作流' }}</h1>
<ElTag <ElTag
v-if="!shareMode"
size="small" size="small"
:type="shareable ? 'success' : 'info'" :type="shareable ? 'success' : 'info'"
effect="light" effect="light"
@@ -1119,13 +1474,16 @@ function executionTraceText(
</div> </div>
<template v-else> <template v-else>
<ChatTimeline <ChatTimeline
ref="timelineRef"
class="workflow-chat__timeline" class="workflow-chat__timeline"
:style="timelineStyle"
:assistant-avatar="defaultAssistantAvatar" :assistant-avatar="defaultAssistantAvatar"
:items="timelineItems" :items="timelineItems"
:empty-text="emptyText" :empty-text="emptyText"
:empty-title="descriptor.title || '工作流'" :empty-title="descriptor.title || '工作流'"
:copy-action="copyMessage" :copy-action="copyMessage"
:copyable="(item) => item.parts.some((part) => part.content)" :copyable="(item) => item.parts.some((part) => part.content)"
@bottom-pinned-change="timelinePinnedToBottom = $event"
> >
<template #custom-item="{ item }"> <template #custom-item="{ item }">
<WorkflowFinalOutput <WorkflowFinalOutput
@@ -1151,6 +1509,7 @@ function executionTraceText(
@submit.prevent @submit.prevent
> >
<WorkflowFormItem <WorkflowFormItem
:public-share="shareMode"
:parameters="confirmParameters" :parameters="confirmParameters"
:run-params="confirmValues" :run-params="confirmValues"
@update:run-params="confirmValues = $event" @update:run-params="confirmValues = $event"
@@ -1182,7 +1541,21 @@ function executionTraceText(
</div> </div>
</section> </section>
<div class="workflow-chat__composer"> <div ref="composerRef" class="workflow-chat__composer">
<Transition name="workflow-scroll-latest">
<button
v-if="!timelinePinnedToBottom && timelineItems.length > 0"
class="workflow-chat__scroll-latest"
type="button"
aria-label="回到最新消息"
title="回到最新消息"
@click="scrollToLatest"
>
<ElIcon aria-hidden="true">
<ArrowDown />
</ElIcon>
</button>
</Transition>
<section <section
class="workflow-chat__input-shell" class="workflow-chat__input-shell"
aria-label="工作流运行输入" aria-label="工作流运行输入"
@@ -1266,6 +1639,7 @@ function executionTraceText(
<WorkflowFormItem <WorkflowFormItem
:disabled="composerDisabled || parametersLocked" :disabled="composerDisabled || parametersLocked"
:parameters="primaryParameters" :parameters="primaryParameters"
:public-share="shareMode"
:run-params="extraValues" :run-params="extraValues"
@update:run-params="extraValues = $event" @update:run-params="extraValues = $event"
/> />
@@ -1299,6 +1673,7 @@ function executionTraceText(
<WorkflowFormItem <WorkflowFormItem
:disabled="composerDisabled || parametersLocked" :disabled="composerDisabled || parametersLocked"
:parameters="moreParameters" :parameters="moreParameters"
:public-share="shareMode"
:run-params="extraValues" :run-params="extraValues"
@update:run-params="extraValues = $event" @update:run-params="extraValues = $event"
/> />
@@ -1376,7 +1751,7 @@ function executionTraceText(
<div v-if="detailLoadError" class="workflow-chat__detail-load-error"> <div v-if="detailLoadError" class="workflow-chat__detail-load-error">
<span>{{ detailLoadError }}</span> <span>{{ detailLoadError }}</span>
<ElButton text type="primary" @click="loadExecutionDetail"> <ElButton text type="primary" @click="loadExecutionDetail()">
重试 重试
</ElButton> </ElButton>
</div> </div>
@@ -1569,8 +1944,51 @@ function executionTraceText(
} }
.workflow-chat__timeline { .workflow-chat__timeline {
width: 100%;
padding: 0;
margin: 0;
scrollbar-gutter: stable;
scrollbar-color: color-mix(
in srgb,
var(--el-text-color-placeholder) 42%,
transparent
)
transparent;
scrollbar-width: thin;
}
.workflow-chat__timeline::-webkit-scrollbar {
width: var(--space-2);
}
.workflow-chat__timeline::-webkit-scrollbar-track {
background: transparent;
}
.workflow-chat__timeline::-webkit-scrollbar-thumb {
background: color-mix(
in srgb,
var(--el-text-color-placeholder) 42%,
transparent
);
background-clip: padding-box;
border: 2px solid transparent;
border-radius: var(--radius-pill);
}
.workflow-chat__timeline::-webkit-scrollbar-thumb:hover {
background: color-mix(
in srgb,
var(--el-text-color-secondary) 54%,
transparent
);
background-clip: padding-box;
}
.workflow-chat__timeline :deep(.chat-timeline__content) {
width: min(920px, 100%); width: min(920px, 100%);
padding: 24px 24px 152px; padding: 24px 24px
calc(var(--workflow-chat-composer-height, 152px) + var(--space-6));
margin: 0 auto; margin: 0 auto;
} }
@@ -1658,6 +2076,56 @@ function executionTraceText(
); );
} }
.workflow-chat__scroll-latest {
display: inline-grid;
place-items: center;
width: var(--space-8);
height: var(--space-8);
padding: 0;
color: var(--el-text-color-regular);
pointer-events: auto;
cursor: pointer;
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-subtle);
backdrop-filter: blur(12px);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.workflow-chat__scroll-latest:hover {
color: var(--el-color-primary);
background: var(--el-bg-color);
border-color: var(--el-color-primary-light-7);
transform: translateY(-1px);
}
.workflow-chat__scroll-latest:active {
transform: translateY(0);
}
.workflow-chat__scroll-latest:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.workflow-scroll-latest-enter-active,
.workflow-scroll-latest-leave-active {
transition:
opacity var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.workflow-scroll-latest-enter-from,
.workflow-scroll-latest-leave-to {
opacity: 0;
transform: translateY(var(--space-2));
}
.workflow-chat__input-shell { .workflow-chat__input-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1684,6 +2152,10 @@ function executionTraceText(
} }
.workflow-chat__input-row { .workflow-chat__input-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
overflow: hidden; overflow: hidden;
border-radius: var(--radius-panel); border-radius: var(--radius-panel);
box-shadow: var(--shadow-toolbar); box-shadow: var(--shadow-toolbar);
@@ -1918,13 +2390,6 @@ function executionTraceText(
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.workflow-chat__input-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
}
.workflow-chat__input-row textarea { .workflow-chat__input-row textarea {
flex: 1; flex: 1;
min-height: 48px; min-height: 48px;
@@ -2253,7 +2718,12 @@ function executionTraceText(
} }
.workflow-chat__timeline { .workflow-chat__timeline {
padding: 16px 16px 168px; padding: 0;
}
.workflow-chat__timeline :deep(.chat-timeline__content) {
padding: 16px 16px
calc(var(--workflow-chat-composer-height, 168px) + var(--space-6));
} }
.workflow-chat__composer { .workflow-chat__composer {

View File

@@ -31,6 +31,14 @@ const props = defineProps({
type: [Array, Object], type: [Array, Object],
default: undefined, default: undefined,
}, },
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -63,7 +71,11 @@ async function uploadFiles(files: File[]) {
validateWorkflowFileSelection(currentFiles.value, files); validateWorkflowFileSelection(currentFiles.value, files);
const uploadedFiles = []; const uploadedFiles = [];
for (const file of files) { for (const file of files) {
const res = await api.upload('/api/v1/commons/upload', { file }, {}); const res = await api.upload(
props.uploadUrl,
{ file, ...props.uploadData },
{},
);
uploadedFiles.push( uploadedFiles.push(
buildWorkflowFileValueFromUpload(file, res?.data?.path), buildWorkflowFileValueFromUpload(file, res?.data?.path),
); );

View File

@@ -33,6 +33,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
publicShare: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['update:runParams']); const emit = defineEmits(['update:runParams']);
function getContentType(item: any) { function getContentType(item: any) {
@@ -178,13 +182,26 @@ function choose(data: any, propName: string) {
<WorkflowFileInput <WorkflowFileInput
:disabled="disabled" :disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
:upload-data="{ parameterName: item.name }"
:upload-url="
publicShare
? '/api/v1/workflowChat/public/upload'
: '/api/v1/commons/upload'
"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
/> />
</template> </template>
<template v-if="getContentType(item) === 'image'"> <template v-if="getContentType(item) === 'image'">
<WorkflowImageInput <WorkflowImageInput
:allow-resource-picker="!publicShare"
:disabled="disabled" :disabled="disabled"
:model-value="runParams[item.name]" :model-value="runParams[item.name]"
:upload-data="{ parameterName: item.name }"
:upload-url="
publicShare
? '/api/v1/workflowChat/public/upload'
: '/api/v1/commons/upload'
"
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
/> />
</template> </template>
@@ -196,6 +213,7 @@ function choose(data: any, propName: string) {
:placeholder="item.formPlaceholder" :placeholder="item.formPlaceholder"
/> />
<ChooseResource <ChooseResource
v-if="!publicShare"
:attr-name="item.name" :attr-name="item.name"
:disabled="disabled" :disabled="disabled"
@choose="choose" @choose="choose"

View File

@@ -27,6 +27,18 @@ const props = defineProps({
type: [String, Object], type: [String, Object],
default: undefined, default: undefined,
}, },
allowResourcePicker: {
type: Boolean,
default: true,
},
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -74,7 +86,11 @@ async function handleNativeFileChange(event: Event) {
uploadLoading.value = true; uploadLoading.value = true;
try { try {
validateWorkflowImageFile(file); validateWorkflowImageFile(file);
const response = await api.upload('/api/v1/commons/upload', { file }, {}); const response = await api.upload(
props.uploadUrl,
{ file, ...props.uploadData },
{},
);
if (props.disabled) { if (props.disabled) {
return; return;
} }
@@ -178,6 +194,7 @@ function clearImage() {
{{ currentImage ? '替换图片' : $t('button.upload') }} {{ currentImage ? '替换图片' : $t('button.upload') }}
</ElButton> </ElButton>
<ChooseResource <ChooseResource
v-if="allowResourcePicker"
attr-name="image" attr-name="image"
:disabled="disabled" :disabled="disabled"
:resource-type="0" :resource-type="0"

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
resolveWorkflowExecutionRecoveryOutput,
resolveWorkflowExecutionRecoveryStatus,
} from '../workflowExecutionRecovery';
describe('workflowExecutionRecovery', () => {
it('uses live runtime state ahead of a stale persisted record', () => {
expect(
resolveWorkflowExecutionRecoveryStatus({
record: { status: 1 },
runtime: { status: 'SUSPEND', statusValue: 5 },
}),
).toBe('SUSPEND');
});
it('falls back to persisted terminal status and JSON output', () => {
const detail = {
record: { output: '{"answer":"done"}', status: 20 },
runtime: {},
};
expect(resolveWorkflowExecutionRecoveryStatus(detail)).toBe('SUCCEEDED');
expect(resolveWorkflowExecutionRecoveryOutput(detail)).toEqual({
answer: 'done',
});
});
it('prefers the runtime output before persistence catches up', () => {
expect(
resolveWorkflowExecutionRecoveryOutput({
record: { output: undefined, status: 1 },
runtime: { output: { answer: 'live' }, status: 'SUCCEEDED' },
}),
).toEqual({ answer: 'live' });
});
});

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildWorkflowFormInitialValues,
resolveWorkflowFormParameters, resolveWorkflowFormParameters,
resolveWorkflowParameterDisplayName, resolveWorkflowParameterDisplayName,
resolveWorkflowParameterLabel, resolveWorkflowParameterLabel,
} from '../workflowFormParameters'; } from '../workflowFormParameters';
describe('resolveWorkflowFormParameters', () => { describe('workflowFormParameters', () => {
it('uses the image parameter when a legacy schema still declares text', () => { it('uses the image parameter when a legacy schema still declares text', () => {
const parameters = resolveWorkflowFormParameters({ const parameters = resolveWorkflowFormParameters({
parameters: [ parameters: [
@@ -119,4 +120,33 @@ describe('resolveWorkflowFormParameters', () => {
'流程开始 > 用户问题123', '流程开始 > 用户问题123',
); );
}); });
it('builds initial values without submitting blank images', () => {
const imageDefault = {
sourceType: 'url',
url: 'https://example.com/default.png',
};
const choicesDefault = ['a'];
const values = buildWorkflowFormInitialValues([
{ name: 'files', contentType: 'file', defaultValue: ' ' },
{ name: 'image', contentType: 'image', defaultValue: '' },
{
name: 'defaultImage',
contentType: 'image',
defaultValue: imageDefault,
},
{ name: 'text', contentType: 'text', defaultValue: 'hello' },
{ name: 'choices', formType: 'checkbox', defaultValue: choicesDefault },
]);
expect(values).toEqual({
files: [],
defaultImage: imageDefault,
text: 'hello',
choices: ['a'],
});
expect(values).not.toHaveProperty('image');
expect(values.defaultImage).not.toBe(imageDefault);
expect(values.choices).not.toBe(choicesDefault);
});
}); });

View File

@@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue';
import WorkflowFormItem from '../WorkflowFormItem.vue';
import WorkflowImageInput from '../WorkflowImageInput.vue';
describe('workflow public form item', () => {
it('routes file uploads through the isolated public endpoint', () => {
const wrapper = mount(WorkflowFormItem, {
props: {
parameters: [
{
contentType: 'file',
name: 'attachment',
},
],
publicShare: true,
runParams: {},
},
});
const input = wrapper.getComponent(WorkflowFileInput);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
expect(input.props('uploadData')).toEqual({
parameterName: 'attachment',
});
});
it('keeps image URL and upload while hiding the internal resource picker', () => {
const wrapper = mount(WorkflowFormItem, {
props: {
parameters: [
{
contentType: 'image',
name: 'image',
},
],
publicShare: true,
runParams: {},
},
});
const input = wrapper.getComponent(WorkflowImageInput);
expect(input.props('allowResourcePicker')).toBe(false);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
});
});

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { import {
buildWorkflowRunDraftKey, buildWorkflowRunDraftKey,
getWorkflowRunDraftStorage,
hasWorkflowRunDraftContent, hasWorkflowRunDraftContent,
readWorkflowRunDraft, readWorkflowRunDraft,
removeWorkflowRunDraft, removeWorkflowRunDraft,
@@ -16,6 +17,7 @@ const parameters = [
describe('workflowRunDraft', () => { describe('workflowRunDraft', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
}); });
@@ -29,6 +31,14 @@ describe('workflowRunDraft', () => {
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe( expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false), buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false),
); );
expect(buildWorkflowRunDraftKey('flow-1', 'visitor-1', true)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'visitor-2', true),
);
});
it('uses local storage only for persistent share drafts', () => {
expect(getWorkflowRunDraftStorage()).toBe(sessionStorage);
expect(getWorkflowRunDraftStorage(true)).toBe(localStorage);
}); });
it('restores current compatible fields within twelve hours', () => { it('restores current compatible fields within twelve hours', () => {

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
buildWorkflowShareConversationKey,
buildWorkflowShareStorageScope,
readWorkflowShareConversation,
removeWorkflowShareConversation,
WORKFLOW_SHARE_CONVERSATION_TTL_MS,
writeWorkflowShareConversation,
} from '../workflowShareConversationStorage';
describe('workflowShareConversationStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('isolates snapshots without exposing the raw share key', () => {
const shareKey = 'secret-share-key';
const firstScope = buildWorkflowShareStorageScope('visitor-1', shareKey);
const secondScope = buildWorkflowShareStorageScope('visitor-2', shareKey);
const otherShareScope = buildWorkflowShareStorageScope(
'visitor-1',
'other-share-key',
);
const key = buildWorkflowShareConversationKey('flow-1', firstScope);
expect(firstScope).not.toBe(secondScope);
expect(firstScope).not.toBe(otherShareScope);
expect(key).not.toContain(shareKey);
expect(key).not.toContain('visitor-1');
});
it('restores a valid snapshot within twelve hours', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
writeWorkflowShareConversation(
localStorage,
key,
{
executeId: 'exec-1',
executionState: 'completed',
parametersLocked: true,
runStatusKey: 'run-1',
timelineItems: [
{
id: 'message-1',
parts: [{ content: '处理完成', id: 'part-1', type: 'text' }],
role: 'user',
type: 'message',
},
],
},
1000,
);
expect(readWorkflowShareConversation(localStorage, key, 2000)).toEqual({
executeId: 'exec-1',
executionElapsed: undefined,
executionStartedAt: undefined,
executionState: 'completed',
parametersLocked: true,
runStatusKey: 'run-1',
timelineItems: [
{
id: 'message-1',
parts: [{ content: '处理完成', id: 'part-1', type: 'text' }],
role: 'user',
type: 'message',
},
],
});
});
it('drops expired or malformed snapshots', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
writeWorkflowShareConversation(
localStorage,
key,
{
executeId: '',
executionState: 'idle',
parametersLocked: false,
runStatusKey: '',
timelineItems: [],
},
1000,
);
expect(
readWorkflowShareConversation(
localStorage,
key,
1000 + WORKFLOW_SHARE_CONVERSATION_TTL_MS,
),
).toBeUndefined();
expect(localStorage.getItem(key)).toBeNull();
localStorage.setItem(key, '{"version":1,"timelineItems":"invalid"}');
expect(readWorkflowShareConversation(localStorage, key)).toBeUndefined();
expect(localStorage.getItem(key)).toBeNull();
});
it('supports an explicit reset', () => {
const key = buildWorkflowShareConversationKey(
'flow-1',
buildWorkflowShareStorageScope('visitor-1', 'share-1'),
);
localStorage.setItem(key, 'cached');
removeWorkflowShareConversation(localStorage, key);
expect(localStorage.getItem(key)).toBeNull();
});
});

View File

@@ -150,7 +150,7 @@ export function hydrateWorkflowExecutionSteps(
error: textValue(step.errorInfo) || undefined, error: textValue(step.errorInfo) || undefined,
hasInput: step.input !== undefined && step.input !== null, hasInput: step.input !== undefined && step.input !== null,
hasOutput: step.output !== undefined && step.output !== null, hasOutput: step.output !== undefined && step.output !== null,
input: parseExecutionValue(step.input), input: parseWorkflowExecutionValue(step.input),
key: key:
textValue(step.attemptKey) || textValue(step.attemptKey) ||
textValue(step.id) || textValue(step.id) ||
@@ -158,7 +158,7 @@ export function hydrateWorkflowExecutionSteps(
nodeId: textValue(step.nodeId), nodeId: textValue(step.nodeId),
nodeName: nodeName:
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点', textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
output: parseExecutionValue(step.output), output: parseWorkflowExecutionValue(step.output),
startTime: timeValue(step.startTime), startTime: timeValue(step.startTime),
status: resolvePersistedStatus(step.status), status: resolvePersistedStatus(step.status),
traces: [], traces: [],
@@ -252,7 +252,7 @@ function findStepIndex(
return -1; return -1;
} }
function parseExecutionValue(value: unknown) { export function parseWorkflowExecutionValue(value: unknown) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
} }

View File

@@ -0,0 +1,34 @@
import { parseWorkflowExecutionValue } from './workflowExecutionDetails';
export function resolveWorkflowExecutionRecoveryStatus(
detail: Record<string, any>,
) {
const runtimeStatus = String(detail.runtime?.status || '')
.trim()
.toUpperCase();
if (runtimeStatus) {
return runtimeStatus;
}
const statusValue = String(
detail.runtime?.statusValue ?? detail.record?.status ?? '',
);
const labels: Record<string, string> = {
'0': 'READY',
'1': 'RUNNING',
'10': 'ERROR',
'20': 'SUCCEEDED',
'21': 'FAILED',
'22': 'CANCELLED',
'5': 'SUSPEND',
};
return labels[statusValue] || statusValue.toUpperCase();
}
export function resolveWorkflowExecutionRecoveryOutput(
detail: Record<string, any>,
) {
if (Object.prototype.hasOwnProperty.call(detail.runtime || {}, 'output')) {
return detail.runtime.output;
}
return parseWorkflowExecutionValue(detail.record?.output);
}

View File

@@ -136,6 +136,48 @@ function resolveDataType(type: string, contentType: string) {
return 'String'; return 'String';
} }
function isBlankMediaValue(value: unknown) {
return (
value === null ||
value === undefined ||
(typeof value === 'string' && value.trim() === '')
);
}
/**
* 构建发布运行表单的初始值,确保空媒体参数使用稳定的运行时语义。
*
* @param parameters 工作流运行参数
* @returns 可直接绑定到发布运行表单的初始值
*/
export function buildWorkflowFormInitialValues(parameters: any[]) {
const values: Record<string, any> = {};
for (const parameter of parameters || []) {
const defaultValue = parameter?.defaultValue;
if (
parameter?.contentType === 'file' &&
isBlankMediaValue(defaultValue)
) {
values[parameter.name] = [];
continue;
}
if (
parameter?.contentType === 'image' &&
isBlankMediaValue(defaultValue)
) {
continue;
}
if (Array.isArray(defaultValue)) {
values[parameter.name] = [...defaultValue];
} else if (defaultValue && typeof defaultValue === 'object') {
values[parameter.name] = { ...defaultValue };
} else {
values[parameter.name] = defaultValue ?? '';
}
}
return values;
}
export function resolveWorkflowFormParameters(workflowParams: any) { export function resolveWorkflowFormParameters(workflowParams: any) {
const schema = Array.isArray(workflowParams?.startFormSchema) const schema = Array.isArray(workflowParams?.startFormSchema)
? workflowParams.startFormSchema ? workflowParams.startFormSchema

View File

@@ -15,10 +15,10 @@ interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>; type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取可用的会话存储。 */ /** 获取可用的草稿存储;分享模式使用本地存储支持刷新恢复。 */
export function getWorkflowRunDraftStorage() { export function getWorkflowRunDraftStorage(persistent = false) {
try { try {
return globalThis.sessionStorage; return persistent ? globalThis.localStorage : globalThis.sessionStorage;
} catch { } catch {
return undefined; return undefined;
} }
@@ -31,7 +31,7 @@ export function buildWorkflowRunDraftKey(
shareMode: boolean, shareMode: boolean,
) { ) {
const mode = shareMode ? 'share' : 'private'; const mode = shareMode ? 'share' : 'private';
const scope = shareMode ? 'public' : identity || 'anonymous'; const scope = identity || (shareMode ? 'public' : 'anonymous');
return [ return [
WORKFLOW_RUN_DRAFT_PREFIX, WORKFLOW_RUN_DRAFT_PREFIX,
`v${WORKFLOW_RUN_DRAFT_VERSION}`, `v${WORKFLOW_RUN_DRAFT_VERSION}`,

View File

@@ -0,0 +1,229 @@
import type { ChatTimelineItem } from '@easyflow/common-ui';
const WORKFLOW_SHARE_CONVERSATION_PREFIX =
'easyflow:workflow-share-conversation';
const WORKFLOW_SHARE_CONVERSATION_VERSION = 1;
const MAX_TIMELINE_ITEMS = 200;
export const WORKFLOW_SHARE_CONVERSATION_TTL_MS = 12 * 60 * 60 * 1000;
export type WorkflowShareExecutionState =
| 'cancelled'
| 'completed'
| 'failed'
| 'idle'
| 'running'
| 'waiting';
export interface WorkflowShareConversationSnapshot {
executeId: string;
executionElapsed?: number;
executionStartedAt?: number;
executionState: WorkflowShareExecutionState;
parametersLocked: boolean;
runStatusKey: string;
timelineItems: ChatTimelineItem[];
}
interface StoredWorkflowShareConversation
extends WorkflowShareConversationSnapshot {
expiresAt: number;
version: number;
}
type ConversationStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取分享页使用的浏览器本地存储。 */
export function getWorkflowShareConversationStorage() {
try {
return globalThis.localStorage;
} catch {
return undefined;
}
}
/**
* 生成分享页本地快照范围。原始分享密钥和访客标识都不写入本地存储。
*/
export function buildWorkflowShareStorageScope(
visitorId: string,
shareKey: string,
) {
const normalizedVisitorId = visitorId.trim();
const normalizedShareKey = shareKey.trim();
if (!normalizedVisitorId || !normalizedShareKey) {
return '';
}
return fingerprint(`${normalizedVisitorId}\u0000${normalizedShareKey}`);
}
/** 生成按工作流、分享链接和标签页访客隔离的快照键。 */
export function buildWorkflowShareConversationKey(
workflowId: string,
storageScope: string,
) {
if (!workflowId || !storageScope) {
return '';
}
return [
WORKFLOW_SHARE_CONVERSATION_PREFIX,
`v${WORKFLOW_SHARE_CONVERSATION_VERSION}`,
encodeURIComponent(storageScope),
encodeURIComponent(workflowId),
].join(':');
}
/** 读取未过期且结构有效的分享页快照。 */
export function readWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
now = Date.now(),
): undefined | WorkflowShareConversationSnapshot {
if (!storage || !key) {
return undefined;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return undefined;
}
const snapshot = JSON.parse(
raw,
) as Partial<StoredWorkflowShareConversation>;
if (!isValidSnapshot(snapshot, now)) {
storage.removeItem(key);
return undefined;
}
return {
executeId: snapshot.executeId,
executionElapsed: numberValue(snapshot.executionElapsed),
executionStartedAt: numberValue(snapshot.executionStartedAt),
executionState: snapshot.executionState,
parametersLocked: snapshot.parametersLocked,
runStatusKey: snapshot.runStatusKey,
timelineItems: snapshot.timelineItems.slice(-MAX_TIMELINE_ITEMS),
};
} catch {
try {
storage.removeItem(key);
} catch {
// 存储不可用时不影响分享页加载。
}
return undefined;
}
}
/** 保存最近 200 条分享页时间线和最近执行引用。 */
export function writeWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
snapshot: WorkflowShareConversationSnapshot,
now = Date.now(),
) {
if (!storage || !key) {
return;
}
try {
storage.setItem(
key,
JSON.stringify({
...snapshot,
expiresAt: now + WORKFLOW_SHARE_CONVERSATION_TTL_MS,
timelineItems: snapshot.timelineItems.slice(-MAX_TIMELINE_ITEMS),
version: WORKFLOW_SHARE_CONVERSATION_VERSION,
} satisfies StoredWorkflowShareConversation),
);
} catch {
// 存储不可用或空间不足时不阻断工作流运行。
}
}
/** 删除分享页本地快照。 */
export function removeWorkflowShareConversation(
storage: ConversationStorage | undefined,
key: string,
) {
if (!storage || !key) {
return;
}
try {
storage.removeItem(key);
} catch {
// 存储不可用时不影响清空流程。
}
}
function isValidSnapshot(
snapshot: Partial<StoredWorkflowShareConversation>,
now: number,
): snapshot is StoredWorkflowShareConversation {
return (
snapshot.version === WORKFLOW_SHARE_CONVERSATION_VERSION &&
typeof snapshot.expiresAt === 'number' &&
snapshot.expiresAt > now &&
typeof snapshot.executeId === 'string' &&
typeof snapshot.runStatusKey === 'string' &&
typeof snapshot.parametersLocked === 'boolean' &&
isExecutionState(snapshot.executionState) &&
Array.isArray(snapshot.timelineItems) &&
snapshot.timelineItems.every((item) => isTimelineItem(item))
);
}
function isExecutionState(
value: unknown,
): value is WorkflowShareExecutionState {
return (
value === 'cancelled' ||
value === 'completed' ||
value === 'failed' ||
value === 'idle' ||
value === 'running' ||
value === 'waiting'
);
}
function isTimelineItem(value: unknown): value is ChatTimelineItem {
if (!value || typeof value !== 'object') {
return false;
}
const item = value as Record<string, unknown>;
if (typeof item.id !== 'string' || typeof item.type !== 'string') {
return false;
}
if (item.type === 'message') {
return Array.isArray(item.parts) && typeof item.role === 'string';
}
if (item.type === 'status') {
return (
typeof item.label === 'string' &&
typeof item.status === 'string' &&
typeof item.statusKey === 'string'
);
}
if (item.type === 'error') {
return typeof item.message === 'string';
}
return item.type === 'custom';
}
function numberValue(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function fingerprint(value: string) {
let first = 2_166_136_261;
let second = 2_654_435_769;
for (const character of value) {
const code = character.codePointAt(0) || 0;
first = Math.imul(first ^ code, 16_777_619);
second = Math.imul(second ^ code, 2_246_822_507);
}
return `${unsignedHex(first)}${unsignedHex(second)}`;
}
function unsignedHex(value: number) {
return (value >>> 0).toString(16).padStart(8, '0');
}

View File

@@ -4,9 +4,11 @@ import {
isWorkflowShareRequest, isWorkflowShareRequest,
readWorkflowShareKey, readWorkflowShareKey,
resolveWorkflowShareFailureReason, resolveWorkflowShareFailureReason,
resolveWorkflowShareVisitorId,
resolveWorkflowShareWorkflowId, resolveWorkflowShareWorkflowId,
withWorkflowShareHeader, withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER, WORKFLOW_SHARE_HEADER,
WORKFLOW_SHARE_VISITOR_HEADER,
} from '#/utils/workflow-share-context'; } from '#/utils/workflow-share-context';
describe('workflow share context', () => { describe('workflow share context', () => {
@@ -41,12 +43,15 @@ describe('workflow share context', () => {
{ {
pageUrl: 'https://example.test/share/workflow?shareKey=abc123', pageUrl: 'https://example.test/share/workflow?shareKey=abc123',
requestMethod: 'GET', requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1', requestUrl: '/api/v1/workflowChat/public/descriptor',
visitorId: '00112233445566778899aabbccddeeff',
}, },
), ),
).toEqual({ ).toEqual({
'Accept-Language': 'zh-CN', 'Accept-Language': 'zh-CN',
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'abc123', [WORKFLOW_SHARE_HEADER]: 'abc123',
[WORKFLOW_SHARE_VISITOR_HEADER]: '00112233445566778899aabbccddeeff',
}); });
}); });
@@ -57,7 +62,7 @@ describe('workflow share context', () => {
withWorkflowShareHeader(headers, { withWorkflowShareHeader(headers, {
pageUrl: 'https://example.test/share/workflow', pageUrl: 'https://example.test/share/workflow',
requestMethod: 'GET', requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1', requestUrl: '/api/v1/workflowChat/public/descriptor',
}), }),
).toEqual(headers); ).toEqual(headers);
}); });
@@ -120,18 +125,37 @@ describe('workflow share context', () => {
requestUrl: '/api/v1/workflowChat/run', requestUrl: '/api/v1/workflowChat/run',
}, },
), ),
).toEqual({});
expect(
withWorkflowShareHeader(
{ 'easyflow-token': 'authenticated-token' },
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflowChat/public/run',
visitorId: 'ffeeddccbbaa99887766554433221100',
},
),
).toEqual({ ).toEqual({
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'workflow-key', [WORKFLOW_SHARE_HEADER]: 'workflow-key',
[WORKFLOW_SHARE_VISITOR_HEADER]: 'ffeeddccbbaa99887766554433221100',
}); });
}); });
it('matches only the workflow sharing endpoint whitelist', () => { it('matches only the workflow sharing endpoint whitelist', () => {
expect( expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'), isWorkflowShareRequest('/flow/api/v1/workflowChat/public/run', 'post'),
).toBe(true); ).toBe(true);
expect( expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/execution', 'get'), isWorkflowShareRequest(
'/flow/api/v1/workflowChat/public/execution',
'get',
),
).toBe(true); ).toBe(true);
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
).toBe(false);
expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe( expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe(
false, false,
); );
@@ -143,6 +167,26 @@ describe('workflow share context', () => {
); );
}); });
it('keeps one cryptographic visitor id in the current tab storage', () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
const randomBytes = (size: number) =>
Uint8Array.from({ length: size }, (_, index) => index);
const first = resolveWorkflowShareVisitorId(storage, randomBytes);
const second = resolveWorkflowShareVisitorId(storage, () => {
throw new Error('should not regenerate');
});
expect(first).toBe('000102030405060708090a0b0c0d0e0f');
expect(second).toBe(first);
});
it('resolves the workflow id for a shared URL', async () => { it('resolves the workflow id for a shared URL', async () => {
const resolve = vi.fn().mockResolvedValue('workflow-1'); const resolve = vi.fn().mockResolvedValue('workflow-1');
const onFailure = vi.fn(); const onFailure = vi.fn();

View File

@@ -9,7 +9,14 @@ import type {
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
} from './types'; } from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue'; import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue'; import ChatTimelineItem from './ChatTimelineItem.vue';
@@ -35,6 +42,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload]; approve: [payload: ChatTimelineToolApprovalPayload];
bottomPinnedChange: [pinned: boolean];
copyMessage: [item: ChatTimelineMessageItem]; copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem]; errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem]; regenerateMessage: [item: ChatTimelineMessageItem];
@@ -44,12 +52,14 @@ const emit = defineEmits<{
}>(); }>();
const containerRef = ref<HTMLElement>(); const containerRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true); const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false); const suppressNextAutoScroll = ref(false);
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number }; let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };
const bottomThreshold = 24; const bottomThreshold = 24;
let scrollFrame = 0; let scrollFrame = 0;
let contentResizeObserver: ResizeObserver | undefined;
const assistantActionAnchorByRound = computed(() => { const assistantActionAnchorByRound = computed(() => {
const latestAssistantByRound = new Map<string, string>(); const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) { for (const item of props.items) {
@@ -116,7 +126,12 @@ function updatePinnedState() {
if (suppressNextAutoScroll.value && preservedAnchor) { if (suppressNextAutoScroll.value && preservedAnchor) {
return; return;
} }
isPinnedToBottom.value = isNearBottom(container); const pinned = isNearBottom(container);
if (pinned === isPinnedToBottom.value) {
return;
}
isPinnedToBottom.value = pinned;
emit('bottomPinnedChange', pinned);
} }
function scrollToBottom() { function scrollToBottom() {
@@ -206,6 +221,22 @@ function handleLegacyLayoutToggle() {
}); });
} }
defineExpose({
scrollToBottom,
});
onMounted(() => {
if (typeof ResizeObserver === 'undefined' || !contentRef.value) {
return;
}
contentResizeObserver = new ResizeObserver(() => {
if (isPinnedToBottom.value && !suppressNextAutoScroll.value) {
scrollToBottom();
}
});
contentResizeObserver.observe(contentRef.value);
});
function canCopyMessage(item: ChatTimelineItemType) { function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false); return item.type === 'message' && (props.copyable?.(item) ?? false);
} }
@@ -232,6 +263,7 @@ function isVariantLoading(item: ChatTimelineItemType) {
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
contentResizeObserver?.disconnect();
if (scrollFrame) { if (scrollFrame) {
cancelAnimationFrame(scrollFrame); cancelAnimationFrame(scrollFrame);
} }
@@ -257,6 +289,7 @@ watch(
class="chat-timeline" class="chat-timeline"
@scroll.passive="handleTimelineScroll" @scroll.passive="handleTimelineScroll"
> >
<div ref="contentRef" class="chat-timeline__content">
<div v-if="items.length === 0" class="chat-timeline__empty"> <div v-if="items.length === 0" class="chat-timeline__empty">
<div <div
class="chat-timeline__empty-icon" class="chat-timeline__empty-icon"
@@ -338,6 +371,7 @@ watch(
</template> </template>
</template> </template>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -345,12 +379,19 @@ watch(
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
gap: var(--space-3);
min-height: 0; min-height: 0;
padding: 16px;
overflow: auto; overflow: auto;
} }
.chat-timeline__content {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--space-3);
min-height: 100%;
padding: 16px;
}
.chat-timeline__empty-icon { .chat-timeline__empty-icon {
width: 72px; width: 72px;
height: 72px; height: 72px;

View File

@@ -395,6 +395,28 @@ describe('chat timeline turn', () => {
wrapper.unmount(); wrapper.unmount();
}); });
it('reports when the reader leaves and returns to the latest message', async () => {
const wrapper = mount(ChatTimeline, {
props: {
items: completedTurnItems(),
},
});
const container = wrapper.find('.chat-timeline').element as HTMLElement;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 1200 },
scrollTop: { configurable: true, value: 200, writable: true },
});
await wrapper.find('.chat-timeline').trigger('scroll');
expect(wrapper.emitted('bottomPinnedChange')).toEqual([[false]]);
container.scrollTop = 700;
await wrapper.find('.chat-timeline').trigger('scroll');
expect(wrapper.emitted('bottomPinnedChange')).toEqual([[false], [true]]);
expect(wrapper.find('.chat-timeline__content').exists()).toBe(true);
});
it('keeps running and approval content expanded in one turn', () => { it('keeps running and approval content expanded in one turn', () => {
const items: ChatTimelineItem[] = [ const items: ChatTimelineItem[] = [
{ {

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { authenticateResponseInterceptor } from './preset-interceptors';
function createInterceptor(
shouldHandleUnauthorized?: (config: any) => boolean,
) {
const doReAuthenticate = vi.fn(async () => undefined);
const interceptor = authenticateResponseInterceptor({
client: {} as any,
doReAuthenticate,
doRefreshToken: vi.fn(async () => 'new-token'),
enableRefreshToken: false,
formatToken: (token) => token,
shouldHandleUnauthorized,
});
return { doReAuthenticate, interceptor };
}
describe('authenticate response interceptor', () => {
it('leaves anonymous endpoint 401 errors to the page', async () => {
const { doReAuthenticate, interceptor } = createInterceptor(() => false);
const error = {
config: { method: 'get', url: '/api/v1/workflowShare/resolve' },
response: { status: 401 },
};
await expect(interceptor.rejected?.(error)).rejects.toBe(error);
expect(doReAuthenticate).not.toHaveBeenCalled();
});
it('keeps the existing reauthentication behavior by default', async () => {
const { doReAuthenticate, interceptor } = createInterceptor();
const error = {
config: { method: 'get', url: '/api/v1/workflow/page' },
response: { status: 401 },
};
await expect(interceptor.rejected?.(error)).rejects.toBe(error);
expect(doReAuthenticate).toHaveBeenCalledOnce();
});
});

View File

@@ -59,12 +59,14 @@ export const authenticateResponseInterceptor = ({
doRefreshToken, doRefreshToken,
enableRefreshToken, enableRefreshToken,
formatToken, formatToken,
shouldHandleUnauthorized,
}: { }: {
client: RequestClient; client: RequestClient;
doReAuthenticate: () => Promise<void>; doReAuthenticate: () => Promise<void>;
doRefreshToken: () => Promise<string>; doRefreshToken: () => Promise<string>;
enableRefreshToken: boolean; enableRefreshToken: boolean;
formatToken: (token: string) => null | string; formatToken: (token: string) => null | string;
shouldHandleUnauthorized?: (config: any) => boolean;
}): ResponseInterceptorConfig => { }): ResponseInterceptorConfig => {
return { return {
rejected: async (error) => { rejected: async (error) => {
@@ -73,6 +75,10 @@ export const authenticateResponseInterceptor = ({
if (response?.status !== 401) { if (response?.status !== 401) {
throw error; throw error;
} }
// 匿名接口的 401 由页面自身处理,不能触发刷新登录态或跳转登录页。
if (shouldHandleUnauthorized?.(config) === false) {
throw error;
}
// 判断是否启用了 refreshToken 功能 // 判断是否启用了 refreshToken 功能
// 如果没有启用或者已经是重试请求了,直接跳转到重新登录 // 如果没有启用或者已经是重试请求了,直接跳转到重新登录
if (!enableRefreshToken || config.__isRetryRequest) { if (!enableRefreshToken || config.__isRetryRequest) {

View File

@@ -8,6 +8,7 @@
key: string; key: string;
icon?: string | Snippet; icon?: string | Snippet;
title: string | Snippet; title: string | Snippet;
badge?: string | Snippet;
titleHelp?: string; titleHelp?: string;
description?: string | Snippet; description?: string | Snippet;
content: string | Snippet; content: string | Snippet;
@@ -50,6 +51,11 @@
{/if} {/if}
<Render target={item.title} /> <Render target={item.title} />
{#if item.badge}
<span class="tf-collapse-item-title-badge">
<Render target={item.badge} />
</span>
{/if}
{#if item.titleHelp} {#if item.titleHelp}
<span <span
class="tf-collapse-item-title-help" class="tf-collapse-item-title-help"
@@ -106,6 +112,22 @@
position: relative; position: relative;
} }
.tf-collapse-item-title-badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
margin-left: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.2;
color: var(--tf-primary-color);
white-space: nowrap;
background: var(--tf-primary-soft-bg);
border: 1px solid var(--tf-primary-soft-border);
border-radius: 999px;
}
.tf-collapse-item-title-help:hover { .tf-collapse-item-title-help:hover {
border-color: var(--tf-border-color-strong); border-color: var(--tf-border-color-strong);
color: var(--tf-text-primary); color: var(--tf-text-primary);

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import {
JOIN_MODE_ALL,
JOIN_MODE_ANY,
type JoinMode,
} from '../utils/joinMode';
const {
value,
name,
allDisabledReason = '',
invalidMode = false,
onChange,
}: {
value: JoinMode | null;
name: string;
allDisabledReason?: string;
invalidMode?: boolean;
onChange?: (value: JoinMode) => void;
} = $props();
const descriptionId = $derived(`${name}-description`);
const warningId = $derived(`${name}-warning`);
</script>
<fieldset class="join-mode-setting" aria-describedby={descriptionId}>
<legend>执行时机</legend>
<p id={descriptionId} class="join-mode-description">
多条入边时,选择节点何时开始执行
</p>
<div class="join-mode-options">
<label class:selected={value === JOIN_MODE_ANY} class="join-mode-option">
<input
type="radio"
{name}
value={JOIN_MODE_ANY}
checked={value === JOIN_MODE_ANY}
aria-describedby={invalidMode || allDisabledReason ? warningId : undefined}
onchange={() => onChange?.(JOIN_MODE_ANY)}
/>
<span class="join-mode-copy">
<strong>任一上游完成</strong>
<span>任一入边到达,即开始执行</span>
</span>
</label>
<label
class:selected={value === JOIN_MODE_ALL}
class:disabled={Boolean(allDisabledReason)}
class="join-mode-option"
>
<input
type="radio"
{name}
value={JOIN_MODE_ALL}
checked={value === JOIN_MODE_ALL}
disabled={Boolean(allDisabledReason)}
aria-describedby={allDisabledReason ? warningId : undefined}
onchange={() => onChange?.(JOIN_MODE_ALL)}
/>
<span class="join-mode-copy">
<strong>全部上游完成</strong>
<span>等待所有必需入边到达后执行</span>
</span>
</label>
</div>
{#if invalidMode || allDisabledReason}
<div
id={warningId}
class:error={invalidMode || value === JOIN_MODE_ALL}
class="join-mode-warning"
role={invalidMode || value === JOIN_MODE_ALL ? 'alert' : 'status'}
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 1 21h22L12 2Zm0 5.5 6.5 11h-13L12 7.5ZM11 10v4h2v-4h-2Zm0 5.5v2h2v-2h-2Z" />
</svg>
<span>
{invalidMode
? '当前执行时机配置无效,请重新选择。'
: allDisabledReason}
</span>
</div>
{/if}
</fieldset>
<style lang="less">
.join-mode-setting {
min-width: 0;
padding: 12px 0 0;
margin: 2px 0 0;
border: 0;
border-top: 1px solid var(--tf-border-color-soft);
}
legend {
float: left;
width: 100%;
padding: 0;
margin: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: var(--tf-text-primary);
}
.join-mode-description {
clear: both;
padding: 3px 0 0;
margin: 0 0 8px;
font-size: 11px;
line-height: 1.45;
color: var(--tf-text-muted);
}
.join-mode-options {
overflow: hidden;
background: var(--tf-bg-surface-alt);
border: 1px solid var(--tf-border-color);
border-radius: 10px;
}
.join-mode-option {
display: flex;
gap: 8px;
align-items: center;
min-height: 52px;
padding: 8px 10px;
box-sizing: border-box;
cursor: pointer;
background: transparent;
transition: background 140ms ease, box-shadow 140ms ease;
& + & {
border-top: 1px solid var(--tf-border-color-soft);
}
&:hover {
background: var(--tf-bg-hover);
}
&:active {
background: var(--tf-bg-active);
}
&:focus-within {
position: relative;
z-index: 1;
box-shadow: inset 0 0 0 1px var(--tf-primary-color), var(--tf-focus-shadow);
}
&.selected {
background: var(--tf-primary-ghost-bg);
strong {
color: var(--tf-primary-color);
}
}
&.disabled {
color: var(--tf-text-disabled);
cursor: not-allowed;
background: var(--tf-bg-input-disabled);
&:active,
&:hover {
background: var(--tf-bg-input-disabled);
}
}
}
input[type='radio'] {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--tf-primary-color);
}
.join-mode-copy {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 12px;
font-weight: 600;
line-height: 1.35;
color: var(--tf-text-primary);
}
span {
font-size: 11px;
line-height: 1.45;
color: var(--tf-text-secondary);
}
}
.join-mode-option.disabled .join-mode-copy strong,
.join-mode-option.disabled .join-mode-copy span {
color: var(--tf-text-disabled);
}
.join-mode-warning {
display: flex;
gap: 6px;
align-items: flex-start;
padding: 7px 8px;
margin-top: 8px;
font-size: 11px;
line-height: 1.45;
color: var(--tf-warning-soft-text);
background: var(--tf-warning-soft-bg);
border: 0;
border-radius: 8px;
svg {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin-top: 1px;
color: var(--tf-warning-icon-color);
}
&.error {
color: var(--tf-danger-soft-text);
background: var(--tf-danger-soft-bg);
svg {
color: var(--tf-danger-icon-color);
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
describe('NodeJoinModeSetting', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('uses native radios and reports a selected mode', async () => {
const onChange = vi.fn();
const host = document.createElement('div');
host.className = 'tf-theme-light';
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-a',
value: 'any',
onChange,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios).toHaveLength(2);
expect(radios[0].checked).toBe(true);
radios[1].focus();
expect(document.activeElement).toBe(radios[1]);
radios[1].click();
expect(onChange).toHaveBeenCalledWith('all');
await unmount(app);
});
it('keeps an unsafe existing all selected while allowing any as recovery', async () => {
const onChange = vi.fn();
const host = document.createElement('div');
host.className = 'tf-theme-dark';
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-b',
value: 'all',
allDisabledReason: '存在条件路径,可能永久等待。',
onChange,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios[1].checked).toBe(true);
expect(radios[1].disabled).toBe(true);
expect(host.querySelector('[role="alert"]')?.textContent).toContain('永久等待');
radios[0].click();
expect(onChange).toHaveBeenCalledWith('any');
await unmount(app);
});
it('shows an invalid configuration error without selecting a fallback', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-invalid',
value: null,
invalidMode: true,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(Array.from(radios).every((radio) => !radio.checked)).toBe(true);
expect(host.querySelector('[role="alert"]')?.textContent).toContain('配置无效');
await unmount(app);
});
});

View File

@@ -15,6 +15,13 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {TinyflowNodeData} from '#types'; import type {TinyflowNodeData} from '#types';
import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver'; import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver';
import {useTinyflowStore} from '#store/stores.svelte';
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
import {
analyzeJoinMode,
getJoinModeBadge,
type JoinMode,
} from '../utils/joinMode';
const { const {
data, data,
@@ -52,13 +59,22 @@
const activeKeys = $derived.by(() => data.expand ? ['key'] : []); const activeKeys = $derived.by(() => data.expand ? ['key'] : []);
const { updateNodeData, getNode } = useSvelteFlow(); const { updateNodeData, getNode } = useSvelteFlow();
const store = useTinyflowStore();
const updateNodeInternals = useUpdateNodeInternals(); const updateNodeInternals = useUpdateNodeInternals();
const joinModeAnalysis = $derived.by(() => analyzeJoinMode(
store.getNodes(),
store.getEdges(),
id,
));
const joinModeBadge = $derived(getJoinModeBadge(data.joinMode));
const items = $derived.by(() => { const items = $derived.by(() => {
return [{ return [{
key: 'key', key: 'key',
icon, icon,
title: data.title as string, title: data.title as string,
badge: joinModeBadge,
titleHelp, titleHelp,
description: data.description as string, description: data.description as string,
content: children content: children
@@ -87,6 +103,17 @@
const MIN_LOOP_COUNT = 1; const MIN_LOOP_COUNT = 1;
const MAX_LOOP_COUNT = 300; const MAX_LOOP_COUNT = 300;
let loopCountHint = $state(''); let loopCountHint = $state('');
let advancedConditionOpen = $state(false);
const updateJoinMode = (joinMode: JoinMode) => {
updateNodeData(id, {joinMode});
};
$effect(() => {
if (String(data.condition || '').trim()) {
advancedConditionOpen = true;
}
});
const normalizeLoopCount = (value: unknown) => { const normalizeLoopCount = (value: unknown) => {
const parsed = Number(value); const parsed = Number(value);
@@ -210,16 +237,35 @@
}} value={data.description} /> }} value={data.description} />
</div> </div>
{#if joinModeAnalysis.incomingCount > 1}
<NodeJoinModeSetting
name={`join-mode-${id}`}
value={joinModeAnalysis.mode}
invalidMode={joinModeAnalysis.invalidMode}
allDisabledReason={joinModeAnalysis.allAllowed
? ''
: joinModeAnalysis.allDisabledReason}
onChange={updateJoinMode}
/>
{/if}
{#if allowSettingOfCondition} {#if allowSettingOfCondition}
<div class="input-item"> <details class="advanced-condition" bind:open={advancedConditionOpen}>
执行条件: <summary>高级执行条件</summary>
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{ <div class="advanced-condition-content">
const value = (event.target as any).value; <Textarea
updateNodeData(currentNodeId,{ rows={3}
condition: value style="width: 100%;"
}) placeholder="可选:输入 JavaScript 条件表达式"
}} value={data.condition} /> onchange={(event)=>{
const value = (event.target as HTMLTextAreaElement).value;
updateNodeData(currentNodeId, {condition: value})
}}
value={data.condition}
/>
<span>汇聚条件满足后才会计算此表达式。</span>
</div> </div>
</details>
{/if} {/if}
<label class="input-item-inline"> <label class="input-item-inline">
@@ -362,13 +408,18 @@
.settings { .settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
padding: 10px; padding: 12px;
background: var(--tf-bg-surface); background: var(--tf-bg-surface);
border: 1px solid var(--tf-border-color-strong); border: 1px solid var(--tf-border-color-strong);
border-radius: 5px; box-sizing: border-box;
width: 200px; width: 304px;
max-height: min(560px, 72vh);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 12px;
box-shadow: var(--tf-shadow-medium); box-shadow: var(--tf-shadow-medium);
scrollbar-color: var(--tf-scrollbar-thumb) transparent;
.input-item { .input-item {
display: flex; display: flex;
@@ -390,6 +441,66 @@
line-height: 1.4; line-height: 1.4;
} }
.advanced-condition {
color: var(--tf-text-secondary);
border-top: 1px solid var(--tf-border-color-soft);
summary {
display: flex;
gap: 6px;
align-items: center;
padding: 10px 0 0;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: var(--tf-text-primary);
cursor: pointer;
list-style: none;
outline: none;
&::-webkit-details-marker {
display: none;
}
&::before {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 16px;
font-weight: 400;
line-height: 1;
color: var(--tf-text-muted);
content: '';
transition: transform 140ms ease;
transform-origin: center;
}
}
summary:focus-visible {
border-radius: 5px;
box-shadow: var(--tf-focus-shadow);
}
&[open] > summary::before {
transform: rotate(90deg);
}
&-content {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
span {
font-size: 11px;
line-height: 1.4;
color: var(--tf-text-muted);
}
}
}
input[type='checkbox'] { input[type='checkbox'] {
width: 14px; width: 14px;
height: 14px; height: 14px;

View File

@@ -0,0 +1,157 @@
import type { Edge, Node } from '@xyflow/svelte';
import { describe, expect, it } from 'vitest';
import {
analyzeJoinMode,
getJoinModeBadge,
parseJoinMode,
} from './joinMode';
const node = (
id: string,
type = 'codeNode',
data: Record<string, unknown> = {},
parentId?: string,
) => ({ id, type, data, parentId, position: { x: 0, y: 0 } }) as Node;
const edge = (
id: string,
source: string,
target: string,
condition = '',
) => ({
id,
source,
target,
data: condition ? { condition } : {},
}) as Edge;
describe('join mode graph analysis', () => {
it('defaults to any and only exposes a multi-inbound setting', () => {
const nodes = [node('start', 'startNode'), node('join')];
expect(analyzeJoinMode(nodes, [], 'join')).toMatchObject({
incomingCount: 0,
mode: 'any',
allAllowed: true,
});
expect(analyzeJoinMode(
nodes,
[edge('e1', 'start', 'join')],
'join',
).incomingCount).toBe(1);
});
it('allows all when every parallel source is guaranteed', () => {
const nodes = [
node('start', 'startNode'),
node('a'),
node('b'),
node('join', 'codeNode', { joinMode: 'all' }),
];
const edges = [
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
];
expect(analyzeJoinMode(nodes, edges, 'join')).toMatchObject({
incomingCount: 2,
mode: 'all',
invalidMode: false,
allAllowed: true,
});
});
it('blocks conditional and custom-conditioned upstream paths', () => {
const conditionalNodes = [
node('start', 'startNode'),
node('a'),
node('b'),
node('join'),
];
const conditionalEdges = [
edge('start-a', 'start', 'a', 'matched === true'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
];
const conditional = analyzeJoinMode(
conditionalNodes,
conditionalEdges,
'join',
);
expect(conditional.allAllowed).toBe(false);
expect(conditional.allDisabledReason).toContain('无法证明必达');
const directConditional = analyzeJoinMode(
conditionalNodes,
[
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join', 'matched === true'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(directConditional.allAllowed).toBe(false);
expect(directConditional.allDisabledReason).toContain('直接入边');
const customCondition = analyzeJoinMode(
[
node('start', 'startNode'),
node('a', 'codeNode', { condition: 'score > 0' }),
node('b'),
node('join'),
],
[
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(customCondition.allAllowed).toBe(false);
expect(customCondition.allDisabledReason).toContain('高级执行条件');
});
it('blocks loop children without silently rewriting existing all', () => {
const analysis = analyzeJoinMode(
[
node('loop', 'loopNode'),
node('a', 'codeNode', {}, 'loop'),
node('b', 'codeNode', {}, 'loop'),
node('join', 'codeNode', { joinMode: 'all' }, 'loop'),
],
[
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(analysis.mode).toBe('all');
expect(analysis.allAllowed).toBe(false);
expect(analysis.allDisabledReason).toContain('显式循环子图');
});
it('keeps invalid raw values visible and exposes the all badge', () => {
expect(parseJoinMode('unexpected')).toBeNull();
expect(parseJoinMode('')).toBeNull();
expect(parseJoinMode(null)).toBeNull();
expect(parseJoinMode(undefined)).toBe('any');
expect(getJoinModeBadge('ALL')).toBe('等待全部');
expect(getJoinModeBadge('any')).toBe('');
const analysis = analyzeJoinMode(
[node('join', 'codeNode', { joinMode: 'unexpected' })],
[],
'join',
);
expect(analysis.invalidMode).toBe(true);
expect(analysis.mode).toBeNull();
});
});

View File

@@ -0,0 +1,163 @@
import type { Edge, Node } from '@xyflow/svelte';
export const JOIN_MODE_ANY = 'any';
export const JOIN_MODE_ALL = 'all';
export type JoinMode = typeof JOIN_MODE_ANY | typeof JOIN_MODE_ALL;
export type JoinModeAnalysis = {
incomingCount: number;
mode: JoinMode | null;
invalidMode: boolean;
allAllowed: boolean;
allDisabledReason: string;
};
const text = (value: unknown) => (value == null ? '' : String(value).trim());
export function parseJoinMode(value: unknown): JoinMode | null {
if (value === undefined) {
return JOIN_MODE_ANY;
}
const normalized = text(value).toLowerCase();
if (normalized === JOIN_MODE_ANY) {
return JOIN_MODE_ANY;
}
if (normalized === JOIN_MODE_ALL) {
return JOIN_MODE_ALL;
}
return null;
}
export function getJoinModeBadge(value: unknown) {
return parseJoinMode(value) === JOIN_MODE_ALL ? '等待全部' : '';
}
export function analyzeJoinMode(
nodes: Node[],
edges: Edge[],
nodeId: string,
): JoinModeAnalysis {
const targetNode = nodes.find((node) => node.id === nodeId);
const directInward = edges.filter((edge) => edge.target === nodeId);
const hasJoinMode = targetNode?.data
? Object.prototype.hasOwnProperty.call(targetNode.data, 'joinMode')
: false;
const mode = parseJoinMode(targetNode?.data?.joinMode);
const invalidMode = hasJoinMode && mode === null;
if (!targetNode) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: '节点不存在,请刷新画布后重试。',
};
}
if (text(targetNode.parentId)) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: '显式循环子图暂不支持等待全部上游,请使用“任一上游完成”。',
};
}
if (directInward.length <= 1) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: true,
allDisabledReason: '',
};
}
const guaranteedNodes = findGuaranteedNodes(nodes, edges);
const allAllowed = directInward.every(
(edge) => !hasEdgeCondition(edge) && guaranteedNodes.has(edge.source),
);
if (allAllowed) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: true,
allDisabledReason: '',
};
}
let reason = '存在条件、互斥或无法证明必达的上游路径,可能永久等待。请调整连线或使用“任一上游完成”。';
if (directInward.some(hasEdgeCondition)) {
reason = '存在带条件的直接入边,部分入边可能不会到达。请调整连线或使用“任一上游完成”。';
} else if (directInward.some((edge) => {
const source = nodes.find((node) => node.id === edge.source);
return hasAdvancedCondition(source);
})) {
reason = '上游节点包含高级执行条件,无法保证每条入边都会到达。请调整条件或使用“任一上游完成”。';
}
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: reason,
};
}
function findGuaranteedNodes(nodes: Node[], edges: Edge[]) {
const guaranteed = new Set(
nodes
.filter((node) => !text(node.parentId) && node.type === 'startNode')
.map((node) => node.id),
);
const inwardByTarget = new Map<string, Edge[]>();
edges.forEach((edge) => {
const inward = inwardByTarget.get(edge.target) || [];
inward.push(edge);
inwardByTarget.set(edge.target, inward);
});
let changed = true;
while (changed) {
changed = false;
nodes.forEach((node) => {
if (
text(node.parentId)
|| guaranteed.has(node.id)
|| hasAdvancedCondition(node)
) {
return;
}
const mode = parseJoinMode(node.data?.joinMode);
if (!mode) {
return;
}
const inward = inwardByTarget.get(node.id) || [];
const isGuaranteed = mode === JOIN_MODE_ALL
? inward.length > 0 && inward.every(
(edge) => !hasEdgeCondition(edge) && guaranteed.has(edge.source),
)
: inward.some(
(edge) => !hasEdgeCondition(edge) && guaranteed.has(edge.source),
);
if (isGuaranteed) {
guaranteed.add(node.id);
changed = true;
}
});
}
return guaranteed;
}
function hasAdvancedCondition(node: Node | undefined) {
return Boolean(text(node?.data?.condition));
}
function hasEdgeCondition(edge: Edge) {
return Boolean(text(edge.data?.condition));
}

View File

@@ -94,6 +94,26 @@ http {
proxy_redirect off; proxy_redirect off;
} }
location ^~ /flow/public-api/ {
set $easyflow_backend http://backend:8111;
rewrite ^/flow/public-api/(.*)$ /public-api/$1 break;
proxy_pass $easyflow_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Prefix /flow;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
}
location ^~ /flow/storage/ { location ^~ /flow/storage/ {
set $easyflow_minio http://minio-shared:9000; set $easyflow_minio http://minio-shared:9000;
rewrite ^/flow/storage/(.*)$ /easyflow/$1 break; rewrite ^/flow/storage/(.*)$ /easyflow/$1 break;