Compare commits

16 Commits

Author SHA1 Message Date
9ef20119a9 fix: 修复 Hash 路由跳转丢失部署基路径
- 修复懒加载分包失败后跳转到站点根路径的问题

- 统一管理端和用户中心新窗口路由解析

- 增加部署基路径与 Hash 路由回归测试
2026-08-31 18:48:01 +08:00
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
be10eabb64 feat: 接入一库一工具知识检索
- 编译知识库英文运行名、描述、检索配置和独立 Registration

- 统一最终分数阈值并保持模型上下文、检索事件与引用一致

- 完善 AG-UI 知识库检索运行态与完成态展示
2026-08-29 15:44:54 +08:00
7e1490d5f8 fix: 支持可信内部文件引用读取
- 通过服务端文件记录和存储配置恢复可信物理读取句柄

- 文档解析与工作流文档节点优先读取已确认的内部存储对象

- 补齐路径校验、记录异常和读取边界测试
2026-08-29 15:44:53 +08:00
7aed4bcc37 fix: 避免空引用集合触发无效查询
- Agent 与 Skill 引用查询在空 ID 集合时直接返回

- 补充不调用 listByIds 的回归测试
2026-08-26 23:29:00 +08:00
6248e2c7b8 fix: 支持模型异常后继续文档对话
- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
2026-08-26 22:56:31 +08:00
4de8cc5bd0 feat: 优化工作流运行输入交互
- 合并运行参数与问题输入并锁定首轮参数

- 优化文件上传、参数摘要与十二小时草稿恢复

- 补充输入表单国际化与相关测试
2026-08-26 22:49:02 +08:00
71b3d3d620 fix: 修正工作流试运行参数名称展示
- 默认字段标题回退为实际配置的参数名称

- 保留显式展示标题并统一表单与引用口径
2026-08-26 22:49:01 +08:00
1870ac4028 fix: 修正自动导入完成提示
- 根据关联文档真实状态识别已完成的历史失败批次

- 完成后自动移除提示并低频复查失败或中断批次
2026-08-26 18:58:54 +08:00
2a383ef3f2 Merge pull request '发布 v1.10' (#5) from develop into main
Reviewed-on: #5
2026-08-20 11:36:26 +08:00
34ff62d317 Merge pull request 'fix: 修复 ip 地址登录系统报错,ts 方法兼容旧版本浏览器' (#4) from hotfix/encrypt_compatibility into main
Reviewed-on: #4
2026-06-24 17:00:02 +08:00
119 changed files with 8608 additions and 566 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;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -96,11 +97,10 @@ public class WorkflowShareController {
* @return 工作流标识
*/
@GetMapping("/resolve")
@SaIgnore
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShare share = workflowShareService.resolveChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
loginAccount.getTenantId()
WorkflowShare share = workflowShareService.resolvePublicChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
);
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 javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
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.AtomicLong;
@@ -40,6 +46,15 @@ public class WorkflowChatEventStream {
private final ChainExecutor chainExecutor;
private final Map<String, StreamSession> sessions =
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);
}
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/**
* 启动工作流并返回其 SSE 连接。
*
@@ -67,11 +91,53 @@ public class WorkflowChatEventStream {
* @return SSE 连接
*/
public SseEmitter start(String definitionId, Map<String, Object> variables) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
StreamSession session = new StreamSession(emitter);
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
emitter.onError(error -> disconnect(session, "运行连接已断开"));
emitter.onCompletion(() -> removeSession(session));
return start(definitionId, variables, () -> {
});
}
/**
* 启动工作流并在流会话结束时执行清理回调。
*
* @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 {
chainExecutor.executeAsync(
@@ -79,6 +145,9 @@ public class WorkflowChatEventStream {
variables,
executeId -> {
session.attach(executeId);
if (session.cleaned.get()) {
return;
}
sessions.put(executeId, session);
session.send("execution_started", Map.of(
"executeId", executeId
@@ -92,6 +161,13 @@ public class WorkflowChatEventStream {
return emitter;
}
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/**
* 将工作流事件转发到对应执行流。
*
@@ -162,20 +238,21 @@ public class WorkflowChatEventStream {
}
/**
* 处理 SSE 连接异常,并取消尚未结束的工作流
* 分离已经断开的浏览器传输,不影响工作流 Runtime
*
* @param session 流会话
* @param message 取消原因
*/
private void disconnect(StreamSession session, String message) {
private void detach(StreamSession session) {
if (session == null || session.terminal.get()) {
return;
}
String executeId = session.executeId;
session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
removeSession(session);
if (executeId != null) {
chainExecutor.cancel(executeId, message);
return;
}
session.scheduleDetachedCleanup();
}
/**
@@ -187,6 +264,9 @@ public class WorkflowChatEventStream {
if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session);
}
if (session != null) {
session.cleanup();
}
}
/**
@@ -231,6 +311,11 @@ public class WorkflowChatEventStream {
private final SseEmitter emitter;
private final AtomicLong sequence = new AtomicLong();
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;
/**
@@ -238,8 +323,36 @@ public class WorkflowChatEventStream {
*
* @param emitter SSE 发送器
*/
private StreamSession(SseEmitter emitter) {
private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
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;
}
/**
* 标记浏览器传输已经断开,后续事件只推进 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);
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.complete();
}
}
/**
* 发送 SSE 事件。
@@ -434,6 +581,9 @@ public class WorkflowChatEventStream {
* @param data 事件数据
*/
private void send(String type, Map<String, ?> data) {
if (!connected.get()) {
return;
}
long nextSequence = sequence.incrementAndGet();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", executeId + ":" + nextSequence);
@@ -453,7 +603,7 @@ public class WorkflowChatEventStream {
executeId,
error
);
disconnect(this, "运行连接已断开");
detach(this);
}
}
@@ -468,9 +618,11 @@ public class WorkflowChatEventStream {
"message", safeErrorMessage(error)
));
removeSession(this);
if (connected.compareAndSet(true, false)) {
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 org.testng.Assert;
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.Proxy;
import java.util.Locale;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowShareController} 分享地址构建测试。
*/
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;
}
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 org.testng.Assert;
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.List;
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.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -69,4 +75,81 @@ public class WorkflowChatEventStreamTest {
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

@@ -11,6 +11,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService {
return serviceForHandle(handle).readRecoverable(handle);
}
/**
* 使用当前后端解析服务端可信文件引用。
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录无法安全解析时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return currentService().resolveTrustedFile(reference);
}
/**
* 严格按句柄中的后端精确删除物理对象。
*

View File

@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
/**
* EasyFlow 文件存储统一接口。
@@ -105,6 +106,21 @@ public interface FileStorageService {
throw unsupportedRecoverableOperation("readRecoverable");
}
/**
* 将服务端可信文件引用解析为物理读取句柄。
*
* <p>实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源
* 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。</p>
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或存储配置不兼容时抛出
*/
default Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return Optional.empty();
}
/**
* 精确且幂等地删除句柄对应的物理对象。
*

View File

@@ -21,6 +21,7 @@ import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.Optional;
/**
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
@@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
*
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
*
* @param reference 文件 URL
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
if (!StringUtils.hasText(reference)) {
return Optional.empty();
}
FileInfo fileInfo = null;
try {
fileInfo = fileStorageService.getFileInfoByUrl(reference);
} catch (RuntimeException exception) {
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
LOG.warn("查询 x-file-storage 文件记录失败继续按服务端存储配置识别reference={}",
reference, exception);
}
if (fileInfo != null) {
if (!reference.equals(fileInfo.getUrl())) {
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
}
try {
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
}
}
return resolveConfiguredStorageReference(reference);
}
/**
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
*
@@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
*
* @param fileInfo 服务端文件记录
* @param handle 恢复出的物理读取句柄
*/
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|| !handle.getBasePath().equals(actualBasePath)
|| !physicalPath(handle).equals(actualPath)
|| !handle.getFilename().equals(fileInfo.getFilename())) {
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
}
}
/**
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
*
* @param fileInfo 服务端文件信息
* @return 可信物理读取句柄
*/
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
return new FileStorageWriteHandle(
RECOVERABLE_BACKEND,
fileInfo.getPlatform(),
basePath,
recordedRelativePath(basePath, fileInfo.getPath()),
fileInfo.getFilename());
}
/**
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
*
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
* host 或字符串前缀放行其他私网目标。</p>
*
* @param reference 待识别 URL
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
*/
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
String reference) throws IOException {
if (fileStorageService.getFileStorageList() == null) {
return Optional.empty();
}
for (FileStorage storage : fileStorageService.getFileStorageList()) {
String domain = readDomainBestEffort(storage);
if (!StringUtils.hasText(domain)) {
continue;
}
String basePath = readRequiredBasePath(storage);
String prefix = domain + basePath;
if (!reference.startsWith(prefix)) {
continue;
}
try {
String remainder = reference.substring(prefix.length());
int filenameIndex = remainder.lastIndexOf('/') + 1;
FileInfo fileInfo = new FileInfo()
.setUrl(reference)
.setPlatform(storage.getPlatform())
.setBasePath(basePath)
.setPath(remainder.substring(0, filenameIndex))
.setFilename(remainder.substring(filenameIndex));
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
}
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
}
}
return Optional.empty();
}
/**
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
*
* @param basePath 平台基础路径
* @param recordedPath recorder 中保存的物理目录
* @return 不带前导斜杠的相对目录
*/
private String recordedRelativePath(String basePath, String recordedPath) {
String path = recordedPath == null ? "" : recordedPath;
if (basePath.isEmpty() || basePath.endsWith("/")) {
if (path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
}
return path;
}
if (!path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
}
return path.substring(1);
}
/**
* 构造仅包含精确物理定位字段的 FileInfo。
*
@@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService {
* @return 可推导 URL平台不支持时返回 null
*/
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
String domain = readDomainBestEffort(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
}
/**
* 使用平台公开的 getDomain 方法读取文件访问域名。
*
* @param storage 具体平台存储
* @return 平台访问域名;平台不支持时返回 null
*/
private String readDomainBestEffort(FileStorage storage) {
try {
Method method = storage.getClass().getMethod("getDomain");
if (!String.class.equals(method.getReturnType())) {
return null;
}
String domain = (String) method.invoke(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
return (String) method.invoke(storage);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
return null;

View File

@@ -7,6 +7,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
@@ -49,6 +50,27 @@ public class FileStorageManagerTest {
assertFalse(exists);
}
/**
* 验证服务端文件记录解析使用当前配置的具体存储后端。
*
* @throws IOException 文件记录解析失败时抛出
*/
@Test
public void recordedFileResolutionUsesCurrentBackend() throws IOException {
RecordingStorage local = new RecordingStorage("local");
RecordingStorage xFile = new RecordingStorage("xFileStorage");
FileStorageManager manager = new FileStorageManager(
() -> "xFileStorage",
backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
Optional<FileStorageWriteHandle> resolved = manager.resolveTrustedFile(
"http://127.0.0.1:39000/easyflow/attachment/demo.pdf");
assertSame(xFile.recordedHandle, resolved.orElseThrow());
assertEquals(1, xFile.resolveCalls);
assertEquals(0, local.resolveCalls);
}
/**
* 可记录可恢复调用的存储测试替身。
*/
@@ -57,6 +79,8 @@ public class FileStorageManagerTest {
private final String backend;
/** 固定结果。 */
private final FileStorageWriteResult result;
/** 固定服务端文件记录句柄。 */
private final FileStorageWriteHandle recordedHandle;
/** 固定可恢复读取流。 */
private final InputStream recoverableInput = InputStream.nullInputStream();
/** prepare 调用次数。 */
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
private int deleteCalls;
/** exists 调用次数。 */
private int existsCalls;
/** 服务端文件记录解析调用次数。 */
private int resolveCalls;
/**
* 创建指定名称的存储替身。
@@ -80,6 +106,8 @@ public class FileStorageManagerTest {
FileStorageWriteHandle handle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
this.recordedHandle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
}
/** {@inheritDoc} */
@@ -114,6 +142,13 @@ public class FileStorageManagerTest {
return recoverableInput;
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
resolveCalls++;
return Optional.of(recordedHandle);
}
/** {@inheritDoc} */
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {

View File

@@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest {
client.lastArgs.object());
}
/**
* 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void recordedLoopbackUrlUsesExactMinioObject() throws Exception {
byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/")
.setFilename("投标文件否决(废标)违规事项汇总.pdf");
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertEquals("attachment", handle.getBasePath());
assertEquals(
"1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/",
handle.getPath());
assertArrayEquals(content, actual);
assertEquals("easyflow", client.lastArgs.bucket());
assertEquals(
"attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception {
byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf";
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertArrayEquals(content, actual);
assertEquals(
"attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证未配置为存储地址的回环 URL 不会被识别为可信附件。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void unconfiguredLoopbackUrlIsNotResolved() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
assertTrue(service.resolveTrustedFile(
"http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty());
}
/**
* 验证 recorder 中非规范物理路径会失败关闭。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void corruptedRecordedLocationIsRejected() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("missing-leading-slash/")
.setFilename("demo.pdf");
XFIleStorageServiceImpl service = createService(delegate);
IOException exception = assertThrows(
IOException.class,
() -> service.resolveTrustedFile(fileUrl));
assertTrue(exception.getMessage().contains("无法恢复"));
}
/**
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
*
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
private int recorderDeleteCalls;
/** recorder 删除是否抛出异常。 */
private boolean recorderDeleteThrows;
/** recorder 返回的服务端文件记录。 */
private FileInfo recordedFileInfo;
/**
* 创建聚合服务替身。
@@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest {
*/
private RecoverableStorageService(FileStorage platform) {
this.platform = platform;
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
java.util.List.of(platform)));
setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { }
@Override public FileInfo getByUrl(String url) { return null; }
@Override public FileInfo getByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
@Override public boolean delete(String url) {
recorderDeleteCalls++;
if (recorderDeleteThrows) {
@@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest {
return platform.getPlatform().equals(name) ? (T) platform : null;
}
/** {@inheritDoc} */
@Override
public FileInfo getFileInfoByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
/** {@inheritDoc} */
@Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {

View File

@@ -423,7 +423,7 @@ public class AgentRunService {
chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
documentContext.tokenEstimate());
String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia);
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext);
threadPoolTaskExecutor.execute(() -> startRuntime(
agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
@@ -646,24 +646,6 @@ public class AgentRunService {
return agentDocumentService.bindDraft(documentUploads);
}
/**
* 将本轮文档正文追加到临时运行定义的系统提示词中。
*
* <p>正文只存在于本轮模型调用定义,不写入 chatlog 或 AgentScope 消息记忆。</p>
*
* @param bundle 临时运行时编译结果
* @param documentContext 本轮文档上下文
*/
private void appendDocumentContext(AgentRuntimeBundle bundle, AgentDocumentContext documentContext) {
if (bundle == null || bundle.getDefinition() == null
|| documentContext == null || documentContext.text().isBlank()) {
return;
}
String current = bundle.getDefinition().getSystemPrompt();
bundle.getDefinition().setSystemPrompt(
(current == null ? "" : current) + documentContext.text());
}
/**
* 为仅附件输入生成可持久化的最小用户意图。
*
@@ -1187,6 +1169,8 @@ public class AgentRunService {
StringBuilder answer = new StringBuilder();
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker =
new KnowledgeRetrievalStatusTracker();
// 注册 emit 服务
registerEmitterCancellation(requestId, runOutput, chatContext, answer,
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
@@ -1195,7 +1179,7 @@ public class AgentRunService {
if (isAguiCancellationRequested(runOutput)) {
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
answer, assistantAccumulator, legacyThinkingTagParser,
chatContext, finished, persistChatlog);
knowledgeRetrievalStatusTracker, chatContext, finished, persistChatlog);
if (lockHandle != null) {
releaseRunLockQuietly(lockHandle, requestId);
}
@@ -1206,7 +1190,6 @@ public class AgentRunService {
}
AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId);
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
appendDocumentContext(bundle, documentContext);
AgentRuntime runtime = agentRuntimeFactory.create();
// 会话初始化请求
AgentInitRequest request = new AgentInitRequest();
@@ -1214,7 +1197,7 @@ public class AgentRunService {
request.setAgentDefinition(bundle.getDefinition());
request.setRuntimeContext(runtimeContext);
request.setToolInvokers(bundle.getToolInvokers());
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
request.setKnowledgeRegistrations(bundle.getKnowledgeRegistrations());
request.setSessionStore(runtimeSessionStore);
request.setMediaResolver(agentMediaService.runtimeResolver(account));
request.getMetadata().put("assistantCode", assistantCode);
@@ -1243,6 +1226,7 @@ public class AgentRunService {
runRuntimeCallbackSafely(
() -> handleRuntimeEvent(event, requestId, runOutput, answer,
assistantAccumulator, legacyThinkingTagParser,
knowledgeRetrievalStatusTracker,
chatContext, finished, persistChatlog),
requestId, runOutput, chatContext, finished, persistChatlog);
}
@@ -1532,7 +1516,8 @@ public class AgentRunService {
AtomicBoolean finished,
boolean persistChatlog) {
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
new LegacyThinkingTagParser(), chatContext, finished, persistChatlog);
new LegacyThinkingTagParser(), new KnowledgeRetrievalStatusTracker(),
chatContext, finished, persistChatlog);
}
private void handleRuntimeEvent(AgentRuntimeEvent event,
@@ -1544,6 +1529,35 @@ public class AgentRunService {
ChatRuntimeContext chatContext,
AtomicBoolean finished,
boolean persistChatlog) {
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
legacyThinkingTagParser, new KnowledgeRetrievalStatusTracker(),
chatContext, finished, persistChatlog);
}
/**
* 将单个 Runtime 事件投影到聊天协议,并复用本轮知识库工具状态追踪器。
*
* @param event Runtime 事件
* @param requestId 请求 ID
* @param runOutput 运行输出
* @param answer 回答累积器
* @param assistantAccumulator Assistant 结构化累积器
* @param legacyThinkingTagParser 旧思考标签解析器
* @param knowledgeRetrievalStatusTracker 知识库工具状态追踪器
* @param chatContext 聊天上下文
* @param finished 终态仲裁标记
* @param persistChatlog 是否持久化聊天日志
*/
private void handleRuntimeEvent(AgentRuntimeEvent event,
String requestId,
AgentRunOutput runOutput,
StringBuilder answer,
ChatAssistantAccumulator assistantAccumulator,
LegacyThinkingTagParser legacyThinkingTagParser,
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker,
ChatRuntimeContext chatContext,
AtomicBoolean finished,
boolean persistChatlog) {
if (event == null || event.getEventType() == null) {
return;
}
@@ -1661,6 +1675,17 @@ public class AgentRunService {
return;
}
Map<String, Object> toolPayload = toolStatus;
if (isKnowledgeToolEvent(event)) {
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
knowledgeRetrievalStatusTracker.update(event));
LOG.info("Agent runtime knowledge tool call, requestId={}, toolCallId={}, toolName={}",
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"));
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
}
return;
}
if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
@@ -1683,6 +1708,20 @@ public class AgentRunService {
}
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
Map<String, Object> toolPayload = toolStatus;
if (isKnowledgeToolEvent(event)) {
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
knowledgeRetrievalStatusTracker.update(event));
LOG.info("Agent runtime knowledge tool result, requestId={}, toolCallId={}, toolName={}, status={}",
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
stringValue(statusPayload, "status"));
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
return;
}
legacyThinkingTagParser.reset();
return;
}
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
stringValue(toolPayload, "status"));
@@ -1708,10 +1747,7 @@ public class AgentRunService {
if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
requestId, event.getPayload(), event.getMetadata());
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
legacyThinkingTagParser, finished, persistChatlog);
}
// 文档摘要事件用于引用与监察UI 完成态统一以 TOOL_RESULT 为准。
return;
}
if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
@@ -1769,6 +1805,10 @@ public class AgentRunService {
if (event.getEventType() == AgentRuntimeEventType.FAILED) {
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
if (knowledgeRetrievalStatusTracker.failActiveCalls()) {
sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS,
buildKnowledgeRetrievalStatusPayload("error"));
}
runOutput.emitRuntimeEvent(event);
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
if (persistChatlog) {
@@ -2340,13 +2380,30 @@ public class AgentRunService {
return message;
}
private AgentMessage buildAgentMessage(String prompt, List<AgentBoundMedia> media) {
/**
* 构建发送给 AgentScope 的用户消息。
*
* <p>文档正文属于用户提供的不可信材料,作为用户内容块进入本轮模型调用和 AgentScope
* memory。聊天记录仍单独保存原始输入与附件引用页面不会展示正文内容块。</p>
*
* @param prompt 用户输入
* @param media 图片附件
* @param documentContext 本轮选中的文档上下文
* @return 可持久化的运行时用户消息
*/
private AgentMessage buildAgentMessage(String prompt,
List<AgentBoundMedia> media,
AgentDocumentContext documentContext) {
AgentMessage message = new AgentMessage();
message.setRole(AgentMessageRole.USER);
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
if (prompt != null && !prompt.isBlank()) {
blocks.add(new AgentTextBlock(prompt));
}
if (documentContext != null && documentContext.text() != null
&& !documentContext.text().isBlank()) {
blocks.add(new AgentTextBlock(documentContext.text()));
}
if (media != null) {
for (AgentBoundMedia item : media) {
AgentMediaBlock image = new AgentMediaBlock("image");
@@ -2815,7 +2872,8 @@ public class AgentRunService {
Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
Map<String, Object> payload = selectPayload(rawPayload,
"name", "status", "success", "toolDisplayName", "toolName",
"skillDisplayName", "skillId");
"skillDisplayName", "skillId", "toolCategory",
"knowledgeId", "knowledgeName", "knowledgeRuntimeName");
String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
if (toolCallId != null && !toolCallId.isBlank()) {
payload.put("toolCallId", toolCallId);
@@ -2951,17 +3009,110 @@ public class AgentRunService {
/**
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
*
* @param event 知识库检索运行时事件
* @param status running、done 或 error
* @return 知识库检索状态载荷
*/
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) {
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(String status) {
String normalizedStatus = "running".equals(status) || "error".equals(status)
? status : "done";
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("statusKey", "knowledge-retrieval");
payload.put("status", "done");
payload.put("label", "已检索知识库");
payload.put("status", normalizedStatus);
payload.put("label", switch (normalizedStatus) {
case "running" -> "正在检索知识库";
case "error" -> "知识库检索失败";
default -> "已检索知识库";
});
return payload;
}
/**
* 判断标准工具生命周期事件是否属于知识库工具。
*
* @param event 运行时工具事件
* @return 知识库工具事件时为 true
*/
private boolean isKnowledgeToolEvent(AgentRuntimeEvent event) {
String category = stringPayload(event, "toolCategory");
if ("KNOWLEDGE".equalsIgnoreCase(category)) {
return true;
}
String toolName = firstText(stringPayload(event, "toolName"), stringPayload(event, "name"));
if (toolName == null) {
return false;
}
String normalizedName = toolName.trim().toLowerCase(Locale.ROOT);
return "retrieve_knowledge".equals(normalizedName)
|| normalizedName.startsWith("retrieve_knowledge_");
}
/**
* 聚合同一批知识库工具调用,避免并行检索中首个结果提前结束 UI 状态。
*/
static final class KnowledgeRetrievalStatusTracker {
private final Set<String> activeToolCallIds = new LinkedHashSet<>();
private boolean failed;
/**
* 应用一次知识库工具生命周期事件。
*
* @param event TOOL_CALL 或 TOOL_RESULT 事件
* @return 聚合后的 running、done 或 error 状态
*/
String update(AgentRuntimeEvent event) {
String toolCallId = toolCallIdentity(event);
if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) {
if (activeToolCallIds.isEmpty()) {
failed = false;
}
activeToolCallIds.add(toolCallId);
return "running";
}
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
activeToolCallIds.remove(toolCallId);
failed = failed || !toolSucceeded(event);
if (!activeToolCallIds.isEmpty()) {
return "running";
}
return failed ? "error" : "done";
}
throw new IllegalArgumentException("Knowledge status only accepts TOOL_CALL or TOOL_RESULT events.");
}
/**
* 将运行失败时仍未结束的知识库调用收口为失败。
*
* @return 存在未结束调用时为 true
*/
boolean failActiveCalls() {
if (activeToolCallIds.isEmpty()) {
return false;
}
activeToolCallIds.clear();
failed = true;
return true;
}
private String toolCallIdentity(AgentRuntimeEvent event) {
String toolCallId = event.getToolCallId();
if (toolCallId == null || toolCallId.isBlank()) {
Object payloadId = event.getPayload() == null ? null : event.getPayload().get("toolCallId");
toolCallId = payloadId == null ? event.getEventId() : String.valueOf(payloadId);
}
return toolCallId;
}
private boolean toolSucceeded(AgentRuntimeEvent event) {
Map<String, Object> payload = event.getPayload() == null ? Map.of() : event.getPayload();
if (Boolean.FALSE.equals(payload.get("success"))) {
return false;
}
Object status = payload.get("status");
return status == null || !"FAILED".equalsIgnoreCase(String.valueOf(status));
}
}
/**
* 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
*

View File

@@ -1,10 +1,12 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -14,7 +16,7 @@ public class AgentRuntimeBundle {
private AgentDefinition definition;
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 获取 Agent 定义。
@@ -57,16 +59,18 @@ public class AgentRuntimeBundle {
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRegistrations;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
* @param knowledgeRegistrations 知识库运行时绑定
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
}
}

View File

@@ -6,9 +6,10 @@ import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.memory.AgentMemoryType;
@@ -118,11 +119,11 @@ public class AgentRuntimeCompiler {
bundle.setDefinition(definition);
compileTools(agent, definition, bundle);
compileKnowledge(agent, definition, bundle);
if (agentBuiltinToolsConfigResolver != null) {
validateBuiltinTools(definition,
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
}
compileKnowledge(agent, definition, bundle);
return bundle;
}
@@ -294,7 +295,7 @@ public class AgentRuntimeCompiler {
if (config.artifactPublish().enabled()) {
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
}
assertToolBudget(specs, definition.getMcpSpecs());
assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size());
}
private void attachBuiltinTools(Agent agent,
@@ -510,11 +511,21 @@ public class AgentRuntimeCompiler {
return names;
}
/**
* 校验内置工具与普通工具、知识库工具及 MCP 工具不存在运行名冲突。
*
* @param definition 已编译 Agent 定义
* @param builtinNames 待启用内置工具名称
* @throws BusinessException 工具名称冲突时抛出
*/
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
Set<String> existing = new LinkedHashSet<>();
for (AgentToolSpec spec : definition.getToolSpecs()) {
existing.add(spec.getName());
}
for (AgentKnowledgeSpec spec : definition.getKnowledgeSpecs()) {
existing.add(AgentKnowledgeToolNames.build(spec.getRuntimeName()));
}
for (McpSpec mcp : definition.getMcpSpecs()) {
if (mcp.getFrozenToolManifest() != null) {
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
@@ -540,6 +551,14 @@ public class AgentRuntimeCompiler {
assertToolBudget(toolSpecs, mcpSpecs, 0);
}
/**
* 校验最终工具数量和 Schema 大小预算。
*
* @param toolSpecs 静态 Tool 声明
* @param mcpSpecs MCP 声明
* @param additionalToolCount 知识库等额外工具数量
* @throws BusinessException 超出预算时抛出
*/
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
List<McpSpec> mcpSpecs,
int additionalToolCount) {
@@ -570,7 +589,7 @@ public class AgentRuntimeCompiler {
}
}
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少工具、知识库或 Skill 绑定");
}
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB请减少工具或精简 Schema");
@@ -591,12 +610,27 @@ public class AgentRuntimeCompiler {
}
}
/**
* 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。
*
* @param agent Agent 发布视图
* @param definition 中立 Agent 定义
* @param bundle 运行时编译结果
* @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出
*/
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
if (agent.getKnowledgeBindings() == null) {
return;
}
List<AgentKnowledgeSpec> specs = new ArrayList<>();
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
List<AgentKnowledgeRegistration> registrations = new ArrayList<>();
Set<String> knowledgeToolNames = new LinkedHashSet<>();
Set<String> existingToolNames = new LinkedHashSet<>();
definition.getToolSpecs().stream()
.filter(Objects::nonNull)
.map(AgentToolSpec::getName)
.filter(Objects::nonNull)
.forEach(existingToolNames::add);
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
@@ -607,9 +641,9 @@ public class AgentRuntimeCompiler {
}
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId(binding.getKnowledgeId().toString());
spec.setRuntimeName(requireKnowledgeRuntimeName(knowledge));
spec.setName(knowledge.getTitle());
spec.setDescription(knowledge.getDescription());
spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC);
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
Integer limit = intValue(binding.getOptionsJson(), "limit");
@@ -618,11 +652,37 @@ public class AgentRuntimeCompiler {
if (threshold != null) {
spec.setScoreThreshold(threshold);
}
String toolName = AgentKnowledgeToolNames.build(spec.getRuntimeName());
if (!knowledgeToolNames.add(toolName) || existingToolNames.contains(toolName)) {
throw new BusinessException("Agent 知识库工具运行名冲突:" + toolName);
}
specs.add(spec);
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
registrations.add(new AgentKnowledgeRegistration(spec,
request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())));
}
definition.setKnowledgeSpecs(specs);
bundle.setKnowledgeRetrievers(retrievers);
bundle.setKnowledgeRegistrations(registrations);
}
/**
* 获取并校验知识库英文运行名。
*
* @param knowledge 知识库发布视图
* @return 合法英文运行名
* @throws BusinessException 英文运行名缺失或非法时抛出
*/
private String requireKnowledgeRuntimeName(DocumentCollection knowledge) {
String runtimeName = knowledge == null ? null : knowledge.getEnglishName();
try {
AgentKnowledgeToolNames.build(runtimeName);
return runtimeName.trim();
} catch (RuntimeException exception) {
String knowledgeName = knowledge == null || knowledge.getTitle() == null
? "未知知识库"
: knowledge.getTitle();
throw new BusinessException(400, 400, "知识库“" + knowledgeName
+ "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception);
}
}
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {

View File

@@ -18,6 +18,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@@ -563,7 +564,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
}
private static boolean isHiddenToolName(String toolName) {
return "retrieve_knowledge".equalsIgnoreCase(toolName)
String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT);
return "retrieve_knowledge".equals(normalizedName)
|| normalizedName.startsWith("retrieve_knowledge_")
|| "context_reload".equalsIgnoreCase(toolName)
|| "__fragment__".equalsIgnoreCase(toolName);
}

View File

@@ -51,6 +51,9 @@ public class AgentSkillReferenceProvider implements SkillReferenceProvider {
ids.add(agent.getId());
}
}
if (ids.isEmpty()) {
return List.of();
}
List<String> result = new ArrayList<>();
for (Agent agent : agentService.listByIds(ids)) {
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "");

View File

@@ -8,6 +8,7 @@ import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Model;
@@ -44,6 +45,8 @@ public class AgentDefinitionCompilerMcpTest {
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
setField(toolCompiler, "mcpService", mcpService(mcp));
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
setField(compiler, "agentSkillRuntimeCompiler", new AgentSkillRuntimeCompiler(
null, toolCompiler, new com.fasterxml.jackson.databind.ObjectMapper()));
Agent agent = agent(modelId, mcpId);

View File

@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentTextBlock;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
@@ -69,6 +70,28 @@ import java.util.concurrent.atomic.AtomicBoolean;
*/
public class AgentRunServiceDraftAndHitlTest {
/**
* 验证文档上下文随用户消息进入可持久化 memory同时保持独立内容块边界。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildAgentMessageShouldIncludeDocumentContext() throws Exception {
AgentRunService service = new AgentRunService();
AgentDocumentContext documentContext = new AgentDocumentContext(
"\n<<<DOCUMENT name=\"demo.docx\">>>\n正文\n<<<END_DOCUMENT>>>", 8, List.of());
AgentMessage message = invoke(service, "buildAgentMessage",
new Class<?>[]{String.class, List.class, AgentDocumentContext.class},
"请介绍文档", List.of(), documentContext);
Assert.assertEquals(2, message.getContentBlocks().size());
Assert.assertEquals("请介绍文档",
((AgentTextBlock) message.getContentBlocks().get(0)).getText());
Assert.assertEquals(documentContext.text(),
((AgentTextBlock) message.getContentBlocks().get(1)).getText());
}
/**
* 创建用于 owner 恢复测试的运行描述。
*
@@ -453,18 +476,48 @@ public class AgentRunServiceDraftAndHitlTest {
}
/**
* 验证知识检索状态不会携带命中文档和内部 metadata
* 验证知识库工具开始事件会投影为脱敏的检索状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
event.setToolCallId("knowledge-call-1");
event.getPayload().put("toolCallId", "knowledge-call-1");
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
event.getPayload().put("toolCategory", "KNOWLEDGE");
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals(Map.of(
"label", "正在检索知识库",
"status", "running",
"statusKey", "knowledge-retrieval"), payload);
}
/**
* 验证知识库工具结果事件会投影为完成状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldProjectKnowledgeToolResultAsDoneStatus() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", true);
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
@@ -479,6 +532,48 @@ public class AgentRunServiceDraftAndHitlTest {
"statusKey", "knowledge-retrieval"), payload);
}
/**
* 验证文档摘要事件不会抢先把知识库工具状态标记为完成。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldNotCompleteKnowledgeStatusFromDocumentEvent() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertTrue(emitter.envelopes.isEmpty());
}
/**
* 验证并行知识库调用全部结束后才进入终态,并保留任一调用失败结果。
*/
@Test
public void knowledgeStatusTrackerShouldAggregateParallelToolCalls() {
AgentRunService.KnowledgeRetrievalStatusTracker tracker =
new AgentRunService.KnowledgeRetrievalStatusTracker();
AgentRuntimeEvent firstCall = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-1", true);
AgentRuntimeEvent secondCall = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-2", true);
AgentRuntimeEvent firstResult = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", false);
AgentRuntimeEvent secondResult = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-2", true);
Assert.assertEquals("running", tracker.update(firstCall));
Assert.assertEquals("running", tracker.update(secondCall));
Assert.assertEquals("running", tracker.update(firstResult));
Assert.assertEquals("error", tracker.update(secondResult));
}
/**
* 验证完成事件不会再次发送正文消息,只用于最终收口。
*
@@ -1556,6 +1651,29 @@ public class AgentRunServiceDraftAndHitlTest {
}
}
/**
* 创建知识库工具生命周期测试事件。
*
* @param eventType 工具开始或结果事件类型
* @param toolCallId 工具调用 ID
* @param success 工具结果是否成功
* @return 知识库工具事件
*/
private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType,
String toolCallId,
boolean success) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType);
event.setToolCallId(toolCallId);
event.getPayload().put("toolCallId", toolCallId);
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
event.getPayload().put("toolCategory", "KNOWLEDGE");
if (eventType == AgentRuntimeEventType.TOOL_RESULT) {
event.getPayload().put("success", success);
event.getPayload().put("status", success ? "SUCCESS" : "FAILED");
}
return event;
}
private Class<?>[] runtimeEventParameterTypes() {
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
ChatAssistantAccumulator.class,

View File

@@ -0,0 +1,268 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.core.document.Document;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* Agent 知识库一库一工具运行时编译测试。
*/
public class AgentRuntimeCompilerKnowledgeTest {
/**
* 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception {
AtomicReference<KnowledgeRetrievalRequest> capturedRequest = new AtomicReference<>();
Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。");
document.setId("chunk-1");
document.setTitle("如家 FAQ");
document.setScore(0.92D);
document.addMetadata("documentId", "faq-document-1");
document.addMetadata("chunkId", "faq-chunk-1");
AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document));
Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)));
AgentRuntimeBundle bundle = compiler.compile(agent);
Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size());
AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0);
Assert.assertEquals("homeinn_faq", spec.getRuntimeName());
Assert.assertEquals("如家 FAQ", spec.getName());
Assert.assertTrue(spec.getDescription().contains("入住"));
Assert.assertEquals(7, spec.getLimit());
Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D);
Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size());
AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0);
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery("如家几点入住");
retrievalRequest.setLimit(spec.getLimit());
retrievalRequest.setScoreThreshold(spec.getScoreThreshold());
AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest);
Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery());
Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit());
Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity());
Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType());
Assert.assertEquals(1, result.getDocuments().size());
AgentKnowledgeDocument mapped = result.getDocuments().get(0);
Assert.assertEquals("faq-document-1", mapped.getDocumentId());
Assert.assertEquals("faq-chunk-1", mapped.getChunkId());
Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D);
}
/**
* 验证缺失知识库英文名称时在发布编译阶段明确失败。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception {
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L)));
try {
compiler.compile(agent);
Assert.fail("缺失英文名称时应拒绝编译");
} catch (BusinessException expected) {
Assert.assertTrue(expected.getMessage().contains("英文名称不能为空"));
}
}
/**
* 验证多个知识库生成相同工具名时在编译阶段拒绝发布。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception {
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L));
AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L));
Agent agent = agent(first, second);
try {
compiler.compile(agent);
Assert.fail("重复知识库工具名时应拒绝编译");
} catch (BusinessException expected) {
Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq"));
}
}
/**
* 创建仅含测试模型与知识库服务的运行时编译器。
*
* @param capturedRequest 检索请求捕获器
* @param documents 检索服务返回文档
* @return 已注入依赖的编译器
* @throws Exception 反射注入失败时抛出
*/
private AgentRuntimeCompiler compiler(AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
List<Document> documents) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
setField(compiler, "objectMapper", objectMapper);
setField(compiler, "modelService", modelService(model()));
setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents));
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
setField(compiler, "agentSkillRuntimeCompiler",
new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper));
return compiler;
}
/**
* 创建带知识库绑定的 Agent。
*
* @param bindings 知识库绑定
* @return Agent 测试对象
*/
private Agent agent(AgentKnowledgeBinding... bindings) {
Agent agent = new Agent();
agent.setId(BigInteger.ONE);
agent.setName("如家助手");
agent.setModelId(BigInteger.TEN);
agent.setKnowledgeBindings(List.of(bindings));
return agent;
}
/**
* 创建冻结知识库绑定。
*
* @param englishName 知识库英文名称
* @param knowledgeId 知识库 ID
* @return 知识库绑定
*/
private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) {
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
binding.setAgentId(BigInteger.ONE);
binding.setKnowledgeId(knowledgeId);
binding.setRetrievalMode("HYBRID");
binding.setEnabled(true);
binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D));
binding.setResourceSnapshot(Map.of(
"id", knowledgeId,
"title", "如家 FAQ",
"description", "如家酒店入住、退房和会员服务常见问题",
"collectionType", "FAQ",
"englishName", englishName == null ? "" : englishName));
return binding;
}
/**
* 创建模型服务代理。
*
* @param model 测试模型
* @return 模型服务代理
*/
private ModelService modelService(Model model) {
return (ModelService) Proxy.newProxyInstance(
ModelService.class.getClassLoader(),
new Class<?>[]{ModelService.class},
(proxy, method, args) -> "getModelInstance".equals(method.getName())
? model
: defaultValue(method.getReturnType()));
}
/**
* 创建知识库服务代理。
*
* @param capturedRequest 检索请求捕获器
* @param documents 返回文档
* @return 知识库服务代理
*/
private DocumentCollectionService documentCollectionService(
AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
List<Document> documents) {
return (DocumentCollectionService) Proxy.newProxyInstance(
DocumentCollectionService.class.getClassLoader(),
new Class<?>[]{DocumentCollectionService.class},
(proxy, method, args) -> {
if ("search".equals(method.getName()) && args != null && args.length == 1
&& args[0] instanceof KnowledgeRetrievalRequest request) {
capturedRequest.set(request);
return documents;
}
return defaultValue(method.getReturnType());
});
}
/**
* 创建可映射为 AgentScope 模型配置的测试模型。
*
* @return 测试模型
*/
private Model model() {
ModelProvider provider = new ModelProvider();
provider.setProviderType("openai");
provider.setProviderName("OpenAI");
Model model = new Model();
model.setId(BigInteger.TEN);
model.setModelProvider(provider);
model.setModelName("gpt-test");
model.setEndpoint("https://example.com");
model.setRequestPath("/v1/chat/completions");
model.setApiKey("test-key");
return model;
}
/**
* 返回代理方法所需的默认值。
*
* @param type 返回类型
* @return 对应默认值
*/
private Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
return 0;
}
if (type == double.class || type == float.class) {
return 0D;
}
return null;
}
/**
* 反射注入测试依赖。
*
* @param target 目标对象
* @param fieldName 字段名称
* @param value 字段值
* @throws Exception 字段不存在或不可写时抛出
*/
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -50,6 +50,22 @@ public class AgentSkillReferenceProviderTest {
"智能体“线上引用智能体”"), references);
}
/**
* 没有 Agent 引用 Skill 时不应执行空主键集合查询。
*/
@Test
public void shouldSkipEntityQueryWhenSkillHasNoReferences() {
AgentService agentService = Mockito.mock(AgentService.class);
AgentSkillBindingService bindingService = Mockito.mock(AgentSkillBindingService.class);
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
AgentSkillReferenceProvider provider = new AgentSkillReferenceProvider(
agentService, bindingService);
Assert.assertTrue(provider.listReferences(BigInteger.TEN).isEmpty());
Mockito.verify(agentService, Mockito.never()).listByIds(Mockito.anyCollection());
}
/**
* 创建 Agent 摘要。
*

View File

@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import java.io.IOException;
@@ -164,7 +165,7 @@ public class DocumentSourceLoader {
}
/**
* 优先打开经过上传记录验证的受管 URL再执行普通公网 URL 校验与下载。
* 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。
*
* @param remoteUrl 远端 URL
* @param maxBytes 最大允许读取字节数
@@ -176,6 +177,13 @@ public class DocumentSourceLoader {
if (managed.isPresent()) {
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
}
Optional<FileStorageWriteHandle> trusted =
fileStorageService.resolveTrustedFile(remoteUrl);
if (trusted.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trusted.get()),
maxBytes);
}
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
}

View File

@@ -252,6 +252,7 @@ public final class DocumentImportBatchDtos {
private BigInteger batchId;
private String importMode;
private String status;
private Boolean actualCompleted;
private Integer totalCount;
private Long totalBytes;
private Integer completedCount;
@@ -292,6 +293,24 @@ public final class DocumentImportBatchDtos {
this.status = status;
}
/**
* 获取批次关联任务是否已经按真实文档状态全部完成。
*
* @return 全部完成时返回 {@code true}
*/
public Boolean getActualCompleted() {
return actualCompleted;
}
/**
* 设置批次关联任务是否已经按真实文档状态全部完成。
*
* @param actualCompleted 是否全部完成
*/
public void setActualCompleted(Boolean actualCompleted) {
this.actualCompleted = actualCompleted;
}
public Integer getTotalCount() {
return totalCount;
}

View File

@@ -21,6 +21,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
@@ -435,7 +436,79 @@ public class DocumentImportBatchAppService {
.orderBy(DocumentImportBatch::getCreated, false)
.limit(1)
);
return batch == null ? null : batchTracker.toStatusResponse(batch);
if (batch == null) {
return null;
}
DocumentImportBatchDtos.StatusResponse response =
batchTracker.toStatusResponse(batch);
response.setActualCompleted(isAutoBatchActuallyCompleted(batch));
return response;
}
/**
* 根据批次项及其关联文档的真实状态判断失败批次是否已经完成。
*
* <p>正常完成批次直接返回成功;仅对部分失败或中断批次执行补充查询,
* 避免运行中轮询产生额外数据库压力。批次项数量不完整、文档缺失、
* 跨知识库或文档仍未完成时均保持失败提示。</p>
*
* @param batch 自动导入批次
* @return 批次关联任务是否已经全部完成
*/
private boolean isAutoBatchActuallyCompleted(DocumentImportBatch batch) {
if (DocumentImportBatchStatus.COMPLETED.name().equals(batch.getStatus())) {
return true;
}
if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
&& !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
return false;
}
List<DocumentImportBatchItem> items = itemService.list(
QueryWrapper.create()
.eq(DocumentImportBatchItem::getBatchId, batch.getId())
.orderBy(DocumentImportBatchItem::getId, true)
);
int totalCount = valueOrZero(batch.getTotalCount());
if (items == null || totalCount <= 0 || items.size() != totalCount) {
return false;
}
Set<BigInteger> unresolvedDocumentIds = new LinkedHashSet<BigInteger>();
for (DocumentImportBatchItem item : items) {
String itemStatus = item.getStatus();
if (DocumentImportBatchItemStatus.COMPLETED.name().equals(itemStatus)
|| DocumentImportBatchItemStatus.SKIPPED.name().equals(itemStatus)
|| DocumentImportBatchItemStatus.CANCELLED.name().equals(itemStatus)) {
continue;
}
if (item.getDocumentId() == null
|| !batch.getKnowledgeId().equals(item.getKnowledgeId())) {
return false;
}
unresolvedDocumentIds.add(item.getDocumentId());
}
if (unresolvedDocumentIds.isEmpty()) {
return true;
}
List<tech.easyflow.ai.entity.Document> completedDocuments =
documentMapper.selectListByQuery(
QueryWrapper.create()
.select(tech.easyflow.ai.entity.Document::getId)
.eq(tech.easyflow.ai.entity.Document::getCollectionId,
batch.getKnowledgeId())
.eq(tech.easyflow.ai.entity.Document::getProcessStatus,
DocumentProcessStatus.COMPLETED.name())
.in(tech.easyflow.ai.entity.Document::getId,
unresolvedDocumentIds)
);
if (completedDocuments == null) {
return false;
}
Set<BigInteger> completedDocumentIds = completedDocuments.stream()
.map(tech.easyflow.ai.entity.Document::getId)
.collect(java.util.stream.Collectors.toSet());
return completedDocumentIds.containsAll(unresolvedDocumentIds);
}
/**

View File

@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setStartTime(new Date());
record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
try {
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 int MIN_LOOP_COUNT = 1;
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
private WorkflowService workflowService;
@@ -196,6 +198,10 @@ public class WorkflowCheckService {
edge.id = trimToNull(edgeJson.getString("id"));
edge.source = trimToNull(edgeJson.getString("source"));
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)) {
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
@@ -228,10 +234,162 @@ public class WorkflowCheckService {
parsedWorkflow.nodes = nodes;
parsedWorkflow.edges = edges;
parsedWorkflow.nodeMap = nodeMap;
checkJoinModes(parsedWorkflow, issues, issueKeys);
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
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 source;
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 variables 原始运行变量
@@ -162,7 +162,13 @@ public class WorkflowRunningParameterResolver {
if (isFileParameter(parameter)) {
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
} 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;
@@ -683,6 +689,10 @@ public class WorkflowRunningParameterResolver {
if (value == null) {
return;
}
if (value instanceof String stringValue
&& !StringUtils.hasText(stringValue)) {
return;
}
if (value instanceof Collection<?> collection) {
for (Object item : collection) {
collectFileValues(item, result);

View File

@@ -15,6 +15,7 @@ import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.document.support.DocumentParseSourceType;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -326,20 +327,29 @@ public class DocNodeFileContentExtractor {
private void copySourceToTemporaryFile(
DocumentSourceRef sourceRef, Path target) throws IOException {
String filePath = sourceRef.getFilePath();
boolean managedUpload = StringUtil.hasText(filePath)
&& uploadedFileReader != null
&& uploadedFileReader.isManagedPathCandidate(filePath);
Optional<FileStorageWriteHandle> trustedFile = Optional.empty();
if (StringUtil.hasText(filePath)
&& isRemoteUrl(filePath)
&& !managedUpload) {
trustedFile = fileStorageService.resolveTrustedFile(filePath);
}
boolean localStorage = StringUtil.hasText(filePath)
&& (!isRemoteUrl(filePath)
|| (uploadedFileReader != null
&& uploadedFileReader.isManagedPathCandidate(filePath)));
|| managedUpload
|| trustedFile.isPresent());
if (localStorage) {
try (IoBulkhead.Permit ignored =
IoBulkhead.storage().acquire("storage:document-read");
InputStream inputStream = openInputStream(sourceRef);
InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) {
copy(inputStream, outputStream);
}
return;
}
try (InputStream inputStream = openInputStream(sourceRef);
try (InputStream inputStream = openInputStream(sourceRef, trustedFile);
OutputStream outputStream = Files.newOutputStream(target)) {
copy(inputStream, outputStream);
}
@@ -362,7 +372,17 @@ public class DocNodeFileContentExtractor {
}
}
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException {
/**
* 按可信受管上传、服务端文件记录、本地路径和普通公网 URL 的顺序打开源流。
*
* @param sourceRef 文档源
* @param trustedFile 已通过服务端记录或存储配置确认的物理读取句柄
* @return 受实际字节数限制的输入流
* @throws IOException 文件无法安全读取时抛出
*/
private InputStream openInputStream(
DocumentSourceRef sourceRef,
Optional<FileStorageWriteHandle> trustedFile) throws IOException {
String filePath = sourceRef.getFilePath();
if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
@@ -372,6 +392,11 @@ public class DocNodeFileContentExtractor {
FILE_MAX_SINGLE_SIZE);
}
}
if (trustedFile.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trustedFile.get()),
FILE_MAX_SINGLE_SIZE);
}
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
}

View File

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

View File

@@ -187,7 +187,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
RagScoreNormalizer.normalize(searchDocuments, retrievalMode, reranked);
List<Document> formattedDocuments = formatDocuments(
searchDocuments,
shouldApplyMinSimilarityFilter(retrievalMode, reranked),
true,
minSimilarity,
docRecallMaxNum
);
@@ -396,10 +396,6 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return modelRerank.toRerankModel();
}
private boolean shouldApplyMinSimilarityFilter(RetrievalMode retrievalMode, boolean reranked) {
return !reranked && retrievalMode == RetrievalMode.VECTOR;
}
/**
* 解析本次查询使用的召回上限,优先采用请求参数,其次回退到知识库默认配置。
*

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
@@ -23,6 +24,7 @@ import java.math.BigInteger;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
/**
@@ -184,6 +186,61 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
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) {
throw invalidShare();
}
WorkflowShare share = getOne(QueryWrapper.create()
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
.eq(WorkflowShare::getSharePurpose, purpose.name())
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
WorkflowShare share = findShare(shareKey, purpose, true);
if (share == null || !tenantId.equals(share.getTenantId())) {
throw invalidShare();
}
@@ -220,6 +274,34 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
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.HexFormat;
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_VISITOR_HEADER = "X-Workflow-Chat-Visitor";
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
private static final Duration DEFAULT_CHAT_EXPIRE_DURATION = Duration.ofDays(7);
private static final Set<String> ALLOWED_REQUESTS = Set.of(
@@ -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_KEY = "workflow";
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
public final static String CREATED_BY_MEMORY_KEY = "workflowCreatedBy";
public static String removeSensitiveInfo(String originJson) {
JSONObject workflowInfo = JSON.parseObject(originJson);
@@ -56,6 +57,35 @@ public class WorkFlowUtil {
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() {
LoginAccount account = new LoginAccount();
account.setId(new BigInteger("0"));

View File

@@ -7,6 +7,7 @@ import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -73,6 +74,24 @@ public class DocumentSourceLoaderTest {
}
}
/**
* 验证服务端已登记的普通附件 URL 在公网地址校验前通过物理句柄直读。
*/
@Test
public void shouldLoadRecordedInternalStorageUrlBeforeRemoteAddressGuard() {
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/demo.pdf";
byte[] body = "recorded-pdf".getBytes(StandardCharsets.UTF_8);
DocumentSourceLoader loader = new DocumentSourceLoader(
new RecordedFileStorageService(fileUrl, body));
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFileName("demo.pdf");
sourceRef.setFilePath(fileUrl);
LoadedDocumentSource loadedSource = loader.load(sourceRef);
Assert.assertArrayEquals(body, loadedSource.getContentBytes());
}
/**
* 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。
*
@@ -217,4 +236,42 @@ public class DocumentSourceLoaderTest {
return 0L;
}
}
/**
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
*/
private static class RecordedFileStorageService
extends FailingFileStorageService {
/** 允许解析的精确 URL。 */
private final String recordedUrl;
/** 固定文件内容。 */
private final byte[] content;
/** 固定可信读取句柄。 */
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
"recorded", "", "/storage", "attachment", "demo.pdf");
/**
* 创建服务端记录存储替身。
*
* @param recordedUrl 允许解析的精确 URL
* @param content 固定文件内容
*/
private RecordedFileStorageService(String recordedUrl, byte[] content) {
this.recordedUrl = recordedUrl;
this.content = content.clone();
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
}
/** {@inheritDoc} */
@Override
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
Assert.assertSame(handle, requestedHandle);
return new ByteArrayInputStream(content);
}
}
}

View File

@@ -19,6 +19,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
@@ -47,6 +48,89 @@ import java.util.function.BooleanSupplier;
*/
public class DocumentImportBatchAppServiceTest {
/**
* 验证历史失败批次关联文档均已完成时返回真实完成标记。
*/
@Test
public void latestAutoBatchShouldDetectActuallyCompletedDocuments() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
batch.setTotalCount(2);
DocumentImportBatchItem completed = uploadedItem(
BigInteger.valueOf(11), batch.getId()
);
completed.setStatus(DocumentImportBatchItemStatus.COMPLETED.name());
completed.setDocumentId(BigInteger.valueOf(101));
DocumentImportBatchItem staleFailed = uploadedItem(
BigInteger.valueOf(12), batch.getId()
);
staleFailed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
staleFailed.setDocumentId(BigInteger.valueOf(102));
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(completed, staleFailed));
tech.easyflow.ai.entity.Document recovered =
new tech.easyflow.ai.entity.Document();
recovered.setId(staleFailed.getDocumentId());
recovered.setCollectionId(batch.getKnowledgeId());
recovered.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
Mockito.when(context.documentMapper.selectListByQuery(Mockito.any()))
.thenReturn(List.of(recovered));
DocumentImportBatchDtos.StatusResponse batchResponse =
new DocumentImportBatchDtos.StatusResponse();
batchResponse.setStatus(batch.getStatus());
Mockito.when(context.batchTracker.toStatusResponse(batch))
.thenReturn(batchResponse);
DocumentImportBatchDtos.StatusResponse response =
context.service.getLatestAutoBatch(batch.getKnowledgeId());
Assert.assertTrue(response.getActualCompleted());
Mockito.verify(context.documentMapper)
.selectListByQuery(Mockito.any(QueryWrapper.class));
}
/**
* 验证失败项关联文档仍未完成时继续保留批次提示。
*/
@Test
public void latestAutoBatchShouldKeepIncompleteFailureVisible() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
batch.setTotalCount(1);
DocumentImportBatchItem failed = uploadedItem(
BigInteger.valueOf(13), batch.getId()
);
failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
failed.setDocumentId(BigInteger.valueOf(103));
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(failed));
Mockito.when(context.documentMapper.selectListByQuery(Mockito.any()))
.thenReturn(List.of());
DocumentImportBatchDtos.StatusResponse batchResponse =
new DocumentImportBatchDtos.StatusResponse();
batchResponse.setStatus(batch.getStatus());
Mockito.when(context.batchTracker.toStatusResponse(batch))
.thenReturn(batchResponse);
DocumentImportBatchDtos.StatusResponse response =
context.service.getLatestAutoBatch(batch.getKnowledgeId());
Assert.assertFalse(response.getActualCompleted());
}
/**
* 验证上传领取通过单条多表更新同步刷新批次进度时间。
*/

View File

@@ -22,6 +22,163 @@ import java.util.Map;
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);
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<?, ?>);
}
/**
* 空文件参数应统一归一化为空数组,避免旧客户端空字符串触发格式错误。
*
* @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 字符串数组并自动提取文件名。
*
@@ -446,6 +478,30 @@ public class WorkflowRunningParameterResolverTest {
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 写入工作流状态和审计参数。
*

View File

@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream;
@@ -203,6 +204,27 @@ public class DocNodeFileContentExtractorTest {
Assert.assertNull(bridgeService.lastSource);
}
/**
* 验证默认读取器也能通过服务端文件记录读取内部附件 URL。
*/
@Test
public void shouldReadRecordedInternalUrlForUnsupportedType() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/note.txt";
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new RecordedFileStorageService(fileUrl, "recorded text"),
new ReadingReaderManager());
String content = extractor.extract(buildFileValue(
"note.txt",
fileUrl,
"text/plain"));
Assert.assertEquals("recorded text", content);
Assert.assertNull(bridgeService.lastSource);
}
/**
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
*
@@ -574,4 +596,42 @@ public class DocNodeFileContentExtractorTest {
return 0L;
}
}
/**
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
*/
private static class RecordedFileStorageService
extends FailingFileStorageService {
/** 允许解析的精确 URL。 */
private final String recordedUrl;
/** 固定内容。 */
private final byte[] content;
/** 固定可信读取句柄。 */
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
"recorded", "", "/storage", "attachment", "note.txt");
/**
* 创建服务端记录存储替身。
*
* @param recordedUrl 允许解析的精确 URL
* @param content 固定文本内容
*/
private RecordedFileStorageService(String recordedUrl, String content) {
this.recordedUrl = recordedUrl;
this.content = content.getBytes(StandardCharsets.UTF_8);
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
}
/** {@inheritDoc} */
@Override
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
Assert.assertSame(handle, requestedHandle);
return new ByteArrayInputStream(content);
}
}
}

View File

@@ -32,6 +32,28 @@ import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOL
*/
public class DocumentCollectionServiceImplTest {
/**
* 验证最终相关度阈值会过滤所有已统一到零到一范围的检索结果。
*/
@Test
public void formatDocumentsShouldApplyFinalScoreThreshold() {
Document lowScore = buildHit(BigInteger.ONE, 0.49D);
Document thresholdScore = buildHit(BigInteger.TWO, 0.5D);
Document highScore = buildHit(BigInteger.valueOf(3), 0.9D);
DocumentCollectionServiceImpl service = new DocumentCollectionServiceImpl();
List<Document> result = service.formatDocuments(
List.of(lowScore, thresholdScore, highScore),
true,
0.5F,
5
);
Assert.assertEquals(2, result.size());
Assert.assertEquals(highScore.getId(), result.get(0).getId());
Assert.assertEquals(thresholdScore.getId(), result.get(1).getId());
}
/**
* 验证检索结果会在重排前过滤掉未完成文档,避免高分进行中文档挤占最终名额。
*

View File

@@ -24,6 +24,26 @@ public class WorkflowSharePolicyTest {
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 分钟。
*/

View File

@@ -69,6 +69,9 @@ public class SkillToolReferenceProviderImpl implements SkillToolReferenceProvide
ids.add(skill.getId());
}
}
if (ids.isEmpty()) {
return List.of();
}
List<OfflineImpactBindingVo> result = new ArrayList<>();
for (Skill skill : skillService.listByIds(ids)) {
OfflineImpactBindingVo item = new OfflineImpactBindingVo();

View File

@@ -73,6 +73,7 @@ public class SkillToolReferenceProviderImplTest {
skillService, bindingService);
Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty());
Mockito.verify(skillService, Mockito.never()).listByIds(Mockito.anyCollection());
}
/**

View File

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

@@ -22,6 +22,7 @@
"visibilityScopePublic": "Public",
"visibilityScopePublicDesc": "Available to internal users matched by category",
"params": "Params",
"runInputForm": "Input form",
"steps": "Steps",
"result": "Result",
"confirm": "For contents to be confirmed, please confirm first!",

View File

@@ -22,6 +22,7 @@
"visibilityScopePublic": "公开",
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
"params": "执行参数",
"runInputForm": "输入表单",
"steps": "执行步骤",
"result": "执行结果",
"confirm": "有待确认的内容,请先确认!",

View File

@@ -0,0 +1,26 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { describe, expect, it } from 'vitest';
import { resolveChunkReloadHref } from '../guard';
describe('chunk error reload', () => {
it('preserves the deployment base and hash route', () => {
const router = createRouter({
history: createWebHashHistory('/flow/'),
routes: [
{
component: { template: '<div>workflow</div>' },
path: '/ai/workflow',
},
],
});
const href = resolveChunkReloadHref(router, '/ai/workflow?source=sidebar');
expect(href).toBe('#/ai/workflow?source=sidebar');
expect(new URL(href, 'https://easyflowtech.cn/flow/').href).toBe(
'https://easyflowtech.cn/flow/#/ai/workflow?source=sidebar',
);
});
});

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

View File

@@ -71,6 +71,10 @@ function isDynamicImportChunkError(error: unknown) {
);
}
function resolveChunkReloadHref(router: Router, fullPath: string) {
return router.resolve(fullPath).href;
}
function setupChunkErrorGuard(router: Router) {
router.onError((error, to) => {
if (!isDynamicImportChunkError(error)) {
@@ -94,7 +98,7 @@ function setupChunkErrorGuard(router: Router) {
return;
}
sessionStorage.setItem(CHUNK_ERROR_RELOAD_KEY, fullPath);
window.location.assign(fullPath);
window.location.assign(resolveChunkReloadHref(router, fullPath));
});
router.isReady().finally(() => {
@@ -152,6 +156,12 @@ function setupAccessGuard(router: Router) {
let devLoginPromise: null | Promise<void> = null;
router.beforeEach(async (to, from) => {
// 公开路由必须在读取或刷新登录态之前短路,避免浏览器残留的过期
// token 把匿名分享页重定向到登录页。
if (to.meta.ignoreAccess) {
return true;
}
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
@@ -227,11 +237,6 @@ function setupAccessGuard(router: Router) {
// accessToken 检查
if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
const cleanFullPath =
@@ -324,4 +329,4 @@ function createRouterGuard(router: Router) {
setupChunkErrorGuard(router);
}
export { createRouterGuard };
export { createRouterGuard, resolveChunkReloadHref };

View File

@@ -33,6 +33,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/ai/workflow/WorkflowShareView.vue'),
meta: {
title: 'Workflow Share',
ignoreAccess: true,
noBasicLayout: true,
hideInMenu: true,
hideInBreadcrumb: true,
@@ -46,6 +47,7 @@ const routes: RouteRecordRaw[] = [
import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'),
meta: {
title: 'Workflow Share Expired',
ignoreAccess: true,
noBasicLayout: true,
hideInMenu: 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_VISITOR_HEADER = 'X-Workflow-Chat-Visitor';
const WORKFLOW_SHARE_VISITOR_STORAGE_KEY =
'easyflow.workflow-chat-share.visitor';
interface WorkflowShareResolutionOptions<T> {
currentWorkflowId?: null | T;
onFailure: (error: unknown) => Promise<void> | void;
@@ -16,16 +24,19 @@ interface WorkflowShareHeaderOptions {
pageUrl?: string;
requestMethod?: string;
requestUrl?: string;
storage?: Pick<Storage, 'getItem' | 'setItem'>;
visitorId?: string;
}
const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
const WORKFLOW_SHARE_REQUESTS = [
['GET', '/api/v1/workflowChat/descriptor'],
['GET', '/api/v1/workflowChat/execution'],
['GET', '/api/v1/workflowChat/public/descriptor'],
['GET', '/api/v1/workflowChat/public/execution'],
['GET', '/api/v1/workflowShare/resolve'],
['POST', '/api/v1/workflowChat/cancel'],
['POST', '/api/v1/workflowChat/resume'],
['POST', '/api/v1/workflowChat/run'],
['POST', '/api/v1/workflowChat/public/cancel'],
['POST', '/api/v1/workflowChat/public/resume'],
['POST', '/api/v1/workflowChat/public/run'],
['POST', '/api/v1/workflowChat/public/upload'],
] 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(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
return withWorkflowShareHeaders(headers, options);
}
/**
* 在保留通用请求头的基础上附加匿名分享密钥与当前标签页访客标识。
*/
export function withWorkflowShareHeaders(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
return headers;
@@ -105,12 +146,32 @@ export function withWorkflowShareHeader(
if (!shareKey) {
return headers;
}
const visitorId =
options.visitorId || resolveWorkflowShareVisitorId(options.storage);
return {
...headers,
'easyflow-token': '',
[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

@@ -181,6 +181,57 @@ describe('agentChatRuntimeManager', () => {
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
});
it('输入已确认后将模型错误转换为可重试的用户提示', async () => {
let resolveRun: (() => void) | undefined;
let runOptions: EasyFlowAguiRunOptions | undefined;
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
runOptions = options;
return new Promise<void>((resolve) => {
resolveRun = resolve;
});
});
useUserStore().setUserInfo({
avatar: '',
id: 'retry-user',
loginName: 'retry-user',
nickname: '重试用户',
tenantId: 'tenant-1',
});
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '介绍文档',
sessionId: 'retry-session',
});
runOptions?.onEvent({
name: easyFlowAguiCustomEvent.inputAccepted,
type: EventType.CUSTOM,
value: {},
});
runOptions?.onEvent({
code: 'MODEL_ERROR',
message: 'Retries exhausted: 2/2',
runId: 'run-test',
threadId: 'retry-session',
type: EventType.RUN_ERROR,
});
resolveRun?.();
await Promise.resolve();
await Promise.resolve();
const snapshot = agentChatRuntimeManager.getSnapshot('retry-session');
expect(snapshot).toEqual(
expect.objectContaining({
error: '模型连接异常',
retryContextReady: true,
terminalOutcome: 'failed',
}),
);
expect(snapshot?.items.find((item) => item.type === 'error')).toEqual(
expect.objectContaining({ message: '模型连接异常' }),
);
});
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
const account = {
avatar: '',

View File

@@ -45,6 +45,7 @@ interface RuntimeSessionState {
prompt: string;
projectionToolArgs: Record<string, string>;
projectionToolNames: Record<string, string>;
retryContextReady: boolean;
runId?: string;
roundId: string;
sending: boolean;
@@ -65,6 +66,7 @@ interface StoredRuntimeSession {
prompt: string;
projectionToolArgs?: Record<string, string>;
projectionToolNames?: Record<string, string>;
retryContextReady?: boolean;
runId?: string;
roundId: string;
sending: boolean;
@@ -86,10 +88,12 @@ interface StartOptions {
images?: ChatImageAttachment[];
onInputAccepted?: () => Promise<void> | void;
prompt: string;
retryContextReady?: boolean;
sessionId?: string;
}
const STORAGE_VERSION = 5;
const STORAGE_VERSION = 6;
const MODEL_CONNECTION_ERROR_MESSAGE = '模型连接异常';
const STREAM_NOTIFY_INTERVAL_MS = 50;
const STREAM_PERSIST_INTERVAL_MS = 300;
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
@@ -177,6 +181,7 @@ function persistSession(state: RuntimeSessionState) {
prompt: state.prompt,
projectionToolArgs: state.projectionToolArgs,
projectionToolNames: state.projectionToolNames,
retryContextReady: state.retryContextReady,
runId: state.runId,
roundId: state.roundId,
sending: state.sending,
@@ -298,7 +303,7 @@ function restoreSession(identity: string, sessionId: string) {
}
const parsed = JSON.parse(raw) as StoredRuntimeSession;
if (
![3, 4, STORAGE_VERSION].includes(parsed.version) ||
![3, 4, 5, STORAGE_VERSION].includes(parsed.version) ||
parsed.sessionId !== sessionId
) {
return undefined;
@@ -324,6 +329,7 @@ function restoreSession(identity: string, sessionId: string) {
typeof parsed.projectionToolNames === 'object'
? parsed.projectionToolNames
: {},
retryContextReady: Boolean(parsed.retryContextReady),
runId: parsed.runId,
roundId: parsed.roundId,
sending: Boolean(parsed.sending && parsed.runId),
@@ -386,6 +392,7 @@ function acceptInput(
) {
replaceAcceptedAttachments(state.items, state.roundId, payload);
state.inputAccepted = true;
state.retryContextReady = true;
persistSession(state);
notifyInputAccepted(state);
}
@@ -563,7 +570,9 @@ function finishRuntimeSuccess(state: RuntimeSessionState) {
}
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
state.error = errorMessage(error);
state.error = state.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: errorMessage(error);
state.sending = false;
state.completed = true;
const last = state.items[state.items.length - 1];
@@ -664,7 +673,9 @@ export const agentChatRuntimeManager = {
event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED'
) {
current.error = event.message || '发送失败,请稍后再试';
current.error = current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: event.message || '发送失败,请稍后再试';
}
observeTerminalEvent(current, event.type);
applyAguiEventToTimeline(
@@ -674,6 +685,9 @@ export const agentChatRuntimeManager = {
onInputAccepted(payload) {
acceptInput(current, payload);
},
runErrorMessage: current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: undefined,
roundId: current.roundId,
startedAt: current.startedAt,
},
@@ -757,6 +771,7 @@ export const agentChatRuntimeManager = {
prompt: options.prompt,
projectionToolArgs: {},
projectionToolNames: {},
retryContextReady: Boolean(options.retryContextReady),
runId,
roundId,
sending: true,
@@ -794,7 +809,9 @@ export const agentChatRuntimeManager = {
event.type === EventType.RUN_ERROR &&
event.code !== 'RUN_CANCELLED'
) {
current.error = event.message || '发送失败,请稍后再试';
current.error = current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: event.message || '发送失败,请稍后再试';
}
observeTerminalEvent(current, event.type);
applyAguiEventToTimeline(
@@ -804,6 +821,9 @@ export const agentChatRuntimeManager = {
onInputAccepted(payload) {
acceptInput(current, payload);
},
runErrorMessage: current.retryContextReady
? MODEL_CONNECTION_ERROR_MESSAGE
: undefined,
roundId,
startedAt,
},

View File

@@ -2,6 +2,13 @@ import type { AgentInfo } from '../agents/types';
import { api } from '#/api/request';
export type AgentChatAgentOption = Pick<
AgentInfo,
'avatar' | 'description' | 'id' | 'interactionConfigJson' | 'name'
> & {
supportImage?: boolean | null;
};
export interface RequestResult<T = any> {
data: T;
errorCode: number;
@@ -74,9 +81,12 @@ export interface AgentChatCapabilityPayload {
}
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 },
});
},
);
}
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

@@ -2,13 +2,13 @@
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineErrorItem,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui';
import type { AgentInfo } from '../agents/types';
import type { AgentChatSessionView } from './api';
import type { AgentChatAgentOption, AgentChatSessionView } from './api';
import type {
ChatInputTriggerGroup,
@@ -77,6 +77,12 @@ import {
renameAgentSession,
saveAgentSessionExtraKnowledges,
} from './api';
import {
AGENT_CHAT_ATTACHMENT_ACCEPT,
AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE,
AGENT_IMAGE_UNSUPPORTED_MESSAGE,
supportsAgentImageInput,
} from './imageCapability';
import {
isMissingAgentSessionError,
resolveAgentSessionErrorMessage,
@@ -85,7 +91,7 @@ import {
const route = useRoute();
const router = useRouter();
const agents = ref<AgentInfo[]>([]);
const agents = ref<AgentChatAgentOption[]>([]);
const sessions = ref<AgentChatSessionView[]>([]);
const timelineItems = ref<ChatTimelineItem[]>([]);
const selectedAgentId = ref('');
@@ -108,6 +114,7 @@ const loadingKnowledges = ref(false);
const savingExtraKnowledges = ref(false);
const sending = ref(false);
const runtimeRunning = ref(false);
const retryableErrorRoundId = ref('');
const approvalLoadingKey = ref('');
const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
@@ -120,9 +127,7 @@ const selectedAgent = computed(() =>
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
);
const selectedAgentImageSupport = computed(() =>
Boolean(
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
),
supportsAgentImageInput(selectedAgent.value?.supportImage),
);
const supportedAttachmentFormats = computed(() =>
selectedAgentImageSupport.value === false
@@ -463,12 +468,19 @@ function syncRuntimeSnapshot(sessionId = currentSessionId.value) {
: undefined;
if (!snapshot) {
sending.value = false;
retryableErrorRoundId.value = '';
return false;
}
currentSessionId.value = snapshot.sessionId;
selectedAgentId.value = String(snapshot.agentId);
timelineItems.value = snapshot.items;
sending.value = snapshot.sending;
retryableErrorRoundId.value =
snapshot.terminalOutcome === 'failed' &&
snapshot.retryContextReady &&
!snapshot.sending
? snapshot.roundId
: '';
maybeRefreshCompletedRuntimeSession(snapshot);
if (snapshot.prompt && !currentSession.value) {
upsertSessionRecord(
@@ -492,15 +504,20 @@ async function loadConversation(sessionId: string) {
if (!sessionId) {
timelineItems.value = [];
sending.value = false;
retryableErrorRoundId.value = '';
return;
}
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
if (runtimeSnapshot?.sending) {
if (
runtimeSnapshot?.sending ||
runtimeSnapshot?.terminalOutcome === 'failed'
) {
syncRuntimeSnapshot(sessionId);
await syncSessionRoute(sessionId);
return;
}
loadingConversation.value = true;
retryableErrorRoundId.value = '';
try {
const detailRes = await getAgentSession(sessionId);
const res = await getAgentConversation(sessionId);
@@ -600,7 +617,7 @@ async function handleAgentChange() {
selectedAgentImageSupport.value === false &&
composer.images.items.value.length > 0
) {
ElMessage.warning('当前智能体不支持图片,请先移除图片');
ElMessage.warning(AGENT_IMAGE_REMOVE_REQUIRED_MESSAGE);
}
}
@@ -660,12 +677,25 @@ function buildCapabilities() {
];
}
async function sendContent(rawContent: string) {
interface SendContentOptions {
includeComposer?: boolean;
retryContextReady?: boolean;
}
async function sendContent(
rawContent: string,
options: SendContentOptions = {},
) {
const content = rawContent.trim();
const includeComposer = options.includeComposer !== false;
const readyImageCount = includeComposer
? composer.images.readyItems.value.length
: 0;
const readyDocumentCount = includeComposer
? composer.documents.readyItems.value.length
: 0;
if (
(!content &&
composer.images.readyItems.value.length === 0 &&
composer.documents.readyItems.value.length === 0) ||
(!content && readyImageCount === 0 && readyDocumentCount === 0) ||
!selectedAgentId.value ||
sending.value
) {
@@ -675,44 +705,54 @@ async function sendContent(rawContent: string) {
ElMessage.warning('当前回复完成后再发送新消息');
return;
}
if (composer.images.uploading.value) {
if (includeComposer && composer.images.uploading.value) {
ElMessage.warning('图片上传完成后再发送');
return;
}
if (composer.documents.processing.value) {
if (includeComposer && composer.documents.processing.value) {
ElMessage.warning('文档读取完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
const failedImage = includeComposer
? composer.images.items.value.find((item) => item.status === 'error')
: undefined;
if (failedImage) {
ElMessage.error(failedImage.error || '请处理上传失败的图片');
return;
}
const failedDocument = composer.documents.items.value.find(
(item) => item.status === 'error',
);
const failedDocument = includeComposer
? composer.documents.items.value.find((item) => item.status === 'error')
: undefined;
if (failedDocument) {
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
return;
}
if (includeComposer) {
await composer.flush();
}
sending.value = true;
retryableErrorRoundId.value = '';
try {
const sessionId = await agentChatRuntimeManager.start({
agentId: selectedAgentId.value,
agentName: selectedAgent.value?.name,
baseItems: timelineItems.value,
capabilities: buildCapabilities(),
documentUploadIds: composer.documents.uploadIds.value,
documents: composer.documents.readyItems.value.map((item) => ({
...item,
})),
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
onInputAccepted: markComposerInputAccepted,
documentUploadIds: includeComposer
? composer.documents.uploadIds.value
: undefined,
documents: includeComposer
? composer.documents.readyItems.value.map((item) => ({ ...item }))
: undefined,
imageUploadIds: includeComposer
? composer.images.uploadIds.value
: undefined,
images: includeComposer
? composer.images.readyItems.value.map((item) => ({ ...item }))
: undefined,
onInputAccepted: includeComposer ? markComposerInputAccepted : undefined,
prompt: content,
retryContextReady: Boolean(options.retryContextReady),
sessionId: composer.sessionId.value,
});
await bindCreatedSession(sessionId, content);
@@ -725,6 +765,24 @@ async function sendContent(rawContent: string) {
}
}
function modelErrorAction(item: ChatTimelineErrorItem) {
return item.roundId === retryableErrorRoundId.value ? '请重试' : undefined;
}
async function handleModelErrorRetry(item: ChatTimelineErrorItem) {
if (
item.roundId !== retryableErrorRoundId.value ||
sending.value ||
runtimeRunning.value
) {
return;
}
await sendContent('继续', {
includeComposer: false,
retryContextReady: true,
});
}
async function handleSend() {
await sendContent(promptText.value);
}
@@ -767,7 +825,7 @@ async function addImageFiles(files: File[]) {
if (selectedAgentImageSupport.value === false) {
ElMessage.warning({
grouping: true,
message: `当前智能体不支持图片,支持:${CHAT_DOCUMENT_SUPPORTED_FORMATS}`,
message: AGENT_IMAGE_UNSUPPORTED_MESSAGE,
});
return;
}
@@ -1266,12 +1324,15 @@ onBeforeUnmount(() => {
:artifact-loader="loadCurrentAgentArtifact"
:items="timelineItems"
:document-loader="loadAgentChatDocument"
:error-action="modelErrorAction"
:error-action-disabled="sending || runtimeRunning"
:image-loader="loadAgentChatImage"
empty-text="选择智能体后开始对话"
:approval-loading="Boolean(approvalLoadingKey)"
:copy-action="handleCopyMessage"
:copyable="canCopyMessage"
@approve="handleApprove"
@error-action="handleModelErrorRetry"
@reject="handleReject"
@select-next-variant="() => undefined"
@select-previous-variant="() => undefined"
@@ -1337,11 +1398,7 @@ onBeforeUnmount(() => {
ref="attachmentFileInputRef"
class="agent-chat__image-file-input"
type="file"
: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'
"
:accept="AGENT_CHAT_ATTACHMENT_ACCEPT"
multiple
@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 agentName = String(agent?.name || '').trim() || '智能体';
return {

View File

@@ -51,7 +51,7 @@ describe('documentImportBatchStatus', () => {
wrapper.unmount();
});
it('当前页面跟踪的批次完成后继续展示结果', async () => {
it('当前页面跟踪的批次完成后移除提示', async () => {
vi.useFakeTimers();
apiMocks.get
.mockResolvedValueOnce({
@@ -73,10 +73,62 @@ describe('documentImportBatchStatus', () => {
await vi.advanceTimersByTimeAsync(3000);
await flushPromises();
expect(wrapper.find('.batch-status').exists()).toBe(false);
wrapper.unmount();
});
it('失败批次关联任务实际完成后不再展示提示', async () => {
apiMocks.get.mockResolvedValue({
data: {
...createBatch('RUNNING', 0),
actualCompleted: true,
failedCount: 2,
pendingCount: 0,
status: 'PARTIAL_SUCCEEDED',
},
errorCode: 0,
});
const wrapper = mount(DocumentImportBatchStatus, {
props: { knowledgeId: 'knowledge-1' },
});
await flushPromises();
expect(wrapper.find('.batch-status').exists()).toBe(false);
wrapper.unmount();
});
it('失败批次低频复查后在关联任务完成时移除提示', async () => {
vi.useFakeTimers();
const failedBatch = {
...createBatch('RUNNING', 0),
actualCompleted: false,
failedCount: 2,
pendingCount: 0,
status: 'PARTIAL_SUCCEEDED',
};
apiMocks.get
.mockResolvedValueOnce({
data: failedBatch,
errorCode: 0,
})
.mockResolvedValueOnce({
data: { ...failedBatch, actualCompleted: true },
errorCode: 0,
});
const wrapper = mount(DocumentImportBatchStatus, {
props: { knowledgeId: 'knowledge-1' },
});
await flushPromises();
expect(wrapper.find('.batch-status').exists()).toBe(true);
expect(wrapper.text()).toContain(
'documentCollection.importDoc.batchCompleted',
);
await vi.advanceTimersByTimeAsync(15_000);
await flushPromises();
expect(apiMocks.get).toHaveBeenCalledTimes(2);
expect(wrapper.find('.batch-status').exists()).toBe(false);
wrapper.unmount();
});
@@ -239,9 +291,7 @@ describe('documentImportBatchStatus', () => {
expect(apiMocks.get).toHaveBeenCalledTimes(3);
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
expect(wrapper.text()).toContain(
'documentCollection.importDoc.batchCompleted',
);
expect(wrapper.find('.batch-status').exists()).toBe(false);
wrapper.unmount();
});
});

View File

@@ -8,6 +8,7 @@ import { ElAlert, ElButton, ElProgress } from 'element-plus';
import { api } from '#/api/request';
interface BatchStatus {
actualCompleted?: boolean;
batchId: string;
cancelledCount?: number;
completedCount: number;
@@ -53,6 +54,7 @@ let pollTimer: null | ReturnType<typeof setTimeout> = null;
let disposed = false;
let refreshGeneration = 0;
let pollDelayMs = 3000;
const TERMINAL_RECHECK_DELAY_MS = 15_000;
const canContinue = computed(
() =>
@@ -122,7 +124,7 @@ const interruptMetadata = computed(() => {
return details.join(' · ');
});
async function refresh(hideCompletedOnRestore = false) {
async function refresh() {
if (!props.knowledgeId) return;
const currentGeneration = ++refreshGeneration;
refreshing.value = true;
@@ -142,10 +144,9 @@ async function refresh(hideCompletedOnRestore = false) {
loadError.value = '';
pollDelayMs = 3000;
const restoredBatch = response.data || undefined;
batch.value =
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
? undefined
: restoredBatch;
const completed =
restoredBatch?.status === 'COMPLETED' || restoredBatch?.actualCompleted;
batch.value = completed ? undefined : restoredBatch;
} catch {
if (!disposed && currentGeneration === refreshGeneration) {
loadError.value = $t(
@@ -175,13 +176,16 @@ function schedulePoll() {
if (
!batch.value ||
batch.value.status === 'COMPLETED' ||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
batch.value.status === 'INTERRUPTED' ||
batch.value.status === 'CANCELLED'
) {
return;
}
pollTimer = setTimeout(refresh, 3000);
const delay =
batch.value.status === 'PARTIAL_SUCCEEDED' ||
batch.value.status === 'INTERRUPTED'
? TERMINAL_RECHECK_DELAY_MS
: 3000;
pollTimer = setTimeout(refresh, delay);
}
async function continueBatch() {
@@ -204,7 +208,7 @@ async function continueBatch() {
onMounted(() => {
disposed = false;
refresh(true);
refresh();
});
onBeforeUnmount(() => {
@@ -222,7 +226,7 @@ watch(
loadError.value = '';
pollDelayMs = 3000;
}
refresh(knowledgeChanged);
refresh();
},
);
</script>

View File

@@ -14,6 +14,10 @@ const props = defineProps({
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
multiple: {
type: Boolean,
default: false,
@@ -37,13 +41,20 @@ const pageUrl = computed(() => {
: `${baseUrl}?resourceType=${props.resourceType}`;
});
function openDialog() {
if (props.disabled) {
return;
}
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
}
function confirm() {
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
emit(
'choose',
props.multiple ? chooseResources.value : currentChoose.value,
props.attrName,
);
closeDialog();
}
watch(
@@ -85,7 +96,7 @@ watch(
</ElButton>
</template>
</EasyFlowPanelModal>
<ElButton @click="openDialog()">
<ElButton :disabled="disabled" @click="openDialog()">
{{ $t('button.choose') }}
</ElButton>
</div>

View File

@@ -2,6 +2,7 @@ import { EventType } from '@ag-ui/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
import { easyFlowAguiCustomEvent } from './custom-events';
import { isRetryableAguiTransportError } from './reconnect';
vi.mock('#/api/request', () => ({
@@ -276,6 +277,69 @@ describe('easyFlowAguiClient', () => {
expect(received.at(-1)).toBe(EventType.RUN_FINISHED);
});
it('yields a paint opportunity after knowledge retrieval starts', async () => {
vi.useFakeTimers();
let paintCallback: FrameRequestCallback | undefined;
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
paintCallback = callback;
return 1;
}),
);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sse([
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
status: 'running',
statusKey: 'knowledge-retrieval',
},
},
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
status: 'done',
statusKey: 'knowledge-retrieval',
},
},
{
runId: 'run-1',
threadId: '101',
type: EventType.RUN_FINISHED,
},
]),
),
);
const receivedStatuses: string[] = [];
const runPromise = new EasyFlowAguiClient().run({
onEvent: (event) => {
if (event.type === EventType.CUSTOM) {
const value = event.value as Record<string, unknown>;
receivedStatuses.push(String(value.status));
}
},
threadId: '101',
url: '/api/v1/agent/1/agui/run',
userMessage: { content: '几点退房', id: 'user-1', role: 'user' },
});
await vi.advanceTimersByTimeAsync(0);
expect(receivedStatuses).toEqual(['running']);
paintCallback?.(0);
await vi.advanceTimersByTimeAsync(0);
await runPromise;
expect(receivedStatuses).toEqual(['running', 'done']);
});
it('replays a completed run from the server journal after refresh', async () => {
vi.stubGlobal(
'fetch',

View File

@@ -5,6 +5,8 @@ import { events } from 'fetch-event-stream';
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
import { easyFlowAguiCustomEvent } from './custom-events';
export interface EasyFlowAguiRunOptions {
forwardedProps?: Record<string, unknown>;
onCursor?: (cursor: number) => void;
@@ -101,6 +103,31 @@ function waitForToolStartPaint(): Promise<void> {
});
}
/**
* 判断事件是否开启了需要即时呈现的工具执行状态。
*
* @param event AG-UI 事件
* @returns 标准工具开始或知识库检索开始时为 true
*/
function startsVisibleToolExecution(event: AguiEvent) {
if (event.type === EventType.TOOL_CALL_START) {
return true;
}
if (
event.type !== EventType.CUSTOM ||
event.name !== easyFlowAguiCustomEvent.knowledgeRetrievalStatus
) {
return false;
}
const value =
event.value &&
typeof event.value === 'object' &&
!Array.isArray(event.value)
? (event.value as Record<string, unknown>)
: {};
return String(value.status || '').toLowerCase() === 'running';
}
/**
* EasyFlow 的无头 AG-UI 运行客户端。
*
@@ -174,7 +201,7 @@ export class EasyFlowAguiClient {
) {
terminalReceived = true;
}
if (event.type === EventType.TOOL_CALL_START) {
if (startsVisibleToolExecution(event as AguiEvent)) {
await waitForToolStartPaint();
}
}
@@ -233,7 +260,7 @@ export class EasyFlowAguiClient {
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
if (event.type === EventType.TOOL_CALL_START) {
if (startsVisibleToolExecution(event as AguiEvent)) {
await waitForToolStartPaint();
}
}

View File

@@ -336,6 +336,76 @@ describe('aG-UI wire contract and timeline projection', () => {
).toBe(true);
});
it('merges knowledge retrieval tool and status events within one turn', () => {
const items: ChatTimelineItem[] = [];
const state = createAguiTimelineProjectionState();
const events = [
{
toolCallId: 'tool-faq-1',
toolCallName: 'retrieve_knowledge_homeinn_faq',
type: EventType.TOOL_CALL_START,
},
{
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
label: '已检索知识库',
status: 'done',
statusKey: 'knowledge-retrieval',
},
},
{
content: 'Retrieved 1 relevant document(s)',
messageId: 'tool-result-faq-1',
role: 'tool',
toolCallId: 'tool-faq-1',
type: EventType.TOOL_CALL_RESULT,
},
].map((event) => EventSchemas.parse(event));
for (const event of events) {
applyAguiEventToTimeline(items, event, { roundId: 'round-faq' }, state);
}
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
label: '已检索知识库',
roundId: 'round-faq',
status: 'done',
statusKey: 'knowledge-retrieval:round-faq',
type: 'status',
});
});
it('projects failed knowledge retrieval without exposing tool details', () => {
const items: ChatTimelineItem[] = [];
applyAguiEventToTimeline(
items,
EventSchemas.parse({
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
type: EventType.CUSTOM,
value: {
internalError: 'private stack',
status: 'error',
statusKey: 'knowledge-retrieval',
},
}),
{ roundId: 'round-failed-knowledge' },
createAguiTimelineProjectionState(),
);
expect(items).toEqual([
expect.objectContaining({
label: '知识库检索失败',
status: 'error',
statusKey: 'knowledge-retrieval:round-failed-knowledge',
tone: 'danger',
type: 'status',
}),
]);
expect(JSON.stringify(items)).not.toContain('private stack');
});
it('projects Skill invocation status in place through the strict public fields', () => {
const items: ChatTimelineItem[] = [];
const state = createAguiTimelineProjectionState();

View File

@@ -3,6 +3,7 @@ import type {
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusStatus,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
@@ -18,6 +19,7 @@ import { easyFlowAguiCustomEvent } from './custom-events';
export interface AguiTimelineProjectionOptions {
finishedAt?: number;
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
runErrorMessage?: string;
roundId?: string;
startedAt?: number;
}
@@ -130,6 +132,13 @@ function asyncToolStatus(
return 'running';
}
function knowledgeRetrievalStatus(value: unknown): ChatTimelineStatusStatus {
const status = asText(value).trim().toLowerCase();
if (status === 'running') return 'running';
if (status === 'error' || status === 'failed') return 'error';
return 'done';
}
function statusKey(
payload: Record<string, unknown>,
options: AguiTimelineProjectionOptions,
@@ -274,7 +283,7 @@ function applyCustomEvent(
if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done',
knowledgeRetrievalStatus(payload.status),
statusKey(payload, options, 'knowledge-retrieval'),
turnMetadata,
);
@@ -361,7 +370,7 @@ export function applyAguiEventToTimeline(
}
ChatTimelineBuilder.appendError(
items,
event.message || '请求失败',
options.runErrorMessage || event.message || '请求失败',
metadata(options, state),
);
ChatTimelineBuilder.finalize(items, {

View File

@@ -12,6 +12,8 @@ import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
interface Props {
workflowId: any;
node: any;
@@ -31,7 +33,7 @@ const parameterDisplayNameMap = computed(() => {
return new Map(
singleRunParameters.value.map((parameter: any) => [
String(parameter.name || ''),
String(parameter.displayName || parameter.formLabel || parameter.name || ''),
resolveWorkflowParameterDisplayName(parameter),
]),
);
});

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ElButton, ElLink, ElMessage } from 'element-plus';
import {
CircleCheck,
Delete,
Document,
UploadFilled,
} from '@element-plus/icons-vue';
import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import {
appendWorkflowFileValues,
buildWorkflowFileValueFromResource,
buildWorkflowFileValueFromUpload,
formatWorkflowFileSize,
normalizeWorkflowFileValues,
@@ -19,30 +23,46 @@ import {
} from './workflowFileValue';
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
modelValue: {
type: [Array, Object],
default: undefined,
},
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
});
const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false);
const dragActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
const currentFiles = computed(() =>
normalizeWorkflowFileValues(props.modelValue),
);
const maxSingleFileSizeText = formatWorkflowFileSize(
WORKFLOW_FILE_LIMITS.maxSingleSize,
).replace('.0 ', ' ');
function triggerSelectFile() {
if (uploadLoading.value) {
if (props.disabled || uploadLoading.value) {
return;
}
fileInputRef.value?.click();
}
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) {
async function uploadFiles(files: File[]) {
if (props.disabled || files.length === 0) {
return;
}
@@ -51,10 +71,22 @@ async function handleNativeFileChange(event: Event) {
validateWorkflowFileSelection(currentFiles.value, files);
const uploadedFiles = [];
for (const file of files) {
const res = await api.upload('/api/v1/commons/upload', { file }, {});
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
const res = await api.upload(
props.uploadUrl,
{ file, ...props.uploadData },
{},
);
uploadedFiles.push(
buildWorkflowFileValueFromUpload(file, res?.data?.path),
);
}
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
if (props.disabled) {
return;
}
const nextFiles = appendWorkflowFileValues(
currentFiles.value,
uploadedFiles,
);
validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles);
} catch (error: any) {
@@ -62,31 +94,37 @@ async function handleNativeFileChange(event: Event) {
console.error('工作流文件上传失败', error);
} finally {
uploadLoading.value = false;
input.value = '';
}
}
function handleChooseResource(resources: any) {
try {
const resourceList = Array.isArray(resources) ? resources : [resources];
const fileValues = resourceList
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
.filter(Boolean);
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
validateWorkflowFileValues(nextFiles);
emit('update:modelValue', nextFiles);
} catch (error: any) {
ElMessage.error(error?.message || '素材文件选择失败');
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
await uploadFiles([...(input.files || [])]);
input.value = '';
}
function setDragActive(active: boolean) {
if (!props.disabled) {
dragActive.value = active;
}
}
async function handleDrop(event: DragEvent) {
dragActive.value = false;
if (props.disabled || uploadLoading.value) {
return;
}
await uploadFiles([...(event.dataTransfer?.files || [])]);
}
function removeFile(filePath: string) {
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
emit('update:modelValue', nextFiles);
if (props.disabled) {
return;
}
function clearFiles() {
emit('update:modelValue', []);
const nextFiles = currentFiles.value.filter(
(item) => item.filePath !== filePath,
);
emit('update:modelValue', nextFiles);
}
</script>
@@ -96,14 +134,42 @@ function clearFiles() {
ref="fileInputRef"
class="workflow-file-input__native"
type="file"
:disabled="disabled"
multiple
@change="handleNativeFileChange"
/>
<div class="workflow-file-input__hint">
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件单个不超过
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }}总计不超过
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
<div
v-if="currentFiles.length === 0"
class="workflow-file-input__dropzone"
:class="{ 'is-disabled': disabled, 'is-dragging': dragActive }"
@dragenter.prevent="setDragActive(true)"
@dragover.prevent="setDragActive(true)"
@dragleave.prevent="setDragActive(false)"
@drop.prevent="handleDrop"
>
<button
class="workflow-file-input__upload-trigger"
type="button"
:disabled="disabled || uploadLoading"
@click="triggerSelectFile"
>
<ElIcon class="workflow-file-input__upload-icon">
<UploadFilled />
</ElIcon>
<span class="workflow-file-input__dropzone-copy">
<span>
{{
disabled
? '未上传文件'
: uploadLoading
? '正在上传…'
: '拖入文件或点击上传'
}}
</span>
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
</span>
</button>
</div>
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
@@ -112,12 +178,21 @@ function clearFiles() {
:key="item.filePath"
class="workflow-file-input__summary"
>
<ElIcon class="workflow-file-input__file-icon">
<Document />
</ElIcon>
<div class="workflow-file-input__content">
<div class="workflow-file-input__name">
{{ item.fileName }}
</div>
<div class="workflow-file-input__meta">
<span>{{ formatWorkflowFileSize(item.size) }}</span>
<span class="workflow-file-input__ready">
<ElIcon><CircleCheck /></ElIcon>
已上传
</span>
</div>
</div>
<ElLink
v-if="item.url || item.filePath"
:href="item.url || item.filePath"
@@ -126,32 +201,15 @@ function clearFiles() {
>
{{ $t('button.view') }}
</ElLink>
</div>
</div>
<ElButton text type="danger" @click="removeFile(item.filePath)">
{{ $t('button.delete') }}
</ElButton>
</div>
</div>
<div class="workflow-file-input__actions">
<ElButton
type="primary"
plain
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
</ElButton>
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
<ElButton
v-if="currentFiles.length > 0"
v-if="!disabled"
:icon="Delete"
text
type="danger"
@click="clearFiles"
>
清空
</ElButton>
circle
aria-label="删除文件"
@click="removeFile(item.filePath)"
/>
</div>
</div>
</div>
</template>
@@ -161,16 +219,83 @@ function clearFiles() {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.workflow-file-input__native {
display: none;
}
.workflow-file-input__hint {
font-size: 12px;
.workflow-file-input__dropzone {
display: flex;
align-items: center;
width: 100%;
min-height: 56px;
color: var(--el-text-color-secondary);
line-height: 1.5;
background: hsl(var(--surface-subtle));
border: 1px dashed var(--el-border-color);
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
}
.workflow-file-input__dropzone:hover,
.workflow-file-input__dropzone.is-dragging {
color: hsl(var(--primary));
background: hsl(var(--primary) / 6%);
border-color: hsl(var(--primary) / 48%);
}
.workflow-file-input__dropzone.is-disabled,
.workflow-file-input__dropzone.is-disabled:hover {
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
border-color: var(--el-border-color-lighter);
}
.workflow-file-input__upload-trigger {
display: inline-flex;
flex: 1;
gap: var(--space-2);
align-items: center;
align-self: stretch;
min-width: 0;
padding: var(--space-2) var(--space-3);
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.workflow-file-input__upload-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px hsl(var(--primary) / 12%);
}
.workflow-file-input__upload-trigger:disabled {
cursor: default;
opacity: 0.72;
}
.workflow-file-input__upload-icon {
font-size: 18px;
}
.workflow-file-input__dropzone-copy {
display: flex;
flex-flow: row wrap;
gap: var(--space-1) var(--space-2);
align-items: center;
line-height: 1.4;
}
.workflow-file-input__dropzone-copy small {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.workflow-file-input__list {
@@ -181,39 +306,46 @@ function clearFiles() {
.workflow-file-input__summary {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
padding: var(--space-3);
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-light);
border-radius: var(--radius-control);
}
.workflow-file-input__file-icon {
flex: 0 0 auto;
font-size: 18px;
color: hsl(var(--primary));
}
.workflow-file-input__content {
min-width: 0;
flex: 1;
min-width: 0;
}
.workflow-file-input__name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
word-break: break-word;
overflow-wrap: anywhere;
}
.workflow-file-input__meta {
display: flex;
align-items: center;
gap: 10px;
align-items: center;
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.workflow-file-input__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.workflow-file-input__ready {
display: inline-flex;
gap: var(--space-1);
align-items: center;
color: var(--el-color-success);
}
</style>

View File

@@ -13,9 +13,14 @@ import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
import { hasWorkflowImageValue } from './workflowImageValue';
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
parameters: {
type: Array<any>,
required: true,
@@ -28,6 +33,10 @@ const props = defineProps({
type: String,
default: '',
},
publicShare: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:runParams']);
function getContentType(item: any) {
@@ -45,6 +54,10 @@ function isResource(contentType: any) {
function isFileContentType(contentType: any) {
return contentType === 'file';
}
function isWideItem(item: any) {
const contentType = getContentType(item);
return item.formType === 'textarea' || contentType === 'image';
}
function getCheckboxOptions(item: any) {
if (item.enums) {
return (
@@ -73,7 +86,9 @@ function buildRules(item: any) {
return;
}
if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required')));
callback(
value.length > 0 ? undefined : new Error($t('message.required')),
);
return;
}
if (value && typeof value === 'object') {
@@ -90,10 +105,16 @@ function buildRules(item: any) {
];
}
function updateParam(name: string, value: any) {
if (props.disabled) {
return;
}
const newValue = { ...props.runParams, [name]: value };
emit('update:runParams', newValue);
}
function choose(data: any, propName: string) {
if (props.disabled) {
return;
}
updateParam(propName, data.resourceUrl);
}
</script>
@@ -101,20 +122,24 @@ function choose(data: any, propName: string) {
<template>
<ElFormItem
v-for="(item, idx) in parameters"
class="workflow-form-item"
:class="{ 'is-wide': isWideItem(item) }"
:prop="`${propPrefix}${item.name}`"
:key="idx"
:label="item.formLabel || item.name"
:label="resolveWorkflowParameterLabel(item)"
:rules="buildRules(item)"
>
<template v-if="getContentType(item) === 'text'">
<ElInput
v-if="item.formType === 'input' || !item.formType"
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
/>
<ElSelect
v-if="item.formType === 'select'"
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
@@ -123,6 +148,7 @@ function choose(data: any, propName: string) {
/>
<ElInput
v-if="item.formType === 'textarea'"
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
@@ -131,12 +157,14 @@ function choose(data: any, propName: string) {
/>
<ElRadioGroup
v-if="item.formType === 'radio'"
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)"
/>
<ElCheckboxGroup
v-if="item.formType === 'checkbox'"
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:options="getCheckboxOptions(item)"
@@ -144,6 +172,7 @@ function choose(data: any, propName: string) {
</template>
<template v-if="getContentType(item) === 'other'">
<ElInput
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
@@ -151,23 +180,44 @@ function choose(data: any, propName: string) {
</template>
<template v-if="isFileContentType(getContentType(item))">
<WorkflowFileInput
:disabled="disabled"
: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)"
/>
</template>
<template v-if="getContentType(item) === 'image'">
<WorkflowImageInput
:allow-resource-picker="!publicShare"
:disabled="disabled"
: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)"
/>
</template>
<template v-if="isResource(getContentType(item))">
<ElInput
:disabled="disabled"
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
:placeholder="item.formPlaceholder"
/>
<ChooseResource :attr-name="item.name" @choose="choose" />
<ChooseResource
v-if="!publicShare"
:attr-name="item.name"
:disabled="disabled"
@choose="choose"
/>
</template>
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
{{ item.formDescription }}

View File

@@ -19,10 +19,26 @@ import {
} from './workflowImageValue';
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
modelValue: {
type: [String, Object],
default: undefined,
},
allowResourcePicker: {
type: Boolean,
default: true,
},
uploadData: {
type: Object,
default: () => ({}),
},
uploadUrl: {
type: String,
default: '/api/v1/commons/upload',
},
});
const emit = defineEmits(['update:modelValue']);
@@ -33,9 +49,7 @@ const urlInput = ref('');
const currentImage = computed(() =>
normalizeWorkflowImageValue(props.modelValue),
);
const previewUrl = computed(() =>
getWorkflowImagePreviewUrl(props.modelValue),
);
const previewUrl = computed(() => getWorkflowImagePreviewUrl(props.modelValue));
watch(
() => props.modelValue,
@@ -47,6 +61,9 @@ watch(
);
function applyUrl() {
if (props.disabled) {
return;
}
try {
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
} catch (error: any) {
@@ -55,7 +72,7 @@ function applyUrl() {
}
function triggerSelectFile() {
if (!uploadLoading.value) {
if (!props.disabled && !uploadLoading.value) {
fileInputRef.value?.click();
}
}
@@ -63,13 +80,20 @@ function triggerSelectFile() {
async function handleNativeFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
if (props.disabled || !file) {
return;
}
uploadLoading.value = true;
try {
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) {
return;
}
emit(
'update:modelValue',
buildWorkflowImageValueFromUpload(file, response?.data?.path),
@@ -84,6 +108,9 @@ async function handleNativeFileChange(event: Event) {
}
function handleChooseResource(resource: any) {
if (props.disabled) {
return;
}
try {
emit(
'update:modelValue',
@@ -95,6 +122,9 @@ function handleChooseResource(resource: any) {
}
function clearImage() {
if (props.disabled) {
return;
}
urlInput.value = '';
emit('update:modelValue', undefined);
}
@@ -107,6 +137,7 @@ function clearImage() {
class="workflow-image-input__native"
type="file"
:accept="WORKFLOW_IMAGE_LIMITS.accept"
:disabled="disabled"
@change="handleNativeFileChange"
/>
@@ -143,11 +174,12 @@ function clearImage() {
<ElInput
v-model="urlInput"
clearable
:disabled="disabled"
placeholder="输入 HTTP/HTTPS 图片 URL"
@keyup.enter="applyUrl"
>
<template #append>
<ElButton @click="applyUrl">使用 URL</ElButton>
<ElButton :disabled="disabled" @click="applyUrl">使用 URL</ElButton>
</template>
</ElInput>
@@ -155,17 +187,25 @@ function clearImage() {
<ElButton
type="primary"
plain
:disabled="disabled"
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentImage ? '替换图片' : $t('button.upload') }}
</ElButton>
<ChooseResource
v-if="allowResourcePicker"
attr-name="image"
:disabled="disabled"
:resource-type="0"
@choose="handleChooseResource"
/>
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
<ElButton
v-if="currentImage && !disabled"
text
type="danger"
@click="clearImage"
>
清空
</ElButton>
</div>

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

@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue';
describe('workflow file input', () => {
it('shows the upload area again after the uploaded file is deleted', async () => {
const Host = defineComponent({
components: { WorkflowFileInput },
setup() {
const value = ref([
{
fileName: '需求说明.pdf',
filePath: '/files/requirements.pdf',
size: 1024,
},
]);
return { value };
},
template: '<WorkflowFileInput v-model="value" />',
});
const wrapper = mount(Host);
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
await wrapper.get('button[aria-label="删除文件"]').trigger('click');
await nextTick();
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(true);
});
it('hides the delete action and disables upload when parameters are locked', async () => {
const wrapper = mount(WorkflowFileInput, {
props: {
disabled: true,
modelValue: [
{
fileName: '需求说明.pdf',
filePath: '/files/requirements.pdf',
size: 1024,
},
],
},
});
expect(wrapper.find('button[aria-label="删除文件"]').exists()).toBe(false);
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
await wrapper.setProps({ modelValue: [] });
expect(
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
).toHaveProperty('disabled');
});
});

View File

@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest';
import { resolveWorkflowFormParameters } from '../workflowFormParameters';
import {
buildWorkflowFormInitialValues,
resolveWorkflowFormParameters,
resolveWorkflowParameterDisplayName,
resolveWorkflowParameterLabel,
} from '../workflowFormParameters';
describe('resolveWorkflowFormParameters', () => {
describe('workflowFormParameters', () => {
it('uses the image parameter when a legacy schema still declares text', () => {
const parameters = resolveWorkflowFormParameters({
parameters: [
@@ -73,4 +78,75 @@ describe('resolveWorkflowFormParameters', () => {
formLabel: '背景资料',
});
});
it('uses the configured parameter name for a default trial-run label', () => {
expect(
resolveWorkflowParameterLabel({
name: 'customer_name',
formLabel: '新字段',
}),
).toBe('customer_name');
expect(
resolveWorkflowParameterDisplayName({
name: 'start_1.customer_name',
displayName: '开始节点 > 新字段',
}),
).toBe('开始节点 > customer_name');
});
it('uses the configured parameter name instead of a type-derived label', () => {
const parameter = {
name: 'start_1.file111',
formLabel: '文件',
displayName: '开始节点 > 文件',
};
expect(resolveWorkflowParameterLabel(parameter)).toBe('file111');
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
'开始节点 > file111',
);
});
it('preserves the configured system question label', () => {
const parameter = {
name: 'user_input',
formLabel: '用户问题123',
displayName: '流程开始 > 用户问题123',
systemReserved: true,
};
expect(resolveWorkflowParameterLabel(parameter)).toBe('用户问题123');
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
'流程开始 > 用户问题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

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildWorkflowFormParameterSummaries,
buildWorkflowFormSubmissionImages,
buildWorkflowFormSubmissionText,
hasRequiredWorkflowFormParameters,
@@ -67,4 +68,43 @@ describe('workflowFormPresentation', () => {
},
]);
});
it('builds compact summaries for configured workflow parameters', () => {
expect(
buildWorkflowFormParameterSummaries(
[
{ name: 'customer', formLabel: '客户名称', required: true },
{ name: 'scene', formLabel: '业务场景', required: false },
{ name: 'files', formLabel: '需求附件', required: true },
],
{
customer: '华北分公司',
scene: '',
files: [{ fileName: '需求说明.pdf' }],
},
),
).toEqual([
{
key: 'customer',
label: 'customer',
ready: true,
required: true,
value: '华北分公司',
},
{
key: 'scene',
label: 'scene',
ready: false,
required: false,
value: '待填写',
},
{
key: 'files',
label: 'files',
ready: true,
required: true,
value: '需求说明.pdf',
},
]);
});
});

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

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
buildWorkflowRunDraftKey,
getWorkflowRunDraftStorage,
hasWorkflowRunDraftContent,
readWorkflowRunDraft,
removeWorkflowRunDraft,
WORKFLOW_RUN_DRAFT_TTL_MS,
writeWorkflowRunDraft,
} from '../workflowRunDraft';
const parameters = [
{ contentType: 'text', formType: 'input', name: 'company' },
{ contentType: 'file', formType: 'input', name: 'attachment' },
];
describe('workflowRunDraft', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('isolates drafts by workflow, account and run mode', () => {
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
buildWorkflowRunDraftKey('flow-1', 'tenant:user-2', false),
);
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
buildWorkflowRunDraftKey('flow-2', 'tenant:user-1', false),
);
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
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', () => {
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(
sessionStorage,
key,
{
question: '分析合同',
values: {
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
company: '华北分公司',
removedField: '旧字段',
},
},
1000,
);
expect(readWorkflowRunDraft(sessionStorage, key, parameters, 2000)).toEqual(
{
question: '分析合同',
values: {
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
company: '华北分公司',
},
},
);
});
it('drops expired drafts', () => {
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(
sessionStorage,
key,
{
question: '过期内容',
values: { attachment: '错误文件值', company: ['错误文本值'] },
},
1000,
);
expect(
readWorkflowRunDraft(
sessionStorage,
key,
parameters,
1000 + WORKFLOW_RUN_DRAFT_TTL_MS,
),
).toBeUndefined();
expect(sessionStorage.getItem(key)).toBeNull();
});
it('only persists user changes and supports an explicit reset', () => {
const defaults = { attachment: [], company: '' };
expect(hasWorkflowRunDraftContent('', defaults, defaults)).toBe(false);
expect(
hasWorkflowRunDraftContent(
'',
{ ...defaults, company: '华北' },
defaults,
),
).toBe(true);
expect(hasWorkflowRunDraftContent('待处理', defaults, defaults)).toBe(true);
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
writeWorkflowRunDraft(sessionStorage, key, {
question: '待处理',
values: defaults,
});
removeWorkflowRunDraft(sessionStorage, key);
expect(sessionStorage.getItem(key)).toBeNull();
});
});

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

@@ -17,6 +17,85 @@ const DEFAULT_FIELD_LABELS = new Set([
const GENERATED_FIELD_KEY_PATTERN =
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
function configuredParameterName(name: unknown) {
const normalizedName = String(name || '').trim();
const nameParts = normalizedName.split('.').filter(Boolean);
return nameParts[nameParts.length - 1] || normalizedName;
}
function isSystemParameter(parameter: any, name: string) {
return parameter?.systemReserved === true || name === 'user_input';
}
function withConfiguredParameterName(label: unknown, name: unknown) {
const normalizedLabel = String(label || '').trim();
const parameterName = configuredParameterName(name);
if (!parameterName) {
return normalizedLabel;
}
const parts = normalizedLabel.split('>').map((part) => part.trim());
return parts.length > 1
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
: parameterName;
}
function replaceDefaultParameterLabel(label: unknown, name: unknown) {
const normalizedLabel = String(label || '').trim();
const normalizedName = String(name || '').trim();
if (!normalizedLabel || !normalizedName) {
return normalizedLabel;
}
const parts = normalizedLabel.split('>').map((part) => part.trim());
const lastPart = parts[parts.length - 1] || '';
if (!DEFAULT_FIELD_LABELS.has(lastPart)) {
return normalizedLabel;
}
const parameterName = configuredParameterName(normalizedName);
return parts.length > 1
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
: parameterName;
}
/**
* 解析工作流参数在表单中的展示名称。
*
* @param parameter 工作流运行参数
* @returns 用户可见的参数名称
*/
export function resolveWorkflowParameterLabel(parameter: any) {
const name = String(parameter?.name || '').trim();
if (!isSystemParameter(parameter, name) && name) {
return configuredParameterName(name);
}
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,
);
return formLabel || displayName || name || '参数';
}
/**
* 解析工作流参数在引用内容中的展示名称。
*
* @param parameter 工作流运行参数
* @returns 用户可见的引用参数名称
*/
export function resolveWorkflowParameterDisplayName(parameter: any) {
const name = String(parameter?.name || '').trim();
if (!isSystemParameter(parameter, name) && name) {
return withConfiguredParameterName(
parameter?.displayName || parameter?.formLabel,
name,
);
}
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,
);
return displayName || resolveWorkflowParameterLabel(parameter);
}
function resolveFieldLabel(field: any) {
const key = String(field?.key || '').trim();
const label = String(field?.label || '').trim();
@@ -57,6 +136,48 @@ function resolveDataType(type: string, contentType: 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) {
const schema = Array.isArray(workflowParams?.startFormSchema)
? workflowParams.startFormSchema

View File

@@ -1,10 +1,19 @@
import type { ChatImageAttachment } from '@easyflow/common-ui';
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
import {
getWorkflowImagePreviewUrl,
normalizeWorkflowImageValue,
} from './workflowImageValue';
export interface WorkflowFormParameterSummary {
key: string;
label: string;
ready: boolean;
required: boolean;
value: string;
}
/**
* 判断附加表单是否存在必填参数。
*
@@ -44,6 +53,29 @@ export function buildWorkflowFormSubmissionText(
.join('\n');
}
/**
* 构建运行参数的紧凑摘要。
*
* @param parameters 运行参数
* @param values 表单值
* @returns 可用于收起态展示的参数摘要
*/
export function buildWorkflowFormParameterSummaries(
parameters: any[],
values: Record<string, any>,
): WorkflowFormParameterSummary[] {
return parameters.map((parameter) => {
const value = formatWorkflowFormValue(values[parameter?.name]);
return {
key: String(parameter?.name || ''),
label: resolveWorkflowParameterLabel(parameter),
ready: Boolean(value),
required: parameter?.required === true,
value: value || '待填写',
};
});
}
/**
* 将图片表单字段转换为聊天图片附件。
*
@@ -86,7 +118,7 @@ export function buildWorkflowFormSubmissionImages(
* @param value 表单字段值
* @returns 用户可读文本;空值返回空字符串
*/
function formatWorkflowFormValue(value: any): string {
export function formatWorkflowFormValue(value: any): string {
if (value === null || value === undefined || value === '') {
return '';
}

View File

@@ -0,0 +1,173 @@
const WORKFLOW_RUN_DRAFT_PREFIX = 'easyflow:workflow-run-draft';
const WORKFLOW_RUN_DRAFT_VERSION = 1;
export const WORKFLOW_RUN_DRAFT_TTL_MS = 12 * 60 * 60 * 1000;
interface WorkflowRunDraftPayload {
question: string;
values: Record<string, unknown>;
}
interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
expiresAt: number;
version: number;
}
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
/** 获取可用的草稿存储;分享模式使用本地存储支持刷新恢复。 */
export function getWorkflowRunDraftStorage(persistent = false) {
try {
return persistent ? globalThis.localStorage : globalThis.sessionStorage;
} catch {
return undefined;
}
}
/** 生成按工作流、账号和运行模式隔离的草稿键。 */
export function buildWorkflowRunDraftKey(
workflowId: string,
identity: string,
shareMode: boolean,
) {
const mode = shareMode ? 'share' : 'private';
const scope = identity || (shareMode ? 'public' : 'anonymous');
return [
WORKFLOW_RUN_DRAFT_PREFIX,
`v${WORKFLOW_RUN_DRAFT_VERSION}`,
mode,
encodeURIComponent(scope),
encodeURIComponent(workflowId),
].join(':');
}
/** 读取并按当前工作流参数定义过滤草稿。 */
export function readWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
parameters: any[],
now = Date.now(),
): undefined | WorkflowRunDraftPayload {
if (!storage || !key) {
return undefined;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return undefined;
}
const draft = JSON.parse(raw) as Partial<StoredWorkflowRunDraft>;
if (
draft.version !== WORKFLOW_RUN_DRAFT_VERSION ||
typeof draft.expiresAt !== 'number' ||
draft.expiresAt <= now ||
typeof draft.question !== 'string' ||
!draft.values ||
typeof draft.values !== 'object' ||
Array.isArray(draft.values)
) {
storage.removeItem(key);
return undefined;
}
const values: Record<string, unknown> = {};
for (const parameter of parameters) {
const name = String(parameter?.name || '').trim();
if (
name &&
Object.prototype.hasOwnProperty.call(draft.values, name) &&
isCompatibleDraftValue(parameter, draft.values[name])
) {
values[name] = draft.values[name];
}
}
return { question: draft.question, values };
} catch {
try {
storage.removeItem(key);
} catch {
// 存储不可用时无需影响页面加载。
}
return undefined;
}
}
/** 保存工作流运行草稿,并设置 12 小时过期时间。 */
export function writeWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
draft: WorkflowRunDraftPayload,
now = Date.now(),
) {
if (!storage || !key) {
return;
}
try {
storage.setItem(
key,
JSON.stringify({
...draft,
expiresAt: now + WORKFLOW_RUN_DRAFT_TTL_MS,
version: WORKFLOW_RUN_DRAFT_VERSION,
} satisfies StoredWorkflowRunDraft),
);
} catch {
// 存储不可用或空间不足时不阻断工作流输入。
}
}
/** 删除工作流运行草稿。 */
export function removeWorkflowRunDraft(
storage: DraftStorage | undefined,
key: string,
) {
if (!storage || !key) {
return;
}
try {
storage.removeItem(key);
} catch {
// 存储不可用时无需影响重置流程。
}
}
/** 判断当前输入是否包含需要持久化的用户修改。 */
export function hasWorkflowRunDraftContent(
question: string,
values: Record<string, unknown>,
defaults: Record<string, unknown>,
) {
if (question.length > 0) {
return true;
}
return Object.keys(values).some(
(name) => !isSameDraftValue(values[name], defaults[name]),
);
}
function isCompatibleDraftValue(parameter: any, value: unknown) {
const contentType = String(parameter?.contentType || '').toLowerCase();
const formType = String(parameter?.formType || '').toLowerCase();
if (contentType === 'file' || formType === 'checkbox') {
return Array.isArray(value);
}
if (contentType === 'image') {
return Boolean(value) && typeof value === 'object';
}
return (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
);
}
function isSameDraftValue(left: unknown, right: unknown) {
if (left === right) {
return true;
}
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}

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,
readWorkflowShareKey,
resolveWorkflowShareFailureReason,
resolveWorkflowShareVisitorId,
resolveWorkflowShareWorkflowId,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
WORKFLOW_SHARE_VISITOR_HEADER,
} from '#/utils/workflow-share-context';
describe('workflow share context', () => {
@@ -41,12 +43,15 @@ describe('workflow share context', () => {
{
pageUrl: 'https://example.test/share/workflow?shareKey=abc123',
requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
requestUrl: '/api/v1/workflowChat/public/descriptor',
visitorId: '00112233445566778899aabbccddeeff',
},
),
).toEqual({
'Accept-Language': 'zh-CN',
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'abc123',
[WORKFLOW_SHARE_VISITOR_HEADER]: '00112233445566778899aabbccddeeff',
});
});
@@ -57,7 +62,7 @@ describe('workflow share context', () => {
withWorkflowShareHeader(headers, {
pageUrl: 'https://example.test/share/workflow',
requestMethod: 'GET',
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
requestUrl: '/api/v1/workflowChat/public/descriptor',
}),
).toEqual(headers);
});
@@ -120,18 +125,37 @@ describe('workflow share context', () => {
requestUrl: '/api/v1/workflowChat/run',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{ 'easyflow-token': 'authenticated-token' },
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflowChat/public/run',
visitorId: 'ffeeddccbbaa99887766554433221100',
},
),
).toEqual({
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: 'workflow-key',
[WORKFLOW_SHARE_VISITOR_HEADER]: 'ffeeddccbbaa99887766554433221100',
});
});
it('matches only the workflow sharing endpoint whitelist', () => {
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
isWorkflowShareRequest('/flow/api/v1/workflowChat/public/run', 'post'),
).toBe(true);
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/execution', 'get'),
isWorkflowShareRequest(
'/flow/api/v1/workflowChat/public/execution',
'get',
),
).toBe(true);
expect(
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
).toBe(false);
expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe(
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 () => {
const resolve = vi.fn().mockResolvedValue('workflow-1');
const onFailure = vi.fn();

View File

@@ -30,7 +30,7 @@
"preview": "turbo-run preview",
"publint": "vsh publint",
"reinstall": "pnpm clean --del-lock && pnpm install",
"test:deployment-contract": "vitest run --dom app/vite-base-path-redirect.test.ts app/src/startup-error.test.ts app/src/router/__tests__/environment-contract.test.ts app/src/router/navigation-user-info.test.ts app/src/utils/share-route-context.test.ts app/src/utils/__tests__/login-redirect.test.ts app/src/views/ai/workflow/workflow-share-context.test.ts",
"test:deployment-contract": "vitest run --dom app/vite-base-path-redirect.test.ts app/src/startup-error.test.ts app/src/router/__tests__/environment-contract.test.ts app/src/router/__tests__/chunk-error-reload.test.ts app/src/router/navigation-user-info.test.ts app/src/utils/share-route-context.test.ts app/src/utils/__tests__/login-redirect.test.ts app/src/views/ai/workflow/workflow-share-context.test.ts",
"test:unit": "vitest run --dom",
"test:e2e": "turbo run test:e2e",
"update:deps": "npx taze -r -w",

View File

@@ -1,17 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { openWindow } from '../window';
import { openRouteInNewWindow, openWindow } from '../window';
describe('openWindow', () => {
// 保存原始的 window.open 函数
let originalOpen: typeof window.open;
let originalUrl: string;
beforeEach(() => {
originalOpen = window.open;
originalUrl = window.location.href;
});
afterEach(() => {
window.open = originalOpen;
window.history.replaceState({}, '', originalUrl);
});
it('should call window.open with correct arguments', () => {
@@ -30,4 +33,17 @@ describe('openWindow', () => {
'noopener=yes,noreferrer=yes',
);
});
it('should preserve a router-resolved base and hash route', () => {
window.history.replaceState({}, '', '/flow/#/dashboard');
window.open = vi.fn();
openRouteInNewWindow('#/ai/workflow');
expect(window.open).toHaveBeenCalledWith(
`${window.location.origin}/flow/#/ai/workflow`,
'_blank',
'noopener=yes,noreferrer=yes',
);
});
});

View File

@@ -25,13 +25,10 @@ function openWindow(url: string, options: OpenWindowOptions = {}): void {
/**
* 在新窗口中打开路由。
* @param path
* @param href Vue Router 解析后的地址
*/
function openRouteInNewWindow(path: string) {
const { hash, origin } = location;
const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' });
function openRouteInNewWindow(href: string) {
openWindow(new URL(href, location.href).href, { target: '_blank' });
}
export { openRouteInNewWindow, openWindow };

View File

@@ -1,13 +1,30 @@
<script setup lang="ts">
defineProps<{
actionDisabled?: boolean;
actionLabel?: string;
message: string;
}>();
const emit = defineEmits<{
action: [];
}>();
</script>
<template>
<div class="chat-error-notice" role="alert">
<span class="chat-error-notice__icon" aria-hidden="true">!</span>
<span>{{ message }}</span>
<span class="chat-error-notice__content">
<span>{{ message }}<template v-if="actionLabel"></template></span>
<button
v-if="actionLabel"
type="button"
class="chat-error-notice__action"
:disabled="actionDisabled"
@click="emit('action')"
>
{{ actionLabel }}
</button>
</span>
</div>
</template>
@@ -39,4 +56,41 @@ defineProps<{
border: 1px solid currentColor;
border-radius: 50%;
}
.chat-error-notice__content {
min-width: 0;
}
.chat-error-notice__action {
padding: 0;
margin-left: var(--space-1);
font: inherit;
font-weight: 600;
line-height: inherit;
color: currentcolor;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--el-border-radius-small);
}
.chat-error-notice__action:hover:not(:disabled) {
opacity: 0.8;
}
.chat-error-notice__action:active:not(:disabled) {
opacity: 0.65;
}
.chat-error-notice__action:focus-visible {
outline: 2px solid var(--el-color-primary-light-3);
outline-offset: 2px;
}
.chat-error-notice__action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>

View File

@@ -3,12 +3,20 @@ import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} 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 ChatTimelineItem from './ChatTimelineItem.vue';
@@ -23,6 +31,8 @@ const props = defineProps<{
documentLoader?: ChatDocumentLoader;
emptyText?: string;
emptyTitle?: string;
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
errorActionDisabled?: boolean;
imageLoader?: ChatImageLoader;
items: ChatTimelineItemType[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
@@ -32,7 +42,9 @@ const props = defineProps<{
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
bottomPinnedChange: [pinned: boolean];
copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
@@ -40,12 +52,14 @@ const emit = defineEmits<{
}>();
const containerRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };
const bottomThreshold = 24;
let scrollFrame = 0;
let contentResizeObserver: ResizeObserver | undefined;
const assistantActionAnchorByRound = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
@@ -112,7 +126,12 @@ function updatePinnedState() {
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
const pinned = isNearBottom(container);
if (pinned === isPinnedToBottom.value) {
return;
}
isPinnedToBottom.value = pinned;
emit('bottomPinnedChange', pinned);
}
function scrollToBottom() {
@@ -202,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) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
@@ -210,6 +245,10 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function errorActionLabel(item: ChatTimelineItemType) {
return item.type === 'error' ? props.errorAction?.(item) : undefined;
}
function isAssistantActionAnchor(item: ChatTimelineItemType) {
return (
item.type === 'message' &&
@@ -224,6 +263,7 @@ function isVariantLoading(item: ChatTimelineItemType) {
}
onBeforeUnmount(() => {
contentResizeObserver?.disconnect();
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
@@ -249,6 +289,7 @@ watch(
class="chat-timeline"
@scroll.passive="handleTimelineScroll"
>
<div ref="contentRef" class="chat-timeline__content">
<div v-if="items.length === 0" class="chat-timeline__empty">
<div
class="chat-timeline__empty-icon"
@@ -275,6 +316,8 @@ watch(
:copy-action="copyAction"
:copyable="copyable"
:document-loader="documentLoader"
:error-action="errorAction"
:error-action-disabled="errorActionDisabled"
:image-loader="imageLoader"
:items="entry.items"
:regenerable="regenerable"
@@ -283,6 +326,7 @@ watch(
:variant-loading="variantLoading"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@layout-changed="handleLayoutChanged"
@layout-toggle="handleLayoutToggle"
@regenerate-message="emit('regenerateMessage', $event)"
@@ -306,6 +350,8 @@ watch(
:assistant-avatar="assistantAvatar"
:item="entry.item"
:document-loader="documentLoader"
:error-action-disabled="errorActionDisabled"
:error-action-label="errorActionLabel(entry.item)"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
@@ -315,6 +361,7 @@ watch(
:variant-loading="isVariantLoading(entry.item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@error-action="emit('errorAction', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@@ -324,6 +371,7 @@ watch(
</template>
</template>
</div>
</div>
</template>
<style scoped>
@@ -331,12 +379,19 @@ watch(
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-3);
min-height: 0;
padding: 16px;
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 {
width: 72px;
height: 72px;

View File

@@ -3,6 +3,7 @@ import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
@@ -31,6 +32,8 @@ const props = defineProps<{
copyable?: boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
errorActionDisabled?: boolean;
errorActionLabel?: string;
imageLoader?: ChatImageLoader;
item: ChatTimelineItem;
regenerable?: boolean;
@@ -41,6 +44,7 @@ const props = defineProps<{
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
@@ -280,7 +284,10 @@ function handleCopyAction() {
</div>
<ChatErrorNotice
v-else-if="item.type === 'error'"
:action-disabled="errorActionDisabled"
:action-label="errorActionLabel"
:message="item.message"
@action="emit('errorAction', item)"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More