Compare commits

...

25 Commits

Author SHA1 Message Date
cb379e071c chore: 发布 easyflow 1.0.0
- 将 Maven revision 升级为 1.0.0

- 同步 easy-agents 依赖版本为 1.0.0
2026-06-05 14:06:17 +08:00
8b80770960 feat: 增加代码混淆支持 2026-06-05 13:53:26 +08:00
c316eff5be feat: 归档 XL10 异步工具业务编译层
- 将 AgentDefinitionCompiler 升级为 AgentRuntimeCompiler

- 接入 Workflow 和 Plugin 的同步/异步工具编译与 Redis 任务态

- 增加异步执行配置开关、聊天时间线聚合和后端测试
2026-06-04 15:23:56 +08:00
1ea863cb2c chore: 调整 Dockerfile 构造 2026-05-31 20:11:38 +08:00
0f4d10c43c feat: 增强多实例分布式部署兼容
- 增加定时任务分布式锁并覆盖 chatlog、文档导入和 Agent HITL 过期扫描

- 增强 Redis MQ 多实例 consumer 标识、pending reclaim 和单条处理能力

- 增加文档导入状态 Redis 广播和 Agent HITL 跨节点路由确认
2026-05-29 18:27:46 +08:00
cc3bb9cff0 feat: 完成 Agent MCP 对接
- 增加 MCP 连接类型、环境检测接口和容器运行环境支持

- 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec

- 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
2026-05-29 11:09:21 +08:00
e39f7521e2 chore: 弃用 bot 对接的 mcptool 2026-05-28 11:30:56 +08:00
1c205c3720 feat: 先进智能体功能上线
- 基于 agent-runtime 打造,默认 ReAct agent
- 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力
- 全新 agent 编排界面,支持可视化便捷配置 agent
- 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
2026-05-28 11:29:18 +08:00
11e595b088 perf: 收敛后端资源与健康检查开销
- 缩小模块扫描范围并显式注册各业务模块自动配置

- 增加可配置线程池、MQ 连接池与消费线程池,降低默认资源占用

- 将 RAG 与分析库中间件探活下沉到健康检查并增加短缓存

- 补齐文档向量库生命周期释放与 SSE 断连清理
2026-05-28 11:22:14 +08:00
72df00f25b feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
2026-05-25 11:42:48 +08:00
6c3d98eaac feat: license校验方式增加 2026-05-18 10:03:19 +08:00
b7f3ae2854 fix: 修复管理端类型检查错误
- 为分页数据列表补充明确行类型

- 修正插件数组默认值解析的类型转换

- 删除 tinyflow 未使用辅助函数
2026-05-18 10:00:41 +08:00
2907acac95 feat: 默认选择首个聊天智能体
- 拉取智能体列表后在无会话上下文时复用现有切换逻辑选择第一个智能体

- 保留历史会话和已选智能体的优先级
2026-05-18 10:00:25 +08:00
0947009ee6 feat: 统一聊天 Markdown 渲染
- 新增 ChatTimeMarkdown 并在管理端、用户端聊天入口复用

- 收敛聊天页面重复 Markdown 样式,保留工具和思考块独立渲染
2026-05-18 09:59:59 +08:00
a186066641 调整菜单顺序 2026-05-14 21:31:35 +08:00
1a6ea64e80 feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力

- 支持基于指定轮次重新生成并同步前后端多版本状态

- 保留 application.yml 与本地截图文件为未提交状态
2026-05-14 21:23:20 +08:00
da58077d59 feat: 完成工作流 Public API 授权闭环
- 新增访问令牌工作流 API 全局授权与 Public Workflow API 权限断言

- 补齐 API Key 执行记录归属、状态查询与下线后不可恢复边界

- 增加管理端接口调用说明与访问令牌授权开关
2026-05-14 20:41:34 +08:00
47c2bad839 feat: 完成管理端聊天工作台收口
- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
2026-05-14 20:22:46 +08:00
2ad8935a61 chore: 清理根目录废弃协议与配置文件
- 删除未再使用的聊天协议草稿与许可证说明文档

- 移除根目录废弃的 lefthook 模板配置,保留子工作区自有配置
2026-05-11 21:26:34 +08:00
21b1bc82f6 feat: 增加工作流英文名格式校验
- 在工作流弹窗中要求 englishName 必填且只允许字母数字下划线和中划线
2026-05-11 21:26:00 +08:00
4a15124183 feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
2026-05-11 21:25:21 +08:00
e27834ee0c fix: 统一适配 DeepSeek 工具与思考历史回传
- 结构化持久化 assistant/tool 历史,恢复真实消息链回放

- 为 DeepSeek 显式开启 thinking 协议配置,并补齐 public-api 与工具英文名兜底

- 增加聊天历史回放、tool 名称与请求参数解析相关测试
2026-05-11 21:24:20 +08:00
c1590b0d8a feat: 收口聊天时知识库工具可见性
- 新增 chatTime 工具可见性抽象与知识库 resolver

- 聊天装配链路按当前用户过滤知识库工具并补齐调用兜底

- 补充聊天时显式登录快照与对应后端测试
2026-05-11 20:54:13 +08:00
ff863e3c27 feat: 增加 EasyFlow license 启动校验能力
- 新增 license 解析、验签、机器指纹校验与失败日志分流

- 支持开发态 classpath 读取与打包排除 lic 资源
2026-05-10 17:07:24 +08:00
516d43ce7d feat: 增强管理端工作台用户活跃榜能力
- 新增用户活跃榜接口、筛选与导出能力

- 支持按智能体过滤排行榜并补充前后端测试
2026-05-10 17:06:22 +08:00
444 changed files with 55673 additions and 2602 deletions

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ build/
.jlsp/ .jlsp/
.arts/ .arts/
luceneKnowledge luceneKnowledge
**/*.lic
# v1 # v1
/easyflow-ui-react /easyflow-ui-react

View File

@@ -1,3 +1,4 @@
# 后端构建脚本
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
@@ -8,12 +9,40 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
ENV EASYFLOW_LOG_FILE=/app/logs/app.log ENV EASYFLOW_LOG_FILE=/app/logs/app.log
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30 ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
WORKDIR /app WORKDIR /app
RUN useradd --system --create-home easyflow && \ RUN useradd --system --create-home easyflow && \
apt-get update && \ apt-get update && \
apt-get install -y --no-install-recommends python3 inotify-tools tini && \ apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource.gpg.key && \
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource.gpg.key && \
chmod 644 /etc/apt/keyrings/nodesource.gpg && \
printf "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main\n" > /etc/apt/sources.list.d/nodesource.list && \
rm -f /tmp/nodesource.gpg.key && \
apt-get update && \
apt-get install -y --no-install-recommends \
inotify-tools \
nodejs \
python3 \
python3-pip \
python3-venv \
tini && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
npm install -g pnpm@10.17.1 && \
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
mkdir -p /etc/pip && \
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/logs /app/artifacts /app/data && \ mkdir -p /app/logs /app/artifacts /app/data && \
chown -R easyflow:easyflow /app chown -R easyflow:easyflow /app

View File

@@ -0,0 +1,61 @@
-dontshrink
-dontoptimize
-dontpreverify
-ignorewarnings
-dontnote
-libraryjars <java.home>/jmods/java.base.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.compiler.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.datatransfer.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.desktop.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.instrument.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.logging.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.management.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.naming.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.net.http.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.prefs.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.rmi.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.scripting.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.security.jgss.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.security.sasl.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.sql.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.transaction.xa.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.xml.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.xml.crypto.jmod(!**.jar;!module-info.class)
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod,Record,SourceFile,LineNumberTable,MethodParameters
-keep @org.springframework.stereotype.Controller class * { *; }
-keep @org.springframework.web.bind.annotation.RestController class * { *; }
-keep @org.springframework.context.annotation.Configuration class * { *; }
-keep @org.springframework.boot.context.properties.ConfigurationProperties class * { *; }
-keep @org.springframework.boot.autoconfigure.SpringBootApplication class * { *; }
-keep class **.*Controller { *; }
-keep class **.*Mapper { *; }
-keep class **.mapper.** { *; }
-keep class **.entity.** { *; }
-keep class **.dto.** { *; }
-keep class **.vo.** { *; }
-keep class **.model.** { *; }
-keep class **.config.** { *; }
-keep class **.enums.** { *; }
-keep class **.annotation.** { *; }
-keep class **.*Exception { *; }
-keep class **.*ErrorCode { *; }
-keep class **.*Properties { *; }
-keep class **.*Config { *; }
-keep class **.*Configuration { *; }
-keep interface tech.easyflow.** { *; }
-keep enum tech.easyflow.** { *; }
-keepclassmembers class * {
@jakarta.annotation.Resource <fields>;
@org.springframework.beans.factory.annotation.Autowired <fields>;
@org.springframework.beans.factory.annotation.Value <fields>;
@org.springframework.context.annotation.Bean <methods>;
}
-keepclassmembers class * {
public <init>(...);
}

View File

@@ -0,0 +1,28 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.ai.chattime.** { *; }
-keep class tech.easyflow.ai.constants.** { *; }
-keep class tech.easyflow.ai.document.** { *; }
-keep class tech.easyflow.ai.documentimport.** { *; }
-keep class tech.easyflow.ai.easyagents.** { *; }
-keep class tech.easyflow.ai.exception.** { *; }
-keep class tech.easyflow.ai.mcp.** { *; }
-keep class tech.easyflow.ai.node.** { *; }
-keep class tech.easyflow.ai.permission.** { *; }
-keep class tech.easyflow.ai.plugin.** { *; }
-keep class tech.easyflow.ai.publish.** { *; }
-keep class tech.easyflow.ai.rag.** { *; }
-keep class tech.easyflow.ai.service.** { *; }
-keep class tech.easyflow.ai.support.** { *; }
-keep class tech.easyflow.ai.utils.** { *; }
-keep class tech.easyflow.ai.invoke.service.** { *; }
-keep class tech.easyflow.ai.invoke.model.** { *; }
-keep class tech.easyflow.ai.invoke.protocol.** { *; }
-keep class tech.easyflow.ai.invoke.exception.** { *; }
-keep class tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper { *; }
-keep class tech.easyflow.ai.invoke.provider.ModelProviderGateway { *; }
-keep class tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver { *; }
-keep class tech.easyflow.ai.easyagentsflow.config.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.entity.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.service.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.support.** { *; }

View File

@@ -0,0 +1,5 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseBootstrapValidator { *; }
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseProperties { *; }
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseVerificationResult { *; }

View File

@@ -0,0 +1,10 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.datacenter.connector.DatacenterConnector { *; }
-keep class tech.easyflow.datacenter.connector.QueryExecutor { *; }
-keep class tech.easyflow.datacenter.connector.WriteExecutor { *; }
-keep class tech.easyflow.datacenter.connector.MetadataExplorer { *; }
-keep class tech.easyflow.datacenter.connector.SourceHealthChecker { *; }
-keep class tech.easyflow.datacenter.connector.SqlDialect { *; }
-keep class tech.easyflow.datacenter.execution.model.** { *; }
-keep class tech.easyflow.datacenter.meta.enums.** { *; }

View File

@@ -20,6 +20,10 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId> <artifactId>easyflow-module-ai</artifactId>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId> <artifactId>easyflow-module-chatlog</artifactId>

View File

@@ -0,0 +1,87 @@
package tech.easyflow.admin.controller.agent;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentCategory;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.service.AgentCategoryService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import javax.annotation.Resource;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Agent 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/agentCategory")
@UsePermission(moduleName = "/api/v1/agent")
public class AgentCategoryController extends BaseCurdController<AgentCategoryService, AgentCategory> {
@Resource
private AgentMapper agentMapper;
@Resource
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Agent 分类管理控制器。
*
* @param service Agent 分类服务
*/
public AgentCategoryController(AgentCategoryService service) {
super(service);
}
/**
* 查询当前用户可见的 Agent 分类。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return 可见分类列表
*/
@GetMapping("visibleList")
public Result<List<AgentCategory>> visibleList(AgentCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
if (access.isRestricted()) {
if (access.getCategoryIds().isEmpty()) {
return Result.ok(Collections.emptyList());
}
queryWrapper.in("id", access.getCategoryIds());
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
}
/**
* 删除分类前校验是否仍被 Agent 使用。
*
* @param ids 分类 ID 集合
* @return 校验结果
*/
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
if (agents != null && !agents.isEmpty()) {
throw new BusinessException("请先删除该分类下的所有 Agent");
}
}
return super.onRemoveBefore(ids);
}
}

View File

@@ -0,0 +1,351 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.publish.AgentPublishAppService;
import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.agent.service.AgentApprovalStateService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT;
/**
* Agent 管理端控制器。
*/
@RestController
@RequestMapping("/api/v1/agent")
public class AgentController extends BaseCurdController<AgentService, Agent> {
@Resource
private AgentToolBindingService agentToolBindingService;
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource
private AgentRunService agentRunService;
@Resource
private AgentPublishAppService agentPublishAppService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private AgentApprovalStateService agentApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
/**
* 创建 Agent 控制器。
*
* @param service Agent 服务
*/
public AgentController(AgentService service) {
super(service);
}
/**
* 获取 Agent 详情。
*
* @param id Agent ID
* @return Agent 详情
*/
@GetMapping("/getDetail")
public Result<Agent> getDetail(BigInteger id) {
Agent agent = service.getDetail(id);
agentApprovalStateService.fillAgentApprovalState(agent);
return Result.ok(agent);
}
/**
* 保存 Agent 草稿。
*
* @param agent Agent 草稿
* @return Agent 详情
*/
@Override
@PostMapping("save")
public Result<?> save(@JsonBody Agent agent) {
return Result.ok(service.saveDraft(agent));
}
/**
* 更新 Agent 草稿。
*
* @param agent Agent 草稿
* @return Agent 详情
*/
@Override
@PostMapping("update")
public Result<?> update(@JsonBody Agent agent) {
return Result.ok(service.updateDraft(agent));
}
/**
* 查询 Agent 列表。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return Agent 列表
*/
@Override
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
HttpServletRequest request = currentRequest();
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
if (!applyCategoryPermission(queryWrapper)) {
return Result.ok(Collections.emptyList());
}
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Agent> agents = service.list(queryWrapper);
if (isPublishedOnlyRequest()) {
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
}
agentApprovalStateService.fillAgentApprovalState(agents);
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
return Result.ok(agents);
}
/**
* 运行 Agent 纯文本聊天。
*
* @param request 聊天请求
* @return SSE Emitter
*/
@PostMapping("chat")
public SseEmitter chat(@JsonBody AgentChatRequest request) {
return agentRunService.chat(request);
}
/**
* 运行 Agent 草稿态纯文本试用。
*
* @param request 草稿试用请求
* @return SSE Emitter
*/
@PostMapping("/chat/draft")
public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) {
return agentRunService.chatDraft(request);
}
/**
* 清理 Agent 草稿试运行会话。
*
* @param sessionId 草稿试运行会话 ID
* @return 操作结果
*/
@PostMapping("/chat/draft/clear")
public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) {
agentRunService.clearDraftSession(sessionId);
return Result.ok();
}
/**
* 批准工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @return 操作结果
*/
@PostMapping("/run/approve")
public Result<Void> approve(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken) {
agentRunService.approve(requestId, resumeToken);
return Result.ok();
}
/**
* 拒绝工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param reason 拒绝原因
* @return 操作结果
*/
@PostMapping("/run/reject")
public Result<Void> reject(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken,
@JsonBody("reason") String reason) {
agentRunService.reject(requestId, resumeToken, reason);
return Result.ok();
}
/**
* 更新 Agent 工具绑定。
*
* @param agentId Agent ID
* @param bindings 工具绑定
* @return 保存后的启用绑定
*/
@PostMapping("/toolBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentToolBinding>> updateToolBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
@JsonBody("bindings") List<AgentToolBinding> bindings) {
return Result.ok(agentToolBindingService.replaceBindings(agentId, bindings));
}
/**
* 更新 Agent 知识库绑定。
*
* @param agentId Agent ID
* @param bindings 知识库绑定
* @return 保存后的启用绑定
*/
@PostMapping("/knowledgeBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentKnowledgeBinding>> updateKnowledgeBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
@JsonBody("bindings") List<AgentKnowledgeBinding> bindings) {
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
}
/**
* 提交发布审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/agent/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
}
/**
* 提交下线审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/agent/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
* 提交删除审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/agent/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
Agent agent = service.getById(String.valueOf(id));
if (agent != null) {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent");
}
}
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
return super.onRemoveBefore(ids);
}
/**
* 查询 Agent 分页。
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return Agent 分页
*/
@Override
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
if (!applyCategoryPermission(queryWrapper)) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
}
applyPublishedOnlyFilter(queryWrapper);
Page<Agent> result = super.queryPage(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
}
agentApprovalStateService.fillAgentApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
return result;
}
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
if (!access.isRestricted()) {
return true;
}
if (access.getCategoryIds().isEmpty()) {
queryWrapper.eq(Agent::getCreatedBy, access.getAccountId());
return true;
}
queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds())));
return true;
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest();
if (request == null) {
return false;
}
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
}
/**
* 获取当前 HTTP 请求。
*
* @return 当前请求,不在 Web 请求上下文中时返回 null
*/
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
return attributes.getRequest();
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
}
}

View File

@@ -0,0 +1,136 @@
package tech.easyflow.admin.controller.agent;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.service.agent.AgentSessionService;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
/**
* Agent 管理端会话控制器。
*/
@RestController
@RequestMapping("/api/v1/agent/session")
public class AgentSessionController {
private final AgentSessionService agentSessionService;
/**
* 创建 Agent 管理端会话控制器。
*
* @param agentSessionService Agent 会话服务
*/
public AgentSessionController(AgentSessionService agentSessionService) {
this.agentSessionService = agentSessionService;
}
/**
* 生成 Agent 会话 ID。
*
* @return 会话 ID 字符串
*/
@GetMapping("/generateId")
public Result<String> generateId() {
long nextId = new SnowFlakeIDKeyGenerator().nextId();
return Result.ok(String.valueOf(nextId));
}
/**
* 查询 Agent 会话分页。
*
* @param agentId Agent ID可为空
* @param query 分页参数
* @return 会话分页
*/
@GetMapping("/list")
public Result<ChatWorkspaceSessionPage> list(BigInteger agentId, ChatPageQuery query) {
return Result.ok(agentSessionService.queryCurrentUserSessions(currentAccount(), agentId, query));
}
/**
* 查询 Agent 会话详情。
*
* @param sessionId 会话 ID
* @return 会话详情
*/
@GetMapping("/{sessionId}")
public Result<ChatWorkspaceSessionDetailView> detail(@PathVariable BigInteger sessionId) {
return Result.ok(agentSessionService.getCurrentUserSession(currentAccount(), sessionId));
}
/**
* 查询 Agent 会话消息。
*
* @param sessionId 会话 ID
* @param query 分页参数
* @return 消息分页
*/
@GetMapping("/{sessionId}/messages")
public Result<ChatHistoryPage> messages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(agentSessionService.queryCurrentUserMessages(currentAccount(), sessionId, query));
}
/**
* 查询 Agent 完整会话。
*
* @param sessionId 会话 ID
* @return 完整会话
*/
@GetMapping("/{sessionId}/conversation")
public Result<ChatWorkspaceConversationView> conversation(@PathVariable BigInteger sessionId) {
return Result.ok(agentSessionService.getCurrentUserConversation(currentAccount(), sessionId));
}
/**
* 重命名 Agent 会话。
*
* @param sessionId 会话 ID
* @param title 新标题
* @return 操作结果
*/
@PostMapping("/{sessionId}/rename")
public Result<Void> rename(@PathVariable BigInteger sessionId,
@JsonBody(value = "title", required = true) String title) {
agentSessionService.renameCurrentUserSession(currentAccount(), sessionId, title);
return Result.ok();
}
/**
* 保存 Agent 会话临时知识库。
*
* @param sessionId 会话 ID
* @param knowledgeIds 临时知识库 ID
* @return 操作结果
*/
@PostMapping("/{sessionId}/extraKnowledges")
public Result<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
}
/**
* 删除 Agent 会话。
*
* @param sessionId 会话 ID
* @return 操作结果
*/
@PostMapping("/{sessionId}/delete")
public Result<Void> delete(@PathVariable BigInteger sessionId) {
agentSessionService.deleteCurrentUserSession(currentAccount(), sessionId);
return Result.ok();
}
private LoginAccount currentAccount() {
return SaTokenUtil.getLoginAccount();
}
}

View File

@@ -13,12 +13,17 @@ import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
import tech.easyflow.ai.entity.*; import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.BotPublishAppService; import tech.easyflow.ai.publish.BotPublishAppService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.*;
@@ -30,9 +35,11 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.chatlog.service.ChatRoundOperateService;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.chat.protocol.sse.ChatSseUtil; import tech.easyflow.core.chat.protocol.sse.ChatSseUtil;
import tech.easyflow.core.runtime.ChatChannel; import tech.easyflow.core.runtime.ChatChannel;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
@@ -73,9 +80,13 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Resource @Resource
private BotPublishAppService botPublishAppService; private BotPublishAppService botPublishAppService;
@Resource @Resource
private ChatRoundOperateService chatRoundOperateService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService; private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource @Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private ChatWorkspaceService chatWorkspaceService;
public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService, public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) { BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) {
@@ -161,13 +172,30 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@JsonBody(value = "botId", required = true) BigInteger botId, @JsonBody(value = "botId", required = true) BigInteger botId,
@JsonBody(value = "conversationId", required = true) BigInteger conversationId, @JsonBody(value = "conversationId", required = true) BigInteger conversationId,
@JsonBody(value = "messages") List<Map<String, String>> messages, @JsonBody(value = "messages") List<Map<String, String>> messages,
@JsonBody(value = "attachments") List<String> attachments @JsonBody(value = "attachments") List<String> attachments,
@JsonBody(value = "publishedOnly") Boolean publishedOnly,
@JsonBody(value = "extraKnowledgeIds") List<BigInteger> extraKnowledgeIds,
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
) { ) {
boolean usePublishedOnly = Boolean.TRUE.equals(publishedOnly);
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult(); BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
if (usePublishedOnly) {
chatWorkspaceService.assertSessionContinuable(requireCurrentLoginAccount(), conversationId, botId);
}
if (regenerateRoundId != null) {
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
}
// 前置校验失败则直接返回错误SseEmitter // 前置校验失败则直接返回错误SseEmitter
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult); SseEmitter errorEmitter = botService.checkChatBeforeStart(
botId,
prompt,
conversationId.toString(),
chatCheckResult,
usePublishedOnly,
regenerateRoundId
);
if (errorEmitter != null) { if (errorEmitter != null) {
return errorEmitter; return errorEmitter;
} }
@@ -178,7 +206,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
messages, messages,
chatCheckResult, chatCheckResult,
attachments, attachments,
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments) buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, extraKnowledgeIds, regenerateRoundId)
); );
} }
@@ -193,16 +221,24 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@GetMapping("getDetail") @GetMapping("getDetail")
@SaIgnore @SaIgnore
public Result<Bot> getDetail(String id) { public Result<Bot> getDetail(String id) {
Bot bot = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id); boolean publishedOnly = isPublishedOnlyRequest();
if (bot != null && StpUtil.isLogin()) { Bot rawBot = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
categoryPermissionService.assertCategoryResourceVisible("BOT", bot.getCreatedBy(), bot.getCategoryId(), "无权限访问聊天助手"); if (rawBot != null && StpUtil.isLogin()) {
categoryPermissionService.assertCategoryResourceVisible("BOT", rawBot.getCreatedBy(), rawBot.getCategoryId(), "无权限访问聊天助手");
} }
if (bot == null) { if (rawBot == null) {
return Result.ok(null); return Result.ok(null);
} }
if (!StpUtil.isLogin() && !tech.easyflow.ai.enums.PublishStatus.from(bot.getPublishStatus()).isExternallyVisible()) { if (!StpUtil.isLogin() && !PublishStatus.from(rawBot.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("聊天助手尚未发布"); throw new BusinessException("聊天助手尚未发布");
} }
Bot bot = rawBot;
if (publishedOnly && StpUtil.isLogin()) {
if (PublishStatus.from(rawBot.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("聊天助手尚未发布");
}
bot = botService.toPublishedView(rawBot);
}
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(bot); aiResourceApprovalStateService.fillBotApprovalState(bot);
} }
@@ -212,17 +248,25 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Override @Override
@SaIgnore @SaIgnore
public Result<Bot> detail(String id) { public Result<Bot> detail(String id) {
Bot data = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id); boolean publishedOnly = isPublishedOnlyRequest();
if (data == null) { Bot rawData = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
return Result.ok(data); if (rawData == null) {
return Result.ok(rawData);
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
categoryPermissionService.assertCategoryResourceVisible("BOT", data.getCreatedBy(), data.getCategoryId(), "无权限访问聊天助手"); categoryPermissionService.assertCategoryResourceVisible("BOT", rawData.getCreatedBy(), rawData.getCategoryId(), "无权限访问聊天助手");
} }
if (!StpUtil.isLogin() && !tech.easyflow.ai.enums.PublishStatus.from(data.getPublishStatus()).isExternallyVisible()) { if (!StpUtil.isLogin() && !PublishStatus.from(rawData.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("聊天助手尚未发布"); throw new BusinessException("聊天助手尚未发布");
} }
Bot data = rawData;
if (publishedOnly && StpUtil.isLogin()) {
if (PublishStatus.from(rawData.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("聊天助手尚未发布");
}
data = botService.toPublishedView(rawData);
}
Map<String, Object> llmOptions = data.getModelOptions(); Map<String, Object> llmOptions = data.getModelOptions();
if (llmOptions == null) { if (llmOptions == null) {
@@ -297,8 +341,12 @@ public class BotController extends BaseCurdController<BotService, Bot> {
public Result<List<Bot>> list(Bot entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Bot>> list(Bot entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Bot> bots = service.list(queryWrapper); List<Bot> bots = service.list(queryWrapper);
if (isPublishedOnlyRequest()) {
bots = bots.stream().map(botService::toPublishedView).toList();
}
aiResourceApprovalStateService.fillBotApprovalState(bots); aiResourceApprovalStateService.fillBotApprovalState(bots);
return Result.ok(bots); return Result.ok(bots);
} }
@@ -306,7 +354,11 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Override @Override
protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) { protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
Page<Bot> result = super.queryPage(page, queryWrapper); Page<Bot> result = super.queryPage(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
}
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords()); aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords()); aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
return result; return result;
@@ -406,24 +458,55 @@ public class BotController extends BaseCurdController<BotService, Bot> {
return result; return result;
} }
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) { private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
LoginAccount account = SaTokenUtil.getLoginAccount(); List<BigInteger> extraKnowledgeIds,
BigInteger regenerateRoundId) {
LoginAccount account = requireCurrentLoginAccount();
ChatRuntimeContext context = new ChatRuntimeContext(); ChatRuntimeContext context = new ChatRuntimeContext();
context.setChannel(ChatChannel.ADMIN); context.setChannel(ChatChannel.ADMIN);
context.setSessionId(conversationId); context.setSessionId(conversationId);
context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId()); context.setTenantId(account.getTenantId());
context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId()); context.setDeptId(account.getDeptId());
context.setUserId(account == null ? BigInteger.ZERO : account.getId()); context.setUserId(account.getId());
context.setUserAccount(account == null ? "admin" : account.getLoginName()); context.setUserAccount(account.getLoginName());
context.setUserName(account == null ? "管理员" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName())); context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName());
context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId()); context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId());
context.setAssistantCode(bot == null ? null : bot.getAlias()); context.setAssistantCode(bot == null ? null : bot.getAlias());
context.setAssistantName(bot == null ? null : bot.getTitle()); context.setAssistantName(bot == null ? null : bot.getTitle());
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt); context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
context.setAttachments(attachments); context.setAttachments(attachments);
if (extraKnowledgeIds != null) {
context.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, extraKnowledgeIds);
}
if (regenerateRoundId != null) {
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
}
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
return context; return context;
} }
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
private boolean isPublishedOnlyRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return false;
}
return "true".equalsIgnoreCase(attributes.getRequest().getParameter("publishedOnly"));
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
@Override @Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) { protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids); QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids);

View File

@@ -2,17 +2,23 @@ package tech.easyflow.admin.controller.ai;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage; import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery; import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.service.ChatHistoryManageService; import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/chatHistory") @RequestMapping("/api/v1/chatHistory")
@@ -38,4 +44,18 @@ public class ChatHistoryController {
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) { public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query)); return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
} }
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) {
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId));
}
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(sessionId, roundId, variantIndex, account.getId()));
}
} }

View File

@@ -0,0 +1,86 @@
package tech.easyflow.admin.controller.ai;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 管理端聊天工作台控制器。
*/
@RestController
@RequestMapping("/api/v1/chatWorkspace")
public class ChatWorkspaceController {
private final ChatWorkspaceService chatWorkspaceService;
public ChatWorkspaceController(ChatWorkspaceService chatWorkspaceService) {
this.chatWorkspaceService = chatWorkspaceService;
}
@GetMapping("/sessions")
public Result<ChatWorkspaceSessionPage> listSessions(BigInteger assistantId, ChatPageQuery query) {
return Result.ok(chatWorkspaceService.queryCurrentUserSessions(currentAccount(), assistantId, query));
}
@GetMapping("/sessions/{sessionId}")
public Result<ChatWorkspaceSessionDetailView> getSession(@PathVariable BigInteger sessionId) {
return Result.ok(chatWorkspaceService.getCurrentUserSession(currentAccount(), sessionId));
}
@GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(chatWorkspaceService.queryCurrentUserMessages(currentAccount(), sessionId, query));
}
@GetMapping("/sessions/{sessionId}/conversation")
public Result<ChatWorkspaceConversationView> getConversation(@PathVariable BigInteger sessionId) {
return Result.ok(chatWorkspaceService.getCurrentUserConversation(currentAccount(), sessionId));
}
@PostMapping("/sessions/{sessionId}/rename")
public Result<Void> renameSession(@PathVariable BigInteger sessionId,
@JsonBody(value = "title", required = true) String title) {
chatWorkspaceService.renameCurrentUserSession(currentAccount(), sessionId, title);
return Result.ok();
}
@PostMapping("/sessions/{sessionId}/delete")
public Result<Void> deleteSession(@PathVariable BigInteger sessionId) {
chatWorkspaceService.deleteCurrentUserSession(currentAccount(), sessionId);
return Result.ok();
}
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) {
return Result.ok(chatWorkspaceService.listCurrentUserRoundVariants(currentAccount(), sessionId, roundId));
}
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
return Result.ok(chatWorkspaceService.selectCurrentUserRoundVariant(currentAccount(), sessionId, roundId, variantIndex));
}
private LoginAccount currentAccount() {
return SaTokenUtil.getLoginAccount();
}
}

View File

@@ -9,6 +9,7 @@ import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService; import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
@@ -93,22 +94,26 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
if (documentStore == null) { if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库"); return Result.fail(2, "知识库没有配置向量库");
} }
// 设置向量模型 try {
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId()); // 设置向量模型
if (model == null) { Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
return Result.fail(3, "知识库没有配置向量模型"); if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
Document document = Document.of(documentChunk.getContent());
document.setId(documentChunk.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("keywords", documentChunk.getMetadataKeyWords());
metadata.put("questions", documentChunk.getMetadataQuestions());
document.setMetadataMap(metadata);
StoreResult result = documentStore.update(document, options); // 更新已有记录
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
} }
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
Document document = Document.of(documentChunk.getContent());
document.setId(documentChunk.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("keywords", documentChunk.getMetadataKeyWords());
metadata.put("questions", documentChunk.getMetadataQuestions());
document.setMetadataMap(metadata);
StoreResult result = documentStore.update(document, options); // 更新已有记录
return Result.ok(result);
} }
return Result.ok(false); return Result.ok(false);
} }
@@ -135,19 +140,23 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
if (documentStore == null) { if (documentStore == null) {
return Result.fail(3, "知识库没有配置向量库"); return Result.fail(3, "知识库没有配置向量库");
} }
// 设置向量模型 try {
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId()); // 设置向量模型
if (model == null) { Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
return Result.fail(4, "知识库没有配置向量模型"); if (model == null) {
} return Result.fail(4, "知识库没有配置向量模型");
EmbeddingModel embeddingModel = model.toEmbeddingModel(); }
documentStore.setEmbeddingModel(embeddingModel); EmbeddingModel embeddingModel = model.toEmbeddingModel();
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection()); documentStore.setEmbeddingModel(embeddingModel);
List<BigInteger> deleteList = new ArrayList<>(); StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
deleteList.add(chunkId); List<BigInteger> deleteList = new ArrayList<>();
documentStore.delete(deleteList, options); deleteList.add(chunkId);
documentChunkService.removeChunk(knowledge, chunkId); documentStore.delete(deleteList, options);
documentChunkService.removeChunk(knowledge, chunkId);
return super.remove(chunkId); return super.remove(chunkId);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
} }
} }

View File

@@ -1,5 +1,6 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -64,6 +65,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return Result.ok(service.getMcpTools(id)); return Result.ok(service.getMcpTools(id));
} }
@PostMapping("/check")
public Result<McpEnvironmentCheckResult> check(@JsonBody("configJson") String configJson) {
return Result.ok(service.checkMcp(configJson));
}
@GetMapping("pageTools") @GetMapping("pageTools")
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) { public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {

View File

@@ -1,15 +1,20 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult; import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService; import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/public-chat") @RequestMapping("/api/v1/public-chat")
@@ -35,4 +40,24 @@ public class PublicChatSessionController {
); );
return Result.ok(result); return Result.ok(result);
} }
@GetMapping("/session/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
BigInteger botId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
BigInteger userId = account == null ? null : account.getId();
return Result.ok(publicChatSessionRestoreService.listVariants(userId, botId, sessionId, roundId));
}
@PostMapping("/session/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
BigInteger botId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
BigInteger userId = account == null ? null : account.getId();
BigInteger operatorId = account == null ? BigInteger.ZERO : account.getId();
return Result.ok(publicChatSessionRestoreService.selectVariant(userId, botId, sessionId, roundId, variantIndex, operatorId));
}
} }

View File

@@ -42,6 +42,7 @@ import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.KnowledgeShareAuditService; import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeShareService; import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.vo.FaqImportResultVo; import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext; import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail; import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
@@ -520,19 +521,23 @@ public class ShareKnowledgeController {
if (documentStore == null) { if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库"); return Result.fail(2, "知识库没有配置向量库");
} }
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId()); try {
if (model == null) { Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
return Result.fail(3, "知识库没有配置向量模型"); if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
} }
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
} }
return Result.ok(false); return Result.ok(false);
} }
@@ -559,17 +564,21 @@ public class ShareKnowledgeController {
if (documentStore == null) { if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库"); return Result.fail(2, "知识库没有配置向量库");
} }
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId()); try {
if (model == null) { Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
return Result.fail(3, "知识库没有配置向量模型"); if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
} }
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(true);
} }
/** /**

View File

@@ -1,6 +1,7 @@
package tech.easyflow.admin.controller.ai.support; package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
@@ -66,6 +67,15 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName); fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName);
} }
/**
* 批量填充 Agent 创建人名称。
*
* @param agents Agent 集合
*/
public void fillAgentCreatorNames(Collection<Agent> agents) {
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
}
/** /**
* 通用的创建人名称填充逻辑。 * 通用的创建人名称填充逻辑。
* *

View File

@@ -1,15 +1,25 @@
package tech.easyflow.admin.controller.dashboard; package tech.easyflow.admin.controller.dashboard;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService; import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
/** /**
* 管理端工作台统计接口。 * 管理端工作台统计接口。
*/ */
@@ -28,4 +38,26 @@ public class DashboardController {
public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) { public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) {
return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query)); return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query));
} }
@GetMapping("/user-ranks")
@SaCheckPermission("/api/v1/dashboard/query")
public Result<List<DashboardUserRankItemVo>> userRanks(DashboardUserRankQuery query) {
return Result.ok(dashboardService.getUserRanks(SaTokenUtil.getLoginAccount(), query));
}
@GetMapping(value = "/user-ranks/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
@SaCheckPermission("/api/v1/dashboard/query")
public void exportUserRanks(DashboardUserRankQuery query, HttpServletResponse response) throws Exception {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String fileName = URLEncoder.encode("dashboard_user_ranks_" + timestamp, "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
dashboardService.exportUserRanks(
SaTokenUtil.getLoginAccount(),
query,
response.getOutputStream()
);
}
} }

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService; import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -46,6 +47,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService; private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
@Resource @Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService; private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
/** /**
* 添加(保存)数据 * 添加(保存)数据
* *
@@ -88,6 +91,9 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
if (entity.getKnowledgeShareEnabled() != null) { if (entity.getKnowledgeShareEnabled() != null) {
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled()); knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
} }
if (entity.getWorkflowApiEnabled() != null) {
workflowApiPermissionService.replaceWorkflowApiEnabled(entity.getId(), entity.getWorkflowApiEnabled());
}
} }
@Override @Override
@@ -129,5 +135,11 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId()) .eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE"); .eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0); entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
QueryWrapper workflowWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0);
} }
} }

View File

@@ -1,12 +1,17 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.system.entity.SysApiKeyResource; import tech.easyflow.system.entity.SysApiKeyResource;
import tech.easyflow.system.service.SysApiKeyResourceService; import tech.easyflow.system.service.SysApiKeyResourceService;
import java.util.List;
/** /**
* 请求接口表 控制层。 * 请求接口表 控制层。
* *
@@ -20,4 +25,26 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
public SysApiKeyResourceController(SysApiKeyResourceService service) { public SysApiKeyResourceController(SysApiKeyResourceService service) {
super(service); super(service);
} }
/**
* 查询普通 API Key 接口授权资源。
*
* <p>工作流 Public API 使用独立的全局授权开关,不进入普通接口授权列表,避免用户误以为勾选
* 具体接口资源即可完成工作流调用授权。</p>
*
* @param entity 查询条件
* @param asTree 是否树形返回
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 普通接口授权资源
*/
@Override
@GetMapping("list")
public Result<List<SysApiKeyResource>> list(SysApiKeyResource entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/workflow/");
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/knowledge-share/");
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
}
} }

View File

@@ -0,0 +1,56 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 工作台助手展示快照。
*/
public class ChatWorkspaceAssistantView implements Serializable {
private BigInteger id;
private String alias;
private String title;
private String description;
private String icon;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.admin.dto.chatworkspace;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 管理端聊天工作台完整会话视图。
*/
public class ChatWorkspaceConversationView implements Serializable {
private long total;
private List<ChatMessageRecord> records = new ArrayList<>();
private Map<String, List<ChatMessageRecord>> variantsByRound = new LinkedHashMap<>();
/**
* 获取当前主线可见消息数量。
*
* @return 主线消息数量
*/
public long getTotal() {
return total;
}
/**
* 设置当前主线可见消息数量。
*
* @param total 主线消息数量
*/
public void setTotal(long total) {
this.total = total;
}
/**
* 获取当前主线可见消息。
*
* @return 当前主线可见消息
*/
public List<ChatMessageRecord> getRecords() {
return records;
}
/**
* 设置当前主线可见消息。
*
* @param records 当前主线可见消息
*/
public void setRecords(List<ChatMessageRecord> records) {
this.records = records == null ? new ArrayList<>() : records;
}
/**
* 获取按轮次分组的全部答案版本。
*
* @return roundId 到答案版本列表的映射
*/
public Map<String, List<ChatMessageRecord>> getVariantsByRound() {
return variantsByRound;
}
/**
* 设置按轮次分组的全部答案版本。
*
* @param variantsByRound roundId 到答案版本列表的映射
*/
public void setVariantsByRound(Map<String, List<ChatMessageRecord>> variantsByRound) {
this.variantsByRound = variantsByRound == null ? new LinkedHashMap<>() : variantsByRound;
}
}

View File

@@ -0,0 +1,56 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 工作台知识库展示对象。
*/
public class ChatWorkspaceKnowledgeView implements Serializable {
private BigInteger id;
private String alias;
private String title;
private String description;
private String icon;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.admin.dto.chatworkspace;
/**
* 管理端聊天工作台只读原因。
*/
public enum ChatWorkspaceReadOnlyReason {
ASSISTANT_OFFLINE,
ASSISTANT_DELETED,
NO_PERMISSION
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 工作台会话详情。
*/
public class ChatWorkspaceSessionDetailView extends ChatWorkspaceSessionView implements Serializable {
private ChatWorkspaceAssistantView assistant;
private List<ChatWorkspaceKnowledgeView> boundKnowledges = new ArrayList<>();
private List<ChatWorkspaceKnowledgeView> extraKnowledges = new ArrayList<>();
private List<String> removedExtraKnowledgeNames = new ArrayList<>();
public ChatWorkspaceAssistantView getAssistant() {
return assistant;
}
public void setAssistant(ChatWorkspaceAssistantView assistant) {
this.assistant = assistant;
}
public List<ChatWorkspaceKnowledgeView> getBoundKnowledges() {
return boundKnowledges;
}
public void setBoundKnowledges(List<ChatWorkspaceKnowledgeView> boundKnowledges) {
this.boundKnowledges = boundKnowledges == null ? new ArrayList<>() : boundKnowledges;
}
public List<ChatWorkspaceKnowledgeView> getExtraKnowledges() {
return extraKnowledges;
}
public void setExtraKnowledges(List<ChatWorkspaceKnowledgeView> extraKnowledges) {
this.extraKnowledges = extraKnowledges == null ? new ArrayList<>() : extraKnowledges;
}
public List<String> getRemovedExtraKnowledgeNames() {
return removedExtraKnowledgeNames;
}
public void setRemovedExtraKnowledgeNames(List<String> removedExtraKnowledgeNames) {
this.removedExtraKnowledgeNames = removedExtraKnowledgeNames == null ? new ArrayList<>() : removedExtraKnowledgeNames;
}
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 工作台会话分页结果。
*/
public class ChatWorkspaceSessionPage implements Serializable {
private Long total;
private Long pageNumber;
private Long pageSize;
private List<ChatWorkspaceSessionView> records = new ArrayList<>();
public Long getTotal() {
return total;
}
public void setTotal(Long total) {
this.total = total;
}
public Long getPageNumber() {
return pageNumber;
}
public void setPageNumber(Long pageNumber) {
this.pageNumber = pageNumber;
}
public Long getPageSize() {
return pageSize;
}
public void setPageSize(Long pageSize) {
this.pageSize = pageSize;
}
public List<ChatWorkspaceSessionView> getRecords() {
return records;
}
public void setRecords(List<ChatWorkspaceSessionView> records) {
this.records = records == null ? new ArrayList<>() : records;
}
}

View File

@@ -0,0 +1,111 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 工作台会话摘要。
*/
public class ChatWorkspaceSessionView implements Serializable {
private BigInteger sessionId;
private BigInteger assistantId;
private String assistantCode;
private String assistantName;
private String title;
private String lastMessagePreview;
private Integer messageCount;
private Date accessAt;
private Date lastMessageAt;
private Boolean continuable;
private ChatWorkspaceReadOnlyReason readOnlyReason;
public BigInteger getSessionId() {
return sessionId;
}
public void setSessionId(BigInteger sessionId) {
this.sessionId = sessionId;
}
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
public String getAssistantCode() {
return assistantCode;
}
public void setAssistantCode(String assistantCode) {
this.assistantCode = assistantCode;
}
public String getAssistantName() {
return assistantName;
}
public void setAssistantName(String assistantName) {
this.assistantName = assistantName;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getLastMessagePreview() {
return lastMessagePreview;
}
public void setLastMessagePreview(String lastMessagePreview) {
this.lastMessagePreview = lastMessagePreview;
}
public Integer getMessageCount() {
return messageCount;
}
public void setMessageCount(Integer messageCount) {
this.messageCount = messageCount;
}
public Date getAccessAt() {
return accessAt;
}
public void setAccessAt(Date accessAt) {
this.accessAt = accessAt;
}
public Date getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Date lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
public Boolean getContinuable() {
return continuable;
}
public void setContinuable(Boolean continuable) {
this.continuable = continuable;
}
public ChatWorkspaceReadOnlyReason getReadOnlyReason() {
return readOnlyReason;
}
public void setReadOnlyReason(ChatWorkspaceReadOnlyReason readOnlyReason) {
this.readOnlyReason = readOnlyReason;
}
}

View File

@@ -18,8 +18,6 @@ public class DashboardOverviewVo {
private List<DashboardDistributionItemVo> distribution; private List<DashboardDistributionItemVo> distribution;
private List<DashboardUserRankItemVo> userRanks;
private DashboardOverviewQuery query; private DashboardOverviewQuery query;
private Date updatedAt; private Date updatedAt;
@@ -64,14 +62,6 @@ public class DashboardOverviewVo {
this.assistantTrends = assistantTrends; this.assistantTrends = assistantTrends;
} }
public List<DashboardUserRankItemVo> getUserRanks() {
return userRanks;
}
public void setUserRanks(List<DashboardUserRankItemVo> userRanks) {
this.userRanks = userRanks;
}
public DashboardOverviewQuery getQuery() { public DashboardOverviewQuery getQuery() {
return query; return query;
} }

View File

@@ -9,14 +9,25 @@ public class DashboardUserRankItemVo {
private BigInteger userId; private BigInteger userId;
/**
* 最终展示名称。
*/
private String label; private String label;
/**
* 登录账号。
*/
private String loginName;
/**
* 昵称。
*/
private String nickname;
private Long sessionTotal; private Long sessionTotal;
private Long messageTotal; private Long messageTotal;
private Long assistantTotal;
public BigInteger getUserId() { public BigInteger getUserId() {
return userId; return userId;
} }
@@ -33,6 +44,22 @@ public class DashboardUserRankItemVo {
this.label = label; this.label = label;
} }
public String getLoginName() {
return loginName;
}
public void setLoginName(String loginName) {
this.loginName = loginName;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public Long getSessionTotal() { public Long getSessionTotal() {
return sessionTotal; return sessionTotal;
} }
@@ -48,12 +75,4 @@ public class DashboardUserRankItemVo {
public void setMessageTotal(Long messageTotal) { public void setMessageTotal(Long messageTotal) {
this.messageTotal = messageTotal; this.messageTotal = messageTotal;
} }
public Long getAssistantTotal() {
return assistantTotal;
}
public void setAssistantTotal(Long assistantTotal) {
this.assistantTotal = assistantTotal;
}
} }

View File

@@ -0,0 +1,46 @@
package tech.easyflow.admin.model.dashboard;
import java.math.BigInteger;
/**
* 用户活跃榜查询参数。
*/
public class DashboardUserRankQuery {
private String range;
private String startDate;
private String endDate;
private BigInteger assistantId;
public String getRange() {
return range;
}
public void setRange(String range) {
this.range = range;
}
public String getStartDate() {
return startDate;
}
public void setStartDate(String startDate) {
this.startDate = startDate;
}
public String getEndDate() {
return endDate;
}
public void setEndDate(String endDate) {
this.endDate = endDate;
}
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
}

View File

@@ -0,0 +1,420 @@
package tech.easyflow.admin.service.agent;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.admin.dto.chatworkspace.*;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.*;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.service.ChatSessionCommandService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.*;
/**
* Agent 管理端会话服务。
*/
@Service
public class AgentSessionService {
private static final String ASSISTANT_CODE = "AGENT";
private final ChatSessionQueryService chatSessionQueryService;
private final ChatSessionCommandService chatSessionCommandService;
private final AgentService agentService;
private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService;
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
private final ChatJsonSupport chatJsonSupport;
/**
* 创建 Agent 管理端会话服务。
*
* @param chatSessionQueryService 聊天会话查询服务
* @param chatSessionCommandService 聊天会话命令服务
* @param agentService Agent 服务
* @param documentCollectionService 知识库服务
* @param resourceAccessService 资源访问服务
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
* @param chatJsonSupport 聊天 JSON 工具
*/
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
ChatSessionCommandService chatSessionCommandService,
AgentService agentService,
DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
ChatJsonSupport chatJsonSupport) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
this.agentService = agentService;
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
this.chatJsonSupport = chatJsonSupport;
}
/**
* 查询当前用户的 Agent 会话分页。
*
* @param account 当前登录账号
* @param agentId Agent ID
* @param query 分页参数
* @return Agent 会话分页
*/
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger agentId, ChatPageQuery query) {
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), agentId, ASSISTANT_CODE, query);
Map<BigInteger, AgentAvailability> availabilityMap = resolveAgentAvailability(page.getRecords());
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
result.setTotal(page.getTotal());
result.setPageNumber(page.getPageNumber());
result.setPageSize(page.getPageSize());
List<ChatWorkspaceSessionView> records = new ArrayList<>();
for (ChatSessionSummary summary : page.getRecords()) {
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
}
result.setRecords(records);
return result;
}
/**
* 查询当前用户的 Agent 会话详情。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @return Agent 会话详情
*/
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
AgentAvailability availability = resolveAgentAvailability(List.of(summary)).get(summary.getAssistantId());
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
fillSessionView(detail, summary, availability);
Agent displayAgent = availability == null ? null : availability.displayAgent();
detail.setAssistant(toAssistantView(displayAgent, summary));
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
if (extraKnowledgeResolution.shouldSync()) {
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
}
return detail;
}
/**
* 查询当前用户的 Agent 会话消息。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param query 分页参数
* @return 消息分页
*/
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
requireUserAgentSession(account, sessionId);
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
}
/**
* 查询当前用户的 Agent 完整会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @return 完整会话
*/
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
requireUserAgentSession(account, sessionId);
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
view.setRecords(records);
view.setTotal(records.size());
return view;
}
/**
* 重命名当前用户的 Agent 会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param title 新标题
*/
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
if (!StringUtils.hasText(title)) {
throw new BusinessException("标题不能为空");
}
requireUserAgentSession(account, sessionId);
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
}
/**
* 保存当前用户 Agent 会话的临时知识库。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param knowledgeIds 临时知识库 ID
* @return 更新后的会话详情
*/
public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account,
BigInteger sessionId,
List<BigInteger> knowledgeIds) {
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds));
if (!resolution.removedNames().isEmpty()) {
throw new BusinessException("所选知识库已失效或无权限使用");
}
syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId());
return getCurrentUserSession(account, sessionId);
}
/**
* 删除当前用户的 Agent 会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
*/
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
requireUserAgentSession(account, sessionId);
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
}
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
throw new BusinessException("Agent 会话不存在");
}
if (!Objects.equals(summary.getUserId(), account.getId())) {
throw new BusinessException("无权访问该 Agent 会话");
}
return summary;
}
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
Map<BigInteger, AgentAvailability> result = new LinkedHashMap<>();
if (sessions == null || sessions.isEmpty()) {
return result;
}
Set<BigInteger> agentIds = new LinkedHashSet<>();
for (ChatSessionSummary session : sessions) {
if (session != null && session.getAssistantId() != null) {
agentIds.add(session.getAssistantId());
}
}
if (agentIds.isEmpty()) {
return result;
}
List<Agent> agents = agentService.list(QueryWrapper.create().in("id", agentIds));
Map<BigInteger, Agent> agentMap = new LinkedHashMap<>();
for (Agent agent : agents) {
agentMap.put(agent.getId(), agent);
}
for (BigInteger agentId : agentIds) {
Agent currentAgent = agentMap.get(agentId);
if (currentAgent == null) {
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
continue;
}
if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, currentAgent, ResourceAction.USE)) {
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
continue;
}
boolean online = Integer.valueOf(1).equals(currentAgent.getStatus())
&& PublishStatus.from(currentAgent.getPublishStatus()) == PublishStatus.PUBLISHED;
result.put(agentId, new AgentAvailability(
online,
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
toDisplayAgent(currentAgent)
));
}
return result;
}
private Agent toDisplayAgent(Agent currentAgent) {
if (currentAgent.getPublishedSnapshotJson() != null && !currentAgent.getPublishedSnapshotJson().isEmpty()) {
return agentService.fromSnapshot(currentAgent.getPublishedSnapshotJson());
}
return currentAgent;
}
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AgentAvailability availability) {
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
fillSessionView(view, summary, availability);
return view;
}
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AgentAvailability availability) {
view.setSessionId(summary.getId());
view.setAssistantId(summary.getAssistantId());
view.setAssistantCode(summary.getAssistantCode());
view.setAssistantName(summary.getAssistantName());
view.setTitle(summary.getTitle());
view.setLastMessagePreview(summary.getLastMessagePreview());
view.setMessageCount(summary.getMessageCount());
view.setAccessAt(summary.getAccessAt());
view.setLastMessageAt(summary.getLastMessageAt());
view.setContinuable(availability != null && availability.continuable());
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
}
private ChatWorkspaceAssistantView toAssistantView(Agent agent, ChatSessionSummary summary) {
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
if (agent != null) {
view.setId(agent.getId());
view.setAlias(agent.getId() == null ? null : agent.getId().toString());
view.setTitle(agent.getName());
view.setDescription(agent.getDescription());
view.setIcon(agent.getAvatar());
return view;
}
view.setId(summary == null ? null : summary.getAssistantId());
view.setAlias(summary == null ? null : summary.getAssistantCode());
view.setTitle(summary == null ? null : summary.getAssistantName());
return view;
}
@SuppressWarnings("unchecked")
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Agent displayAgent) {
if (displayAgent == null || displayAgent.getKnowledgeBindings() == null || displayAgent.getKnowledgeBindings().isEmpty()) {
return List.of();
}
List<BigInteger> knowledgeIds = displayAgent.getKnowledgeBindings().stream()
.map(binding -> binding.getKnowledgeId())
.filter(Objects::nonNull)
.toList();
if (knowledgeIds.isEmpty()) {
return List.of();
}
List<DocumentCollection> collections = documentCollectionService.listByIds(knowledgeIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
List<ChatWorkspaceKnowledgeView> views = new ArrayList<>();
for (BigInteger knowledgeId : knowledgeIds) {
DocumentCollection collection = collectionMap.get(knowledgeId);
if (collection == null || PublishStatus.from(collection.getPublishStatus()) != PublishStatus.PUBLISHED) {
continue;
}
views.add(toKnowledgeView(documentCollectionService.toPublishedView(collection)));
}
return views;
}
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
view.setId(collection.getId());
view.setAlias(collection.getAlias());
view.setTitle(collection.getTitle());
view.setDescription(collection.getDescription());
view.setIcon(collection.getIcon());
return view;
}
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
}
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
if (normalizedIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
List<BigInteger> validKnowledgeIds = new ArrayList<>();
List<String> removedNames = new ArrayList<>();
boolean changed = false;
for (BigInteger knowledgeId : normalizedIds) {
DocumentCollection current = collectionMap.get(knowledgeId);
if (current == null) {
removedNames.add("知识库#" + knowledgeId);
changed = true;
continue;
}
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
validKnowledgeIds.add(current.getId());
}
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
changed = true;
}
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
}
private List<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return List.of();
}
List<BigInteger> normalizedIds = new ArrayList<>();
for (BigInteger knowledgeId : knowledgeIds) {
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
normalizedIds.add(knowledgeId);
}
}
if (normalizedIds.size() > 3) {
throw new BusinessException("临时知识库最多选择 3 个");
}
return normalizedIds;
}
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
ChatSessionExtPayload payload = new ChatSessionExtPayload();
payload.setExtraKnowledgeIds(validKnowledgeIds);
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
command.setSessionId(summary.getId());
command.setTenantId(summary.getTenantId());
command.setDeptId(summary.getDeptId());
command.setUserId(summary.getUserId());
command.setUserAccount(summary.getUserAccount());
command.setAssistantId(summary.getAssistantId());
command.setAssistantCode(summary.getAssistantCode());
command.setAssistantName(summary.getAssistantName());
command.setTitle(summary.getTitle());
command.setExtJson(chatJsonSupport.toJson(payload));
command.setOperatorId(operatorId);
chatSessionCommandService.createOrTouchSession(command);
}
private record AgentAvailability(boolean continuable,
ChatWorkspaceReadOnlyReason reason,
Agent displayAgent) {
}
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
List<BigInteger> validKnowledgeIds,
List<String> removedNames,
boolean shouldSync) {
}
}

View File

@@ -0,0 +1,515 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceAssistantView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceKnowledgeView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceReadOnlyReason;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionView;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot;
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionExtPayload;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.service.ChatRoundOperateService;
import tech.easyflow.chatlog.service.ChatSessionCommandService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static tech.easyflow.ai.entity.table.BotTableDef.BOT;
/**
* 管理端聊天工作台服务。
*/
@Service
public class ChatWorkspaceService {
private final ChatSessionQueryService chatSessionQueryService;
private final ChatSessionCommandService chatSessionCommandService;
private final ChatRoundOperateService chatRoundOperateService;
private final BotService botService;
private final DocumentCollectionService documentCollectionService;
private final CategoryPermissionService categoryPermissionService;
private final KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper;
private final ChatJsonSupport chatJsonSupport;
public ChatWorkspaceService(ChatSessionQueryService chatSessionQueryService,
ChatSessionCommandService chatSessionCommandService,
ChatRoundOperateService chatRoundOperateService,
BotService botService,
DocumentCollectionService documentCollectionService,
CategoryPermissionService categoryPermissionService,
KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper,
ChatJsonSupport chatJsonSupport) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
this.chatRoundOperateService = chatRoundOperateService;
this.botService = botService;
this.documentCollectionService = documentCollectionService;
this.categoryPermissionService = categoryPermissionService;
this.knowledgeVisibilityQueryHelper = knowledgeVisibilityQueryHelper;
this.chatJsonSupport = chatJsonSupport;
}
/**
* 查询当前用户会话分页。
*
* @param account 当前登录用户
* @param assistantId 助手过滤条件
* @param query 分页参数
* @return 工作台会话分页
*/
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger assistantId, ChatPageQuery query) {
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), assistantId, query);
Map<BigInteger, AssistantAvailability> availabilityMap = resolveAssistantAvailability(account, page.getRecords());
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
result.setTotal(page.getTotal());
result.setPageNumber(page.getPageNumber());
result.setPageSize(page.getPageSize());
List<ChatWorkspaceSessionView> records = new ArrayList<>();
for (ChatSessionSummary summary : page.getRecords()) {
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
}
result.setRecords(records);
return result;
}
/**
* 查询当前用户会话详情。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
* @return 工作台会话详情
*/
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = requireUserSession(account, sessionId);
AssistantAvailability availability = resolveAssistantAvailability(account, List.of(summary)).get(summary.getAssistantId());
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
fillSessionView(detail, summary, availability);
if (availability != null && availability.displayBot() != null) {
detail.setAssistant(toAssistantView(availability.displayBot(), summary));
detail.setBoundKnowledges(resolveBoundKnowledges(availability.displayBot()));
} else {
detail.setAssistant(toAssistantView(null, summary));
}
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
if (extraKnowledgeResolution.shouldSync()) {
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
}
return detail;
}
/**
* 查询当前用户会话消息。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
* @param query 分页参数
* @return 历史消息分页
*/
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
ChatSessionSummary summary = requireUserSession(account, sessionId);
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
if (firstPage != null) {
return firstPage;
}
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
}
/**
* 查询当前用户完整工作台会话。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
* @return 完整会话视图
*/
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
requireUserSession(account, sessionId);
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
Map<String, List<ChatMessageRecord>> variantsByRound = new LinkedHashMap<>();
Set<BigInteger> roundIds = new LinkedHashSet<>();
for (ChatMessageRecord record : records) {
if (record == null || record.getRoundId() == null) {
continue;
}
Integer variantCount = record.getVariantCount();
if (variantCount != null && variantCount > 1) {
roundIds.add(record.getRoundId());
}
}
for (BigInteger roundId : roundIds) {
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId));
}
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
view.setRecords(records);
view.setVariantsByRound(variantsByRound);
view.setTotal(records.size());
return view;
}
/**
* 重命名当前用户会话。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
* @param title 新标题
*/
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
if (!StringUtils.hasText(title)) {
throw new BusinessException("标题不能为空");
}
requireUserSession(account, sessionId);
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
}
/**
* 删除当前用户会话。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
*/
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
requireUserSession(account, sessionId);
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
}
public List<ChatMessageRecord> listCurrentUserRoundVariants(LoginAccount account, BigInteger sessionId, BigInteger roundId) {
requireUserSession(account, sessionId);
return chatRoundOperateService.listVariants(sessionId, roundId);
}
public ChatMessageRecord selectCurrentUserRoundVariant(LoginAccount account, BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
requireUserSession(account, sessionId);
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, account.getId());
}
/**
* 发送前校验会话是否仍可继续聊天。
*
* @param account 当前登录用户
* @param sessionId 会话 ID
* @param requestBotId 本次请求助手 ID
*/
public void assertSessionContinuable(LoginAccount account, BigInteger sessionId, BigInteger requestBotId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
return;
}
if (!Objects.equals(summary.getUserId(), account.getId())) {
throw new BusinessException("无权访问该会话");
}
if (requestBotId != null && summary.getAssistantId() != null && !Objects.equals(summary.getAssistantId(), requestBotId)) {
throw new BusinessException("当前会话与所选聊天助手不匹配");
}
AssistantAvailability availability = resolveAssistantAvailability(account, List.of(summary)).get(summary.getAssistantId());
if (availability == null || !availability.continuable()) {
throw new BusinessException(buildReadOnlyMessage(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason()));
}
}
private ChatSessionSummary requireUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
throw new BusinessException("会话不存在");
}
if (!Objects.equals(summary.getUserId(), account.getId())) {
throw new BusinessException("无权访问该会话");
}
return summary;
}
/**
* 首屏优先从热态恢复最近消息,避免分析库延迟导致刚完成的回复不可见。
*
* @param summary 会话摘要
* @param query 分页参数
* @return 命中热态时返回恢复结果,否则返回 null 继续走历史库
*/
private ChatHistoryPage restoreRecentMessages(ChatSessionSummary summary, ChatPageQuery query) {
if (summary == null || query == null || query.getPageNumber() != 1) {
return null;
}
List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> records =
chatSessionQueryService.getRecentTail(summary.getId(), Math.toIntExact(query.getPageSize()));
if (records == null || records.isEmpty()) {
return null;
}
if (!isRestoredTailReliable(records)) {
return null;
}
ChatHistoryPage page = new ChatHistoryPage();
page.setPageNumber(query.getPageNumber());
page.setPageSize(query.getPageSize());
page.setRecords(records);
long total = summary.getMessageCount() == null ? 0L : summary.getMessageCount();
page.setTotal(Math.max(total, records.size()));
return page;
}
/**
* 校验 Redis tail 是否仍符合当前主线版本语义。
*
* @param records Redis tail 消息
* @return true 表示可直接用于首屏恢复
*/
private boolean isRestoredTailReliable(List<ChatMessageRecord> records) {
Map<BigInteger, Integer> selectedVariantByRound = new LinkedHashMap<>();
Map<BigInteger, Set<Integer>> assistantVariantsByRound = new LinkedHashMap<>();
for (ChatMessageRecord record : records) {
if (record == null || record.getRoundId() == null) {
continue;
}
Integer selectedVariantIndex = record.getSelectedVariantIndex();
if (selectedVariantIndex != null && selectedVariantIndex > 0) {
Integer previous = selectedVariantByRound.putIfAbsent(record.getRoundId(), selectedVariantIndex);
if (previous != null && !Objects.equals(previous, selectedVariantIndex)) {
return false;
}
}
if ("assistant".equalsIgnoreCase(record.getSenderRole())
&& record.getVariantIndex() != null
&& record.getVariantIndex() > 0) {
assistantVariantsByRound
.computeIfAbsent(record.getRoundId(), key -> new LinkedHashSet<>())
.add(record.getVariantIndex());
}
}
for (Map.Entry<BigInteger, Integer> entry : selectedVariantByRound.entrySet()) {
Set<Integer> visibleVariants = assistantVariantsByRound.get(entry.getKey());
if (visibleVariants != null && !visibleVariants.isEmpty() && !visibleVariants.contains(entry.getValue())) {
return false;
}
}
return true;
}
private Map<BigInteger, AssistantAvailability> resolveAssistantAvailability(LoginAccount account, List<ChatSessionSummary> sessions) {
Map<BigInteger, AssistantAvailability> result = new LinkedHashMap<>();
if (sessions == null || sessions.isEmpty()) {
return result;
}
Set<BigInteger> assistantIds = new LinkedHashSet<>();
for (ChatSessionSummary session : sessions) {
if (session != null && session.getAssistantId() != null) {
assistantIds.add(session.getAssistantId());
}
}
if (assistantIds.isEmpty()) {
return result;
}
List<Bot> bots = botService.list(QueryWrapper.create().where(BOT.ID.in(assistantIds)));
Map<BigInteger, Bot> botMap = new LinkedHashMap<>();
for (Bot bot : bots) {
botMap.put(bot.getId(), bot);
}
RoleCategoryAccessSnapshot accessSnapshot = categoryPermissionService.getAccess("BOT", account);
for (BigInteger assistantId : assistantIds) {
Bot currentBot = botMap.get(assistantId);
if (currentBot == null) {
result.put(assistantId, new AssistantAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
continue;
}
if (!accessSnapshot.canAccess(currentBot.getCreatedBy(), currentBot.getCategoryId())) {
result.put(assistantId, new AssistantAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
continue;
}
Bot displayBot = botService.toPublishedView(currentBot);
boolean online = Integer.valueOf(1).equals(currentBot.getStatus())
&& PublishStatus.from(currentBot.getPublishStatus()) == PublishStatus.PUBLISHED;
result.put(assistantId, new AssistantAvailability(
online,
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
displayBot
));
}
return result;
}
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AssistantAvailability availability) {
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
fillSessionView(view, summary, availability);
return view;
}
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AssistantAvailability availability) {
view.setSessionId(summary.getId());
view.setAssistantId(summary.getAssistantId());
view.setAssistantCode(summary.getAssistantCode());
view.setAssistantName(summary.getAssistantName());
view.setTitle(summary.getTitle());
view.setLastMessagePreview(summary.getLastMessagePreview());
view.setMessageCount(summary.getMessageCount());
view.setAccessAt(summary.getAccessAt());
view.setLastMessageAt(summary.getLastMessageAt());
view.setContinuable(availability != null && availability.continuable());
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
}
private ChatWorkspaceAssistantView toAssistantView(Bot bot, ChatSessionSummary summary) {
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
if (bot != null) {
view.setId(bot.getId());
view.setAlias(bot.getAlias());
view.setTitle(bot.getTitle());
view.setDescription(bot.getDescription());
view.setIcon(bot.getIcon());
return view;
}
view.setId(summary == null ? null : summary.getAssistantId());
view.setAlias(summary == null ? null : summary.getAssistantCode());
view.setTitle(summary == null ? null : summary.getAssistantName());
return view;
}
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Bot displayBot) {
if (displayBot == null || displayBot.getPublishedSnapshotJson() == null) {
return List.of();
}
Object rawBindings = displayBot.getPublishedSnapshotJson().get("knowledgeBindings");
if (!(rawBindings instanceof List<?> bindings) || bindings.isEmpty()) {
return List.of();
}
List<BigInteger> knowledgeIds = new ArrayList<>();
for (Object binding : bindings) {
if (!(binding instanceof Map<?, ?> bindingMap) || bindingMap.get("knowledgeId") == null) {
continue;
}
knowledgeIds.add(new BigInteger(String.valueOf(bindingMap.get("knowledgeId"))));
}
return resolveVisibleKnowledgeViews(knowledgeIds).validKnowledges();
}
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
}
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<BigInteger> normalizedIds = new ArrayList<>();
for (BigInteger knowledgeId : knowledgeIds) {
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
normalizedIds.add(knowledgeId);
}
}
if (normalizedIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
KnowledgeReadAccessSnapshot accessSnapshot = knowledgeVisibilityQueryHelper.getCurrentReadSnapshot();
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
List<BigInteger> validKnowledgeIds = new ArrayList<>();
List<String> removedNames = new ArrayList<>();
boolean changed = false;
for (BigInteger knowledgeId : normalizedIds) {
DocumentCollection current = collectionMap.get(knowledgeId);
if (current == null) {
removedNames.add("知识库#" + knowledgeId);
changed = true;
continue;
}
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
if (!knowledgeVisibilityQueryHelper.canRead(current, accessSnapshot)) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
validKnowledgeIds.add(current.getId());
}
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
changed = true;
}
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
}
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
view.setId(collection.getId());
view.setAlias(collection.getAlias());
view.setTitle(collection.getTitle());
view.setDescription(collection.getDescription());
view.setIcon(collection.getIcon());
return view;
}
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
ChatSessionExtPayload payload = new ChatSessionExtPayload();
payload.setExtraKnowledgeIds(validKnowledgeIds);
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
command.setSessionId(summary.getId());
command.setTenantId(summary.getTenantId());
command.setDeptId(summary.getDeptId());
command.setUserId(summary.getUserId());
command.setUserAccount(summary.getUserAccount());
command.setAssistantId(summary.getAssistantId());
command.setAssistantCode(summary.getAssistantCode());
command.setAssistantName(summary.getAssistantName());
command.setTitle(summary.getTitle());
command.setExtJson(chatJsonSupport.toJson(payload));
command.setOperatorId(operatorId);
command.setOperateAt(new Date());
chatSessionCommandService.createOrTouchSession(command);
}
private String buildReadOnlyMessage(ChatWorkspaceReadOnlyReason reason) {
if (reason == ChatWorkspaceReadOnlyReason.NO_PERMISSION) {
return "当前会话对应的聊天助手已无权限访问,仅支持查看历史记录";
}
if (reason == ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE) {
return "当前会话对应的聊天助手已下架,无法继续聊天";
}
return "当前会话对应的聊天助手已删除,无法继续聊天";
}
private record AssistantAvailability(boolean continuable,
ChatWorkspaceReadOnlyReason reason,
Bot displayBot) {
}
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
List<BigInteger> validKnowledgeIds,
List<String> removedNames,
boolean shouldSync) {
}
}

View File

@@ -2,12 +2,42 @@ package tech.easyflow.admin.service.dashboard;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import java.io.OutputStream;
import java.util.List;
/** /**
* 工作台统计服务。 * 工作台统计服务。
*/ */
public interface DashboardService { public interface DashboardService {
/**
* 获取工作台总览。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 工作台总览
*/
DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query); DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query);
/**
* 获取用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 用户活跃榜
*/
List<DashboardUserRankItemVo> getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query);
/**
* 导出用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @param outputStream 输出流
*/
void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream);
} }

View File

@@ -1,11 +1,15 @@
package tech.easyflow.admin.service.dashboard.impl; package tech.easyflow.admin.service.dashboard.impl;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.write.style.column.SimpleColumnWidthStyleStrategy;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.row.Db; import com.mybatisflex.core.row.Db;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
@@ -15,6 +19,7 @@ import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService; import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
@@ -25,13 +30,16 @@ import tech.easyflow.chatlog.service.ChatDashboardQueryService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountRoleService;
import tech.easyflow.system.service.SysRoleService; import tech.easyflow.system.service.SysRoleService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.OutputStream;
import java.math.BigInteger; import java.math.BigInteger;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -58,6 +66,7 @@ public class DashboardServiceImpl implements DashboardService {
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5; private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8; private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8;
private static final int DEFAULT_USER_RANK_LIMIT = 5; private static final int DEFAULT_USER_RANK_LIMIT = 5;
private static final int USER_RANK_EXPORT_COLUMN_WIDTH = 20;
@Resource @Resource
private SysAccountRoleService sysAccountRoleService; private SysAccountRoleService sysAccountRoleService;
@@ -71,6 +80,12 @@ public class DashboardServiceImpl implements DashboardService {
@Resource @Resource
private ChatDashboardQueryService chatDashboardQueryService; private ChatDashboardQueryService chatDashboardQueryService;
@Resource
private BotService botService;
@Resource
private CategoryPermissionService categoryPermissionService;
/** /**
* 获取工作台总览信息。 * 获取工作台总览信息。
* *
@@ -90,7 +105,6 @@ public class DashboardServiceImpl implements DashboardService {
result.setTrends(chatPayload.trends); result.setTrends(chatPayload.trends);
result.setAssistantTrends(chatPayload.assistantTrends); result.setAssistantTrends(chatPayload.assistantTrends);
result.setDistribution(chatPayload.distribution); result.setDistribution(chatPayload.distribution);
result.setUserRanks(chatPayload.userRanks);
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery(); DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
normalizedQuery.setRange(context.range); normalizedQuery.setRange(context.range);
@@ -101,6 +115,37 @@ public class DashboardServiceImpl implements DashboardService {
return result; return result;
} }
/**
* 获取用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 用户活跃榜
*/
@Override
public List<DashboardUserRankItemVo> getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query) {
DashboardQueryContext context = buildContext(loginAccount, query);
return queryUserRanks(context, DEFAULT_USER_RANK_LIMIT);
}
/**
* 导出用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @param outputStream 输出流
*/
@Override
public void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream) {
DashboardQueryContext context = buildContext(loginAccount, query);
List<DashboardUserRankItemVo> userRanks = queryUserRanks(context, null);
EasyExcel.write(outputStream)
.head(buildUserRankExportHead())
.registerWriteHandler(new SimpleColumnWidthStyleStrategy(USER_RANK_EXPORT_COLUMN_WIDTH))
.sheet("用户活跃榜")
.doWrite(buildUserRankExportRows(userRanks));
}
/** /**
* 构建顶部汇总卡片。 * 构建顶部汇总卡片。
* *
@@ -144,7 +189,6 @@ public class DashboardServiceImpl implements DashboardService {
chatStatus, chatStatus,
new ArrayList<>(), new ArrayList<>(),
new ArrayList<>(), new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>() new ArrayList<>()
); );
} }
@@ -176,14 +220,7 @@ public class DashboardServiceImpl implements DashboardService {
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution( List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size())) rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
); );
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks( return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution);
startDate,
endDate,
context.tenantFilterId,
DEFAULT_USER_RANK_LIMIT
);
List<DashboardUserRankItemVo> userRanks = buildUserRanks(rawUserRanks);
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks);
} catch (Exception ex) { } catch (Exception ex) {
log.warn("加载工作台聊天统计失败已降级为不可用状态range={}, tenantId={}", log.warn("加载工作台聊天统计失败已降级为不可用状态range={}, tenantId={}",
context.range, context.range,
@@ -199,7 +236,6 @@ public class DashboardServiceImpl implements DashboardService {
chatStatus, chatStatus,
new ArrayList<>(), new ArrayList<>(),
new ArrayList<>(), new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>() new ArrayList<>()
); );
} }
@@ -328,6 +364,27 @@ public class DashboardServiceImpl implements DashboardService {
return seriesList; return seriesList;
} }
/**
* 查询用户活跃排行。
*
* @param context 查询上下文
* @param limit 返回条数,空表示全部
* @return 用户活跃排行
*/
private List<DashboardUserRankItemVo> queryUserRanks(DashboardQueryContext context, Integer limit) {
if (!chatDashboardQueryService.available()) {
return new ArrayList<>();
}
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
context.startTime.toLocalDate(),
context.endTime.toLocalDate(),
context.tenantFilterId,
context.assistantId,
limit
);
return buildUserRanks(rawUserRanks);
}
/** /**
* 构建用户活跃排行。 * 构建用户活跃排行。
* *
@@ -335,20 +392,62 @@ public class DashboardServiceImpl implements DashboardService {
* @return 页面排行项 * @return 页面排行项
*/ */
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) { private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
if (ranks == null || ranks.isEmpty()) {
return new ArrayList<>();
}
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size()); List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
Map<BigInteger, String> displayNameMap = resolveUserDisplayNameMap(ranks); Map<BigInteger, AccountIdentitySnapshot> identityMap = resolveAccountIdentityMap(ranks);
for (ChatActiveUserRank rank : ranks) { for (ChatActiveUserRank rank : ranks) {
ResolvedUserIdentity identity = resolveUserIdentity(
rank.userId(),
rank.userAccount(),
identityMap.get(rank.userId())
);
DashboardUserRankItemVo item = new DashboardUserRankItemVo(); DashboardUserRankItemVo item = new DashboardUserRankItemVo();
item.setUserId(rank.userId()); item.setUserId(rank.userId());
item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap)); item.setLabel(identity.label);
item.setLoginName(identity.loginName);
item.setNickname(identity.nickname);
item.setSessionTotal(rank.sessionTotal()); item.setSessionTotal(rank.sessionTotal());
item.setMessageTotal(rank.messageTotal()); item.setMessageTotal(rank.messageTotal());
item.setAssistantTotal(rank.assistantTotal());
items.add(item); items.add(item);
} }
return items; return items;
} }
/**
* 构建导出表头。
*
* @return 表头
*/
private List<List<String>> buildUserRankExportHead() {
List<List<String>> head = new ArrayList<>(4);
head.add(List.of("登录账号"));
head.add(List.of("昵称"));
head.add(List.of("会话数"));
head.add(List.of("消息数"));
return head;
}
/**
* 构建导出数据行。
*
* @param userRanks 用户排行
* @return 数据行
*/
private List<List<String>> buildUserRankExportRows(List<DashboardUserRankItemVo> userRanks) {
List<List<String>> rows = new ArrayList<>(userRanks.size());
for (DashboardUserRankItemVo item : userRanks) {
List<String> row = new ArrayList<>(4);
row.add(defaultIfBlank(item.getLoginName()));
row.add(defaultIfBlank(item.getNickname()));
row.add(String.valueOf(item.getSessionTotal() == null ? 0L : item.getSessionTotal()));
row.add(String.valueOf(item.getMessageTotal() == null ? 0L : item.getMessageTotal()));
rows.add(row);
}
return rows;
}
/** /**
* 按租户统计平台资源数量。 * 按租户统计平台资源数量。
* *
@@ -442,8 +541,47 @@ public class DashboardServiceImpl implements DashboardService {
* @return 查询上下文 * @return 查询上下文
*/ */
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) { private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
return buildContext(
loginAccount,
query == null ? null : query.getRange(),
query == null ? null : query.getStartDate(),
query == null ? null : query.getEndDate()
);
}
/**
* 构建用户榜查询上下文。
*
* @param loginAccount 当前登录账号
* @param query 用户榜查询
* @return 查询上下文
*/
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardUserRankQuery query) {
DashboardQueryContext context = buildContext(
loginAccount,
query == null ? null : query.getRange(),
query == null ? null : query.getStartDate(),
query == null ? null : query.getEndDate()
);
context.assistantId = validateAssistantId(loginAccount, query == null ? null : query.getAssistantId());
return context;
}
/**
* 构建基础查询上下文。
*
* @param loginAccount 当前登录账号
* @param range 时间范围
* @param startDate 开始日期
* @param endDate 结束日期
* @return 查询上下文
*/
private DashboardQueryContext buildContext(LoginAccount loginAccount,
String range,
String startDate,
String endDate) {
DashboardQueryContext context = new DashboardQueryContext(); DashboardQueryContext context = new DashboardQueryContext();
context.range = normalizeRange(query == null ? null : query.getRange()); context.range = normalizeRange(range);
context.superAdmin = isSuperAdmin(loginAccount); context.superAdmin = isSuperAdmin(loginAccount);
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID); LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
@@ -463,8 +601,8 @@ public class DashboardServiceImpl implements DashboardService {
context.queryStartDate = today.minusDays(29).toString(); context.queryStartDate = today.minusDays(29).toString();
context.queryEndDate = today.toString(); context.queryEndDate = today.toString();
} else { } else {
LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空"); LocalDate customStartDate = parseRequiredDate(startDate, "开始日期不能为空");
LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空"); LocalDate customEndDate = parseRequiredDate(endDate, "结束日期不能为空");
if (customStartDate.isAfter(customEndDate)) { if (customStartDate.isAfter(customEndDate)) {
throw new BusinessException("开始日期不能晚于结束日期"); throw new BusinessException("开始日期不能晚于结束日期");
} }
@@ -645,7 +783,7 @@ public class DashboardServiceImpl implements DashboardService {
* @param ranks 活跃排行 * @param ranks 活跃排行
* @return 名称映射 * @return 名称映射
*/ */
private Map<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) { private Map<BigInteger, AccountIdentitySnapshot> resolveAccountIdentityMap(List<ChatActiveUserRank> ranks) {
List<BigInteger> userIds = ranks.stream() List<BigInteger> userIds = ranks.stream()
.map(ChatActiveUserRank::userId) .map(ChatActiveUserRank::userId)
.filter(java.util.Objects::nonNull) .filter(java.util.Objects::nonNull)
@@ -654,7 +792,24 @@ public class DashboardServiceImpl implements DashboardService {
if (userIds.isEmpty()) { if (userIds.isEmpty()) {
return new HashMap<>(); return new HashMap<>();
} }
return sysAccountService.resolveDisplayNameMap(userIds); QueryWrapper queryWrapper = QueryWrapper.create()
.select(SysAccount::getId, SysAccount::getLoginName, SysAccount::getNickname)
.in(SysAccount::getId, userIds);
List<SysAccount> accounts = sysAccountService.list(queryWrapper);
Map<BigInteger, AccountIdentitySnapshot> identityMap = new HashMap<>(userIds.size());
if (accounts == null) {
return identityMap;
}
for (SysAccount account : accounts) {
if (account == null || account.getId() == null) {
continue;
}
identityMap.put(
account.getId(),
new AccountIdentitySnapshot(trimToNull(account.getLoginName()), trimToNull(account.getNickname()))
);
}
return identityMap;
} }
/** /**
@@ -676,20 +831,75 @@ public class DashboardServiceImpl implements DashboardService {
* *
* @param userId 用户 ID * @param userId 用户 ID
* @param userAccount 聊天侧账号快照 * @param userAccount 聊天侧账号快照
* @param displayNameMap 系统账号名称映射 * @param snapshot 系统账号快照
* @return 展示名称 * @return 用户身份
*/ */
private String resolveUserLabel(BigInteger userId, String userAccount, Map<BigInteger, String> displayNameMap) { private ResolvedUserIdentity resolveUserIdentity(BigInteger userId,
if (userId != null) { String userAccount,
String displayName = displayNameMap.get(userId); AccountIdentitySnapshot snapshot) {
if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) { String loginName = snapshot == null ? null : snapshot.loginName;
return displayName; String nickname = snapshot == null ? null : snapshot.nickname;
String trimmedUserAccount = trimToNull(userAccount);
if (StringUtils.hasText(loginName)) {
if (StringUtils.hasText(nickname)) {
return new ResolvedUserIdentity(loginName, nickname, loginName + "" + nickname + "");
} }
return new ResolvedUserIdentity(loginName, nickname, loginName);
} }
if (StringUtils.hasText(userAccount)) { if (StringUtils.hasText(trimmedUserAccount)) {
return userAccount.trim(); return new ResolvedUserIdentity(trimmedUserAccount, nickname, trimmedUserAccount);
} }
return userId == null ? "用户-未知" : "用户-" + userId; return new ResolvedUserIdentity(null, nickname, userId == null ? "用户-未知" : "用户-" + userId);
}
/**
* 校验智能体筛选条件。
*
* @param loginAccount 当前登录账号
* @param assistantId 智能体 ID
* @return 规范化后的智能体 ID
*/
private BigInteger validateAssistantId(LoginAccount loginAccount, BigInteger assistantId) {
if (assistantId == null) {
return null;
}
Bot bot = botService.getById(assistantId);
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) {
throw new BusinessException("聊天助手不存在或未启用");
}
boolean visible = categoryPermissionService.canAccessCategory(
loginAccount,
"BOT",
bot.getCreatedBy(),
bot.getCategoryId()
);
if (!visible) {
throw new BusinessException("聊天助手不存在或未启用");
}
return assistantId;
}
/**
* 归一化文本。
*
* @param value 原始文本
* @return 去空白后的文本
*/
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
/**
* 把空文本转为空串。
*
* @param value 原始文本
* @return 输出文本
*/
private String defaultIfBlank(String value) {
return StringUtils.hasText(value) ? value.trim() : "";
} }
/** /**
@@ -699,6 +909,7 @@ public class DashboardServiceImpl implements DashboardService {
private String range; private String range;
private BigInteger tenantFilterId; private BigInteger tenantFilterId;
private BigInteger deptFilterId; private BigInteger deptFilterId;
private BigInteger assistantId;
private boolean superAdmin; private boolean superAdmin;
private LocalDateTime startTime; private LocalDateTime startTime;
private LocalDateTime endTime; private LocalDateTime endTime;
@@ -719,6 +930,34 @@ public class DashboardServiceImpl implements DashboardService {
} }
} }
/**
* 系统账号身份快照。
*/
private static class AccountIdentitySnapshot {
private final String loginName;
private final String nickname;
private AccountIdentitySnapshot(String loginName, String nickname) {
this.loginName = loginName;
this.nickname = nickname;
}
}
/**
* 用户展示身份。
*/
private static class ResolvedUserIdentity {
private final String loginName;
private final String nickname;
private final String label;
private ResolvedUserIdentity(String loginName, String nickname, String label) {
this.loginName = loginName;
this.nickname = nickname;
this.label = label;
}
}
/** /**
* 聊天统计页面载荷。 * 聊天统计页面载荷。
*/ */
@@ -726,8 +965,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardChatStatusVo chatStatus, DashboardChatStatusVo chatStatus,
List<DashboardTrendItemVo> trends, List<DashboardTrendItemVo> trends,
List<DashboardAssistantTrendSeriesVo> assistantTrends, List<DashboardAssistantTrendSeriesVo> assistantTrends,
List<DashboardDistributionItemVo> distribution, List<DashboardDistributionItemVo> distribution
List<DashboardUserRankItemVo> userRanks
) { ) {
} }
} }

View File

@@ -1,13 +1,18 @@
package tech.easyflow.admin.service.dashboard.impl; package tech.easyflow.admin.service.dashboard.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.testng.Assert; import org.testng.Assert;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
@@ -15,8 +20,15 @@ import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
import tech.easyflow.chatlog.service.ChatDashboardQueryService; import tech.easyflow.chatlog.service.ChatDashboardQueryService;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService;
import tech.easyflow.system.service.SysRoleService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -25,13 +37,15 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -71,7 +85,7 @@ public class DashboardServiceImplTest {
} }
/** /**
* 验证 today 返回 24 个小时点位,且排行名称与均值回退正确 * 验证 today 返回 24 个小时点位,且 overview 不再触发用户榜查询
* *
* @throws Exception 反射调用失败 * @throws Exception 反射调用失败
*/ */
@@ -91,12 +105,7 @@ public class DashboardServiceImplTest {
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L))); .thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any())) when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
.thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L))); .thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L)));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L)));
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户"));
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.valueOf(9)); Object context = newContext("today", BigInteger.valueOf(9));
DashboardSummaryVo summary = new DashboardSummaryVo(); DashboardSummaryVo summary = new DashboardSummaryVo();
@@ -107,7 +116,6 @@ public class DashboardServiceImplTest {
List<DashboardAssistantTrendSeriesVo> assistantTrends = List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends"); (List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution"); List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable"))); Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
Assert.assertEquals(trends.size(), 24); Assert.assertEquals(trends.size(), 24);
@@ -132,40 +140,31 @@ public class DashboardServiceImplTest {
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1"); Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D)); Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D)); Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户"); verify(chatDashboardQueryService, never()).queryActiveUserRanks(any(), any(), any(), any(), any());
Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L));
} }
/** /**
* 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。 * 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。
*
* @throws Exception 反射调用失败
*/ */
@Test @Test
@SuppressWarnings("unchecked")
public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception { public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl(); DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
when(chatDashboardQueryService.available()).thenReturn(true); when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any())) when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(), any()))
.thenReturn(new ChatDashboardSummary(1L, 1L, 1L, 1L));
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
.thenReturn(List.of());
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L))); .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L)));
SysAccountService sysAccountService = mock(SysAccountService.class); SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(9), "9")); when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(9L, "", "仅昵称")));
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService); setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.ONE); DashboardUserRankQuery query = new DashboardUserRankQuery();
DashboardSummaryVo summary = new DashboardSummaryVo(); query.setRange("7d");
Object payload = invokeBuildChatPayload(service, context, summary);
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks"); List<DashboardUserRankItemVo> userRanks = service.getUserRanks(new LoginAccount(), query);
Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user"); Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user");
Assert.assertEquals(userRanks.get(0).getLoginName(), "chat-user");
Assert.assertEquals(userRanks.get(0).getNickname(), "仅昵称");
} }
/** /**
@@ -194,12 +193,7 @@ public class DashboardServiceImplTest {
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L), new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L),
new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L) new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L)
)); ));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("7d", BigInteger.ONE); Object context = newContext("7d", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo(); DashboardSummaryVo summary = new DashboardSummaryVo();
@@ -238,10 +232,7 @@ public class DashboardServiceImplTest {
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L))); .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L)));
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of()); .thenReturn(List.of());
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", mock(SysAccountService.class));
Object context = newContext( Object context = newContext(
"custom", "custom",
@@ -321,10 +312,7 @@ public class DashboardServiceImplTest {
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L), new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L),
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L) new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L)
)); ));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", mock(SysAccountService.class));
Object context = newContext("30d", BigInteger.ONE); Object context = newContext("30d", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo(); DashboardSummaryVo summary = new DashboardSummaryVo();
@@ -344,6 +332,150 @@ public class DashboardServiceImplTest {
verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8)); verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8));
} }
/**
* 验证用户榜筛选会透传 assistantId。
*/
@Test
public void shouldQueryUserRanksWithAssistantFilter() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
BotService botService = mock(BotService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysAccountService sysAccountService = mock(SysAccountService.class);
SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class);
SysRoleService sysRoleService = mock(SysRoleService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(1);
bot.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8));
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L)));
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
.thenReturn(true);
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户")));
when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "botService", botService);
setField(service, "categoryPermissionService", categoryPermissionService);
setField(service, "sysAccountService", sysAccountService);
setField(service, "sysAccountRoleService", sysAccountRoleService);
setField(service, "sysRoleService", sysRoleService);
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setId(BigInteger.valueOf(12));
loginAccount.setTenantId(BigInteger.valueOf(33));
List<DashboardUserRankItemVo> userRanks = service.getUserRanks(loginAccount, query);
Assert.assertEquals(userRanks.size(), 1);
Assert.assertEquals(userRanks.get(0).getLabel(), "demo-user演示用户");
verify(chatDashboardQueryService).queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5));
}
/**
* 验证未启用智能体会被拒绝。
*/
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
public void shouldRejectDisabledAssistantFilter() {
DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(0);
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(chatDashboardQueryService.available()).thenReturn(true);
setFieldSilently(service, "botService", botService);
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class));
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
service.getUserRanks(new LoginAccount(), query);
}
/**
* 验证当前作用域不可见的智能体会被拒绝。
*/
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
public void shouldRejectInvisibleAssistantFilter() {
DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(1);
bot.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8));
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(chatDashboardQueryService.available()).thenReturn(true);
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
.thenReturn(false);
setFieldSilently(service, "botService", botService);
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
service.getUserRanks(new LoginAccount(), query);
}
/**
* 验证导出结果包含表头与账号昵称列。
*/
@Test
public void shouldExportDashboardUserRanksWithSeparatedIdentityColumns() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
SysAccountService sysAccountService = mock(SysAccountService.class);
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), isNull(), isNull()))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(7), "export-user", 3L, 6L, 1L)));
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(7L, "export-user", "导出演示")));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
service.exportUserRanks(new LoginAccount(), query, outputStream);
try (org.apache.poi.ss.usermodel.Workbook workbook =
WorkbookFactory.create(new ByteArrayInputStream(outputStream.toByteArray()))) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
Assert.assertEquals(sheet.getRow(0).getCell(0).getStringCellValue(), "登录账号");
Assert.assertEquals(sheet.getRow(0).getCell(1).getStringCellValue(), "昵称");
Assert.assertEquals(sheet.getRow(0).getCell(2).getStringCellValue(), "会话数");
Assert.assertEquals(sheet.getRow(0).getCell(3).getStringCellValue(), "消息数");
Assert.assertEquals(sheet.getRow(1).getCell(0).getStringCellValue(), "export-user");
Assert.assertEquals(sheet.getRow(1).getCell(1).getStringCellValue(), "导出演示");
Assert.assertEquals(sheet.getRow(1).getCell(2).getStringCellValue(), "3");
Assert.assertEquals(sheet.getRow(1).getCell(3).getStringCellValue(), "6");
}
}
/** /**
* 构造查询上下文。 * 构造查询上下文。
* *
@@ -475,4 +607,20 @@ public class DashboardServiceImplTest {
} }
throw new IllegalArgumentException("未找到字段: " + fieldName); throw new IllegalArgumentException("未找到字段: " + fieldName);
} }
private void setFieldSilently(Object target, String fieldName, Object value) {
try {
setField(target, fieldName, value);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private SysAccount buildSysAccount(long id, String loginName, String nickname) {
SysAccount account = new SysAccount();
account.setId(BigInteger.valueOf(id));
account.setLoginName(loginName);
account.setNickname(nickname);
return account;
}
} }

View File

@@ -36,6 +36,7 @@ import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService; import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl; import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.vo.FaqImportResultVo; import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
@@ -342,18 +343,22 @@ public class PublicKnowledgeShareController {
if (documentStore == null) { if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库"); return Result.fail(2, "知识库没有配置向量库");
} }
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId()); try {
if (model == null) { Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
return Result.fail(3, "知识库没有配置向量模型"); if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(current.getId());
StoreResult result = documentStore.update(doc, options);
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
} }
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(current.getId());
StoreResult result = documentStore.update(doc, options);
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
return Result.ok(result);
} }
return Result.ok(false); return Result.ok(false);
} }
@@ -376,16 +381,20 @@ public class PublicKnowledgeShareController {
if (documentStore == null) { if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库"); return Result.fail(2, "知识库没有配置向量库");
} }
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId()); try {
if (model == null) { Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
return Result.fail(3, "知识库没有配置向量模型"); if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
} }
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
return Result.ok(true);
} }
/** /**

View File

@@ -4,22 +4,31 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tech.easyflow.approval.annotation.RequirePublishedAccess; import tech.easyflow.approval.annotation.RequirePublishedAccess;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; 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.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.HashMap; import java.util.HashMap;
@@ -43,6 +52,10 @@ public class PublicWorkflowController {
private WorkflowCheckService workflowCheckService; private WorkflowCheckService workflowCheckService;
@Resource @Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver; private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
/** /**
* 通过id或别名获取工作流详情 * 通过id或别名获取工作流详情
@@ -54,8 +67,11 @@ public class PublicWorkflowController {
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
public Result<Workflow> getByIdOrAlias( public Result<Workflow> getByIdOrAlias(
@RequestParam @RequestParam
@NotBlank(message = "key不能为空") String key) { @NotBlank(message = "key不能为空") String key,
HttpServletRequest request) {
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
Workflow workflow = workflowService.getPublishedDetail(key); Workflow workflow = workflowService.getPublishedDetail(key);
assertStrictPublishedWorkflow(workflow);
return Result.ok(workflow); return Result.ok(workflow);
} }
@@ -88,19 +104,20 @@ public class PublicWorkflowController {
* 运行工作流 - v2 * 运行工作流 - v2
*/ */
@PostMapping("/runAsync") @PostMapping("/runAsync")
@SaCheckPermission("/api/v1/workflow/save")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id, public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
@JsonBody("variables") Map<String, Object> variables) { @JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
if (variables == null) { if (variables == null) {
variables = new HashMap<>(); variables = new HashMap<>();
} }
Workflow workflow = workflowService.getPublishedById(id); Workflow workflow = workflowService.getPublishedById(id);
if (workflow == null) { assertStrictPublishedWorkflow(workflow);
throw new RuntimeException("工作流不存在");
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey));
variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY);
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables); String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
return Result.ok(executeId); return Result.ok(executeId);
} }
@@ -110,7 +127,10 @@ public class PublicWorkflowController {
*/ */
@PostMapping("/getChainStatus") @PostMapping("/getChainStatus")
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId, public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) { @JsonBody("nodes") List<NodeInfo> nodes,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
assertApiKeyExecutionOwnership(apiKey, executeId);
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes); ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
return Result.ok(res); return Result.ok(res);
} }
@@ -119,22 +139,23 @@ public class PublicWorkflowController {
* 恢复工作流运行 - v2 * 恢复工作流运行 - v2
*/ */
@PostMapping("/resume") @PostMapping("/resume")
@SaCheckPermission("/api/v1/workflow/save")
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId, public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) { @JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
chainExecutor.resumeAsync(executeId, confirmParams); chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok(); return Result.ok();
} }
@GetMapping("getRunningParameters") @GetMapping("getRunningParameters")
@SaCheckPermission("/api/v1/workflow/query")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
public Result<?> getRunningParameters(@RequestParam BigInteger id) { public Result<?> getRunningParameters(@RequestParam BigInteger id, HttpServletRequest request) {
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
Workflow workflow = workflowService.getPublishedById(id); Workflow workflow = workflowService.getPublishedById(id);
if (workflow == null) { assertStrictPublishedWorkflow(workflow);
return Result.fail(1, "can not find the workflow by id: " + id);
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow); Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) { if (res == null) {
@@ -142,4 +163,72 @@ public class PublicWorkflowController {
} }
return Result.ok(res); return Result.ok(res);
} }
/**
* 构建 API Key 调用方的运行身份。
*
* @param apiKey 访问令牌
* @return 工作流运行身份
*/
private LoginAccount buildApiKeyLoginAccount(SysApiKey apiKey) {
LoginAccount account = new LoginAccount();
account.setId(apiKey.getId());
account.setDeptId(apiKey.getDeptId() == null ? BigInteger.ZERO : apiKey.getDeptId());
account.setTenantId(apiKey.getTenantId() == null ? BigInteger.ZERO : apiKey.getTenantId());
account.setLoginName("apikey:" + apiKey.getId());
account.setNickname("API 调用方");
return account;
}
/**
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
*
* @param workflow 工作流发布视图
*/
private void assertStrictPublishedWorkflow(Workflow workflow) {
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("工作流尚未发布");
}
}
/**
* 校验 Public Workflow API 后续操作只能作用于当前 API Key 发起的执行实例。
*
* @param apiKey 当前 API Key
* @param executeId 执行 ID
* @return 已通过归属校验的执行记录
*/
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
if (execResult == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|| apiKey == null
|| apiKey.getId() == null
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
throw new BusinessException("无权限访问当前工作流执行记录");
}
return execResult;
}
/**
* 校验当前执行实例是否仍允许恢复。
*
* @param execResult 执行记录
*/
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
if (execResult == null || execResult.getWorkflowId() == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("工作流已下线或不可恢复执行");
}
}
} }

View File

@@ -17,6 +17,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.entity.*; import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.*;
import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.ai.service.impl.BotServiceImpl;
@@ -28,7 +29,9 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.chatlog.service.ChatRoundOperateService;
import tech.easyflow.core.runtime.ChatChannel; import tech.easyflow.core.runtime.ChatChannel;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
@@ -66,6 +69,8 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
private Cache<String, Object> cache; private Cache<String, Object> cache;
@Resource @Resource
private AudioServiceManager audioServiceManager; private AudioServiceManager audioServiceManager;
@Resource
private ChatRoundOperateService chatRoundOperateService;
public UcBotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService, public UcBotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService) { BotDocumentCollectionService botDocumentCollectionService) {
@@ -152,13 +157,17 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
@JsonBody(value = "botId", required = true) BigInteger botId, @JsonBody(value = "botId", required = true) BigInteger botId,
@JsonBody(value = "conversationId", required = true) BigInteger conversationId, @JsonBody(value = "conversationId", required = true) BigInteger conversationId,
@JsonBody(value = "messages") List<Map<String, String>> messages, @JsonBody(value = "messages") List<Map<String, String>> messages,
@JsonBody(value = "attachments") List<String> attachments @JsonBody(value = "attachments") List<String> attachments,
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
) { ) {
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult(); BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
if (regenerateRoundId != null) {
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
}
// 前置校验失败则直接返回错误SseEmitter // 前置校验失败则直接返回错误SseEmitter
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult); SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult, regenerateRoundId);
if (errorEmitter != null) { if (errorEmitter != null) {
return errorEmitter; return errorEmitter;
} }
@@ -170,7 +179,7 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
messages, messages,
chatCheckResult, chatCheckResult,
attachments, attachments,
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments) buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, regenerateRoundId)
); );
} }
@@ -286,25 +295,38 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
return super.onSaveOrUpdateBefore(entity, isSave); return super.onSaveOrUpdateBefore(entity, isSave);
} }
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) { private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
LoginAccount account = SaTokenUtil.getLoginAccount(); BigInteger regenerateRoundId) {
LoginAccount account = requireCurrentLoginAccount();
ChatRuntimeContext context = new ChatRuntimeContext(); ChatRuntimeContext context = new ChatRuntimeContext();
context.setChannel(ChatChannel.USER_CENTER); context.setChannel(ChatChannel.USER_CENTER);
context.setSessionId(conversationId); context.setSessionId(conversationId);
context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId()); context.setTenantId(account.getTenantId());
context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId()); context.setDeptId(account.getDeptId());
context.setUserId(account == null ? BigInteger.ZERO : account.getId()); context.setUserId(account.getId());
context.setUserAccount(account == null ? "anonymous" : account.getLoginName()); context.setUserAccount(account.getLoginName());
context.setUserName(account == null ? "匿名用户" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName())); context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName());
context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId()); context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId());
context.setAssistantCode(bot == null ? null : bot.getAlias()); context.setAssistantCode(bot == null ? null : bot.getAlias());
context.setAssistantName(bot == null ? null : bot.getTitle()); context.setAssistantName(bot == null ? null : bot.getTitle());
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt); context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
context.setAnonymous(account == null || BigInteger.ZERO.equals(account.getId())); context.setAnonymous(false);
context.setAttachments(attachments); context.setAttachments(attachments);
if (regenerateRoundId != null) {
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
}
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
return context; return context;
} }
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
private Map<String, Object> getDefaultLlmOptions() { private Map<String, Object> getDefaultLlmOptions() {
Map<String, Object> defaultLlmOptions = new HashMap<>(); Map<String, Object> defaultLlmOptions = new HashMap<>();
defaultLlmOptions.put("temperature", 0.7); defaultLlmOptions.put("temperature", 0.7);

View File

@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage; import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.domain.query.ChatPageQuery;
@@ -17,6 +18,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List;
@RestController @RestController
@RequestMapping("/userCenter/chatHistory") @RequestMapping("/userCenter/chatHistory")
@@ -61,4 +63,19 @@ public class UcChatHistoryController {
chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId()); chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId());
return Result.ok(); return Result.ok();
} }
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.listUserRoundVariants(account.getId(), sessionId, roundId));
}
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.selectUserRoundVariant(account.getId(), sessionId, roundId, variantIndex, account.getId()));
}
} }

View File

@@ -1,418 +0,0 @@
# EasyFlow Chat Protocol Specification v1.1
* **Protocol Name:** `easyflow-chat`
* **Version:** `1.1`
* **Status:** Draft / Recommended
* **Transport:** Server-Sent Events (SSE)
* **Encoding:** UTF-8
## 1. 设计背景与目标
本协议用于描述 **EasyFlow 对话系统中的服务端事件流通信规范**,支持:
* AI 对话的 **流式输出**
* 模型 **思考过程Thinking**
* **工具调用Tool Calling**
* **系统 / 业务错误**
* **工作流 / Agent 状态**
* **对话中的用户交互(表单、确认等)**
* **中断与恢复Suspend / Resume**
设计目标:
* 前后端解耦
* 协议长期可扩展
* 不绑定具体模型厂商
* 易于与 Workflow / Agent / Chain 架构集成
## 2. 传输层规范Transport
* 使用 HTTP + SSE支持未来扩展为其他协议比如 WebSocket 等)
* Response Header
```http
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
```
* 通信方向:**Server → Client**
* 所有业务数据通过 `data` 字段传输,格式为 **JSON 字符串**
## 3. SSE Event 级别规范
### 3.1 Event Name固定
| event | 含义 |
| - |-------|
| message | 正常业务事件 |
| error | 错误事件 |
| done | 流结束事件 |
> ⚠️ **禁止在 event name 中承载业务语义**
## 4. 统一 Envelope 结构(核心)
### 4.1 基本结构
```json
{
"protocol": "easyflow-chat",
"version": "1.1",
"domain": "llm | tool | system | business | workflow | interaction | debug",
"type": "string",
"conversation_id": "string",
"message_id": "string",
"index": 0,
"payload": {},
"meta": {}
}
```
### 4.2 字段说明
| 字段 | 类型 | 必填 | 说明 |
| -- |---------| -- |------------------------|
| protocol | string | ✔ | 固定值 `easyflow-chat` |
| version | string | ✔ | 协议版本 |
| domain | string | ✔ | 事件所属领域 |
| type | string | ✔ | 领域内事件类型 |
| conversation_id | string | ✔ | 会话唯一标识 |
| message_id | string | ✖ | assistant 消息 ID |
| index | number | ✖ | 流式输出序号 |
| payload | object | ✔ | 事件数据 |
| meta | object | ✖ | 元信息token、耗时等 |
## 5. Domain 定义
| Domain | 说明 |
| -- | -- |
| llm | 模型语义输出 |
| tool | 工具调用与结果 |
| system | 系统级事件 |
| business | 业务规则 |
| workflow | 工作流 / Agent 状态 |
| interaction | 用户交互(表单等) |
| debug | 调试信息 |
## 6. llm Domain
### 6.1 thinking
表示模型的思考过程。
#### 流式输出delta
```json
{
"domain": "llm",
"type": "thinking",
"payload": {
"delta": "分析用户需求"
}
}
```
#### 完整输出(可选)
```json
{
"domain": "llm",
"type": "message",
"payload": {
"content": "这是一个完整的回答"
}
}
```
### 6.2 message
#### 流式输出delta
```json
{
"domain": "llm",
"type": "message",
"index": 12,
"payload": {
"delta": "这是一个"
}
}
```
#### 完整输出(可选)
```json
{
"domain": "llm",
"type": "message",
"payload": {
"content": "这是一个完整的回答"
}
}
```
## 7. tool Domain
### 7.1 tool_call
```json
{
"domain": "tool",
"type": "tool_call",
"payload": {
"tool_call_id": "call_1",
"name": "search",
"arguments": {
"query": "SSE 协议设计"
}
}
}
```
### 7.2 tool_result
```json
{
"domain": "tool",
"type": "tool_result",
"payload": {
"tool_call_id": "call_1",
"status": "success | error",
"result": {}
}
}
```
## 8. system Domain
### 8.1 error
```json
{
"domain": "system",
"type": "error",
"payload": {
"code": "MODEL_CONFIG_INVALID",
"message": "模型配置错误",
"retryable": false,
"detail": {}
}
}
```
### 8.2 status
```json
{
"domain": "system",
"type": "status",
"payload": {
"state": "initializing | running | suspended | resumed"
}
}
```
## 9. business Domain
```json
{
"domain": "business",
"type": "error",
"payload": {
"code": "QUOTA_EXCEEDED",
"message": "配额不足"
}
}
```
## 10. workflow Domain
```json
{
"domain": "workflow",
"type": "status",
"payload": {
"node_id": "node_1",
"state": "start | suspend | resume | end",
"reason": "interaction"
}
}
```
## 11. interaction Domain对话内交互
### 11.1 form_request
表示请求用户填写表单,对话进入挂起状态。
```json
{
"domain": "interaction",
"type": "form_request",
"payload": {
"form_id": "user_info_form",
"title": "补充信息",
"description": "请填写以下信息以继续",
"schema": {
"type": "object",
"required": ["age", "email"],
"properties": {
"age": {
"type": "number",
"title": "年龄"
},
"email": {
"type": "string",
"title": "邮箱",
"format": "email"
}
}
},
"ui": {
"submit_text": "继续",
"cancel_text": "取消"
}
}
}
```
> 表单 schema **符合 JSON Schema 标准**
### 11.2 form_cancel
```json
{
"domain": "interaction",
"type": "form_cancel",
"payload": {
"form_id": "user_info_form"
}
}
```
## 12. 表单提交与恢复(非 SSE
表单提交通过 **普通 HTTP / WebSocket 请求**
```json
{
"conversation_id": "conv_1",
"form_id": "user_info_form",
"values": {
"age": 30,
"email": "a@b.com"
}
}
```
成功后服务端恢复 SSE 流。
## 13. done 事件(流结束)
```json
{
"domain": "system",
"type": "done",
"meta": {
"prompt_tokens": 1234,
"completion_tokens": 456,
"latency_ms": 2300
}
}
```
## 14. 错误处理规则
* 收到 `event: error` 后客户端应终止流
* 错误语义由:
```
domain + type + payload.code
```
共同决定
## 15. 状态机视角(推荐)
```text
RUNNING
LLM_OUTPUT
INTERACTION_REQUESTED
SUSPENDED
FORM_SUBMITTED
RESUMED
RUNNING
DONE
```
## 16. 扩展与兼容规则
1. 可新增 domain
2. 可新增 type
3. 不允许删除已有字段
4. payload 可自由扩展
5. 1.x 版本保持向后兼容
## 17. 设计原则
> * SSE 只负责事件流
> * domain 定义责任边界
> * type 定义语义动作
> * payload 定义数据结构
> * 前端不依赖 event name 判断业务,不依赖协议本身,支持其他协议的扩展

View File

@@ -25,6 +25,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId> <artifactId>spring-boot-autoconfigure</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.clickhouse</groupId> <groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId> <artifactId>clickhouse-jdbc</artifactId>

View File

@@ -0,0 +1,41 @@
package tech.easyflow.common.analyticaldb.support;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
/**
* 分析数据库健康检查。
*/
@Component("analyticalDbHealthIndicator")
public class AnalyticalDBHealthIndicator implements HealthIndicator {
private final AnalyticalDBHealthSupport healthSupport;
/**
* 创建分析数据库健康检查器。
*
* @param healthSupport 分析数据库健康检查支持
*/
public AnalyticalDBHealthIndicator(AnalyticalDBHealthSupport healthSupport) {
this.healthSupport = healthSupport;
}
/**
* 检查分析数据库是否可用。
*
* @return 健康状态
*/
@Override
public Health health() {
if (!healthSupport.enabled()) {
return Health.up().withDetail("enabled", false).build();
}
try {
healthSupport.selfCheck();
return Health.up().withDetail("enabled", true).build();
} catch (Exception e) {
return Health.down(e).withDetail("enabled", true).build();
}
}
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.common.audio.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 音频模块线程池配置。
*/
@ConfigurationProperties(prefix = "easyflow.thread-pool.scheduler")
public class AudioThreadPoolProperties {
private int poolSize = 4;
/**
* 获取调度线程池大小。
*
* @return 调度线程池大小
*/
public int getPoolSize() {
return poolSize;
}
/**
* 设置调度线程池大小。
*
* @param poolSize 调度线程池大小
*/
public void setPoolSize(int poolSize) {
this.poolSize = poolSize;
}
}

View File

@@ -1,19 +1,38 @@
package tech.easyflow.common.audio.socket; package tech.easyflow.common.audio.socket;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import tech.easyflow.common.audio.config.AudioThreadPoolProperties;
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@EnableConfigurationProperties(AudioThreadPoolProperties.class)
public class SchedulingConfig { public class SchedulingConfig {
private final AudioThreadPoolProperties properties;
/**
* 创建音频调度配置。
*
* @param properties 音频调度线程池配置
*/
public SchedulingConfig(AudioThreadPoolProperties properties) {
this.properties = properties;
}
/**
* 创建调度线程池。
*
* @return 调度线程池
*/
@Bean @Bean
public TaskScheduler taskScheduler() { public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10); scheduler.setPoolSize(properties.getPoolSize());
scheduler.setThreadNamePrefix("scheduled-task-"); scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setDaemon(true); scheduler.setDaemon(true);
scheduler.initialize(); scheduler.initialize();

View File

@@ -39,7 +39,23 @@
<artifactId>fastjson</artifactId> <artifactId>fastjson</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>

View File

@@ -0,0 +1,35 @@
package tech.easyflow.common.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Spring 定时任务 Redis 分布式锁。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedScheduledLock {
/**
* 获取锁使用的 Redis key。
*
* @return Redis 锁 key
*/
String key();
/**
* 等待锁的秒数。
*
* @return 等待锁的秒数
*/
long waitSeconds() default 0L;
/**
* 锁租约秒数。
*
* @return 锁租约秒数
*/
long leaseSeconds() default 300L;
}

View File

@@ -0,0 +1,111 @@
package tech.easyflow.common.cache;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 定时任务分布式锁切面。
*/
@Aspect
@Component
public class DistributedScheduledLockAspect {
private static final Logger LOG = LoggerFactory.getLogger(DistributedScheduledLockAspect.class);
private final RedisLockExecutor redisLockExecutor;
private final ScheduledExecutorService renewExecutor;
/**
* 创建定时任务分布式锁切面。
*
* @param redisLockExecutor Redis 分布式锁执行器
*/
public DistributedScheduledLockAspect(RedisLockExecutor redisLockExecutor) {
this.redisLockExecutor = redisLockExecutor;
this.renewExecutor = Executors.newScheduledThreadPool(
1,
new DistributedScheduledLockThreadFactory()
);
}
/**
* 拦截带分布式调度锁的定时任务。
*
* @param joinPoint 切点
* @param lock 锁注解
* @return 原方法返回值;未抢到锁时返回 null
* @throws Throwable 原方法执行异常或 Redis 访问异常
*/
@Around("@annotation(lock)")
public Object around(ProceedingJoinPoint joinPoint, DistributedScheduledLock lock) throws Throwable {
Duration waitTimeout = Duration.ofSeconds(Math.max(lock.waitSeconds(), 0L));
Duration leaseTimeout = Duration.ofSeconds(Math.max(lock.leaseSeconds(), 1L));
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(lock.key(), waitTimeout, leaseTimeout);
if (handle == null) {
LOG.info("定时任务分布式锁已被其他实例持有,跳过本轮执行: lockKey={}, method={}",
lock.key(), joinPoint.getSignature().toShortString());
return null;
}
ScheduledFuture<?> renewTask = scheduleRenew(lock.key(), handle, leaseTimeout);
try {
return joinPoint.proceed();
} finally {
renewTask.cancel(false);
handle.release();
}
}
private ScheduledFuture<?> scheduleRenew(String lockKey,
RedisLockExecutor.LockHandle handle,
Duration leaseTimeout) {
long renewIntervalMillis = Math.max(leaseTimeout.toMillis() / 3L, 1000L);
return renewExecutor.scheduleWithFixedDelay(() -> {
if (!handle.renew()) {
LOG.warn("定时任务分布式锁续期失败: lockKey={}", lockKey);
}
}, renewIntervalMillis, renewIntervalMillis, TimeUnit.MILLISECONDS);
}
/**
* 关闭调度锁续期线程池。
*/
@PreDestroy
public void destroy() {
renewExecutor.shutdownNow();
}
/**
* 调度锁续期线程工厂。
*/
private static final class DistributedScheduledLockThreadFactory implements ThreadFactory {
private final AtomicInteger index = new AtomicInteger(1);
/**
* 创建续期线程。
*
* @param runnable 线程任务
* @return 续期线程
*/
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName("distributed-scheduled-lock-renew-" + index.getAndIncrement());
thread.setDaemon(true);
return thread;
}
}
}

View File

@@ -12,6 +12,9 @@ import java.util.Collections;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
/**
* Redis 分布式锁执行器。
*/
@Component @Component
public class RedisLockExecutor { public class RedisLockExecutor {
@@ -20,6 +23,7 @@ public class RedisLockExecutor {
private static final long RETRY_INTERVAL_MILLIS = 50L; private static final long RETRY_INTERVAL_MILLIS = 50L;
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT; private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
static { static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>(); RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
@@ -29,11 +33,26 @@ public class RedisLockExecutor {
"else return 0 end" "else return 0 end"
); );
RELEASE_LOCK_SCRIPT.setResultType(Long.class); RELEASE_LOCK_SCRIPT.setResultType(Long.class);
RENEW_LOCK_SCRIPT = new DefaultRedisScript<>();
RENEW_LOCK_SCRIPT.setScriptText(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else return 0 end"
);
RENEW_LOCK_SCRIPT.setResultType(Long.class);
} }
@Autowired @Autowired
private StringRedisTemplate stringRedisTemplate; private StringRedisTemplate stringRedisTemplate;
/**
* 在分布式锁保护下执行无返回任务。
*
* @param lockKey 锁 key
* @param waitTimeout 等待锁的最大时间
* @param leaseTimeout 锁租约时间
* @param task 业务任务
*/
public void executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Runnable task) { public void executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Runnable task) {
executeWithLock(lockKey, waitTimeout, leaseTimeout, () -> { executeWithLock(lockKey, waitTimeout, leaseTimeout, () -> {
task.run(); task.run();
@@ -41,40 +60,156 @@ public class RedisLockExecutor {
}); });
} }
/**
* 在分布式锁保护下执行有返回任务。
*
* @param lockKey 锁 key
* @param waitTimeout 等待锁的最大时间
* @param leaseTimeout 锁租约时间
* @param task 业务任务
* @param <T> 返回类型
* @return 任务返回值
*/
public <T> T executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Supplier<T> task) { public <T> T executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Supplier<T> task) {
LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout);
try {
return task.get();
} finally {
handle.release();
}
}
/**
* 获取显式释放的分布式锁句柄。
*
* <p>长连接、SSE 或异步任务不能使用 callback 型锁,否则 callback 返回后锁会被提前释放。
* 该方法返回 owner token 绑定的句柄,由调用方在运行完成、失败或取消时显式释放。</p>
*
* @param lockKey 锁 key
* @param waitTimeout 等待时间
* @param leaseTimeout 租约时间
* @return 锁句柄
*/
public LockHandle acquire(String lockKey, Duration waitTimeout, Duration leaseTimeout) {
LockHandle handle = tryAcquire(lockKey, waitTimeout, leaseTimeout);
if (handle == null) {
throw new IllegalStateException("获取分布式锁失败请稍后重试lockKey=" + lockKey);
}
return handle;
}
/**
* 尝试获取显式释放的分布式锁句柄。
*
* <p>返回 {@code null} 表示锁当前被其他节点持有。Redis 访问失败或等待过程被中断仍会抛出异常,
* 调用方可据此区分“正常跳过”和“基础设施异常”。</p>
*
* @param lockKey 锁 key
* @param waitTimeout 等待时间
* @param leaseTimeout 租约时间
* @return 获取成功时返回锁句柄,否则返回 null
*/
public LockHandle tryAcquire(String lockKey, Duration waitTimeout, Duration leaseTimeout) {
String lockValue = UUID.randomUUID().toString(); String lockValue = UUID.randomUUID().toString();
boolean acquired = false; boolean acquired = false;
long deadline = System.nanoTime() + waitTimeout.toNanos(); long deadline = System.nanoTime() + waitTimeout.toNanos();
try { try {
while (System.nanoTime() <= deadline) { do {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, leaseTimeout); Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, leaseTimeout);
if (Boolean.TRUE.equals(success)) { if (Boolean.TRUE.equals(success)) {
acquired = true; acquired = true;
break; break;
} }
if (System.nanoTime() >= deadline) {
break;
}
Thread.sleep(RETRY_INTERVAL_MILLIS); Thread.sleep(RETRY_INTERVAL_MILLIS);
} } while (System.nanoTime() <= deadline);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IllegalStateException("等待分布式锁被中断lockKey=" + lockKey, e); throw new IllegalStateException("等待分布式锁被中断lockKey=" + lockKey, e);
} }
if (!acquired) { if (!acquired) {
throw new IllegalStateException("获取分布式锁失败请稍后重试lockKey=" + lockKey); return null;
}
try {
return task.get();
} finally {
releaseLock(lockKey, lockValue);
} }
return new LockHandle(lockKey, lockValue, leaseTimeout);
} }
private void releaseLock(String lockKey, String lockValue) { /**
* 按 owner token 释放锁。
*
* @param lockKey 锁 key
* @param lockValue owner token
*/
public void releaseLock(String lockKey, String lockValue) {
try { try {
stringRedisTemplate.execute(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), lockValue); stringRedisTemplate.execute(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), lockValue);
} catch (Exception e) { } catch (Exception e) {
log.warn("释放分布式锁失败lockKey={}", lockKey, e); log.warn("释放分布式锁失败lockKey={}", lockKey, e);
} }
} }
/**
* 按 owner token 续期锁。
*
* @param lockKey 锁 key
* @param lockValue owner token
* @param leaseTimeout 新租约时间
* @return 续期成功时为 true
*/
public boolean renewLock(String lockKey, String lockValue, Duration leaseTimeout) {
try {
Long result = stringRedisTemplate.execute(RENEW_LOCK_SCRIPT, Collections.singletonList(lockKey),
lockValue, String.valueOf(leaseTimeout.toMillis()));
return Long.valueOf(1L).equals(result);
} catch (Exception e) {
log.warn("续期分布式锁失败lockKey={}", lockKey, e);
return false;
}
}
/**
* 显式分布式锁句柄。
*/
public final class LockHandle implements AutoCloseable {
private final String lockKey;
private final String lockValue;
private final Duration leaseTimeout;
private volatile boolean released;
private LockHandle(String lockKey, String lockValue, Duration leaseTimeout) {
this.lockKey = lockKey;
this.lockValue = lockValue;
this.leaseTimeout = leaseTimeout;
}
/**
* 续期当前锁。
*
* @return 续期成功时为 true
*/
public boolean renew() {
if (released) {
return false;
}
return renewLock(lockKey, lockValue, leaseTimeout);
}
/**
* 释放当前锁。
*/
public void release() {
if (released) {
return;
}
released = true;
releaseLock(lockKey, lockValue);
}
@Override
public void close() {
release();
}
}
} }

View File

@@ -0,0 +1,108 @@
package tech.easyflow.common.cache;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link DistributedScheduledLockAspect} 回归测试。
*/
public class DistributedScheduledLockAspectTest {
/**
* 验证未抢到调度锁时跳过原方法。
*
* @throws Throwable 切面执行异常
*/
@Test
public void aroundShouldSkipTaskWhenLockIsHeld() throws Throwable {
RedisLockExecutor executor = createExecutor(false);
DistributedScheduledLockAspect aspect = new DistributedScheduledLockAspect(executor);
AtomicInteger proceedCount = new AtomicInteger();
Object result = aspect.around(
mockJoinPoint(proceedCount),
annotatedMethod("lockedTask").getAnnotation(DistributedScheduledLock.class)
);
Assert.assertNull(result);
Assert.assertEquals(0, proceedCount.get());
}
/**
* 验证抢到调度锁时执行原方法并释放锁。
*
* @throws Throwable 切面执行异常
*/
@Test
public void aroundShouldProceedAndReleaseWhenLockAcquired() throws Throwable {
RedisLockExecutor executor = createExecutor(true);
DistributedScheduledLockAspect aspect = new DistributedScheduledLockAspect(executor);
AtomicInteger proceedCount = new AtomicInteger();
Object result = aspect.around(
mockJoinPoint(proceedCount),
annotatedMethod("lockedTask").getAnnotation(DistributedScheduledLock.class)
);
Assert.assertEquals("ok", result);
Assert.assertEquals(1, proceedCount.get());
}
@DistributedScheduledLock(key = "easyflow:test:scheduled", leaseSeconds = 30L)
private void lockedTask() {
}
private Method annotatedMethod(String methodName) throws NoSuchMethodException {
Method method = DistributedScheduledLockAspectTest.class.getDeclaredMethod(methodName);
method.setAccessible(true);
return method;
}
private ProceedingJoinPoint mockJoinPoint(AtomicInteger proceedCount) throws Throwable {
ProceedingJoinPoint joinPoint = Mockito.mock(ProceedingJoinPoint.class);
Signature signature = Mockito.mock(Signature.class);
Mockito.when(signature.toShortString()).thenReturn("lockedTask()");
Mockito.when(joinPoint.getSignature()).thenReturn(signature);
Mockito.when(joinPoint.proceed()).thenAnswer(invocation -> {
proceedCount.incrementAndGet();
return "ok";
});
return joinPoint;
}
@SuppressWarnings("unchecked")
private RedisLockExecutor createExecutor(boolean acquired) throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(valueOperations.setIfAbsent(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.any(Duration.class)
)).thenReturn(acquired);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.<Object[]>any()
)).thenReturn(1L);
RedisLockExecutor executor = new RedisLockExecutor();
Field field = RedisLockExecutor.class.getDeclaredField("stringRedisTemplate");
field.setAccessible(true);
field.set(executor, redisTemplate);
return executor;
}
}

View File

@@ -0,0 +1,98 @@
package tech.easyflow.common.cache;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.List;
/**
* {@link RedisLockExecutor} 回归测试。
*/
public class RedisLockExecutorTest {
/**
* 验证锁被占用时返回 null便于调度任务跳过本轮执行。
*
* @throws Exception 反射注入异常
*/
@Test
public void tryAcquireShouldReturnNullWhenLockIsHeld() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(false);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
RedisLockExecutor.LockHandle handle = executor.tryAcquire(
"easyflow:test:lock",
Duration.ZERO,
Duration.ofSeconds(30)
);
Assert.assertNull(handle);
Mockito.verify(valueOperations).setIfAbsent(
ArgumentMatchers.eq("easyflow:test:lock"),
ArgumentMatchers.anyString(),
ArgumentMatchers.eq(Duration.ofSeconds(30))
);
}
/**
* 验证锁获取成功后释放会执行 owner token 校验脚本。
*
* @throws Exception 反射注入异常
*/
@Test
public void acquiredHandleShouldReleaseLockWithOwnerToken() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.<Object[]>any()
)).thenReturn(1L);
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
RedisLockExecutor.LockHandle handle = executor.tryAcquire(
"easyflow:test:lock",
Duration.ZERO,
Duration.ofSeconds(30)
);
Assert.assertNotNull(handle);
handle.release();
Mockito.verify(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of("easyflow:test:lock")),
ArgumentMatchers.<Object[]>any()
);
}
@SuppressWarnings("unchecked")
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(valueOperations.setIfAbsent(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.any(Duration.class)
)).thenReturn(acquired);
return valueOperations;
}
private void setRedisTemplate(RedisLockExecutor executor, StringRedisTemplate redisTemplate) throws Exception {
Field field = RedisLockExecutor.class.getDeclaredField("stringRedisTemplate");
field.setAccessible(true);
field.set(executor, redisTemplate);
}
}

View File

@@ -6,6 +6,8 @@ public enum ChatType {
TOOL_CALL, TOOL_CALL,
TOOL_RESULT, TOOL_RESULT,
STATUS, STATUS,
CITATIONS,
SESSION_CREATED,
ERROR, ERROR,
FORM_REQUEST, FORM_REQUEST,
FORM_CANCEL, FORM_CANCEL,

View File

@@ -4,9 +4,10 @@ import com.alibaba.fastjson.JSON;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.core.chat.protocol.ChatEnvelope; import tech.easyflow.core.chat.protocol.ChatEnvelope;
import java.io.IOException; import java.io.IOException;
@@ -90,15 +91,21 @@ public class ChatSseEmitter {
.data(json) .data(json)
); );
return true; return true;
} catch (IllegalStateException e) { } catch (AsyncRequestNotUsableException e) {
closed.compareAndSet(false, true); markDisconnected(event, e);
LOG.error("ChatSseEmitter send failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
return false; return false;
} catch (IOException e) { } catch (IOException e) {
LOG.error("ChatSseEmitter send io failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e); markDisconnected(event, e);
safeCompleteWithError(e); return false;
} catch (IllegalStateException e) {
closed.compareAndSet(false, true);
LOG.warn("ChatSseEmitter send failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString());
return false; return false;
} catch (Exception e) { } catch (Exception e) {
if (isClientDisconnected(e)) {
markDisconnected(event, e);
return false;
}
LOG.error("ChatSseEmitter send unexpected failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e); LOG.error("ChatSseEmitter send unexpected failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
safeCompleteWithError(e); safeCompleteWithError(e);
return false; return false;
@@ -165,4 +172,31 @@ public class ChatSseEmitter {
} }
} }
} }
private void markDisconnected(String event, Throwable ex) {
closed.compareAndSet(false, true);
LOG.warn("ChatSseEmitter client disconnected(event={}), message={}, exception={}",
event, ex == null ? null : ex.getMessage(), ex == null ? null : ex.toString());
}
private boolean isClientDisconnected(Throwable ex) {
Throwable current = ex;
while (current != null) {
if (current instanceof AsyncRequestNotUsableException || current instanceof IOException) {
return true;
}
String message = current.getMessage();
if (message != null) {
String lowerMessage = message.toLowerCase();
if (lowerMessage.contains("broken pipe")
|| lowerMessage.contains("connection reset")
|| lowerMessage.contains("disconnected client")
|| lowerMessage.contains("response not usable")) {
return true;
}
}
current = current.getCause();
}
return false;
}
} }

View File

@@ -5,55 +5,163 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 负责聚合单轮聊天中的 assistant thinking、tool 调用和最终回答,
* 并产出可用于历史回放的结构化 payload。
*/
public class ChatAssistantAccumulator { public class ChatAssistantAccumulator {
private final StringBuilder content = new StringBuilder(); private final StringBuilder content = new StringBuilder();
private final StringBuilder reasoning = new StringBuilder(); private final StringBuilder reasoning = new StringBuilder();
private final StringBuilder displayReasoning = new StringBuilder();
private final List<Map<String, Object>> chains = new ArrayList<>(); private final List<Map<String, Object>> chains = new ArrayList<>();
private final List<Map<String, Object>> messageChain = new ArrayList<>();
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
private Map<String, Object> latestToolCallAssistant;
private boolean toolCallBatchOpen;
/**
* 追加当前 assistant 片段的文本内容。
*
* @param delta 内容增量
*/
public void appendContent(String delta) { public void appendContent(String delta) {
if (delta != null && !delta.isEmpty()) { if (delta != null && !delta.isEmpty()) {
content.append(delta); content.append(delta);
} }
} }
/**
* 追加当前 assistant 片段的 reasoning 内容。
*
* @param delta reasoning 增量
*/
public void appendReasoning(String delta) { public void appendReasoning(String delta) {
if (delta != null && !delta.isEmpty()) { if (delta != null && !delta.isEmpty()) {
reasoning.append(delta); reasoning.append(delta);
displayReasoning.append(delta);
} }
} }
/**
* 记录 tool call同时把当前 assistant 片段固化为一条结构化 assistant 消息。
*
* @param id tool call id
* @param name tool 名称
* @param arguments tool 参数
*/
public void appendToolCall(String id, String name, Object arguments) { public void appendToolCall(String id, String name, Object arguments) {
Map<String, Object> chain = findToolChain(id, name); appendToolCall(id, name, null, arguments);
chain.put("status", "TOOL_CALL");
chain.put("result", arguments);
} }
/**
* 记录 tool call同时保留面向前端展示的工具名称。
*
* @param id tool call id
* @param name tool 名称
* @param displayName tool 展示名称
* @param arguments tool 参数
*/
public void appendToolCall(String id, String name, String displayName, Object arguments) {
Map<String, Object> chain = findToolChain(id, name);
chain.put("status", "TOOL_CALL");
chain.put("arguments", arguments);
putIfNotBlank(chain, "toolDisplayName", displayName);
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
@SuppressWarnings("unchecked")
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) assistantMessage.computeIfAbsent("toolCalls",
key -> new ArrayList<Map<String, Object>>());
Map<String, Object> toolCall = new LinkedHashMap<>();
toolCall.put("id", id);
toolCall.put("name", name);
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
putIfNotBlank(toolCall, "toolDisplayName", displayName);
toolCalls.add(toolCall);
}
/**
* 记录 tool result并附加到结构化消息链中。
*
* @param id tool call id
* @param name tool 名称
* @param result tool 结果
*/
public void appendToolResult(String id, String name, Object result) { public void appendToolResult(String id, String name, Object result) {
appendToolResult(id, name, null, result);
}
/**
* 记录 tool result并保留面向前端展示的工具名称。
*
* @param id tool call id
* @param name tool 名称
* @param displayName tool 展示名称
* @param result tool 结果
*/
public void appendToolResult(String id, String name, String displayName, Object result) {
Map<String, Object> chain = findToolChain(id, name); Map<String, Object> chain = findToolChain(id, name);
chain.put("status", "TOOL_RESULT"); chain.put("status", "TOOL_RESULT");
chain.put("result", result); chain.put("result", result);
putIfNotBlank(chain, "toolDisplayName", displayName);
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
id,
result == null ? null : String.valueOf(result)
);
toolMessages.add(toolMessage);
messageChain.add(ChatRuntimeHistoryPayloadHelper.deepCopyMap(toolMessage));
toolCallBatchOpen = false;
} }
/**
* 获取当前 assistant 片段的文本内容。
*
* @return 文本内容
*/
public String getContent() { public String getContent() {
return content.toString(); return content.toString();
} }
public Map<String, Object> buildPayload() { /**
Map<String, Object> payload = new LinkedHashMap<>(); * 获取最近一次 tool-call assistant 的 reasoning 内容,供实时内存消息回写复用。
*
* @return reasoning 内容
*/
public String getLatestToolCallReasoning() {
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("reasoningContent"));
}
/**
* 获取最近一次 tool-call assistant 的内容,供实时内存消息回写复用。
*
* @return 内容
*/
public String getLatestToolCallContent() {
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("content"));
}
/**
* 产出结构化 payload。
*
* @param finalContent 最终 assistant 文本
* @return payload
*/
public Map<String, Object> buildPayload(String finalContent) {
List<Map<String, Object>> payloadChains = new ArrayList<>(); List<Map<String, Object>> payloadChains = new ArrayList<>();
if (reasoning.length() > 0) { if (displayReasoning.length() > 0) {
Map<String, Object> think = new LinkedHashMap<>(); Map<String, Object> think = new LinkedHashMap<>();
think.put("reasoning_content", reasoning.toString()); think.put("reasoning_content", displayReasoning.toString());
think.put("thinkingStatus", "end"); think.put("thinkingStatus", "end");
think.put("thinlCollapse", Boolean.TRUE); think.put("thinlCollapse", Boolean.TRUE);
payloadChains.add(think); payloadChains.add(think);
} }
payloadChains.addAll(chains); payloadChains.addAll(chains);
if (!payloadChains.isEmpty()) { List<Map<String, Object>> payloadMessageChain = ChatRuntimeHistoryPayloadHelper.deepCopyList(messageChain);
payload.put("chains", payloadChains); Map<String, Object> finalAssistantMessage = buildFinalAssistantMessage(finalContent);
if (!finalAssistantMessage.isEmpty()) {
payloadMessageChain.add(finalAssistantMessage);
} }
return payload; return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains);
} }
private Map<String, Object> findToolChain(String id, String name) { private Map<String, Object> findToolChain(String id, String name) {
@@ -71,4 +179,49 @@ public class ChatAssistantAccumulator {
chains.add(chain); chains.add(chain);
return chain; return chain;
} }
private Map<String, Object> ensureToolCallAssistantMessage() {
if (toolCallBatchOpen && latestToolCallAssistant != null && !hasPendingAssistantContent()) {
return latestToolCallAssistant;
}
latestToolCallAssistant = ChatRuntimeHistoryPayloadHelper.assistantMessage(
content.length() == 0 ? null : content.toString(),
reasoning.length() == 0 ? null : reasoning.toString(),
null
);
messageChain.add(latestToolCallAssistant);
content.setLength(0);
reasoning.setLength(0);
toolCallBatchOpen = true;
return latestToolCallAssistant;
}
private Map<String, Object> buildFinalAssistantMessage(String finalContent) {
String assistantContent = finalContent;
if ((assistantContent == null || assistantContent.isEmpty()) && content.length() > 0) {
assistantContent = content.toString();
}
if ((assistantContent == null || assistantContent.isEmpty()) && reasoning.length() == 0) {
return new LinkedHashMap<>();
}
return ChatRuntimeHistoryPayloadHelper.assistantMessage(
assistantContent,
reasoning.length() == 0 ? null : reasoning.toString(),
null
);
}
private boolean hasPendingAssistantContent() {
return content.length() > 0 || reasoning.length() > 0;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private void putIfNotBlank(Map<String, Object> target, String key, String value) {
if (value != null && !value.isBlank()) {
target.put(key, value);
}
}
} }

View File

@@ -0,0 +1,35 @@
package tech.easyflow.core.runtime;
/**
* 聊天运行时扩展字段键。
*/
public final class ChatRuntimeExtKeys {
/**
* 会话级额外知识库 ID 列表。
*/
public static final String EXTRA_KNOWLEDGE_IDS = "extraKnowledgeIds";
/**
* 当前请求要重答的轮次 ID。
*/
public static final String REGENERATE_ROUND_ID = "regenerateRoundId";
/**
* 当前请求归属的轮次 ID。
*/
public static final String CURRENT_ROUND_ID = "currentRoundId";
/**
* 当前请求归属的轮次序号。
*/
public static final String CURRENT_ROUND_NO = "currentRoundNo";
/**
* 当前请求生成的答案版本序号。
*/
public static final String CURRENT_VARIANT_INDEX = "currentVariantIndex";
private ChatRuntimeExtKeys() {
}
}

View File

@@ -0,0 +1,272 @@
package tech.easyflow.core.runtime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 聊天运行时历史 payload 的统一读写工具。
* <p>
* 该工具只处理 {@link Map} / {@link List} 结构,避免把 easy-agents 类型泄漏到通用协议模块。
*/
public final class ChatRuntimeHistoryPayloadHelper {
public static final String KEY_MESSAGE_CHAIN = "messageChain";
public static final String KEY_ASSISTANT_MESSAGE = "assistantMessage";
public static final String KEY_FINAL_ASSISTANT_MESSAGE = "finalAssistantMessage";
public static final String KEY_TOOL_MESSAGES = "toolMessages";
public static final String KEY_DISPLAY_CHAINS = "displayChains";
public static final String KEY_CHAINS = "chains";
private ChatRuntimeHistoryPayloadHelper() {
}
/**
* 构造 assistant 历史消息结构。
*
* @param content assistant 内容
* @param reasoningContent assistant reasoning 内容
* @param toolCalls assistant tool calls
* @return assistant 历史消息结构
*/
public static Map<String, Object> assistantMessage(String content,
String reasoningContent,
List<Map<String, Object>> toolCalls) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("role", "assistant");
if (content != null) {
message.put("content", content);
}
if (reasoningContent != null && !reasoningContent.isEmpty()) {
message.put("reasoningContent", reasoningContent);
}
if (toolCalls != null && !toolCalls.isEmpty()) {
message.put("toolCalls", deepCopyList(toolCalls));
}
return message;
}
/**
* 构造 tool 历史消息结构。
*
* @param toolCallId tool call id
* @param content tool 结果内容
* @return tool 历史消息结构
*/
public static Map<String, Object> toolMessage(String toolCallId, String content) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("role", "tool");
message.put("toolCallId", toolCallId);
if (content != null) {
message.put("content", content);
}
return message;
}
/**
* 构造统一的历史 payload。
*
* @param messageChain 完整 assistant/tool 历史链
* @param toolMessages tool 历史列表
* @param displayChains 前端展示链
* @return payload
*/
public static Map<String, Object> buildPayload(List<Map<String, Object>> messageChain,
List<Map<String, Object>> toolMessages,
List<Map<String, Object>> displayChains) {
Map<String, Object> payload = new LinkedHashMap<>();
List<Map<String, Object>> safeMessageChain = deepCopyList(messageChain);
List<Map<String, Object>> safeToolMessages = deepCopyList(toolMessages);
List<Map<String, Object>> safeDisplayChains = deepCopyList(displayChains);
if (!safeMessageChain.isEmpty()) {
payload.put(KEY_MESSAGE_CHAIN, safeMessageChain);
Map<String, Object> firstAssistant = findAssistant(safeMessageChain, false);
if (!firstAssistant.isEmpty()) {
payload.put(KEY_ASSISTANT_MESSAGE, firstAssistant);
}
Map<String, Object> lastAssistant = findAssistant(safeMessageChain, true);
if (!lastAssistant.isEmpty() && !lastAssistant.equals(firstAssistant)) {
payload.put(KEY_FINAL_ASSISTANT_MESSAGE, lastAssistant);
}
}
if (!safeToolMessages.isEmpty()) {
payload.put(KEY_TOOL_MESSAGES, safeToolMessages);
}
if (!safeDisplayChains.isEmpty()) {
payload.put(KEY_DISPLAY_CHAINS, safeDisplayChains);
payload.put(KEY_CHAINS, deepCopyList(safeDisplayChains));
}
return payload;
}
/**
* 读取结构化消息链。
*
* @param payload contentPayload
* @return 结构化消息链
*/
public static List<Map<String, Object>> getMessageChain(Map<String, Object> payload) {
return getMapList(payload == null ? null : payload.get(KEY_MESSAGE_CHAIN));
}
/**
* 读取 assistant 历史消息。
*
* @param payload contentPayload
* @return assistant 历史消息
*/
public static Map<String, Object> getAssistantMessage(Map<String, Object> payload) {
return getMap(payload == null ? null : payload.get(KEY_ASSISTANT_MESSAGE));
}
/**
* 读取最终 assistant 历史消息。
*
* @param payload contentPayload
* @return 最终 assistant 历史消息
*/
public static Map<String, Object> getFinalAssistantMessage(Map<String, Object> payload) {
return getMap(payload == null ? null : payload.get(KEY_FINAL_ASSISTANT_MESSAGE));
}
/**
* 读取 tool 历史消息列表。
*
* @param payload contentPayload
* @return tool 历史消息列表
*/
public static List<Map<String, Object>> getToolMessages(Map<String, Object> payload) {
return getMapList(payload == null ? null : payload.get(KEY_TOOL_MESSAGES));
}
/**
* 判断 payload 是否已经包含新结构化历史。
*
* @param payload contentPayload
* @return true 表示包含新结构
*/
public static boolean hasStructuredHistory(Map<String, Object> payload) {
return !getMessageChain(payload).isEmpty()
|| !getAssistantMessage(payload).isEmpty()
|| !getToolMessages(payload).isEmpty();
}
/**
* 读取对象为 Map。
*
* @param value 值
* @return Map 视图
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> getMap(Object value) {
if (!(value instanceof Map<?, ?> source)) {
return Collections.emptyMap();
}
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : source.entrySet()) {
if (entry.getKey() != null) {
result.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return result;
}
/**
* 读取对象为 Map 列表。
*
* @param value 值
* @return Map 列表
*/
public static List<Map<String, Object>> getMapList(Object value) {
if (!(value instanceof List<?> source)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>(source.size());
for (Object item : source) {
Map<String, Object> map = getMap(item);
if (!map.isEmpty()) {
result.add(map);
}
}
return result;
}
/**
* 深拷贝 Map 列表。
*
* @param source 原始列表
* @return 深拷贝后的列表
*/
public static List<Map<String, Object>> deepCopyList(List<Map<String, Object>> source) {
if (source == null || source.isEmpty()) {
return new ArrayList<>();
}
List<Map<String, Object>> copy = new ArrayList<>(source.size());
for (Map<String, Object> item : source) {
copy.add(deepCopyMap(item));
}
return copy;
}
/**
* 深拷贝 Map。
*
* @param source 原始 Map
* @return 深拷贝后的 Map
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> deepCopyMap(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
Object value = entry.getValue();
if (value instanceof Map<?, ?> mapValue) {
copy.put(entry.getKey(), deepCopyMap((Map<String, Object>) mapValue));
} else if (value instanceof List<?> listValue) {
copy.put(entry.getKey(), deepCopyValueList((List<Object>) listValue));
} else {
copy.put(entry.getKey(), value);
}
}
return copy;
}
private static List<Object> deepCopyValueList(List<Object> source) {
List<Object> copy = new ArrayList<>(source.size());
for (Object item : source) {
if (item instanceof Map<?, ?> mapItem) {
copy.add(deepCopyMap((Map<String, Object>) mapItem));
} else if (item instanceof List<?> listItem) {
copy.add(deepCopyValueList((List<Object>) listItem));
} else {
copy.add(item);
}
}
return copy;
}
private static Map<String, Object> findAssistant(List<Map<String, Object>> messageChain, boolean reverse) {
if (messageChain == null || messageChain.isEmpty()) {
return Collections.emptyMap();
}
if (reverse) {
for (int i = messageChain.size() - 1; i >= 0; i--) {
Map<String, Object> item = messageChain.get(i);
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
return deepCopyMap(item);
}
}
return Collections.emptyMap();
}
for (Map<String, Object> item : messageChain) {
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
return deepCopyMap(item);
}
}
return Collections.emptyMap();
}
}

View File

@@ -16,6 +16,10 @@ public class ChatRuntimeMessage implements Serializable {
private Date createdAt = new Date(); private Date createdAt = new Date();
private BigInteger senderId; private BigInteger senderId;
private String senderName; private String senderName;
private BigInteger roundId;
private Integer roundNo;
private String messageKind;
private Integer variantIndex;
public BigInteger getMessageId() { public BigInteger getMessageId() {
return messageId; return messageId;
@@ -80,4 +84,36 @@ public class ChatRuntimeMessage implements Serializable {
public void setSenderName(String senderName) { public void setSenderName(String senderName) {
this.senderName = senderName; this.senderName = senderName;
} }
public BigInteger getRoundId() {
return roundId;
}
public void setRoundId(BigInteger roundId) {
this.roundId = roundId;
}
public Integer getRoundNo() {
return roundNo;
}
public void setRoundNo(Integer roundNo) {
this.roundNo = roundNo;
}
public String getMessageKind() {
return messageKind;
}
public void setMessageKind(String messageKind) {
this.messageKind = messageKind;
}
public Integer getVariantIndex() {
return variantIndex;
}
public void setVariantIndex(Integer variantIndex) {
this.variantIndex = variantIndex;
}
} }

View File

@@ -22,5 +22,22 @@
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version> <version>${jackson.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -9,7 +9,9 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisPassword; import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import tech.easyflow.common.mq.core.MQConsumerContainer; import tech.easyflow.common.mq.core.MQConsumerContainer;
import tech.easyflow.common.mq.core.MQConsumerHandler; import tech.easyflow.common.mq.core.MQConsumerHandler;
@@ -24,6 +26,10 @@ import tech.easyflow.common.mq.redis.RedisMQProducer;
import tech.easyflow.common.mq.redis.RedisStreamKeySupport; import tech.easyflow.common.mq.redis.RedisStreamKeySupport;
import tech.easyflow.common.mq.support.MQHealthSupport; import tech.easyflow.common.mq.support.MQHealthSupport;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import io.lettuce.core.api.StatefulConnection;
import java.util.List; import java.util.List;
@Configuration @Configuration
@@ -43,11 +49,27 @@ public class MQConfiguration {
if (redisProperties.getPassword() != null) { if (redisProperties.getPassword() != null) {
configuration.setPassword(RedisPassword.of(redisProperties.getPassword())); configuration.setPassword(RedisPassword.of(redisProperties.getPassword()));
} }
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration); LettuceClientConfiguration clientConfiguration = createClientConfiguration(redisProperties, mqProperties);
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration, clientConfiguration);
connectionFactory.afterPropertiesSet(); connectionFactory.afterPropertiesSet();
return new MQRedisResources(connectionFactory, new StringRedisTemplate(connectionFactory)); return new MQRedisResources(connectionFactory, new StringRedisTemplate(connectionFactory));
} }
private LettuceClientConfiguration createClientConfiguration(RedisProperties redisProperties,
MQProperties mqProperties) {
MQProperties.Redis.Pool pool = mqProperties.getRedis().getPool();
GenericObjectPoolConfig<StatefulConnection<?, ?>> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(pool.getMaxActive());
poolConfig.setMaxIdle(pool.getMaxIdle());
poolConfig.setMinIdle(pool.getMinIdle());
LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder =
LettucePoolingClientConfiguration.builder().poolConfig(poolConfig);
if (redisProperties.getTimeout() != null) {
builder.commandTimeout(redisProperties.getTimeout());
}
return builder.build();
}
@Bean(name = "mqRedisConnectionFactory", autowireCandidate = false, defaultCandidate = false) @Bean(name = "mqRedisConnectionFactory", autowireCandidate = false, defaultCandidate = false)
@ConditionalOnProperty(prefix = "easyflow.mq", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "easyflow.mq", name = "enabled", havingValue = "true", matchIfMissing = true)
public LettuceConnectionFactory mqRedisConnectionFactory(MQRedisResources mqRedisResources) { public LettuceConnectionFactory mqRedisConnectionFactory(MQRedisResources mqRedisResources) {

View File

@@ -1,9 +1,13 @@
package tech.easyflow.common.mq.config; package tech.easyflow.common.mq.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import java.time.Duration; import java.time.Duration;
/**
* EasyFlow MQ 配置。
*/
@ConfigurationProperties(prefix = "easyflow.mq") @ConfigurationProperties(prefix = "easyflow.mq")
public class MQProperties { public class MQProperties {
@@ -35,11 +39,14 @@ public class MQProperties {
private int database = 1; private int database = 1;
private String streamPrefix = "easyflow:mq"; private String streamPrefix = "easyflow:mq";
private String consumerInstanceId = defaultConsumerInstanceId();
private int chatPersistShardCount = 4; private int chatPersistShardCount = 4;
private int consumerBatchSize = 200; private int consumerBatchSize = 200;
private Duration consumerBlockTimeout = Duration.ofMillis(2000); private Duration consumerBlockTimeout = Duration.ofMillis(2000);
private Duration pendingClaimIdle = Duration.ofMillis(60000); private Duration pendingClaimIdle = Duration.ofMillis(60000);
private int maxRetry = 16; private int maxRetry = 16;
private ConsumerExecutor consumerExecutor = new ConsumerExecutor();
private Pool pool = new Pool();
public int getDatabase() { public int getDatabase() {
return database; return database;
@@ -57,6 +64,26 @@ public class MQProperties {
this.streamPrefix = streamPrefix; this.streamPrefix = streamPrefix;
} }
/**
* 获取 Redis Stream 消费实例 ID。
*
* @return 消费实例 ID
*/
public String getConsumerInstanceId() {
return consumerInstanceId;
}
/**
* 设置 Redis Stream 消费实例 ID。
*
* @param consumerInstanceId 消费实例 ID
*/
public void setConsumerInstanceId(String consumerInstanceId) {
this.consumerInstanceId = StringUtils.hasText(consumerInstanceId)
? consumerInstanceId.trim()
: defaultConsumerInstanceId();
}
public int getChatPersistShardCount() { public int getChatPersistShardCount() {
return chatPersistShardCount; return chatPersistShardCount;
} }
@@ -96,5 +123,106 @@ public class MQProperties {
public void setMaxRetry(int maxRetry) { public void setMaxRetry(int maxRetry) {
this.maxRetry = maxRetry; this.maxRetry = maxRetry;
} }
public ConsumerExecutor getConsumerExecutor() {
return consumerExecutor;
}
public void setConsumerExecutor(ConsumerExecutor consumerExecutor) {
this.consumerExecutor = consumerExecutor;
}
public Pool getPool() {
return pool;
}
public void setPool(Pool pool) {
this.pool = pool;
}
/**
* Redis MQ 消费线程池配置。
*/
public static class ConsumerExecutor {
private int coreSize = 4;
private int maxSize = 12;
private int queueCapacity = 64;
private int keepAliveSeconds = 60;
public int getCoreSize() {
return coreSize;
}
public void setCoreSize(int coreSize) {
this.coreSize = coreSize;
}
public int getMaxSize() {
return maxSize;
}
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
}
public int getQueueCapacity() {
return queueCapacity;
}
public void setQueueCapacity(int queueCapacity) {
this.queueCapacity = queueCapacity;
}
public int getKeepAliveSeconds() {
return keepAliveSeconds;
}
public void setKeepAliveSeconds(int keepAliveSeconds) {
this.keepAliveSeconds = keepAliveSeconds;
}
}
/**
* Redis MQ 连接池配置。
*/
public static class Pool {
private int maxActive = 12;
private int maxIdle = 8;
private int minIdle = 1;
public int getMaxActive() {
return maxActive;
}
public void setMaxActive(int maxActive) {
this.maxActive = maxActive;
}
public int getMaxIdle() {
return maxIdle;
}
public void setMaxIdle(int maxIdle) {
this.maxIdle = maxIdle;
}
public int getMinIdle() {
return minIdle;
}
public void setMinIdle(int minIdle) {
this.minIdle = minIdle;
}
}
private static String defaultConsumerInstanceId() {
String hostName = System.getenv("HOSTNAME");
if (StringUtils.hasText(hostName)) {
return hostName.trim();
}
return java.util.UUID.randomUUID().toString();
}
} }
} }

View File

@@ -5,6 +5,7 @@ public class MQSubscription {
private String topic; private String topic;
private String consumerGroup; private String consumerGroup;
private int shardCount; private int shardCount;
private boolean batchEnabled = true;
public String getTopic() { public String getTopic() {
return topic; return topic;
@@ -29,4 +30,22 @@ public class MQSubscription {
public void setShardCount(int shardCount) { public void setShardCount(int shardCount) {
this.shardCount = shardCount; this.shardCount = shardCount;
} }
/**
* 是否启用批量消费。
*
* @return true 表示启用批量消费
*/
public boolean isBatchEnabled() {
return batchEnabled;
}
/**
* 设置是否启用批量消费。
*
* @param batchEnabled 是否启用批量消费
*/
public void setBatchEnabled(boolean batchEnabled) {
this.batchEnabled = batchEnabled;
}
} }

View File

@@ -30,13 +30,17 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.regex.Pattern;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle { public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
private static final Logger LOG = LoggerFactory.getLogger(RedisMQConsumerContainer.class); private static final Logger LOG = LoggerFactory.getLogger(RedisMQConsumerContainer.class);
private static final Pattern UNSAFE_CONSUMER_NAME_CHARS = Pattern.compile("[^A-Za-z0-9_.-]");
private final RedisConnectionFactory redisConnectionFactory; private final RedisConnectionFactory redisConnectionFactory;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
@@ -45,7 +49,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
private final MQDeadLetterService deadLetterService; private final MQDeadLetterService deadLetterService;
private final RedisStreamKeySupport keySupport; private final RedisStreamKeySupport keySupport;
private final List<MQConsumerHandler> handlers; private final List<MQConsumerHandler> handlers;
private final ExecutorService executorService = Executors.newCachedThreadPool(); private final ExecutorService executorService;
private volatile boolean running; private volatile boolean running;
@@ -63,6 +67,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
this.deadLetterService = deadLetterService; this.deadLetterService = deadLetterService;
this.keySupport = keySupport; this.keySupport = keySupport;
this.handlers = handlers; this.handlers = handlers;
this.executorService = createExecutor(properties, handlers);
} }
@Override @Override
@@ -77,7 +82,12 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
int currentShard = shard; int currentShard = shard;
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}", LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName()); subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
executorService.submit(() -> consumeLoop(handler, subscription, currentShard)); try {
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
} catch (RuntimeException e) {
running = false;
throw e;
}
} }
} }
} }
@@ -108,15 +118,62 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
stop(); stop();
} }
private ExecutorService createExecutor(MQProperties properties, List<MQConsumerHandler> handlers) {
MQProperties.Redis.ConsumerExecutor config = properties.getRedis().getConsumerExecutor();
int consumerTaskCount = handlers.stream()
.map(MQConsumerHandler::subscription)
.filter(Objects::nonNull)
.mapToInt(subscription -> Math.max(subscription.getShardCount(), 1))
.sum();
if (config.getCoreSize() > config.getMaxSize()) {
throw new IllegalStateException("Redis MQ 消费线程池配置错误core-size 不能大于 max-size");
}
if (consumerTaskCount > config.getMaxSize()) {
throw new IllegalStateException("Redis MQ 消费线程池配置错误max-size="
+ config.getMaxSize() + " 小于消费循环数 " + consumerTaskCount
+ ",请调大 easyflow.mq.redis.consumer-executor.max-size");
}
int coreSize = Math.max(config.getCoreSize(), consumerTaskCount);
int maxSize = config.getMaxSize();
AtomicInteger threadIndex = new AtomicInteger(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
coreSize,
maxSize,
config.getKeepAliveSeconds(),
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(config.getQueueCapacity()),
task -> {
Thread thread = new Thread(task);
thread.setName("redis-mq-consumer-" + threadIndex.getAndIncrement());
thread.setDaemon(false);
return thread;
},
new ThreadPoolExecutor.AbortPolicy()
);
executor.allowCoreThreadTimeOut(true);
return executor;
}
private void consumeLoop(MQConsumerHandler handler, MQSubscription subscription, int shard) { private void consumeLoop(MQConsumerHandler handler, MQSubscription subscription, int shard) {
String streamKey = keySupport.streamKey(subscription.getTopic(), shard); String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
String consumerName = subscription.getConsumerGroup() + "-" + shard; String consumerName = buildConsumerName(subscription.getConsumerGroup(), shard);
ensureConsumerGroup(streamKey, subscription.getConsumerGroup()); ensureConsumerGroup(streamKey, subscription.getConsumerGroup());
LOG.info("MQ 消费循环已启动: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}", LOG.info("MQ 消费循环已启动: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, handler.getClass().getSimpleName()); subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, handler.getClass().getSimpleName());
while (running) { while (running) {
try { try {
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName); List<MapRecord<String, Object, Object>> pendingRecords =
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
if (!pendingRecords.isEmpty()) {
List<MQMessage> pendingMessages = toMessages(streamKey, pendingRecords);
if (!pendingMessages.isEmpty()) {
LOG.info("MQ 收到重领 pending 消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName,
streamKey, pendingMessages.size());
handleMessages(handler, subscription, streamKey, subscription.getConsumerGroup(), pendingMessages);
continue;
}
}
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read( List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
Consumer.from(subscription.getConsumerGroup(), consumerName), Consumer.from(subscription.getConsumerGroup(), consumerName),
StreamReadOptions.empty() StreamReadOptions.empty()
@@ -133,7 +190,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
LOG.info("MQ 收到消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}", LOG.info("MQ 收到消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, messages.size()); subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, messages.size());
handleMessages(handler, streamKey, subscription.getConsumerGroup(), messages); handleMessages(handler, subscription, streamKey, subscription.getConsumerGroup(), messages);
} catch (Exception exception) { } catch (Exception exception) {
LOG.error("MQ 消费循环异常: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}", LOG.error("MQ 消费循环异常: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
subscription.getTopic(), subscription.getTopic(),
@@ -148,7 +205,20 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
} }
private void reclaimPending(String streamKey, String group, String consumerName) { /**
* 构建 Redis Stream consumer name。
*
* @param consumerGroup 消费组
* @param shard 分片序号
* @return consumer name
*/
String buildConsumerName(String consumerGroup, int shard) {
String instanceId = properties.getRedis().getConsumerInstanceId();
String safeInstanceId = UNSAFE_CONSUMER_NAME_CHARS.matcher(instanceId).replaceAll("-");
return consumerGroup + "-" + shard + "-" + safeInstanceId;
}
List<MapRecord<String, Object, Object>> reclaimPending(String streamKey, String group, String consumerName) {
Duration idle = properties.getRedis().getPendingClaimIdle(); Duration idle = properties.getRedis().getPendingClaimIdle();
try (RedisConnection connection = redisConnectionFactory.getConnection()) { try (RedisConnection connection = redisConnectionFactory.getConnection()) {
RedisStreamCommands.XPendingOptions options = RedisStreamCommands.XPendingOptions RedisStreamCommands.XPendingOptions options = RedisStreamCommands.XPendingOptions
@@ -156,7 +226,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
var pendingMessages = connection.streamCommands() var pendingMessages = connection.streamCommands()
.xPending(streamKey.getBytes(StandardCharsets.UTF_8), group, options); .xPending(streamKey.getBytes(StandardCharsets.UTF_8), group, options);
if (pendingMessages == null || pendingMessages.isEmpty()) { if (pendingMessages == null || pendingMessages.isEmpty()) {
return; return List.of();
} }
List<RecordId> ids = new ArrayList<>(); List<RecordId> ids = new ArrayList<>();
for (PendingMessage pendingMessage : pendingMessages) { for (PendingMessage pendingMessage : pendingMessages) {
@@ -165,15 +235,16 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
} }
if (ids.isEmpty()) { if (ids.isEmpty()) {
return; return List.of();
} }
stringRedisTemplate.opsForStream().claim( List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().claim(
streamKey, streamKey,
group, group,
consumerName, consumerName,
idle, idle,
ids.toArray(new RecordId[0]) ids.toArray(new RecordId[0])
); );
return records == null ? List.of() : records;
} }
} }
@@ -189,7 +260,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
} }
private List<MQMessage> toMessages(String streamKey, List<MapRecord<String, Object, Object>> records) { List<MQMessage> toMessages(String streamKey, List<MapRecord<String, Object, Object>> records) {
List<MQMessage> messages = new ArrayList<>(records.size()); List<MQMessage> messages = new ArrayList<>(records.size());
for (MapRecord<String, Object, Object> record : records) { for (MapRecord<String, Object, Object> record : records) {
Object payload = record.getValue().get("payload"); Object payload = record.getValue().get("payload");
@@ -225,7 +296,15 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
} }
private void handleMessages(MQConsumerHandler handler, String streamKey, String group, List<MQMessage> messages) throws Exception { void handleMessages(MQConsumerHandler handler,
MQSubscription subscription,
String streamKey,
String group,
List<MQMessage> messages) throws Exception {
if (!subscription.isBatchEnabled()) {
handleMessagesIndividually(handler, streamKey, group, messages);
return;
}
try { try {
LOG.info("MQ 开始批量处理消息: group={}, streamKey={}, count={}, handler={}", LOG.info("MQ 开始批量处理消息: group={}, streamKey={}, count={}, handler={}",
group, streamKey, messages.size(), handler.getClass().getSimpleName()); group, streamKey, messages.size(), handler.getClass().getSimpleName());
@@ -244,6 +323,13 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
} }
} }
handleMessagesIndividually(handler, streamKey, group, messages);
}
private void handleMessagesIndividually(MQConsumerHandler handler,
String streamKey,
String group,
List<MQMessage> messages) {
for (MQMessage message : messages) { for (MQMessage message : messages) {
try { try {
LOG.info("MQ 开始单条处理消息: group={}, streamKey={}, messageId={}, handler={}", LOG.info("MQ 开始单条处理消息: group={}, streamKey={}, messageId={}, handler={}",

View File

@@ -0,0 +1,175 @@
package tech.easyflow.common.mq.redis;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStreamCommands;
import org.springframework.data.redis.connection.stream.Consumer;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.PendingMessage;
import org.springframework.data.redis.connection.stream.PendingMessages;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StreamOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQDeadLetterService;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQMessageConverter;
import tech.easyflow.common.mq.core.MQSubscription;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* {@link RedisMQConsumerContainer} 回归测试。
*/
public class RedisMQConsumerContainerTest {
/**
* 验证 consumer name 包含稳定实例 ID且消费组名称不被改变。
*/
@Test
public void buildConsumerNameShouldAppendSanitizedInstanceId() {
MQProperties properties = new MQProperties();
properties.getRedis().setConsumerInstanceId("node/a:1");
RedisMQConsumerContainer container = new RedisMQConsumerContainer(
null,
null,
properties,
null,
null,
null,
List.of()
);
String consumerName = container.buildConsumerName("chat-persist", 2);
Assert.assertEquals("chat-persist-2-node-a-1", consumerName);
}
/**
* 验证关闭批量消费后,容器按单条处理并独立确认消息。
*
* @throws Exception 消息处理异常
*/
@Test
public void handleMessagesShouldProcessIndividuallyWhenBatchDisabled() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
StreamOperations<String, Object, Object> streamOperations = Mockito.mock(StreamOperations.class);
Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
RecordingHandler handler = new RecordingHandler();
MQSubscription subscription = new MQSubscription();
subscription.setBatchEnabled(false);
RedisMQConsumerContainer container = container(redisTemplate, null);
MQMessage first = message("message-1", "1-0");
MQMessage second = message("message-2", "2-0");
container.handleMessages(handler, subscription, "stream-1", "group-1", List.of(first, second));
Assert.assertEquals(List.of(List.of("message-1"), List.of("message-2")), handler.calls);
Mockito.verify(streamOperations).acknowledge("stream-1", "group-1", "1-0");
Mockito.verify(streamOperations).acknowledge("stream-1", "group-1", "2-0");
}
/**
* 验证 pending 消息被 claim 后可以转换为 MQ 消息继续消费。
*/
@Test
public void reclaimPendingShouldReturnClaimedRecordsForConsumption() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
StreamOperations<String, Object, Object> streamOperations = Mockito.mock(StreamOperations.class);
Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
RedisConnection connection = Mockito.mock(RedisConnection.class);
RedisStreamCommands streamCommands = Mockito.mock(RedisStreamCommands.class);
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
Mockito.when(connection.streamCommands()).thenReturn(streamCommands);
PendingMessage pendingMessage = new PendingMessage(
RecordId.of("1-0"), Consumer.from("group-1", "old-consumer"), Duration.ofMinutes(2), 1);
Mockito.when(streamCommands.xPending(
ArgumentMatchers.eq("stream-1".getBytes(java.nio.charset.StandardCharsets.UTF_8)),
ArgumentMatchers.eq("group-1"),
ArgumentMatchers.any(RedisStreamCommands.XPendingOptions.class)))
.thenReturn(new PendingMessages("group-1", List.of(pendingMessage)));
Map<Object, Object> payload = Map.of("payload", "message-1");
MapRecord<String, Object, Object> record = MapRecord
.create("stream-1", payload)
.withId(RecordId.of("1-0"));
Mockito.when(streamOperations.claim(
ArgumentMatchers.eq("stream-1"),
ArgumentMatchers.eq("group-1"),
ArgumentMatchers.eq("consumer-1"),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.any(RecordId[].class)))
.thenReturn(List.of(record));
RedisMQConsumerContainer container = container(redisTemplate, connectionFactory);
List<MapRecord<String, Object, Object>> records =
container.reclaimPending("stream-1", "group-1", "consumer-1");
List<MQMessage> messages = container.toMessages("stream-1", records);
Assert.assertEquals(1, records.size());
Assert.assertEquals(1, messages.size());
Assert.assertEquals("message-1", messages.get(0).getMessageId());
Assert.assertEquals("1-0", messages.get(0).getStreamMessageId());
}
private RedisMQConsumerContainer container(StringRedisTemplate redisTemplate,
RedisConnectionFactory connectionFactory) {
MQProperties properties = new MQProperties();
return new RedisMQConsumerContainer(
connectionFactory,
redisTemplate,
properties,
new PlainMessageConverter(),
Mockito.mock(MQDeadLetterService.class),
null,
List.of()
);
}
private MQMessage message(String messageId, String streamMessageId) {
MQMessage message = new MQMessage();
message.setMessageId(messageId);
message.setStreamMessageId(streamMessageId);
return message;
}
private static final class RecordingHandler implements MQConsumerHandler {
private final List<List<String>> calls = new ArrayList<>();
@Override
public MQSubscription subscription() {
return new MQSubscription();
}
@Override
public void handle(List<MQMessage> messages) {
calls.add(messages.stream().map(MQMessage::getMessageId).toList());
}
}
private static final class PlainMessageConverter implements MQMessageConverter {
@Override
public String serialize(MQMessage message) {
return message.getMessageId();
}
@Override
public MQMessage deserialize(String payload) {
MQMessage message = new MQMessage();
message.setMessageId(payload);
return message;
}
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-modules</artifactId>
<version>${revision}</version>
</parent>
<name>easyflow-module-agent</name>
<artifactId>easyflow-module-agent</artifactId>
<dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-approval</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-system</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-chat-protocol</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-cache</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-mq</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-web</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-satoken</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,16 @@
package tech.easyflow.agent.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
/**
* Agent 模块自动配置。
*/
@AutoConfiguration
@MapperScan("tech.easyflow.agent.mapper")
@ComponentScan("tech.easyflow.agent")
@EnableConfigurationProperties(AgentRuntimeProperties.class)
public class AgentModuleConfig {
}

View File

@@ -0,0 +1,295 @@
package tech.easyflow.agent.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.UUID;
/**
* Agent 运行态生产化配置。
*/
@ConfigurationProperties(prefix = "easyflow.agent.runtime")
public class AgentRuntimeProperties {
/**
* Redis 热态 session 缓存 TTL。
*/
private Duration sessionCacheTtl = Duration.ofHours(24);
/**
* 当前 Agent 运行实例 ID。
*/
private String instanceId = defaultInstanceId();
/**
* Agent 运行路由 TTL。
*/
private Duration routeTtl = Duration.ofHours(24);
/**
* Agent 运行命令 topic 前缀。
*/
private String commandTopicPrefix = "easyflow:agent-runtime-command";
/**
* Agent 运行命令结果等待超时时间。
*/
private Duration commandResultTimeout = Duration.ofSeconds(5);
/**
* Agent 运行命令结果缓存 TTL。
*/
private Duration commandResultTtl = Duration.ofMinutes(5);
/**
* 当前进程启动代 ID。
*/
private final String bootId = UUID.randomUUID().toString();
/**
* HITL pending 默认过期时间。
*/
private Duration hitlPendingTimeout = Duration.ofMinutes(30);
/**
* 运行锁等待时间。
*/
private Duration lockWaitTimeout = Duration.ofSeconds(2);
/**
* 运行锁租约时间。
*/
private Duration lockLeaseTimeout = Duration.ofMinutes(5);
/**
* 运行锁续期间隔。
*/
private Duration lockRenewInterval = Duration.ofMinutes(1);
/**
* Agent 异步工具任务 Redis 运行态 TTL。
*/
private Duration asyncToolTaskTtl = Duration.ofHours(24);
/**
* 获取 Redis 热态 session 缓存 TTL。
*
* @return 缓存 TTL
*/
public Duration getSessionCacheTtl() {
return sessionCacheTtl;
}
/**
* 设置 Redis 热态 session 缓存 TTL。
*
* @param sessionCacheTtl 缓存 TTL
*/
public void setSessionCacheTtl(Duration sessionCacheTtl) {
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
}
/**
* 获取当前 Agent 运行实例 ID。
*
* @return 实例 ID
*/
public String getInstanceId() {
return instanceId;
}
/**
* 设置当前 Agent 运行实例 ID。
*
* @param instanceId 实例 ID
*/
public void setInstanceId(String instanceId) {
this.instanceId = StringUtils.hasText(instanceId) ? instanceId.trim() : defaultInstanceId();
}
/**
* 获取 Agent 运行路由 TTL。
*
* @return 路由 TTL
*/
public Duration getRouteTtl() {
return routeTtl;
}
/**
* 设置 Agent 运行路由 TTL。
*
* @param routeTtl 路由 TTL
*/
public void setRouteTtl(Duration routeTtl) {
this.routeTtl = routeTtl == null ? Duration.ofHours(24) : routeTtl;
}
/**
* 获取 Agent 运行命令 topic 前缀。
*
* @return 命令 topic 前缀
*/
public String getCommandTopicPrefix() {
return commandTopicPrefix;
}
/**
* 设置 Agent 运行命令 topic 前缀。
*
* @param commandTopicPrefix 命令 topic 前缀
*/
public void setCommandTopicPrefix(String commandTopicPrefix) {
this.commandTopicPrefix = StringUtils.hasText(commandTopicPrefix)
? commandTopicPrefix.trim()
: "easyflow:agent-runtime-command";
}
/**
* 获取 Agent 运行命令结果等待超时时间。
*
* @return 等待超时时间
*/
public Duration getCommandResultTimeout() {
return commandResultTimeout;
}
/**
* 设置 Agent 运行命令结果等待超时时间。
*
* @param commandResultTimeout 等待超时时间
*/
public void setCommandResultTimeout(Duration commandResultTimeout) {
this.commandResultTimeout = commandResultTimeout == null ? Duration.ofSeconds(5) : commandResultTimeout;
}
/**
* 获取 Agent 运行命令结果缓存 TTL。
*
* @return 结果缓存 TTL
*/
public Duration getCommandResultTtl() {
return commandResultTtl;
}
/**
* 设置 Agent 运行命令结果缓存 TTL。
*
* @param commandResultTtl 结果缓存 TTL
*/
public void setCommandResultTtl(Duration commandResultTtl) {
this.commandResultTtl = commandResultTtl == null ? Duration.ofMinutes(5) : commandResultTtl;
}
/**
* 获取当前进程启动代 ID。
*
* @return 启动代 ID
*/
public String getBootId() {
return bootId;
}
/**
* 获取 HITL pending 默认过期时间。
*
* @return 过期时间
*/
public Duration getHitlPendingTimeout() {
return hitlPendingTimeout;
}
/**
* 设置 HITL pending 默认过期时间。
*
* @param hitlPendingTimeout 过期时间
*/
public void setHitlPendingTimeout(Duration hitlPendingTimeout) {
this.hitlPendingTimeout = hitlPendingTimeout == null ? Duration.ofMinutes(30) : hitlPendingTimeout;
}
/**
* 获取运行锁等待时间。
*
* @return 等待时间
*/
public Duration getLockWaitTimeout() {
return lockWaitTimeout;
}
/**
* 设置运行锁等待时间。
*
* @param lockWaitTimeout 等待时间
*/
public void setLockWaitTimeout(Duration lockWaitTimeout) {
this.lockWaitTimeout = lockWaitTimeout == null ? Duration.ofSeconds(2) : lockWaitTimeout;
}
/**
* 获取运行锁租约时间。
*
* @return 租约时间
*/
public Duration getLockLeaseTimeout() {
return lockLeaseTimeout;
}
/**
* 设置运行锁租约时间。
*
* @param lockLeaseTimeout 租约时间
*/
public void setLockLeaseTimeout(Duration lockLeaseTimeout) {
this.lockLeaseTimeout = lockLeaseTimeout == null ? Duration.ofMinutes(5) : lockLeaseTimeout;
}
/**
* 获取运行锁续期间隔。
*
* @return 续期间隔
*/
public Duration getLockRenewInterval() {
return lockRenewInterval;
}
/**
* 设置运行锁续期间隔。
*
* @param lockRenewInterval 续期间隔
*/
public void setLockRenewInterval(Duration lockRenewInterval) {
this.lockRenewInterval = lockRenewInterval == null ? Duration.ofMinutes(1) : lockRenewInterval;
}
/**
* 获取 Agent 异步工具任务 Redis 运行态 TTL。
*
* @return 任务 TTL
*/
public Duration getAsyncToolTaskTtl() {
return asyncToolTaskTtl;
}
/**
* 设置 Agent 异步工具任务 Redis 运行态 TTL。
*
* @param asyncToolTaskTtl 任务 TTL
*/
public void setAsyncToolTaskTtl(Duration asyncToolTaskTtl) {
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
}
private static String defaultInstanceId() {
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
if (StringUtils.hasText(envInstanceId)) {
return envInstanceId.trim();
}
String hostName = System.getenv("HOSTNAME");
if (StringUtils.hasText(hostName)) {
return hostName.trim();
}
return UUID.randomUUID().toString();
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.agent.distributed;
/**
* Agent 运行态远程命令动作。
*/
public enum AgentRuntimeCommandAction {
/**
* 批准工具执行。
*/
APPROVE,
/**
* 拒绝工具执行。
*/
REJECT
}

View File

@@ -0,0 +1,127 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import java.util.List;
/**
* Agent 运行态远程命令消费者。
*/
@Component
public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCommandConsumer.class);
private final ObjectMapper objectMapper;
private final AgentRuntimeProperties properties;
private final MQProperties mqProperties;
private final AgentRunService agentRunService;
private final AgentRuntimeCommandResultRegistry resultRegistry;
/**
* 创建 Agent 运行态远程命令消费者。
*
* @param objectMapper JSON 序列化器
* @param properties Agent 运行配置
* @param mqProperties MQ 配置
* @param agentRunService Agent 运行服务
* @param resultRegistry 远程命令结果注册表
*/
public AgentRuntimeCommandConsumer(ObjectMapper objectMapper,
AgentRuntimeProperties properties,
MQProperties mqProperties,
AgentRunService agentRunService,
AgentRuntimeCommandResultRegistry resultRegistry) {
this.objectMapper = objectMapper;
this.properties = properties;
this.mqProperties = mqProperties;
this.agentRunService = agentRunService;
this.resultRegistry = resultRegistry;
}
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(commandTopic());
subscription.setConsumerGroup(commandTopic());
subscription.setShardCount(Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1));
subscription.setBatchEnabled(false);
return subscription;
}
@Override
public void handle(List<MQMessage> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
for (MQMessage message : messages) {
try {
handleCommand(message, objectMapper.readValue(message.getBody(), AgentRuntimeCommandMessage.class));
} catch (Exception e) {
LOG.warn("Agent 远程运行命令解析失败: messageId={}", message.getMessageId(), e);
}
}
}
private void handleCommand(MQMessage message, AgentRuntimeCommandMessage command) {
if (command == null || command.getAction() == null) {
LOG.warn("跳过非法 Agent 远程运行命令: messageId={}", message.getMessageId());
return;
}
if (!properties.getInstanceId().equals(command.getTargetNodeId())) {
LOG.warn("跳过非本节点 Agent 远程运行命令: messageId={}, targetNodeId={}, currentNodeId={}",
message.getMessageId(), command.getTargetNodeId(), properties.getInstanceId());
return;
}
try {
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
agentRunService.approveRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId());
} else if (command.getAction() == AgentRuntimeCommandAction.REJECT) {
agentRunService.rejectRuntimeLocal(
command.getRequestId(), command.getResumeToken(), command.getReason(),
command.getOperatorId(), command.getUserId());
} else {
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",
message.getMessageId(), command.getCommandId(), command.getAction());
return;
}
} catch (RuntimeException e) {
markFailureQuietly(command, e);
LOG.warn("Agent 远程运行命令处理失败: messageId={}, commandId={}",
message.getMessageId(), command.getCommandId(), e);
return;
}
markSuccessQuietly(command);
}
private String commandTopic() {
return properties.getCommandTopicPrefix() + ":" + properties.getInstanceId();
}
private void markSuccessQuietly(AgentRuntimeCommandMessage command) {
try {
resultRegistry.markSuccess(command.getCommandId());
} catch (RuntimeException e) {
LOG.error("Agent 远程运行命令成功结果写入失败: commandId={}", command.getCommandId(), e);
}
}
private void markFailureQuietly(AgentRuntimeCommandMessage command, RuntimeException cause) {
try {
resultRegistry.markFailure(command.getCommandId(), cause.getMessage());
} catch (RuntimeException e) {
LOG.error("Agent 远程运行命令失败结果写入失败: commandId={}", command.getCommandId(), e);
}
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.agent.distributed;
import java.math.BigInteger;
import java.util.Date;
/**
* Agent 运行态远程恢复命令消息。
*/
public class AgentRuntimeCommandMessage {
private String commandId;
private String requestId;
private String resumeToken;
private AgentRuntimeCommandAction action;
private String reason;
private BigInteger operatorId;
private String userId;
private String targetNodeId;
private Date occurredAt;
public String getCommandId() {
return commandId;
}
public void setCommandId(String commandId) {
this.commandId = commandId;
}
public String getRequestId() {
return requestId;
}
public void setRequestId(String requestId) {
this.requestId = requestId;
}
public String getResumeToken() {
return resumeToken;
}
public void setResumeToken(String resumeToken) {
this.resumeToken = resumeToken;
}
public AgentRuntimeCommandAction getAction() {
return action;
}
public void setAction(AgentRuntimeCommandAction action) {
this.action = action;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public BigInteger getOperatorId() {
return operatorId;
}
public void setOperatorId(BigInteger operatorId) {
this.operatorId = operatorId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getTargetNodeId() {
return targetNodeId;
}
public void setTargetNodeId(String targetNodeId) {
this.targetNodeId = targetNodeId;
}
public Date getOccurredAt() {
return occurredAt;
}
public void setOccurredAt(Date occurredAt) {
this.occurredAt = occurredAt;
}
}

View File

@@ -0,0 +1,153 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Date;
import java.util.UUID;
/**
* Agent 运行态远程命令生产者。
*/
@Service
public class AgentRuntimeCommandProducer {
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCommandProducer.class);
private final MQProducer mqProducer;
private final ObjectMapper objectMapper;
private final AgentRuntimeProperties properties;
private final AgentRuntimeCommandResultRegistry resultRegistry;
/**
* 测试子类构造器。
*/
protected AgentRuntimeCommandProducer() {
this.mqProducer = null;
this.objectMapper = null;
this.properties = null;
this.resultRegistry = null;
}
/**
* 创建 Agent 运行态远程命令生产者。
*
* @param mqProducer MQ 生产者
* @param objectMapper JSON 序列化器
* @param properties Agent 运行配置
* @param resultRegistry 远程命令结果注册表
*/
public AgentRuntimeCommandProducer(MQProducer mqProducer,
ObjectMapper objectMapper,
AgentRuntimeProperties properties,
AgentRuntimeCommandResultRegistry resultRegistry) {
this.mqProducer = mqProducer;
this.objectMapper = objectMapper;
this.properties = properties;
this.resultRegistry = resultRegistry;
}
/**
* 投递远程批准命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void sendApprove(String targetNodeId,
String requestId,
String resumeToken,
BigInteger operatorId,
String userId) {
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId);
}
/**
* 投递远程拒绝命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param reason 拒绝原因
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void sendReject(String targetNodeId,
String requestId,
String resumeToken,
String reason,
BigInteger operatorId,
String userId) {
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId);
}
private void sendAndWait(String targetNodeId,
String requestId,
String resumeToken,
AgentRuntimeCommandAction action,
String reason,
BigInteger operatorId,
String userId) {
if (targetNodeId == null || targetNodeId.isBlank()) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
command.setCommandId(UUID.randomUUID().toString());
command.setRequestId(requestId);
command.setResumeToken(resumeToken);
command.setAction(action);
command.setReason(reason);
command.setOperatorId(operatorId);
command.setUserId(userId);
command.setTargetNodeId(targetNodeId);
command.setOccurredAt(new Date());
MQMessage message = new MQMessage();
message.setMessageId(command.getCommandId());
message.setTopic(commandTopic(targetNodeId));
message.setKey(command.getCommandId());
message.setCreatedAt(command.getOccurredAt());
try {
message.setBody(objectMapper.writeValueAsString(command));
String recordId = mqProducer.send(message);
LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, targetNodeId={}, recordId={}",
action, requestId, targetNodeId, recordId);
AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId());
if (!result.isSuccess()) {
throw new BusinessException(result.getMessage());
}
} catch (JsonProcessingException e) {
throw new BusinessException("Agent 运行命令序列化失败");
} catch (BusinessException e) {
throw e;
} catch (RuntimeException e) {
LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, targetNodeId={}",
action, requestId, targetNodeId, e);
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
} finally {
deleteResultQuietly(command.getCommandId());
}
}
private String commandTopic(String nodeId) {
return properties.getCommandTopicPrefix() + ":" + nodeId;
}
private void deleteResultQuietly(String commandId) {
try {
resultRegistry.deleteResult(commandId);
} catch (RuntimeException e) {
LOG.warn("Agent 远程运行命令结果清理失败,等待 TTL 兜底: commandId={}", commandId, e);
}
}
}

View File

@@ -0,0 +1,46 @@
package tech.easyflow.agent.distributed;
/**
* Agent 运行态远程命令结果。
*/
public class AgentRuntimeCommandResult {
private boolean success;
private String message;
/**
* 判断命令是否执行成功。
*
* @return true 表示执行成功
*/
public boolean isSuccess() {
return success;
}
/**
* 设置命令是否执行成功。
*
* @param success 是否执行成功
*/
public void setSuccess(boolean success) {
this.success = success;
}
/**
* 获取结果消息。
*
* @return 结果消息
*/
public String getMessage() {
return message;
}
/**
* 设置结果消息。
*
* @param message 结果消息
*/
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,134 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* Agent 运行态远程命令结果注册表。
*/
@Component
public class AgentRuntimeCommandResultRegistry {
private static final String RESULT_PREFIX = "easyflow:agent:runtime:command-result:";
private static final long POLL_INTERVAL_MILLIS = 50L;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final AgentRuntimeProperties properties;
/**
* 创建 Agent 运行态远程命令结果注册表。
*
* @param stringRedisTemplate Redis 字符串模板
* @param objectMapper JSON 序列化器
* @param properties Agent 运行配置
*/
public AgentRuntimeCommandResultRegistry(StringRedisTemplate stringRedisTemplate,
ObjectMapper objectMapper,
AgentRuntimeProperties properties) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
this.properties = properties;
}
/**
* 写入成功结果。
*
* @param commandId 命令 ID
*/
public void markSuccess(String commandId) {
AgentRuntimeCommandResult result = new AgentRuntimeCommandResult();
result.setSuccess(true);
result.setMessage("OK");
writeResult(commandId, result);
}
/**
* 写入失败结果。
*
* @param commandId 命令 ID
* @param message 失败消息
*/
public void markFailure(String commandId, String message) {
AgentRuntimeCommandResult result = new AgentRuntimeCommandResult();
result.setSuccess(false);
result.setMessage(message == null || message.isBlank() ? "Agent 运行节点不可用,请重新发起对话" : message);
writeResult(commandId, result);
}
/**
* 等待远程命令结果。
*
* @param commandId 命令 ID
* @return 命令结果
*/
public AgentRuntimeCommandResult waitForResult(String commandId) {
long deadline = System.nanoTime() + properties.getCommandResultTimeout().toNanos();
while (System.nanoTime() <= deadline) {
AgentRuntimeCommandResult result = readResult(commandId);
if (result != null) {
return result;
}
sleep();
}
throw new BusinessException("Agent 运行节点响应超时,请稍后重试");
}
/**
* 删除远程命令结果。
*
* @param commandId 命令 ID
*/
public void deleteResult(String commandId) {
if (commandId == null || commandId.isBlank()) {
return;
}
stringRedisTemplate.delete(resultKey(commandId));
}
private AgentRuntimeCommandResult readResult(String commandId) {
if (commandId == null || commandId.isBlank()) {
return null;
}
String value = stringRedisTemplate.opsForValue().get(resultKey(commandId));
if (value == null || value.isBlank()) {
return null;
}
try {
return objectMapper.readValue(value, AgentRuntimeCommandResult.class);
} catch (JsonProcessingException e) {
throw new BusinessException("Agent 运行命令结果解析失败");
}
}
private void writeResult(String commandId, AgentRuntimeCommandResult result) {
if (commandId == null || commandId.isBlank()) {
return;
}
try {
stringRedisTemplate.opsForValue().set(
resultKey(commandId),
objectMapper.writeValueAsString(result),
properties.getCommandResultTtl());
} catch (JsonProcessingException e) {
throw new IllegalStateException("Agent 运行命令结果序列化失败", e);
}
}
private String resultKey(String commandId) {
return RESULT_PREFIX + commandId;
}
private void sleep() {
try {
Thread.sleep(POLL_INTERVAL_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("Agent 运行节点响应等待被中断");
}
}
}

View File

@@ -0,0 +1,43 @@
package tech.easyflow.agent.distributed;
import jakarta.annotation.PostConstruct;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Agent 运行节点心跳维护器。
*/
@Component
public class AgentRuntimeNodeHeartbeat {
private static final Duration HEARTBEAT_TTL = Duration.ofSeconds(90);
private final AgentRuntimeRouteRegistry routeRegistry;
/**
* 创建 Agent 运行节点心跳维护器。
*
* @param routeRegistry Agent 运行态 Redis 路由注册表
*/
public AgentRuntimeNodeHeartbeat(AgentRuntimeRouteRegistry routeRegistry) {
this.routeRegistry = routeRegistry;
}
/**
* 启动时立即写入一次当前节点心跳。
*/
@PostConstruct
public void init() {
refresh();
}
/**
* 定期刷新当前节点心跳。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.node-heartbeat-delay:30000}", initialDelay = 30000L)
public void refresh() {
routeRegistry.heartbeat(HEARTBEAT_TTL);
}
}

View File

@@ -0,0 +1,46 @@
package tech.easyflow.agent.distributed;
/**
* Agent 运行态 owner 路由。
*/
public class AgentRuntimeRoute {
private String nodeId;
private String bootId;
/**
* 获取 owner 节点 ID。
*
* @return owner 节点 ID
*/
public String getNodeId() {
return nodeId;
}
/**
* 设置 owner 节点 ID。
*
* @param nodeId owner 节点 ID
*/
public void setNodeId(String nodeId) {
this.nodeId = nodeId;
}
/**
* 获取 owner 启动代 ID。
*
* @return 启动代 ID
*/
public String getBootId() {
return bootId;
}
/**
* 设置 owner 启动代 ID。
*
* @param bootId 启动代 ID
*/
public void setBootId(String bootId) {
this.bootId = bootId;
}
}

View File

@@ -0,0 +1,222 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import java.time.Duration;
/**
* Agent 运行态 Redis 路由注册表。
*/
@Component
public class AgentRuntimeRouteRegistry {
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeRouteRegistry.class);
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:";
private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
private final StringRedisTemplate stringRedisTemplate;
private final AgentRuntimeProperties properties;
private final ObjectMapper objectMapper;
/**
* 创建 Agent 运行态 Redis 路由注册表。
*
* @param stringRedisTemplate Redis 字符串模板
* @param properties Agent 运行配置
* @param objectMapper JSON 序列化器
*/
public AgentRuntimeRouteRegistry(StringRedisTemplate stringRedisTemplate,
AgentRuntimeProperties properties,
ObjectMapper objectMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.properties = properties;
this.objectMapper = objectMapper;
}
/**
* 注册运行请求 owner 节点。
*
* @param requestId 请求 ID
*/
public void registerRun(String requestId) {
if (requestId == null || requestId.isBlank()) {
return;
}
stringRedisTemplate.opsForValue().set(requestKey(requestId), serializeRoute(currentRoute()), properties.getRouteTtl());
}
/**
* 注册恢复令牌与请求 ID 的关系。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
*/
public void registerResumeToken(String requestId, String resumeToken) {
if (requestId == null || requestId.isBlank() || resumeToken == null || resumeToken.isBlank()) {
return;
}
stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl());
}
/**
* 查询请求 ID 所属节点。
*
* @param requestId 请求 ID
* @return owner 节点 ID
*/
public String findOwnerNode(String requestId) {
AgentRuntimeRoute route = findOwnerRoute(requestId);
return route == null ? null : route.getNodeId();
}
/**
* 查询请求 ID 所属路由。
*
* @param requestId 请求 ID
* @return owner 路由
*/
public AgentRuntimeRoute findOwnerRoute(String requestId) {
if (requestId == null || requestId.isBlank()) {
return null;
}
String value = stringRedisTemplate.opsForValue().get(requestKey(requestId));
if (value == null || value.isBlank()) {
return null;
}
return deserializeRoute(value);
}
/**
* 根据恢复令牌查询请求 ID。
*
* @param resumeToken 恢复令牌
* @return 请求 ID
*/
public String findRequestIdByResumeToken(String resumeToken) {
if (resumeToken == null || resumeToken.isBlank()) {
return null;
}
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
}
/**
* 删除指定运行请求的路由。
*
* @param requestId 请求 ID
*/
public void removeRun(String requestId) {
if (requestId == null || requestId.isBlank()) {
return;
}
deleteQuietly(requestKey(requestId));
}
/**
* 删除指定恢复令牌的路由。
*
* @param resumeToken 恢复令牌
*/
public void removeResumeToken(String resumeToken) {
if (resumeToken == null || resumeToken.isBlank()) {
return;
}
deleteQuietly(tokenKey(resumeToken));
}
/**
* 获取当前节点 ID。
*
* @return 当前节点 ID
*/
public String currentNodeId() {
return properties.getInstanceId();
}
/**
* 刷新当前节点存活心跳。
*
* @param ttl 心跳 TTL
*/
public void heartbeat(Duration ttl) {
stringRedisTemplate.opsForValue().set(nodeKey(properties.getInstanceId()), properties.getBootId(), ttl);
}
/**
* 查询指定节点是否仍有存活心跳。
*
* @param nodeId 节点 ID
* @return true 表示节点心跳仍有效
*/
public boolean isNodeAlive(String nodeId) {
return currentNodeBootId(nodeId) != null;
}
/**
* 查询指定节点当前启动代 ID。
*
* @param nodeId 节点 ID
* @return 启动代 ID
*/
public String currentNodeBootId(String nodeId) {
if (nodeId == null || nodeId.isBlank()) {
return null;
}
return stringRedisTemplate.opsForValue().get(nodeKey(nodeId));
}
private String requestKey(String requestId) {
return REQUEST_ROUTE_PREFIX + requestId;
}
private String tokenKey(String resumeToken) {
return TOKEN_ROUTE_PREFIX + resumeToken;
}
private String nodeKey(String nodeId) {
return NODE_HEARTBEAT_PREFIX + nodeId;
}
private AgentRuntimeRoute currentRoute() {
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId(properties.getInstanceId());
route.setBootId(properties.getBootId());
return route;
}
private String serializeRoute(AgentRuntimeRoute route) {
try {
return objectMapper.writeValueAsString(route);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Agent 运行路由序列化失败", e);
}
}
private AgentRuntimeRoute deserializeRoute(String value) {
try {
if (value.trim().startsWith("{")) {
return objectMapper.readValue(value, AgentRuntimeRoute.class);
}
AgentRuntimeRoute legacyRoute = new AgentRuntimeRoute();
legacyRoute.setNodeId(value);
return legacyRoute;
} catch (JsonProcessingException e) {
throw new IllegalStateException("Agent 运行路由反序列化失败", e);
}
}
private void deleteQuietly(String key) {
try {
stringRedisTemplate.delete(key);
} catch (RuntimeException e) {
LOG.warn("清理 Agent 运行态 Redis 路由失败: key={}", key, e);
}
}
}

View File

@@ -0,0 +1,132 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import tech.easyflow.system.permission.resource.VisibilityResource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Agent 主实体。
*/
@Table("tb_agent")
public class Agent extends DateEntity implements VisibilityResource, Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger deptId;
private String name;
private String description;
private String avatar;
private BigInteger categoryId;
private BigInteger modelId;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> modelConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> generationConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> promptConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> memoryConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> executionConfigJson = new LinkedHashMap<>();
private Integer status;
private String visibilityScope;
private String publishStatus;
private BigInteger currentApprovalInstanceId;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> publishedSnapshotJson = new LinkedHashMap<>();
private Date publishedAt;
private BigInteger publishedBy;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true)
private Boolean approvalPending;
@Column(ignore = true)
private String currentApprovalActionType;
@Column(ignore = true)
private String displayPublishStatus;
@Column(ignore = true)
private String createdByName;
@Column(ignore = true)
private List<AgentToolBinding> toolBindings;
@Column(ignore = true)
private List<AgentKnowledgeBinding> knowledgeBindings;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getDeptId() { return deptId; }
public void setDeptId(BigInteger deptId) { this.deptId = deptId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public BigInteger getCategoryId() { return categoryId; }
public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; }
public BigInteger getModelId() { return modelId; }
public void setModelId(BigInteger modelId) { this.modelId = modelId; }
public Map<String, Object> getModelConfigJson() { return modelConfigJson; }
public void setModelConfigJson(Map<String, Object> modelConfigJson) { this.modelConfigJson = modelConfigJson == null ? new LinkedHashMap<>() : modelConfigJson; }
public Map<String, Object> getGenerationConfigJson() { return generationConfigJson; }
public void setGenerationConfigJson(Map<String, Object> generationConfigJson) { this.generationConfigJson = generationConfigJson == null ? new LinkedHashMap<>() : generationConfigJson; }
public Map<String, Object> getPromptConfigJson() { return promptConfigJson; }
public void setPromptConfigJson(Map<String, Object> promptConfigJson) { this.promptConfigJson = promptConfigJson == null ? new LinkedHashMap<>() : promptConfigJson; }
public Map<String, Object> getMemoryConfigJson() { return memoryConfigJson; }
public void setMemoryConfigJson(Map<String, Object> memoryConfigJson) { this.memoryConfigJson = memoryConfigJson == null ? new LinkedHashMap<>() : memoryConfigJson; }
public Map<String, Object> getExecutionConfigJson() { return executionConfigJson; }
public void setExecutionConfigJson(Map<String, Object> executionConfigJson) { this.executionConfigJson = executionConfigJson == null ? new LinkedHashMap<>() : executionConfigJson; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public String getVisibilityScope() { return visibilityScope; }
public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; }
public String getPublishStatus() { return publishStatus; }
public void setPublishStatus(String publishStatus) { this.publishStatus = publishStatus; }
public BigInteger getCurrentApprovalInstanceId() { return currentApprovalInstanceId; }
public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; }
public Map<String, Object> getPublishedSnapshotJson() { return publishedSnapshotJson; }
public void setPublishedSnapshotJson(Map<String, Object> publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; }
public Date getPublishedAt() { return publishedAt; }
public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; }
public BigInteger getPublishedBy() { return publishedBy; }
public void setPublishedBy(BigInteger publishedBy) { this.publishedBy = publishedBy; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public Boolean getApprovalPending() { return approvalPending; }
public void setApprovalPending(Boolean approvalPending) { this.approvalPending = approvalPending; }
public String getCurrentApprovalActionType() { return currentApprovalActionType; }
public void setCurrentApprovalActionType(String currentApprovalActionType) { this.currentApprovalActionType = currentApprovalActionType; }
public String getDisplayPublishStatus() { return displayPublishStatus; }
public void setDisplayPublishStatus(String displayPublishStatus) { this.displayPublishStatus = displayPublishStatus; }
public String getCreatedByName() { return createdByName; }
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
public List<AgentToolBinding> getToolBindings() { return toolBindings; }
public void setToolBindings(List<AgentToolBinding> toolBindings) { this.toolBindings = toolBindings; }
public List<AgentKnowledgeBinding> getKnowledgeBindings() { return knowledgeBindings; }
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; }
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* Agent 分类实体。
*/
@Table("tb_agent_category")
public class AgentCategory extends DateEntity implements Serializable {
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private String categoryName;
private Integer sortNo;
private Integer status;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
}

View File

@@ -0,0 +1,89 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 工具审批挂起态实体。
*/
@Table("tb_agent_hitl_pending")
public class AgentHitlPending extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger chatSessionId;
private String runtimeSessionId;
private String requestId;
private String resumeToken;
private String toolCallId;
private String toolName;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> toolInputJson = new LinkedHashMap<>();
private String status;
private String rejectReason;
private Date expiresAt;
private Date consumedAt;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
private Integer isDeleted;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAgentId() { return agentId; }
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
public BigInteger getChatSessionId() { return chatSessionId; }
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
public String getRuntimeSessionId() { return runtimeSessionId; }
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
public String getRequestId() { return requestId; }
public void setRequestId(String requestId) { this.requestId = requestId; }
public String getResumeToken() { return resumeToken; }
public void setResumeToken(String resumeToken) { this.resumeToken = resumeToken; }
public String getToolCallId() { return toolCallId; }
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
public String getToolName() { return toolName; }
public void setToolName(String toolName) { this.toolName = toolName; }
public Map<String, Object> getToolInputJson() { return toolInputJson; }
public void setToolInputJson(Map<String, Object> toolInputJson) { this.toolInputJson = toolInputJson == null ? new LinkedHashMap<>() : toolInputJson; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getRejectReason() { return rejectReason; }
public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; }
public Date getExpiresAt() { return expiresAt; }
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
public Date getConsumedAt() { return consumedAt; }
public void setConsumedAt(Date consumedAt) { this.consumedAt = consumedAt; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 知识库绑定实体。
*/
@Table("tb_agent_knowledge_binding")
public class AgentKnowledgeBinding extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger knowledgeId;
private String retrievalMode;
private Boolean enabled;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> optionsJson = new LinkedHashMap<>();
@Column(ignore = true)
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
@Column(ignore = true)
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAgentId() { return agentId; }
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
public BigInteger getKnowledgeId() { return knowledgeId; }
public void setKnowledgeId(BigInteger knowledgeId) { this.knowledgeId = knowledgeId; }
public String getRetrievalMode() { return retrievalMode; }
public void setRetrievalMode(String retrievalMode) { this.retrievalMode = retrievalMode; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Map<String, Object> getOptionsJson() { return optionsJson; }
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; }
public Map<String, Object> getResourceSummary() { return resourceSummary; }
public void setResourceSummary(Map<String, Object> resourceSummary) { this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 运行事件摘要实体。
*/
@Table("tb_agent_run_event")
public class AgentRunEventRecord implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger chatSessionId;
private BigInteger roundId;
private Integer roundNo;
private Integer variantIndex;
private String requestId;
private String eventId;
private String eventType;
private String eventPhase;
private String toolCallId;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> payloadJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private Date created;
private BigInteger createdBy;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAgentId() { return agentId; }
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
public BigInteger getChatSessionId() { return chatSessionId; }
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
public BigInteger getRoundId() { return roundId; }
public void setRoundId(BigInteger roundId) { this.roundId = roundId; }
public Integer getRoundNo() { return roundNo; }
public void setRoundNo(Integer roundNo) { this.roundNo = roundNo; }
public Integer getVariantIndex() { return variantIndex; }
public void setVariantIndex(Integer variantIndex) { this.variantIndex = variantIndex; }
public String getRequestId() { return requestId; }
public void setRequestId(String requestId) { this.requestId = requestId; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public String getEventPhase() { return eventPhase; }
public void setEventPhase(String eventPhase) { this.eventPhase = eventPhase; }
public String getToolCallId() { return toolCallId; }
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
public Map<String, Object> getPayloadJson() { return payloadJson; }
public void setPayloadJson(Map<String, Object> payloadJson) { this.payloadJson = payloadJson == null ? new LinkedHashMap<>() : payloadJson; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
public Date getCreated() { return created; }
public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* AgentScope 会话状态实体。
*/
@Table("tb_agent_session")
public class AgentSession extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private BigInteger chatSessionId;
private String runtimeSessionId;
private String sessionKey;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> stateJson = new LinkedHashMap<>();
private Long version;
private Long cacheVersion;
private Date lastAccessAt;
private Date expiresAt;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
private Integer isDeleted;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAgentId() { return agentId; }
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
public BigInteger getChatSessionId() { return chatSessionId; }
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
public String getRuntimeSessionId() { return runtimeSessionId; }
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
public String getSessionKey() { return sessionKey; }
public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; }
public Map<String, Object> getStateJson() { return stateJson; }
public void setStateJson(Map<String, Object> stateJson) { this.stateJson = stateJson == null ? new LinkedHashMap<>() : stateJson; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public Long getCacheVersion() { return cacheVersion; }
public void setCacheVersion(Long cacheVersion) { this.cacheVersion = cacheVersion; }
public Date getLastAccessAt() { return lastAccessAt; }
public void setLastAccessAt(Date lastAccessAt) { this.lastAccessAt = lastAccessAt; }
public Date getExpiresAt() { return expiresAt; }
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
}

View File

@@ -0,0 +1,82 @@
package tech.easyflow.agent.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 工具绑定实体。
*/
@Table("tb_agent_tool_binding")
public class AgentToolBinding extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger agentId;
private String toolType;
private BigInteger targetId;
private String toolName;
private Boolean enabled;
private Boolean hitlEnabled;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> hitlConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> optionsJson = new LinkedHashMap<>();
@Column(ignore = true)
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
@Column(ignore = true)
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAgentId() { return agentId; }
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
public String getToolType() { return toolType; }
public void setToolType(String toolType) { this.toolType = toolType; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public String getToolName() { return toolName; }
public void setToolName(String toolName) { this.toolName = toolName; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Boolean getHitlEnabled() { return hitlEnabled; }
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
public Map<String, Object> getHitlConfigJson() { return hitlConfigJson; }
public void setHitlConfigJson(Map<String, Object> hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; }
public Map<String, Object> getOptionsJson() { return optionsJson; }
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; }
public Map<String, Object> getResourceSummary() { return resourceSummary; }
public void setResourceSummary(Map<String, Object> resourceSummary) { this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
}

View File

@@ -0,0 +1,26 @@
package tech.easyflow.agent.enums;
import java.util.Locale;
/**
* Agent 工具绑定类型。
*/
public enum AgentToolType {
WORKFLOW,
PLUGIN,
MCP;
/**
* 解析工具类型。
*
* @param value 类型值
* @return 工具类型
*/
public static AgentToolType from(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("toolType 不能为空");
}
return AgentToolType.valueOf(value.trim().toUpperCase(Locale.ROOT));
}
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentCategory;
/**
* Agent 分类 Mapper。
*/
public interface AgentCategoryMapper extends BaseMapper<AgentCategory> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentHitlPending;
/**
* Agent 工具审批挂起态 Mapper。
*/
public interface AgentHitlPendingMapper extends BaseMapper<AgentHitlPending> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
/**
* Agent 知识库绑定 Mapper。
*/
public interface AgentKnowledgeBindingMapper extends BaseMapper<AgentKnowledgeBinding> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.Agent;
/**
* Agent Mapper。
*/
public interface AgentMapper extends BaseMapper<Agent> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentRunEventRecord;
/**
* Agent 运行事件摘要 Mapper。
*/
public interface AgentRunEventRecordMapper extends BaseMapper<AgentRunEventRecord> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentSession;
/**
* AgentScope 会话状态 Mapper。
*/
public interface AgentSessionMapper extends BaseMapper<AgentSession> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.agent.entity.AgentToolBinding;
/**
* Agent 工具绑定 Mapper。
*/
public interface AgentToolBindingMapper extends BaseMapper<AgentToolBinding> {
}

View File

@@ -0,0 +1,168 @@
package tech.easyflow.agent.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
* Agent 审批资源处理器。
*/
@Component
public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Agent> {
private final AgentService agentService;
private final AgentToolBindingService agentToolBindingService;
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
private final ResourceAccessService resourceAccessService;
/**
* 创建 Agent 审批资源处理器。
*
* @param approvalInstanceService 审批实例服务
* @param objectMapper JSON 映射器
* @param agentService Agent 服务
* @param agentToolBindingService Agent 工具绑定服务
* @param agentKnowledgeBindingService Agent 知识库绑定服务
* @param resourceAccessService 资源访问服务
*/
public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
ObjectMapper objectMapper,
AgentService agentService,
AgentToolBindingService agentToolBindingService,
AgentKnowledgeBindingService agentKnowledgeBindingService,
ResourceAccessService resourceAccessService) {
super(approvalInstanceService, objectMapper);
this.agentService = agentService;
this.agentToolBindingService = agentToolBindingService;
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
this.resourceAccessService = resourceAccessService;
}
/**
* {@inheritDoc}
*/
@Override
public String resourceType() {
return ApprovalResourceType.AGENT.getCode();
}
/**
* {@inheritDoc}
*/
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
Agent agent = agentService.getById(String.valueOf(identifier));
if (agent == null || !PublishStatus.from(agent.getPublishStatus()).isExternallyVisible()
|| agent.getPublishedSnapshotJson() == null || agent.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException(denyMessage);
}
}
@Override
protected Agent requireResource(BigInteger resourceId) {
Agent agent = agentService.getById(resourceId);
if (agent == null) {
throw new BusinessException("Agent 不存在");
}
return agent;
}
@Override
protected void assertManagePermission(Agent resource) {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, resource, ResourceAction.MANAGE, "无权限管理该 Agent");
}
@Override
protected BigInteger getCategoryId(Agent resource) {
return resource.getCategoryId();
}
@Override
protected BigInteger getDeptId(Agent resource) {
return resource.getDeptId();
}
@Override
protected String getTitle(Agent resource) {
return resource.getName();
}
@Override
protected PublishStatus getCurrentStatus(Agent resource) {
return PublishStatus.from(resource.getPublishStatus());
}
@Override
protected Map<String, Object> getPublishedSnapshot(Agent resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> buildResourceSnapshot(Agent resource) {
return agentService.buildPublishSnapshot(resource);
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Agent agent = new Agent();
agent.setId(resourceId);
agent.setPublishStatus(publishStatus.getCode());
agent.setCurrentApprovalInstanceId(currentApprovalInstanceId);
agentService.updateById(agent);
}
@Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
Agent agent = new Agent();
agent.setId(resourceId);
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
agent.setPublishedSnapshotJson(resourceSnapshot);
agent.setPublishedAt(new Date());
agent.setPublishedBy(operatorId);
agent.setCurrentApprovalInstanceId(null);
agentService.updateById(agent);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Agent agent = new Agent();
agent.setId(resourceId);
agent.setPublishStatus(PublishStatus.OFFLINE.getCode());
agent.setCurrentApprovalInstanceId(null);
agentService.updateById(agent);
}
@Override
protected void removeResource(BigInteger resourceId) {
agentService.removeById(resourceId);
}
@Override
protected void beforeRemove(BigInteger resourceId) {
agentToolBindingService.remove(QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
agentKnowledgeBindingService.remove(QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
}
@Override
protected String resourceLabel() {
return "Agent";
}
}

View File

@@ -0,0 +1,71 @@
package tech.easyflow.agent.publish;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.publish.AiResourceLifecycleService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
/**
* Agent 发布生命周期应用服务。
*/
@Service
public class AgentPublishAppService {
private final AiResourceLifecycleService aiResourceLifecycleService;
/**
* 创建 Agent 发布应用服务。
*
* @param aiResourceLifecycleService AI 资源生命周期服务
*/
public AgentPublishAppService(AiResourceLifecycleService aiResourceLifecycleService) {
this.aiResourceLifecycleService = aiResourceLifecycleService;
}
/**
* 提交 Agent 发布审批。
*
* @param id Agent ID
* @return 审批动作结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
return submit(id, ApprovalActionType.PUBLISH);
}
/**
* 提交 Agent 下线审批。
*
* @param id Agent ID
* @return 审批动作结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
return submit(id, ApprovalActionType.OFFLINE);
}
/**
* 提交 Agent 删除审批。
*
* @param id Agent ID
* @return 审批动作结果
*/
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
return submit(id, ApprovalActionType.DELETE);
}
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
if (id == null) {
throw new BusinessException("Agent 审批时资源ID不能为空");
}
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.AGENT.getCode(),
id,
actionType.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
}

View File

@@ -0,0 +1,53 @@
package tech.easyflow.agent.runtime;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* Agent 聊天临时能力请求项。
*/
public class AgentChatCapability implements Serializable {
private static final long serialVersionUID = 1L;
private String type;
private List<BigInteger> resourceIds = new ArrayList<>();
/**
* 获取能力类型。
*
* @return 能力类型
*/
public String getType() {
return type;
}
/**
* 设置能力类型。
*
* @param type 能力类型
*/
public void setType(String type) {
this.type = type;
}
/**
* 获取资源 ID 列表。
*
* @return 资源 ID 列表
*/
public List<BigInteger> getResourceIds() {
return resourceIds;
}
/**
* 设置资源 ID 列表。
*
* @param resourceIds 资源 ID 列表
*/
public void setResourceIds(List<BigInteger> resourceIds) {
this.resourceIds = resourceIds == null ? new ArrayList<>() : new ArrayList<>(resourceIds);
}
}

View File

@@ -0,0 +1,171 @@
package tech.easyflow.agent.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.*;
/**
* Agent 聊天临时能力编排服务。
*/
@Service
public class AgentChatCapabilityService {
private static final String KNOWLEDGE_CAPABILITY_TYPE = "KNOWLEDGE";
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
private static final int MAX_EXTRA_KNOWLEDGE_COUNT = 3;
private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService;
private final ObjectMapper objectMapper;
/**
* 创建 Agent 聊天临时能力编排服务。
*
* @param documentCollectionService 知识库服务
* @param resourceAccessService 资源访问服务
* @param objectMapper 对象复制工具
*/
public AgentChatCapabilityService(DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
ObjectMapper objectMapper) {
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.objectMapper = objectMapper;
}
/**
* 将临时聊天能力合并到运行时 Agent 定义中。
*
* @param agent 已发布 Agent 运行视图
* @param capabilities 临时能力请求
* @param account 当前登录账号
* @return 能力解析结果
*/
public AgentChatCapabilityResolution apply(Agent agent,
List<AgentChatCapability> capabilities,
LoginAccount account) {
List<BigInteger> extraKnowledgeIds = resolveKnowledgeIds(capabilities);
boolean knowledgeCapabilityProvided = hasKnowledgeCapability(capabilities);
if (agent == null || extraKnowledgeIds.isEmpty()) {
return new AgentChatCapabilityResolution(agent, extraKnowledgeIds, knowledgeCapabilityProvided);
}
Agent runtimeAgent = objectMapper.convertValue(agent, Agent.class);
List<AgentKnowledgeBinding> mergedBindings = new ArrayList<>();
Set<BigInteger> existingKnowledgeIds = new LinkedHashSet<>();
if (runtimeAgent.getKnowledgeBindings() != null) {
for (AgentKnowledgeBinding binding : runtimeAgent.getKnowledgeBindings()) {
if (binding == null) {
continue;
}
mergedBindings.add(binding);
if (Boolean.TRUE.equals(binding.getEnabled()) && binding.getKnowledgeId() != null) {
existingKnowledgeIds.add(binding.getKnowledgeId());
}
}
}
int sortNo = mergedBindings.size();
for (BigInteger knowledgeId : extraKnowledgeIds) {
if (existingKnowledgeIds.contains(knowledgeId)) {
continue;
}
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
validateKnowledge(knowledge);
resourceAccessService.assertAccess(
CategoryResourceType.KNOWLEDGE,
knowledge,
ResourceAction.USE,
"无权限使用所选知识库"
);
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
binding.setTenantId(account == null ? runtimeAgent.getTenantId() : account.getTenantId());
binding.setAgentId(runtimeAgent.getId());
binding.setKnowledgeId(knowledgeId);
binding.setRetrievalMode(DEFAULT_RETRIEVAL_MODE);
binding.setEnabled(true);
binding.setSortNo(sortNo++);
mergedBindings.add(binding);
existingKnowledgeIds.add(knowledgeId);
}
runtimeAgent.setKnowledgeBindings(mergedBindings);
return new AgentChatCapabilityResolution(runtimeAgent, extraKnowledgeIds, knowledgeCapabilityProvided);
}
/**
* 从能力列表提取知识库 ID。
*
* @param capabilities 临时能力列表
* @return 已去重知识库 ID
*/
public List<BigInteger> resolveKnowledgeIds(List<AgentChatCapability> capabilities) {
if (capabilities == null || capabilities.isEmpty()) {
return List.of();
}
LinkedHashSet<BigInteger> ids = new LinkedHashSet<>();
for (AgentChatCapability capability : capabilities) {
if (capability == null || !isKnowledgeCapability(capability.getType())) {
continue;
}
if (capability.getResourceIds() == null) {
continue;
}
for (BigInteger resourceId : capability.getResourceIds()) {
if (resourceId != null) {
ids.add(resourceId);
}
}
}
if (ids.size() > MAX_EXTRA_KNOWLEDGE_COUNT) {
throw new BusinessException("临时知识库最多选择 3 个");
}
return new ArrayList<>(ids);
}
private boolean isKnowledgeCapability(String type) {
return Objects.equals(KNOWLEDGE_CAPABILITY_TYPE, type == null ? null : type.trim().toUpperCase());
}
private boolean hasKnowledgeCapability(List<AgentChatCapability> capabilities) {
if (capabilities == null || capabilities.isEmpty()) {
return false;
}
for (AgentChatCapability capability : capabilities) {
if (capability != null && isKnowledgeCapability(capability.getType())) {
return true;
}
}
return false;
}
private void validateKnowledge(DocumentCollection knowledge) {
if (knowledge == null) {
throw new BusinessException("所选知识库不存在");
}
if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("所选知识库未发布,无法用于聊天");
}
}
/**
* Agent 聊天临时能力解析结果。
*
* @param agent 合并临时能力后的运行时 Agent
* @param extraKnowledgeIds 本次选择的临时知识库 ID
* @param knowledgeCapabilityProvided 请求是否显式传入知识库能力
*/
public record AgentChatCapabilityResolution(Agent agent,
List<BigInteger> extraKnowledgeIds,
boolean knowledgeCapabilityProvided) {
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.agent.runtime;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* Agent 管理端运行请求。
*/
public class AgentChatRequest {
private BigInteger agentId;
private BigInteger sessionId;
private String prompt;
private List<AgentChatCapability> capabilities = new ArrayList<>();
/**
* 获取 Agent ID。
*
* @return Agent ID
*/
public BigInteger getAgentId() { return agentId; }
/**
* 设置 Agent ID。
*
* @param agentId Agent ID
*/
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
/**
* 获取会话 ID。
*
* @return 会话 ID
*/
public BigInteger getSessionId() { return sessionId; }
/**
* 设置会话 ID。
*
* @param sessionId 会话 ID
*/
public void setSessionId(BigInteger sessionId) { this.sessionId = sessionId; }
/**
* 获取用户纯文本输入。
*
* @return 用户输入
*/
public String getPrompt() { return prompt; }
/**
* 设置用户纯文本输入。
*
* @param prompt 用户输入
*/
public void setPrompt(String prompt) { this.prompt = prompt; }
/**
* 获取本次聊天启用的临时能力。
*
* @return 临时能力列表
*/
public List<AgentChatCapability> getCapabilities() {
return capabilities;
}
/**
* 设置本次聊天启用的临时能力。
*
* @param capabilities 临时能力列表
*/
public void setCapabilities(List<AgentChatCapability> capabilities) {
this.capabilities = capabilities == null ? new ArrayList<>() : new ArrayList<>(capabilities);
}
}

View File

@@ -0,0 +1,109 @@
package tech.easyflow.agent.runtime;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.util.List;
/**
* Agent 草稿态纯文本试用请求。
*/
public class AgentDraftChatRequest {
private Agent agent;
private List<AgentToolBinding> toolBindings;
private List<AgentKnowledgeBinding> knowledgeBindings;
private String sessionId;
private String prompt;
/**
* 获取 Agent 草稿快照。
*
* @return Agent 草稿快照
*/
public Agent getAgent() {
return agent;
}
/**
* 设置 Agent 草稿快照。
*
* @param agent Agent 草稿快照
*/
public void setAgent(Agent agent) {
this.agent = agent;
}
/**
* 获取工具绑定快照。
*
* @return 工具绑定快照
*/
public List<AgentToolBinding> getToolBindings() {
return toolBindings;
}
/**
* 设置工具绑定快照。
*
* @param toolBindings 工具绑定快照
*/
public void setToolBindings(List<AgentToolBinding> toolBindings) {
this.toolBindings = toolBindings;
}
/**
* 获取知识库绑定快照。
*
* @return 知识库绑定快照
*/
public List<AgentKnowledgeBinding> getKnowledgeBindings() {
return knowledgeBindings;
}
/**
* 设置知识库绑定快照。
*
* @param knowledgeBindings 知识库绑定快照
*/
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) {
this.knowledgeBindings = knowledgeBindings;
}
/**
* 获取草稿试运行会话 ID。
*
* @return 会话 ID
*/
public String getSessionId() {
return sessionId;
}
/**
* 设置草稿试运行会话 ID。
*
* @param sessionId 会话 ID
*/
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
/**
* 获取用户纯文本输入。
*
* @return 用户输入
*/
public String getPrompt() {
return prompt;
}
/**
* 设置用户纯文本输入。
*
* @param prompt 用户输入
*/
public void setPrompt(String prompt) {
this.prompt = prompt;
}
}

View File

@@ -0,0 +1,564 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.Disposable;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* Agent 运行态注册表。
*/
@Component
public class AgentRunRegistry {
private static final Logger LOG = LoggerFactory.getLogger(AgentRunRegistry.class);
private final Map<String, AgentRunContext> runs = new ConcurrentHashMap<>();
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
private final Map<String, Set<String>> requestTokens = new ConcurrentHashMap<>();
private final Map<String, RunOwner> owners = new ConcurrentHashMap<>();
private AgentRuntimeRouteRegistry routeRegistry;
/**
* 设置 Agent 运行态 Redis 路由注册表。
*
* @param routeRegistry Redis 路由注册表
*/
@Autowired(required = false)
public void setRouteRegistry(AgentRuntimeRouteRegistry routeRegistry) {
this.routeRegistry = routeRegistry;
}
/**
* 注册运行态。
*
* @param context 运行态上下文
*/
public void register(AgentRunContext context) {
if (context == null || context.requestId() == null || context.requestId().isBlank()) {
throw new BusinessException("Agent 运行请求 ID 不能为空");
}
if (context.sessionId() == null || context.sessionId().isBlank()) {
throw new BusinessException("Agent 会话 ID 不能为空");
}
String existingRequestId = sessionRuns.putIfAbsent(context.sessionId(), context.requestId());
if (existingRequestId != null && !existingRequestId.equals(context.requestId())) {
throw new BusinessException("当前 Agent 会话已有运行中的请求,请稍后再试");
}
AgentRunContext existing = runs.putIfAbsent(context.requestId(), context);
if (existing != null) {
sessionRuns.remove(context.sessionId(), context.requestId());
throw new BusinessException("当前 Agent 运行请求已存在");
}
owners.put(context.requestId(), context.owner());
if (routeRegistry != null) {
routeRegistry.registerRun(context.requestId());
}
}
/**
* 绑定运行订阅。
*
* @param requestId 请求 ID
* @param subscription Reactor 订阅
*/
public void bindSubscription(String requestId, Disposable subscription) {
AgentRunContext context = runs.get(requestId);
if (context != null) {
context.setSubscription(subscription);
}
}
/**
* 获取运行态。
*
* @param requestId 请求 ID
* @return 运行态
*/
public AgentRunContext get(String requestId) {
return requestId == null ? null : runs.get(requestId);
}
/**
* 取消并移除指定会话当前活跃运行。
*
* <p>草稿试运行清理会删除 AgentScope session。若同一会话仍有 SSE 运行中,
* 必须先取消 runtime 订阅并关闭 SSE避免旧运行继续向已清空的会话写入状态。</p>
*
* @param sessionId 会话 ID
*/
public void cancelSession(String sessionId) {
cancelSession(sessionId, null);
}
/**
* 取消并移除指定会话当前活跃运行。
*
* @param sessionId 会话 ID
* @param userId 当前用户 ID非空时会校验运行归属
*/
public void cancelSession(String sessionId, String userId) {
if (sessionId == null || sessionId.isBlank()) {
return;
}
String requestId = sessionRuns.get(sessionId);
if (requestId == null) {
return;
}
assertOwner(requestId, userId);
AgentRunContext context = runs.get(requestId);
if (context != null) {
context.cancelAndComplete();
}
remove(requestId);
}
/**
* 记录等待审批的恢复令牌。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
*/
public void registerResumeToken(String requestId, String resumeToken) {
if (requestId != null && resumeToken != null && !resumeToken.isBlank()) {
resumeTokenIndex.put(resumeToken, requestId);
requestTokens.computeIfAbsent(requestId, ignored -> ConcurrentHashMap.newKeySet()).add(resumeToken);
if (routeRegistry != null) {
routeRegistry.registerResumeToken(requestId, resumeToken);
}
}
}
/**
* 运行结束后移除运行态。
*
* @param requestId 请求 ID
*/
public synchronized void remove(String requestId) {
if (requestId == null) {
return;
}
AgentRunContext context = runs.remove(requestId);
if (context != null) {
sessionRuns.remove(context.sessionId(), requestId);
context.releaseLock();
context.closeRuntime();
}
owners.remove(requestId);
Set<String> tokens = requestTokens.remove(requestId);
if (tokens != null) {
tokens.forEach(token -> {
resumeTokenIndex.remove(token);
if (routeRegistry != null) {
routeRegistry.removeResumeToken(token);
}
});
}
if (routeRegistry != null) {
routeRegistry.removeRun(requestId);
}
}
/**
* 批准工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param userId 当前用户 ID
*/
public void approve(String requestId, String resumeToken, String userId) {
submit(requestId, resumeToken, userId, true, null);
}
/**
* 批准工具执行,并在恢复 runtime 前执行持久化消费动作。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param userId 当前用户 ID
* @param beforeResume runtime resume 前的持久化消费动作
*/
public void approve(String requestId, String resumeToken, String userId, Runnable beforeResume) {
submit(requestId, resumeToken, userId, true, null, beforeResume);
}
/**
* 拒绝工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param userId 当前用户 ID
* @param reason 拒绝原因
*/
public void reject(String requestId, String resumeToken, String userId, String reason) {
submit(requestId, resumeToken, userId, false, reason);
}
/**
* 拒绝工具执行,并在恢复 runtime 前执行持久化消费动作。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param userId 当前用户 ID
* @param reason 拒绝原因
* @param beforeResume runtime resume 前的持久化消费动作
*/
public void reject(String requestId, String resumeToken, String userId, String reason, Runnable beforeResume) {
submit(requestId, resumeToken, userId, false, reason, beforeResume);
}
/**
* 当前进程是否存在指定 token 对应的活跃运行态。
*
* @param requestId 请求 ID可为空
* @param resumeToken 恢复令牌
* @return true 表示当前节点可直接恢复
*/
public boolean containsResumeTarget(String requestId, String resumeToken) {
try {
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
return runs.containsKey(resolvedRequestId);
} catch (BusinessException ignored) {
return false;
}
}
/**
* 当前恢复目标是否为草稿试运行。
*
* @param requestId 请求 ID可为空
* @param resumeToken 恢复令牌
* @return true 表示目标为草稿试运行
*/
public boolean isDraftResumeTarget(String requestId, String resumeToken) {
try {
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
AgentRunContext context = runs.get(resolvedRequestId);
return context != null && !context.persistChatlog();
} catch (BusinessException ignored) {
return false;
}
}
private void submit(String requestId, String resumeToken, String userId, boolean approved, String reason) {
submit(requestId, resumeToken, userId, approved, reason, null);
}
private synchronized void submit(String requestId,
String resumeToken,
String userId,
boolean approved,
String reason,
Runnable beforeResume) {
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
AgentRunContext context = runs.get(resolvedRequestId);
if (context == null) {
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
}
assertOwner(resolvedRequestId, userId);
assertResumeTokenBelongsToRequest(resolvedRequestId, resumeToken);
if (beforeResume != null) {
beforeResume.run();
}
Set<String> tokens = requestTokens.get(resolvedRequestId);
if (tokens != null) {
tokens.remove(resumeToken);
}
resumeTokenIndex.remove(resumeToken);
if (routeRegistry != null) {
routeRegistry.removeResumeToken(resumeToken);
}
AgentResumeToken token = new AgentResumeToken();
token.setValue(resumeToken);
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(token);
request.setApproved(approved);
request.setRejectReason(reason);
request.getMetadata().put("requestId", resolvedRequestId);
request.getMetadata().put("operatorId", userId);
context.resume(request);
}
private String resolveRequestId(String requestId, String resumeToken) {
if (requestId != null && !requestId.isBlank()) {
return requestId;
}
String resolved = resumeTokenIndex.get(resumeToken);
if (resolved == null || resolved.isBlank()) {
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
}
return resolved;
}
private void assertResumeTokenBelongsToRequest(String requestId, String resumeToken) {
if (resumeToken == null || resumeToken.isBlank()) {
throw new BusinessException("Agent 恢复令牌不能为空");
}
Set<String> tokens = requestTokens.get(requestId);
if (tokens == null || !tokens.contains(resumeToken)) {
throw new BusinessException("Agent 审批请求已失效");
}
}
private void assertOwner(String requestId, String userId) {
if (userId == null || userId.isBlank()) {
return;
}
RunOwner owner = owners.get(requestId);
if (owner == null) {
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
}
if (owner.userId() != null && !owner.userId().equals(userId)) {
throw new BusinessException("无权处理该 Agent 运行审批");
}
}
/**
* 当前运行归属信息。
*
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param userId 用户 ID
*/
public record RunOwner(String agentId, String sessionId, String userId) {
}
/**
* 单机内存运行态。
*
*/
public static final class AgentRunContext {
private final String requestId;
private final String sessionId;
private final AgentRuntime runtime;
private final ChatSseEmitter chatSseEmitter;
private final ChatRuntimeContext chatContext;
private final StringBuilder answer;
private final ChatAssistantAccumulator assistantAccumulator;
private final AtomicBoolean finished;
private final boolean persistChatlog;
private final RunOwner owner;
private final AgentRunLock.Handle lockHandle;
private final Consumer<AgentRuntimeEvent> eventConsumer;
private final Consumer<Throwable> errorConsumer;
private final Runnable completionHandler;
private final AtomicBoolean suspended = new AtomicBoolean(false);
private final AtomicReference<Disposable> subscription = new AtomicReference<>();
/**
* 创建运行态。
*
* @param requestId 请求 ID
* @param sessionId 会话 ID
* @param runtime 有状态运行时
* @param chatSseEmitter SSE 连接
* @param chatContext 聊天上下文
* @param answer 助手正文累计缓冲
* @param assistantAccumulator 助手结构化累计器
* @param finished 运行收口标记
* @param persistChatlog 是否持久化聊天日志
* @param owner 运行归属
* @param eventConsumer 运行事件处理器
* @param errorConsumer 错误处理器
* @param completionHandler 完成处理器
*/
public AgentRunContext(String requestId,
String sessionId,
AgentRuntime runtime,
ChatSseEmitter chatSseEmitter,
ChatRuntimeContext chatContext,
StringBuilder answer,
ChatAssistantAccumulator assistantAccumulator,
AtomicBoolean finished,
boolean persistChatlog,
RunOwner owner,
AgentRunLock.Handle lockHandle,
Consumer<AgentRuntimeEvent> eventConsumer,
Consumer<Throwable> errorConsumer,
Runnable completionHandler) {
this.requestId = requestId;
this.sessionId = sessionId;
this.runtime = runtime;
this.chatSseEmitter = chatSseEmitter;
this.chatContext = chatContext;
this.answer = answer;
this.assistantAccumulator = assistantAccumulator;
this.finished = finished;
this.persistChatlog = persistChatlog;
this.owner = owner;
this.lockHandle = lockHandle;
this.eventConsumer = eventConsumer;
this.errorConsumer = errorConsumer;
this.completionHandler = completionHandler;
}
/**
* 获取请求 ID。
*
* @return 请求 ID
*/
public String requestId() {
return requestId;
}
/**
* 获取会话 ID。
*
* @return 会话 ID
*/
public String sessionId() {
return sessionId;
}
/**
* 获取运行归属。
*
* @return 运行归属
*/
public RunOwner owner() {
return owner;
}
/**
* 获取运行事件处理器。
*
* @return 运行事件处理器
*/
public Consumer<AgentRuntimeEvent> eventConsumer() {
return eventConsumer;
}
/**
* 获取错误处理器。
*
* @return 错误处理器
*/
public Consumer<Throwable> errorConsumer() {
return errorConsumer;
}
/**
* 获取完成处理器。
*
* @return 完成处理器
*/
public Runnable completionHandler() {
return completionHandler;
}
/**
* 标记当前运行已进入 HITL 挂起态。
*/
public void markSuspended() {
suspended.set(true);
}
/**
* 当前运行是否处于 HITL 挂起态。
*
* @return true 表示等待审批恢复
*/
public boolean isSuspended() {
return suspended.get();
}
/**
* 当前运行是否持久化聊天日志与运行态。
*
* @return true 表示正式聊天持久化运行
*/
public boolean persistChatlog() {
return persistChatlog;
}
/**
* 绑定运行订阅。
*
* @param subscription Reactor 订阅
*/
public void setSubscription(Disposable subscription) {
if (subscription == null) {
return;
}
Disposable previous = this.subscription.getAndSet(subscription);
if (previous != null && !previous.isDisposed()) {
previous.dispose();
}
}
/**
* 取消当前运行订阅。
*/
public void cancel() {
Disposable subscription = this.subscription.getAndSet(null);
if (subscription != null && !subscription.isDisposed()) {
subscription.dispose();
}
}
/**
* 取消当前运行并关闭 SSE。
*
* <p>该方法用于调用方主动清理会话的场景。它不通过 runtime 事件链发送取消事件,
* 因为调用方此时已经明确要求丢弃当前草稿会话。</p>
*/
public void cancelAndComplete() {
cancel();
if (finished.compareAndSet(false, true) && chatSseEmitter != null) {
chatSseEmitter.complete();
}
}
/**
* 释放当前运行持有的分布式锁。
*/
public void releaseLock() {
if (lockHandle != null) {
lockHandle.release();
}
}
/**
* 关闭底层运行时并释放资源。
*/
public void closeRuntime() {
try {
runtime.close();
} catch (Exception e) {
LOG.warn("Close Agent runtime failed, requestId={}, sessionId={}, message={}",
requestId, sessionId, e.getMessage(), e);
}
}
/**
* 通过同一个 runtime 恢复挂起运行,事件继续写入原 SSE。
*
* @param request 恢复请求
*/
public void resume(AgentResumeRequest request) {
suspended.set(false);
Disposable subscription = runtime.resume(request).subscribe(eventConsumer, errorConsumer, completionHandler);
setSubscription(subscription);
}
}
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 运行时编译结果。
*/
public class AgentRuntimeBundle {
private AgentDefinition definition;
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
/**
* 获取 Agent 定义。
*
* @return Agent 定义
*/
public AgentDefinition getDefinition() {
return definition;
}
/**
* 设置 Agent 定义。
*
* @param definition Agent 定义
*/
public void setDefinition(AgentDefinition definition) {
this.definition = definition;
}
/**
* 获取工具调用器。
*
* @return 工具调用器
*/
public Map<String, AgentToolInvoker> getToolInvokers() {
return toolInvokers;
}
/**
* 设置工具调用器。
*
* @param toolInvokers 工具调用器
*/
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers;
}
/**
* 获取知识库检索器。
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
}
}

View File

@@ -0,0 +1,860 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentExecutionOptions;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.memory.AgentMemoryType;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpTransportType;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentModelProviderType;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.*;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.*;
/**
* 将 Agent 发布快照编译为可执行定义。
*/
@Component
public class AgentRuntimeCompiler {
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCompiler.class);
private static final int LOG_TEXT_MAX_LENGTH = 500;
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
@Resource
private ModelService modelService;
@Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ObjectMapper objectMapper;
@Resource
private AgentToolRuntimeCompiler agentToolRuntimeCompiler;
/**
* 编译 Agent 运行时定义和调用器。
*
* @param agent 已发布 Agent 视图
* @return 运行时编译结果
*/
public AgentRuntimeBundle compile(Agent agent) {
if (agent == null || agent.getId() == null) {
throw new BusinessException("Agent 运行定义不能为空");
}
AgentRuntimeBundle bundle = new AgentRuntimeBundle();
AgentDefinition definition = new AgentDefinition();
definition.setAgentId(agent.getId().toString());
definition.setAgentName(agent.getName());
definition.setDescription(agent.getDescription());
definition.setSystemPrompt(stringValue(agent.getPromptConfigJson(), "systemPrompt", stringValue(agent.getPromptConfigJson(), "prompt", "")));
definition.setModelSpec(buildModelSpec(agent));
definition.setGenerationOptions(buildGenerationOptions(agent.getGenerationConfigJson()));
definition.setExecutionOptions(buildExecutionOptions(agent.getExecutionConfigJson()));
definition.setMemoryPolicy(buildMemoryPolicy(agent.getMemoryConfigJson()));
bundle.setDefinition(definition);
compileTools(agent, definition, bundle);
compileKnowledge(agent, definition, bundle);
return bundle;
}
private AgentModelSpec buildModelSpec(Agent agent) {
Model model = modelService.getModelInstance(agent.getModelId());
if (model == null) {
throw new BusinessException("Agent 模型不存在");
}
Map<String, Object> config = agent.getModelConfigJson();
AgentModelSpec spec = new AgentModelSpec();
String providerType = stringValue(config, "providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
spec.setProviderType(parseProviderType(providerType));
spec.setModelName(stringValue(config, "modelName", model.getModelName()));
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
spec.getMetadata().put("modelId", model.getId());
return spec;
}
private AgentGenerationOptions buildGenerationOptions(Map<String, Object> config) {
AgentGenerationOptions options = new AgentGenerationOptions();
options.setTemperature(doubleValue(config, "temperature"));
options.setTopP(doubleValue(config, "topP"));
options.setTopK(intValue(config, "topK"));
options.setMaxTokens(intValue(config, "maxTokens"));
options.setMaxCompletionTokens(intValue(config, "maxCompletionTokens"));
options.setThinkingBudget(intValue(config, "thinkingBudget"));
options.setReasoningEffort(stringValue(config, "reasoningEffort", stringValue(config, "thinkingLevel", null)));
options.setThinkingEnabled(booleanValue(config, "thinkingEnabled"));
Boolean stream = booleanValue(config, "stream");
if (stream != null) {
options.setStream(stream);
}
options.setAdditionalBodyParams(mapValue(config, "additionalBodyParams"));
options.setAdditionalHeaders(stringMapValue(config, "additionalHeaders"));
options.setAdditionalQueryParams(stringMapValue(config, "additionalQueryParams"));
return options;
}
private AgentExecutionOptions buildExecutionOptions(Map<String, Object> config) {
AgentExecutionOptions options = new AgentExecutionOptions();
Integer maxIters = intValue(config, "maxIters");
if (maxIters != null) {
options.setMaxIters(maxIters);
}
Integer timeoutSeconds = intValue(config, "timeoutSeconds");
if (timeoutSeconds != null) {
options.setTimeout(Duration.ofSeconds(timeoutSeconds));
}
Boolean reasoningEnabled = booleanValue(config, "reasoningEnabled");
if (reasoningEnabled != null) {
options.setReasoningEnabled(reasoningEnabled);
}
Boolean toolCallingEnabled = booleanValue(config, "toolCallingEnabled");
if (toolCallingEnabled != null) {
options.setToolCallingEnabled(toolCallingEnabled);
}
return options;
}
private AgentMemoryPolicy buildMemoryPolicy(Map<String, Object> config) {
AgentMemoryPolicy policy = new AgentMemoryPolicy();
policy.setType(memoryTypeValue(config, "type"));
Map<String, Object> compressionConfig = mapValue(config, "compressionParameter");
if (compressionConfig.isEmpty()) {
compressionConfig = mapValue(config, "autoContext");
}
AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter();
Boolean enabled = booleanValue(compressionConfig, "enabled");
if (enabled != null) {
parameter.setEnabled(enabled);
}
Integer msgThreshold = intValue(compressionConfig, "msgThreshold");
if (msgThreshold == null) {
msgThreshold = intValue(config, "maxAttachedMessageCount");
}
if (msgThreshold == null) {
msgThreshold = intValue(config, "historyLimit");
}
if (msgThreshold != null) {
parameter.setMsgThreshold(msgThreshold);
policy.setMaxAttachedMessageCount(msgThreshold);
}
Integer lastKeep = intValue(compressionConfig, "lastKeep");
if (lastKeep != null) {
parameter.setLastKeep(lastKeep);
}
Double tokenRatio = doubleValue(compressionConfig, "tokenRatio");
if (tokenRatio != null) {
parameter.setTokenRatio(tokenRatio);
}
Long maxToken = longValue(compressionConfig, "maxToken");
if (maxToken != null) {
parameter.setMaxToken(maxToken);
}
Long largePayloadThreshold = longValue(compressionConfig, "largePayloadThreshold");
if (largePayloadThreshold != null) {
parameter.setLargePayloadThreshold(largePayloadThreshold);
}
Integer minCompressionTokenThreshold = intValue(compressionConfig, "minCompressionTokenThreshold");
if (minCompressionTokenThreshold != null) {
parameter.setMinCompressionTokenThreshold(minCompressionTokenThreshold);
}
Double currentRoundCompressionRatio = doubleValue(compressionConfig, "currentRoundCompressionRatio");
if (currentRoundCompressionRatio != null) {
parameter.setCurrentRoundCompressionRatio(currentRoundCompressionRatio);
}
Integer minConsecutiveToolMessages = intValue(compressionConfig, "minConsecutiveToolMessages");
if (minConsecutiveToolMessages != null) {
parameter.setMinConsecutiveToolMessages(minConsecutiveToolMessages);
}
policy.setCompressionParameter(parameter);
return policy;
}
private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
AgentToolRuntimeCompilation compilation = agentToolRuntimeCompiler.compile(agent);
definition.setToolSpecs(compilation.getToolSpecs());
definition.setMcpSpecs(compilation.getMcpSpecs());
bundle.setToolInvokers(compilation.getToolInvokers());
}
private Tool buildTool(AgentToolBinding binding) {
AgentToolType type = AgentToolType.from(binding.getToolType());
if (type == AgentToolType.WORKFLOW) {
Workflow workflow = snapshotOrPublishedWorkflow(binding);
if (workflow == null) {
throw new BusinessException("绑定工作流不存在");
}
return new WorkflowTool(
workflow,
true,
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
);
}
if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
if (pluginItem == null) {
throw new BusinessException("绑定插件不存在");
}
return pluginItem.toFunction();
}
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
}
private McpSpec buildMcpSpec(AgentToolBinding binding) {
Mcp mcp = snapshotOrCurrentMcp(binding);
if (mcp == null) {
throw new BusinessException("绑定 MCP 不存在");
}
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp);
Map<String, Object> serverConfig = server.getValue();
McpTransportType transportType = parseMcpTransportType(mcp, serverConfig);
McpSpec spec = new McpSpec();
spec.setName(mcpRuntimeName(mcp));
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
spec.setTransportType(transportType);
spec.setCommand(resolveMcpInput(stringValue(serverConfig, "command", null)));
spec.setArgs(resolveMcpInputs(stringListValue(serverConfig, "args")));
spec.setEnv(resolveMcpInputMap(stringMapValue(serverConfig, "env")));
spec.setUrl(resolveMcpInput(stringValue(serverConfig, "url", null)));
spec.setHeaders(resolveMcpInputMap(stringMapValue(serverConfig, "headers")));
spec.setQueryParams(resolveMcpInputMap(stringMapValue(serverConfig, "queryParams")));
Duration timeout = durationValue(serverConfig, "timeout");
if (timeout != null) {
spec.setTimeout(timeout);
}
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
if (initializationTimeout != null) {
spec.setInitializationTimeout(initializationTimeout);
}
spec.setGroupName(mcpRuntimeName(mcp));
spec.setApprovalRequired(Boolean.TRUE.equals(mcp.getApprovalRequired()));
spec.setApprovalRequest(buildMcpApprovalRequest(mcp));
spec.setToolNamePrefix(mcpRuntimeToolPrefix(mcp.getId()));
spec.getMetadata().put("toolType", AgentToolType.MCP.name());
spec.getMetadata().put("mcpId", String.valueOf(mcp.getId()));
spec.getMetadata().put("mcpTitle", mcp.getTitle());
spec.getMetadata().put("serverName", server.getKey());
return spec;
}
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
spec.setApprovalRequired(true);
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
}
}
private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt("是否批准执行 MCP 工具:" + firstNonBlank(mcp.getTitle(), mcpRuntimeName(mcp)));
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("toolType", AgentToolType.MCP.name());
metadata.put("mcpId", String.valueOf(mcp.getId()));
metadata.put("mcpTitle", mcp.getTitle());
request.setMetadata(metadata);
return request;
}
private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行 MCP 工具"));
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
metadata.put("toolType", binding.getToolType());
metadata.put("bindingId", binding.getId());
metadata.put("targetId", binding.getTargetId());
request.setMetadata(metadata);
return request;
}
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
AgentToolSpec spec = new AgentToolSpec();
String name = resolveRuntimeToolName(tool, binding);
spec.setName(name);
spec.setDescription(safeDescription(tool == null ? null : tool.getDescription()));
spec.setCategory(AgentToolCategory.valueOf(AgentToolType.from(binding.getToolType()).name()));
spec.setParametersSchema(toSchema(tool == null ? null : tool.getParameters()));
spec.setApprovalRequired(Boolean.TRUE.equals(binding.getHitlEnabled()));
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行工具:" + name));
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
metadata.put("toolType", binding.getToolType());
metadata.put("bindingId", binding.getId());
metadata.put("targetId", binding.getTargetId());
request.setMetadata(metadata);
spec.setApprovalRequest(request);
}
spec.getMetadata().put("bindingId", binding.getId());
spec.getMetadata().put("targetId", binding.getTargetId());
return spec;
}
private Map<String, Object> sanitizedHitlMetadata(Map<String, Object> config) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (config != null) {
config.forEach((key, value) -> {
if (!isHitlPromptKey(key)) {
metadata.put(key, value);
}
});
}
return metadata;
}
private boolean isHitlPromptKey(String key) {
if (key == null) {
return false;
}
String normalized = key.trim();
return "prompt".equalsIgnoreCase(normalized)
|| "question".equalsIgnoreCase(normalized)
|| "approvalPrompt".equalsIgnoreCase(normalized);
}
private AgentToolResult invokeTool(Tool tool, Map<String, Object> arguments) {
String toolName = tool == null ? null : tool.getName();
LOG.info("Agent tool invoke started, toolName={}, arguments={}", toolName, arguments);
try {
Object result = tool.invoke(arguments == null ? Map.of() : arguments);
String resultText = result == null ? "" : String.valueOf(result);
LOG.info("Agent tool invoke completed, toolName={}, result={}", toolName, truncate(resultText));
return AgentToolResult.success(resultText);
} catch (Exception e) {
LOG.error("Agent tool invoke failed, toolName={}, message={}", toolName, e.getMessage(), e);
return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage());
}
}
private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) {
String bindingName = binding == null ? null : binding.getToolName();
if (ChatToolNameHelper.isSafeToolName(bindingName)) {
return bindingName;
}
String toolName = tool == null ? null : tool.getName();
if (ChatToolNameHelper.isSafeToolName(toolName)) {
return toolName;
}
BigInteger targetId = binding == null ? null : binding.getTargetId();
return ChatToolNameHelper.buildFallbackName("tool", targetId);
}
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<>();
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
}
DocumentCollection knowledge = snapshotOrPublishedKnowledge(binding);
if (knowledge == null) {
throw new BusinessException("绑定知识库不存在");
}
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId(binding.getKnowledgeId().toString());
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");
spec.setLimit(limit == null ? 5 : limit);
Double threshold = doubleValue(binding.getOptionsJson(), "scoreThreshold");
if (threshold != null) {
spec.setScoreThreshold(threshold);
}
specs.add(spec);
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
}
definition.setKnowledgeSpecs(specs);
bundle.setKnowledgeRetrievers(retrievers);
}
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(binding.getKnowledgeId());
request.setQuery(query);
request.setLimit(limit <= 0 ? null : limit);
request.setMinSimilarity(scoreThreshold <= 0D ? null : scoreThreshold);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(binding.getRetrievalMode()));
request.setCallerType("AGENT_KNOWLEDGE");
request.setCallerId(binding.getAgentId() == null ? null : binding.getAgentId().toString());
LOG.info(
"Agent knowledge retrieval started, agentId={}, knowledgeId={}, query={}, limit={}, scoreThreshold={}, retrievalMode={}",
request.getCallerId(),
request.getKnowledgeId(),
request.getQuery(),
request.getLimit(),
request.getMinSimilarity(),
request.getRetrievalMode()
);
List<Document> documents = documentCollectionService.search(request);
LOG.info(
"Agent knowledge retrieval completed, agentId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
request.getCallerId(),
request.getKnowledgeId(),
request.getQuery(),
documents == null ? 0 : documents.size(),
summarizeDocuments(documents)
);
List<AgentKnowledgeDocument> mapped = new ArrayList<>();
if (documents != null) {
for (Document document : documents) {
AgentKnowledgeDocument item = new AgentKnowledgeDocument();
item.setDocumentId(firstNonBlank(
metadataString(document.getMetadataMap(), "documentId"),
document.getId() == null ? null : String.valueOf(document.getId())
));
item.setDocumentName(firstNonBlank(
metadataString(document.getMetadataMap(), "sourceFileName"),
document.getTitle()
));
item.setChunkId(firstNonBlank(
metadataString(document.getMetadataMap(), "chunkId"),
document.getId() == null ? null : String.valueOf(document.getId())
));
item.setContent(document.getContent());
item.setScore(document.getScore());
item.setMetadata(document.getMetadataMap());
item.setSourceUri(metadataString(document.getMetadataMap(), "sourceUri"));
mapped.add(item);
}
}
return AgentKnowledgeRetrievalResult.of(mapped);
}
/**
* 构建用于日志排查的知识库命中摘要,避免完整内容撑爆日志。
*
* @param documents 知识库检索命中文档
* @return 文档摘要列表
*/
private List<Map<String, Object>> summarizeDocuments(List<Document> documents) {
List<Map<String, Object>> summaries = new ArrayList<>();
if (documents == null) {
return summaries;
}
for (Document document : documents) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("id", document.getId());
summary.put("title", document.getTitle());
summary.put("score", document.getScore());
summary.put("content", truncate(document.getContent()));
summary.put("metadata", document.getMetadataMap());
summaries.add(summary);
}
return summaries;
}
/**
* 截断日志文本,保留排查所需的前缀内容。
*
* @param text 原始文本
* @return 截断后的文本
*/
private String truncate(String text) {
if (text == null || text.length() <= LOG_TEXT_MAX_LENGTH) {
return text;
}
return text.substring(0, LOG_TEXT_MAX_LENGTH) + "...";
}
private Workflow snapshotOrPublishedWorkflow(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
Workflow workflow = objectMapper.convertValue(binding.getResourceSnapshot(), Workflow.class);
workflow.setId(firstNonNull(workflow.getId(), binding.getTargetId()));
return workflow;
}
return workflowService.getPublishedById(binding.getTargetId());
}
private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class);
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
return pluginItem;
}
return pluginItemService.getById(binding.getTargetId());
}
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
Mcp mcp = objectMapper.convertValue(binding.getResourceSnapshot(), Mcp.class);
mcp.setId(firstNonNull(mcp.getId(), binding.getTargetId()));
return mcp;
}
return mcpService.getById(binding.getTargetId());
}
private Map.Entry<String, Map<String, Object>> firstMcpServer(Mcp mcp) {
Map<String, Object> config = parseMcpConfig(mcp);
Map<String, Object> servers = mapValue(config, "mcpServers");
if (servers.isEmpty()) {
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务名称");
}
Map.Entry<String, Object> first = servers.entrySet().iterator().next();
if (!(first.getValue() instanceof Map<?, ?> rawServer)) {
throw new BusinessException("MCP 服务配置必须是对象:" + first.getKey());
}
Map<String, Object> serverConfig = new LinkedHashMap<>();
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
return Map.entry(first.getKey(), serverConfig);
}
private Map<String, Object> parseMcpConfig(Mcp mcp) {
String configJson = mcp == null ? null : mcp.getConfigJson();
if (configJson == null || configJson.isBlank()) {
throw new BusinessException("MCP 配置 JSON 不能为空");
}
try {
return objectMapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
throw new BusinessException("MCP 配置 JSON 格式错误");
}
}
private McpTransportType parseMcpTransportType(Mcp mcp, Map<String, Object> serverConfig) {
String transport = firstNonBlank(
mcp == null ? null : mcp.getTransportType(),
stringValue(serverConfig, "transport", null)
);
return McpTransportType.from(transport);
}
private String mcpRuntimeName(Mcp mcp) {
BigInteger id = mcp == null ? null : mcp.getId();
return "mcp_" + safeToolNameSegment(id == null ? "unknown" : String.valueOf(id));
}
private String mcpRuntimeToolPrefix(BigInteger mcpId) {
return "mcp_" + safeToolNameSegment(String.valueOf(mcpId)) + "_";
}
private String safeToolNameSegment(String value) {
String normalized = String.valueOf(value == null ? "" : value).trim()
.replaceAll("[^A-Za-z0-9_-]", "_")
.replaceAll("_+", "_");
if (normalized.isBlank()) {
return "tool";
}
return normalized;
}
private List<String> stringListValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value == null) {
return new ArrayList<>();
}
if (value instanceof Collection<?> collection) {
List<String> result = new ArrayList<>();
for (Object item : collection) {
if (item != null) {
result.add(String.valueOf(item));
}
}
return result;
}
throw new BusinessException("Agent 配置字段必须是数组:" + key);
}
private Duration durationValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value == null) {
return null;
}
if (value instanceof Number number) {
return Duration.ofSeconds(number.longValue());
}
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
try {
return Duration.parse(text);
} catch (Exception ignored) {
try {
return Duration.ofSeconds(Long.parseLong(text));
} catch (NumberFormatException e) {
throw new BusinessException("Agent 配置字段必须是秒数或 Duration" + key);
}
}
}
private List<String> resolveMcpInputs(List<String> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<>();
}
List<String> result = new ArrayList<>(values.size());
for (String value : values) {
result.add(resolveMcpInput(value));
}
return result;
}
private Map<String, String> resolveMcpInputMap(Map<String, String> values) {
if (values == null || values.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, String> result = new LinkedHashMap<>();
values.forEach((key, value) -> result.put(key, resolveMcpInput(value)));
return result;
}
private String resolveMcpInput(String value) {
if (value == null || value.isBlank()) {
return value;
}
Matcher matcher = MCP_INPUT_PATTERN.matcher(value);
StringBuffer resolved = new StringBuffer();
while (matcher.find()) {
String inputKey = matcher.group(1);
String resolvedValue = System.getProperty("mcp.input." + inputKey);
if (resolvedValue == null || resolvedValue.isBlank()) {
throw new BusinessException("MCP 输入变量未解析:" + inputKey);
}
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedValue));
}
matcher.appendTail(resolved);
return resolved.toString();
}
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
knowledge.setId(firstNonNull(knowledge.getId(), binding.getKnowledgeId()));
return knowledge;
}
return documentCollectionService.getPublishedById(binding.getKnowledgeId());
}
private BigInteger firstNonNull(BigInteger first, BigInteger second) {
return first == null ? second : first;
}
private String firstNonBlank(String first, String second) {
return first == null || first.isBlank() ? second : first;
}
private String metadataString(Map<String, Object> metadata, String key) {
Object value = metadata == null ? null : metadata.get(key);
return value == null ? null : String.valueOf(value);
}
private Map<String, Object> toSchema(Parameter[] parameters) {
Map<String, Object> schema = new LinkedHashMap<>();
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
if (parameters != null) {
for (Parameter parameter : parameters) {
properties.put(parameter.getName(), parameterSchema(parameter));
if (parameter.isRequired()) {
required.add(parameter.getName());
}
}
}
schema.put("type", "object");
schema.put("properties", properties);
schema.put("required", required);
return schema;
}
private Map<String, Object> parameterSchema(Parameter parameter) {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", parameter.getType() == null ? "string" : parameter.getType());
putOptionalString(schema, "description", parameter.getDescription());
if (parameter.getChildren() != null && !parameter.getChildren().isEmpty()) {
Map<String, Object> children = new LinkedHashMap<>();
for (Parameter child : parameter.getChildren()) {
if (child != null && child.getName() != null && !child.getName().isBlank()) {
children.put(child.getName(), parameterSchema(child));
}
}
if ("array".equalsIgnoreCase(parameter.getType())) {
schema.put("items", firstArrayItemSchema(parameter.getChildren()));
} else {
schema.put("properties", children);
}
}
return schema;
}
private Map<String, Object> firstArrayItemSchema(List<Parameter> children) {
return children.stream()
.filter(Objects::nonNull)
.findFirst()
.map(this::parameterSchema)
.orElse(Map.of("type", "string"));
}
/**
* 写入非空字符串字段,避免向模型 function schema 输出 null。
*
* @param target 目标 schema
* @param key 字段名
* @param value 字段值
*/
private void putOptionalString(Map<String, Object> target, String key, String value) {
if (value != null && !value.isBlank()) {
target.put(key, value);
}
}
/**
* 将工具描述规整为模型协议可接受的字符串。
*
* @param description 原始描述
* @return 非 null 描述
*/
private String safeDescription(String description) {
return description == null ? "" : description;
}
private AgentModelProviderType parseProviderType(String providerType) {
if (providerType == null || providerType.isBlank()) {
return AgentModelProviderType.OPENAI_COMPATIBLE;
}
try {
return AgentModelProviderType.valueOf(providerType.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return AgentModelProviderType.OPENAI_COMPATIBLE;
}
}
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());
try {
return AgentMemoryType.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的 Agent 记忆策略:" + value);
}
}
private String stringValue(Map<String, Object> map, String key, String defaultValue) {
Object value = map == null ? null : map.get(key);
return value == null ? defaultValue : String.valueOf(value);
}
private Integer intValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof Number number) {
return number.intValue();
}
if (value == null) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value));
} catch (NumberFormatException e) {
throw new BusinessException("Agent 配置字段必须是整数:" + key);
}
}
private Long longValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof Number number) {
return number.longValue();
}
if (value == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(value));
} catch (NumberFormatException e) {
throw new BusinessException("Agent 配置字段必须是长整数:" + key);
}
}
private Double doubleValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof Number number) {
return number.doubleValue();
}
if (value == null) {
return null;
}
try {
return Double.parseDouble(String.valueOf(value));
} catch (NumberFormatException e) {
throw new BusinessException("Agent 配置字段必须是数字:" + key);
}
}
private Boolean booleanValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof Boolean bool) {
return bool;
}
return value == null ? null : Boolean.parseBoolean(String.valueOf(value));
}
@SuppressWarnings("unchecked")
private Map<String, Object> mapValue(Map<String, Object> map, String key) {
Object value = map == null ? null : map.get(key);
if (value == null) {
return new LinkedHashMap<>();
}
if (value instanceof Map<?, ?> rawMap) {
Map<String, Object> result = new LinkedHashMap<>();
rawMap.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
return result;
}
throw new BusinessException("Agent 配置字段必须是对象:" + key);
}
private Map<String, String> stringMapValue(Map<String, Object> map, String key) {
Map<String, Object> rawMap = mapValue(map, key);
Map<String, String> result = new LinkedHashMap<>();
rawMap.forEach((rawKey, rawValue) -> {
if (rawValue != null) {
result.put(rawKey, String.valueOf(rawValue));
}
});
return result;
}
}

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