feat: 全新智能体功能

- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
This commit is contained in:
2026-05-25 11:42:48 +08:00
parent 6c3d98eaac
commit 72df00f25b
168 changed files with 22045 additions and 400 deletions

View File

@@ -20,6 +20,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<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.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,122 @@
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;
/**
* 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
* @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

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

View File

@@ -0,0 +1,302 @@
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.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
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.ChatSessionCommandService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
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;
/**
* 创建 Agent 管理端会话服务。
*
* @param chatSessionQueryService 聊天会话查询服务
* @param chatSessionCommandService 聊天会话命令服务
* @param agentService Agent 服务
* @param documentCollectionService 知识库服务
* @param resourceAccessService 资源访问服务
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
*/
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
ChatSessionCommandService chatSessionCommandService,
AgentService agentService,
DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
this.agentService = agentService;
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
}
/**
* 查询当前用户的 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));
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
*/
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 record AgentAvailability(boolean continuable,
ChatWorkspaceReadOnlyReason reason,
Agent displayAgent) {
}
}

View File

@@ -20,6 +20,7 @@ public class RedisLockExecutor {
private static final long RETRY_INTERVAL_MILLIS = 50L;
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
@@ -29,6 +30,13 @@ public class RedisLockExecutor {
"else return 0 end"
);
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
@@ -42,6 +50,26 @@ public class RedisLockExecutor {
}
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) {
String lockValue = UUID.randomUUID().toString();
boolean acquired = false;
long deadline = System.nanoTime() + waitTimeout.toNanos();
@@ -58,23 +86,87 @@ public class RedisLockExecutor {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待分布式锁被中断lockKey=" + lockKey, e);
}
if (!acquired) {
throw new IllegalStateException("获取分布式锁失败请稍后重试lockKey=" + lockKey);
}
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 {
stringRedisTemplate.execute(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), lockValue);
} catch (Exception 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

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

View File

@@ -4,9 +4,10 @@ import com.alibaba.fastjson.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
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.StringUtil;
import tech.easyflow.core.chat.protocol.ChatEnvelope;
import java.io.IOException;
@@ -90,15 +91,21 @@ public class ChatSseEmitter {
.data(json)
);
return true;
} catch (IllegalStateException e) {
closed.compareAndSet(false, true);
LOG.error("ChatSseEmitter send failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
} catch (AsyncRequestNotUsableException e) {
markDisconnected(event, e);
return false;
} catch (IOException e) {
LOG.error("ChatSseEmitter send io failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
safeCompleteWithError(e);
markDisconnected(event, 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;
} 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);
safeCompleteWithError(e);
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

@@ -0,0 +1,67 @@
<?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-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>
</dependencies>
</project>

View File

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

View File

@@ -0,0 +1,127 @@
package tech.easyflow.agent.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Agent 运行态生产化配置。
*/
@ConfigurationProperties(prefix = "easyflow.agent.runtime")
public class AgentRuntimeProperties {
/**
* Redis 热态 session 缓存 TTL。
*/
private Duration sessionCacheTtl = Duration.ofHours(24);
/**
* 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);
/**
* 获取 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;
}
/**
* 获取 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;
}
}

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,55 @@
package tech.easyflow.agent.runtime;
import java.math.BigInteger;
/**
* Agent 管理端运行请求。
*/
public class AgentChatRequest {
private BigInteger agentId;
private BigInteger sessionId;
private String prompt;
/**
* 获取 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; }
}

View File

@@ -0,0 +1,674 @@
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.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.ai.easyagents.tool.ChatToolNameHelper;
import tech.easyflow.ai.easyagents.tool.McpTool;
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.*;
/**
* 将 Agent 发布快照编译为 easy-agents-agent-runtime 可执行定义。
*/
@Component
public class AgentDefinitionCompiler {
private static final Logger LOG = LoggerFactory.getLogger(AgentDefinitionCompiler.class);
private static final int LOG_TEXT_MAX_LENGTH = 500;
@Resource
private ModelService modelService;
@Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ObjectMapper objectMapper;
/**
* 编译 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) {
if (agent.getToolBindings() == null) {
return;
}
List<AgentToolSpec> specs = new ArrayList<>();
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
for (AgentToolBinding binding : agent.getToolBindings()) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
}
Tool tool = buildTool(binding);
AgentToolSpec spec = toToolSpec(tool, binding);
specs.add(spec);
invokers.put(spec.getName(), (arguments, context) -> invokeTool(tool, arguments));
}
definition.setToolSpecs(specs);
bundle.setToolInvokers(invokers);
}
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();
}
Mcp mcp = snapshotOrCurrentMcp(binding);
if (mcp == null) {
throw new BusinessException("绑定 MCP 不存在");
}
McpTool tool = new McpTool();
tool.setMcpId(mcp.getId());
tool.setName(binding.getToolName());
tool.setDescription(mcp.getDescription());
tool.setParameters(new Parameter[0]);
return tool;
}
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 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;
}
}

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,491 @@
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.springframework.stereotype.Component;
import reactor.core.Disposable;
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 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<>();
/**
* 注册运行态。
*
* @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());
}
/**
* 绑定运行订阅。
*
* @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);
}
}
/**
* 运行结束后移除运行态。
*
* @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();
}
owners.remove(requestId);
Set<String> tokens = requestTokens.remove(requestId);
if (tokens != null) {
tokens.forEach(resumeTokenIndex::remove);
}
}
/**
* 批准工具执行。
*
* @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;
}
}
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);
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();
}
/**
* 绑定运行订阅。
*
* @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();
}
}
/**
* 通过同一个 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,16 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentRuntime;
/**
* Agent 运行时工厂
*/
public interface AgentRuntimeFactory {
/**
* 创建新的有状态 Agent 运行时实例。
*
* @return Agent 运行时实例
*/
AgentRuntime create();
}

View File

@@ -0,0 +1,49 @@
package tech.easyflow.agent.runtime;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
import java.math.BigInteger;
/**
* Agent 运行态持久化状态清理服务。
*/
@Service
public class AgentRuntimeStateCleanupService {
private final AgentRunRegistry agentRunRegistry;
private final EasyFlowAgentSessionStore sessionStore;
private final AgentHitlPendingService pendingService;
/**
* 创建清理服务。
*
* @param agentRunRegistry 当前节点运行态注册表
* @param sessionStore AgentScope session store
* @param pendingService HITL pending 服务
*/
public AgentRuntimeStateCleanupService(AgentRunRegistry agentRunRegistry,
EasyFlowAgentSessionStore sessionStore,
AgentHitlPendingService pendingService) {
this.agentRunRegistry = agentRunRegistry;
this.sessionStore = sessionStore;
this.pendingService = pendingService;
}
/**
* 清理指定正式聊天会话关联的 Agent 运行态。
*
* @param chatSessionId chatlog 会话 ID
* @param userId 当前用户 ID
*/
public void clearChatSession(BigInteger chatSessionId, BigInteger userId) {
if (chatSessionId == null) {
return;
}
String runtimeSessionId = chatSessionId.toString();
agentRunRegistry.cancelSession(runtimeSessionId, userId == null ? null : userId.toString());
sessionStore.deleteByChatSessionId(chatSessionId);
pendingService.deleteByChatSessionId(chatSessionId);
}
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.agentscope.AgentScopeReActRuntime;
import org.springframework.stereotype.Component;
/**
* ReActAgent 运行时工厂。
*/
@Component
public class AgentScopeRuntimeFactory implements AgentRuntimeFactory {
/**
* 创建新的 ReAct 运行时实例。
*
* @return 新的运行时实例
*/
@Override
public AgentRuntime create() {
return new AgentScopeReActRuntime();
}
}

View File

@@ -0,0 +1,220 @@
package tech.easyflow.agent.runtime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 工具调用人工确认事件载荷。
*/
public class AgentToolHitlPayload {
private String requestId;
private String resumeToken;
private String sessionId;
private String agentId;
private String toolCallId;
private String toolName;
private String toolDisplayName;
private String toolType;
private Map<String, Object> input = new LinkedHashMap<>();
private String expiresAt;
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 获取运行请求 ID。
*
* @return 运行请求 ID
*/
public String getRequestId() {
return requestId;
}
/**
* 设置运行请求 ID。
*
* @param requestId 运行请求 ID
*/
public void setRequestId(String requestId) {
this.requestId = requestId;
}
/**
* 获取恢复令牌。
*
* @return 恢复令牌
*/
public String getResumeToken() {
return resumeToken;
}
/**
* 设置恢复令牌。
*
* @param resumeToken 恢复令牌
*/
public void setResumeToken(String resumeToken) {
this.resumeToken = resumeToken;
}
/**
* 获取会话 ID。
*
* @return 会话 ID
*/
public String getSessionId() {
return sessionId;
}
/**
* 设置会话 ID。
*
* @param sessionId 会话 ID
*/
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
/**
* 获取 Agent ID。
*
* @return Agent ID
*/
public String getAgentId() {
return agentId;
}
/**
* 设置 Agent ID。
*
* @param agentId Agent ID
*/
public void setAgentId(String agentId) {
this.agentId = agentId;
}
/**
* 获取工具调用 ID。
*
* @return 工具调用 ID
*/
public String getToolCallId() {
return toolCallId;
}
/**
* 设置工具调用 ID。
*
* @param toolCallId 工具调用 ID
*/
public void setToolCallId(String toolCallId) {
this.toolCallId = toolCallId;
}
/**
* 获取工具名称。
*
* @return 工具名称
*/
public String getToolName() {
return toolName;
}
/**
* 设置工具名称。
*
* @param toolName 工具名称
*/
public void setToolName(String toolName) {
this.toolName = toolName;
}
/**
* 获取工具展示名称。
*
* @return 工具展示名称
*/
public String getToolDisplayName() {
return toolDisplayName;
}
/**
* 设置工具展示名称。
*
* @param toolDisplayName 工具展示名称
*/
public void setToolDisplayName(String toolDisplayName) {
this.toolDisplayName = toolDisplayName;
}
/**
* 获取工具类型。
*
* @return 工具类型
*/
public String getToolType() {
return toolType;
}
/**
* 设置工具类型。
*
* @param toolType 工具类型
*/
public void setToolType(String toolType) {
this.toolType = toolType;
}
/**
* 获取工具入参。
*
* @return 工具入参
*/
public Map<String, Object> getInput() {
return input;
}
/**
* 设置工具入参。
*
* @param input 工具入参
*/
public void setInput(Map<String, Object> input) {
this.input = input == null ? new LinkedHashMap<>() : input;
}
/**
* 获取过期时间。
*
* @return 过期时间
*/
public String getExpiresAt() {
return expiresAt;
}
/**
* 设置过期时间。
*
* @param expiresAt 过期时间
*/
public void setExpiresAt(String expiresAt) {
this.expiresAt = expiresAt;
}
/**
* 获取扩展元数据。
*
* @return 扩展元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置扩展元数据。
*
* @param metadata 扩展元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.agent.runtime.event;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import tech.easyflow.core.runtime.ChatRuntimeContext;
/**
* Agent 运行事件落库记录器。
*/
public interface AgentRunEventRecorder {
/**
* 记录运行事件。
*
* @param requestId 请求 ID
* @param chatContext 聊天上下文
* @param event 运行时事件
*/
void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event);
}

View File

@@ -0,0 +1,127 @@
package tech.easyflow.agent.runtime.event;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.entity.AgentRunEventRecord;
import tech.easyflow.agent.mapper.AgentRunEventRecordMapper;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* MySQL Agent 运行事件记录器。
*/
@Service
public class MySqlAgentRunEventRecorder implements AgentRunEventRecorder {
private final AgentRunEventRecordMapper eventRecordMapper;
/**
* 创建记录器。
*
* @param eventRecordMapper 事件 Mapper
*/
public MySqlAgentRunEventRecorder(AgentRunEventRecordMapper eventRecordMapper) {
this.eventRecordMapper = eventRecordMapper;
}
@Override
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || !shouldPersist(event.getEventType())) {
return;
}
AgentRunEventRecord record = new AgentRunEventRecord();
record.setTenantId(chatContext == null ? null : chatContext.getTenantId());
record.setAgentId(resolveAgentId(event, chatContext));
record.setChatSessionId(chatContext == null ? null : chatContext.getSessionId());
record.setRoundId(resolveNumber(chatContext, ChatRuntimeExtKeys.CURRENT_ROUND_ID));
record.setRoundNo(resolveInteger(chatContext, ChatRuntimeExtKeys.CURRENT_ROUND_NO));
record.setVariantIndex(resolveInteger(chatContext, ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1));
record.setRequestId(firstText(requestId, stringValue(event.getPayload().get("requestId"))));
record.setEventId(event.getEventId());
record.setEventType(event.getEventType().name());
record.setEventPhase(stringValue(event.getMetadata().get("phase")));
record.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
record.setPayloadJson(new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()));
record.setMetadataJson(new LinkedHashMap<>(event.getMetadata() == null ? Map.of() : event.getMetadata()));
record.setCreated(toDate(event.getCreatedAt()));
record.setCreatedBy(chatContext == null ? null : chatContext.getUserId());
eventRecordMapper.insert(record);
}
private boolean shouldPersist(AgentRuntimeEventType type) {
return type != AgentRuntimeEventType.MESSAGE_DELTA
&& type != AgentRuntimeEventType.REASONING_DELTA
&& type != AgentRuntimeEventType.STARTED
&& type != AgentRuntimeEventType.COMPLETED;
}
private BigInteger resolveAgentId(AgentRuntimeEvent event, ChatRuntimeContext chatContext) {
String agentId = firstText(event.getAgentId(), stringValue(event.getPayload().get("agentId")));
if (agentId != null && !agentId.isBlank()) {
try {
return new BigInteger(agentId);
} catch (NumberFormatException ignored) {
return chatContext == null ? null : chatContext.getAssistantId();
}
}
return chatContext == null ? null : chatContext.getAssistantId();
}
private BigInteger resolveNumber(ChatRuntimeContext context, String key) {
Object value = context == null || context.getExt() == null ? null : context.getExt().get(key);
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return BigInteger.valueOf(number.longValue());
}
String text = stringValue(value);
if (text == null || text.isBlank()) {
return null;
}
try {
return new BigInteger(text);
} catch (NumberFormatException ignored) {
return null;
}
}
private Integer resolveInteger(ChatRuntimeContext context, String key) {
return resolveInteger(context, key, null);
}
private Integer resolveInteger(ChatRuntimeContext context, String key, Integer defaultValue) {
Object value = context == null || context.getExt() == null ? null : context.getExt().get(key);
if (value instanceof Number number) {
return number.intValue();
}
String text = stringValue(value);
if (text == null || text.isBlank()) {
return defaultValue;
}
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
return defaultValue;
}
}
private Date toDate(Instant instant) {
return instant == null ? new Date() : Date.from(instant);
}
private String firstText(String left, String right) {
return left != null && !left.isBlank() ? left : right;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.agent.runtime.hitl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.AgentHitlPending;
import java.util.List;
/**
* Agent 工具审批 pending 过期清理任务。
*/
@Component
public class AgentHitlPendingExpirationTask {
private static final Logger LOG = LoggerFactory.getLogger(AgentHitlPendingExpirationTask.class);
private static final int BATCH_SIZE = 100;
private final AgentHitlPendingService pendingService;
/**
* 创建任务。
*
* @param pendingService pending 服务
*/
public AgentHitlPendingExpirationTask(AgentHitlPendingService pendingService) {
this.pendingService = pendingService;
}
/**
* 定期将超时 pending 标记为 EXPIRED。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.hitl-expire-scan-delay:60000}", initialDelay = 60000L)
public void expirePending() {
try {
List<AgentHitlPending> expired = pendingService.expirePending(BATCH_SIZE);
if (!expired.isEmpty()) {
LOG.info("Expired Agent HITL pending records, count={}", expired.size());
}
} catch (RuntimeException e) {
LOG.warn("Expire Agent HITL pending records failed, message={}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.agent.runtime.hitl;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.math.BigInteger;
import java.util.List;
/**
* Agent 工具审批 pending 持久化服务。
*/
public interface AgentHitlPendingService {
/**
* 从 TOOL_APPROVAL_REQUIRED 事件创建或刷新 pending。
*
* @param requestId 请求 ID
* @param chatContext 聊天上下文
* @param event 运行时事件
*/
void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event);
/**
* 批准并原子消费 pending。
*
* @param resumeToken 恢复令牌
* @param operatorId 操作人 ID
* @return pending 记录
*/
AgentHitlPending approve(String resumeToken, BigInteger operatorId);
/**
* 拒绝并原子消费 pending。
*
* @param resumeToken 恢复令牌
* @param operatorId 操作人 ID
* @param reason 拒绝原因
* @return pending 记录
*/
AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason);
/**
* 取消指定请求的 pending。
*
* @param requestId 请求 ID
* @param reason 取消原因
*/
void cancelByRequestId(String requestId, String reason);
/**
* 删除指定聊天会话的 pending。
*
* @param chatSessionId 聊天会话 ID
*/
void deleteByChatSessionId(BigInteger chatSessionId);
/**
* 删除指定运行会话的 pending。
*
* @param runtimeSessionId 运行会话 ID
*/
void deleteByRuntimeSessionId(String runtimeSessionId);
/**
* 过期 pending 并返回被过期的记录。
*
* @param limit 每批数量
* @return 过期记录
*/
List<AgentHitlPending> expirePending(int limit);
}

View File

@@ -0,0 +1,279 @@
package tech.easyflow.agent.runtime.hitl;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.mapper.AgentHitlPendingMapper;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Agent 工具审批 pending 持久化服务实现。
*/
@Service
public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
private final AgentHitlPendingMapper pendingMapper;
private final AgentRuntimeProperties properties;
/**
* 创建服务。
*
* @param pendingMapper pending Mapper
* @param properties Agent 运行态配置
*/
public AgentHitlPendingServiceImpl(AgentHitlPendingMapper pendingMapper,
AgentRuntimeProperties properties) {
this.pendingMapper = pendingMapper;
this.properties = properties;
}
@Override
public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
if (event == null || event.getPayload() == null) {
return;
}
String resumeToken = stringValue(event.getPayload().get("resumeToken"));
if (!StringUtils.hasText(resumeToken)) {
return;
}
AgentHitlPending pending = findByToken(resumeToken);
Date now = new Date();
if (pending == null) {
pending = new AgentHitlPending();
pending.setResumeToken(resumeToken);
pending.setCreated(now);
pending.setCreatedBy(chatContext == null ? null : chatContext.getUserId());
pending.setIsDeleted(0);
}
pending.setTenantId(chatContext == null ? null : chatContext.getTenantId());
pending.setAgentId(resolveAgentId(event, chatContext));
pending.setChatSessionId(chatContext == null ? null : chatContext.getSessionId());
pending.setRuntimeSessionId(firstText(event.getSessionId(), stringValue(event.getPayload().get("sessionId"))));
pending.setRequestId(requestId);
pending.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
pending.setToolName(stringValue(event.getPayload().get("toolName")));
pending.setToolInputJson(mapValue(firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input"))));
pending.setStatus(AgentHitlPendingStatus.PENDING.name());
pending.setExpiresAt(resolveExpiresAt(event));
pending.setMetadataJson(metadata(event));
pending.setModified(now);
pending.setModifiedBy(chatContext == null ? null : chatContext.getUserId());
pendingMapper.insertOrUpdate(pending);
}
@Override
@Transactional(rollbackFor = Exception.class)
public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
return consume(resumeToken, operatorId, AgentHitlPendingStatus.APPROVED, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
return consume(resumeToken, operatorId, AgentHitlPendingStatus.REJECTED, reason);
}
@Override
public void cancelByRequestId(String requestId, String reason) {
if (!StringUtils.hasText(requestId)) {
return;
}
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
.eq("request_id", requestId)
.eq("status", AgentHitlPendingStatus.PENDING.name())
.eq("is_deleted", 0));
Date now = new Date();
for (AgentHitlPending record : records) {
record.setStatus(AgentHitlPendingStatus.CANCELLED.name());
record.setRejectReason(reason);
record.setConsumedAt(now);
record.setModified(now);
pendingMapper.update(record);
}
}
@Override
public void deleteByChatSessionId(BigInteger chatSessionId) {
if (chatSessionId == null) {
return;
}
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
.eq("chat_session_id", chatSessionId)
.eq("is_deleted", 0));
softDelete(records);
}
@Override
public void deleteByRuntimeSessionId(String runtimeSessionId) {
if (!StringUtils.hasText(runtimeSessionId)) {
return;
}
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
.eq("runtime_session_id", runtimeSessionId)
.eq("is_deleted", 0));
softDelete(records);
}
private void softDelete(List<AgentHitlPending> records) {
if (records == null || records.isEmpty()) {
return;
}
Date now = new Date();
for (AgentHitlPending record : records) {
record.setIsDeleted(1);
record.setModified(now);
pendingMapper.update(record);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<AgentHitlPending> expirePending(int limit) {
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
.eq("status", AgentHitlPendingStatus.PENDING.name())
.eq("is_deleted", 0)
.le("expires_at", new Date())
.limit(Math.max(1, limit)));
Date now = new Date();
for (AgentHitlPending record : records) {
record.setStatus(AgentHitlPendingStatus.EXPIRED.name());
record.setRejectReason("审批超时,已自动拒绝");
record.setConsumedAt(now);
record.setModified(now);
pendingMapper.update(record);
}
return records;
}
private AgentHitlPending consume(String resumeToken,
BigInteger operatorId,
AgentHitlPendingStatus targetStatus,
String reason) {
if (!StringUtils.hasText(resumeToken)) {
throw new BusinessException("Agent 恢复令牌不能为空");
}
AgentHitlPending pending = findByToken(resumeToken);
if (pending == null || Integer.valueOf(1).equals(pending.getIsDeleted())) {
throw new BusinessException("Agent 审批请求不存在或已失效");
}
if (!AgentHitlPendingStatus.PENDING.name().equals(pending.getStatus())) {
throw new BusinessException("Agent 审批请求已处理");
}
if (pending.getExpiresAt() != null && pending.getExpiresAt().before(new Date())) {
markConsumed(pending, operatorId, AgentHitlPendingStatus.EXPIRED, "审批超时,已自动拒绝");
throw new BusinessException("Agent 审批请求已过期");
}
if (!markConsumed(pending, operatorId, targetStatus, reason)) {
throw new BusinessException("Agent 审批请求已处理");
}
pending.setStatus(targetStatus.name());
pending.setRejectReason(reason);
pending.setConsumedAt(new Date());
pending.setModifiedBy(operatorId);
return pending;
}
private boolean markConsumed(AgentHitlPending pending,
BigInteger operatorId,
AgentHitlPendingStatus targetStatus,
String reason) {
Date now = new Date();
AgentHitlPending update = new AgentHitlPending();
update.setStatus(targetStatus.name());
update.setRejectReason(reason);
update.setConsumedAt(now);
update.setModified(now);
update.setModifiedBy(operatorId);
// 用 status=PENDING 作为消费条件,避免两个审批请求同时把同一个 token 消费两次。
return pendingMapper.updateByQuery(update, QueryWrapper.create()
.eq("id", pending.getId())
.eq("status", AgentHitlPendingStatus.PENDING.name())
.eq("is_deleted", 0)) > 0;
}
private AgentHitlPending findByToken(String resumeToken) {
return pendingMapper.selectOneByQuery(QueryWrapper.create()
.eq("resume_token", resumeToken)
.eq("is_deleted", 0)
.limit(1));
}
private BigInteger resolveAgentId(AgentRuntimeEvent event, ChatRuntimeContext chatContext) {
String agentId = firstText(event.getAgentId(), stringValue(event.getPayload().get("agentId")));
if (StringUtils.hasText(agentId)) {
try {
return new BigInteger(agentId);
} catch (NumberFormatException ignored) {
return chatContext == null ? null : chatContext.getAssistantId();
}
}
return chatContext == null ? null : chatContext.getAssistantId();
}
private Map<String, Object> metadata(AgentRuntimeEvent event) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.putAll(event.getMetadata() == null ? Map.of() : event.getMetadata());
Object approvalMetadata = event.getPayload().get("approvalMetadata");
if (approvalMetadata instanceof Map<?, ?> map) {
map.forEach((key, value) -> metadata.put(String.valueOf(key), value));
}
return metadata;
}
@SuppressWarnings("unchecked")
private Map<String, Object> mapValue(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
return new LinkedHashMap<>();
}
private Date dateValue(Object value) {
if (value instanceof Date date) {
return date;
}
String text = stringValue(value);
if (!StringUtils.hasText(text)) {
return null;
}
try {
return Date.from(Instant.parse(text));
} catch (RuntimeException ignored) {
return null;
}
}
private Date resolveExpiresAt(AgentRuntimeEvent event) {
Date eventExpiresAt = dateValue(event.getPayload().get("expiresAt"));
if (eventExpiresAt != null) {
return eventExpiresAt;
}
return Date.from(Instant.now().plus(properties.getHitlPendingTimeout()));
}
private Object firstNonNull(Object left, Object right) {
return left == null ? right : left;
}
private String firstText(String left, String right) {
return StringUtils.hasText(left) ? left : right;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.agent.runtime.hitl;
/**
* Agent HITL pending 状态。
*/
public enum AgentHitlPendingStatus {
/**
* 等待审批。
*/
PENDING,
/**
* 已批准。
*/
APPROVED,
/**
* 已拒绝。
*/
REJECTED,
/**
* 已过期。
*/
EXPIRED,
/**
* 已取消。
*/
CANCELLED
}

View File

@@ -0,0 +1,41 @@
package tech.easyflow.agent.runtime.lock;
import java.math.BigInteger;
/**
* Agent 会话级运行锁。
*/
public interface AgentRunLock {
/**
* 获取指定 Agent 会话的运行锁。
*
* @param agentId Agent ID
* @param sessionId 运行时会话 ID
* @return 锁句柄
*/
Handle acquire(BigInteger agentId, String sessionId);
/**
* Agent 运行锁句柄。
*/
interface Handle extends AutoCloseable {
/**
* 续期锁。
*
* @return 续期成功时为 true
*/
boolean renew();
/**
* 释放锁。
*/
void release();
@Override
default void close() {
release();
}
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.agent.runtime.lock;
import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 基于 Redis 的 Agent 会话级运行锁。
*/
@Component
public class RedisAgentRunLock implements AgentRunLock {
private static final String LOCK_PREFIX = "easyflow:agent:run:";
private static final ScheduledExecutorService RENEW_EXECUTOR = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "agent-run-lock-renew");
thread.setDaemon(true);
return thread;
});
private final RedisLockExecutor redisLockExecutor;
private final AgentRuntimeProperties properties;
/**
* 创建 Redis Agent 运行锁。
*
* @param redisLockExecutor Redis 锁执行器
* @param properties Agent 运行态配置
*/
public RedisAgentRunLock(RedisLockExecutor redisLockExecutor, AgentRuntimeProperties properties) {
this.redisLockExecutor = redisLockExecutor;
this.properties = properties;
}
@Override
public Handle acquire(BigInteger agentId, String sessionId) {
try {
RedisLockExecutor.LockHandle handle = redisLockExecutor.acquire(
lockKey(agentId, sessionId),
properties.getLockWaitTimeout(),
properties.getLockLeaseTimeout());
return new RedisHandle(handle, scheduleRenew(handle));
} catch (IllegalStateException e) {
throw new BusinessException("当前 Agent 会话正在运行,请稍后再试");
}
}
private ScheduledFuture<?> scheduleRenew(RedisLockExecutor.LockHandle handle) {
long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis());
return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
}
private String lockKey(BigInteger agentId, String sessionId) {
return LOCK_PREFIX + "agent:" + (agentId == null ? "unknown" : agentId)
+ ":session:" + (sessionId == null ? "unknown" : sessionId);
}
private static final class RedisHandle implements Handle {
private final RedisLockExecutor.LockHandle delegate;
private final ScheduledFuture<?> renewTask;
private final AtomicBoolean released = new AtomicBoolean(false);
private RedisHandle(RedisLockExecutor.LockHandle delegate, ScheduledFuture<?> renewTask) {
this.delegate = delegate;
this.renewTask = renewTask;
}
@Override
public boolean renew() {
return delegate.renew();
}
@Override
public void release() {
if (!released.compareAndSet(false, true)) {
return;
}
if (renewTask != null) {
renewTask.cancel(false);
}
delegate.release();
}
}
}

View File

@@ -0,0 +1,348 @@
package tech.easyflow.agent.runtime.session;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.mybatisflex.core.query.QueryWrapper;
import io.agentscope.core.state.State;
import io.agentscope.core.util.JsonUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.entity.AgentSession;
import tech.easyflow.agent.mapper.AgentSessionMapper;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* session store 持久化实现
*/
@Service
public class EasyFlowAgentSessionStore implements AgentSessionStore {
private static final String REDIS_PREFIX = "easyflow:agent:session:";
private static final String ENVELOPE_VERSION = "1";
private static final String SINGLE_STATES = "singleStates";
private static final String LIST_STATES = "listStates";
private final AgentSessionMapper agentSessionMapper;
private final StringRedisTemplate stringRedisTemplate;
private final AgentRuntimeProperties properties;
/**
* 创建 EasyFlow Agent session store。
*
* @param agentSessionMapper session Mapper
* @param stringRedisTemplate Redis 模板
* @param properties Agent 运行态配置
*/
public EasyFlowAgentSessionStore(AgentSessionMapper agentSessionMapper,
StringRedisTemplate stringRedisTemplate,
AgentRuntimeProperties properties) {
this.agentSessionMapper = agentSessionMapper;
this.stringRedisTemplate = stringRedisTemplate;
this.properties = properties;
}
/**
* 绑定业务会话元信息。
*
* <p>AgentScope Session API 不会把 EasyFlow 的 {@code agentId/chatSessionId/tenantId} 传入
* {@code save(...)},因此运行入口必须先调用本方法建立或刷新元信息。后续 state 写入会复用
* 这些字段,避免表里只剩裸 sessionKey。</p>
*
* @param metadata 业务会话元信息
*/
public void bindSession(AgentSessionMetadata metadata) {
if (metadata == null || !StringUtils.hasText(metadata.sessionKey())) {
return;
}
AgentSession session = findBySessionKey(metadata.sessionKey());
Date now = new Date();
if (session == null) {
session = new AgentSession();
session.setTenantId(metadata.tenantId());
session.setAgentId(metadata.agentId());
session.setChatSessionId(metadata.chatSessionId());
session.setRuntimeSessionId(metadata.runtimeSessionId());
session.setSessionKey(metadata.sessionKey());
session.setStateJson(emptyEnvelope());
session.setVersion(0L);
session.setCacheVersion(0L);
session.setCreated(now);
session.setCreatedBy(metadata.operatorId());
session.setIsDeleted(0);
}
session.setTenantId(firstNonNull(metadata.tenantId(), session.getTenantId()));
session.setAgentId(firstNonNull(metadata.agentId(), session.getAgentId()));
session.setChatSessionId(firstNonNull(metadata.chatSessionId(), session.getChatSessionId()));
session.setRuntimeSessionId(firstText(metadata.runtimeSessionId(), session.getRuntimeSessionId()));
session.setLastAccessAt(now);
session.setModified(now);
session.setModifiedBy(metadata.operatorId());
agentSessionMapper.insertOrUpdate(session);
writeCache(session.getSessionKey(), session.getStateJson());
}
/**
* 按聊天会话清理 AgentScope session。
*
* @param chatSessionId 聊天会话 ID
*/
public void deleteByChatSessionId(BigInteger chatSessionId) {
if (chatSessionId == null) {
return;
}
List<AgentSession> sessions = agentSessionMapper.selectListByQuery(QueryWrapper.create()
.eq("chat_session_id", chatSessionId)
.eq("is_deleted", 0));
Date now = new Date();
for (AgentSession session : sessions) {
session.setIsDeleted(1);
session.setModified(now);
agentSessionMapper.update(session);
deleteCache(session.getSessionKey());
}
}
@Override
public void save(String sessionKey, String name, State state) {
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || state == null) {
return;
}
Map<String, Object> envelope = loadEnvelope(sessionKey);
singleStates(envelope).put(name, JsonUtils.getJsonCodec().toJson(state));
persistEnvelope(sessionKey, envelope);
}
@Override
public void saveList(String sessionKey, String name, List<? extends State> states) {
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name)) {
return;
}
List<String> values = new ArrayList<>();
if (states != null) {
for (State state : states) {
values.add(JsonUtils.getJsonCodec().toJson(state));
}
}
Map<String, Object> envelope = loadEnvelope(sessionKey);
listStates(envelope).put(name, values);
persistEnvelope(sessionKey, envelope);
}
@Override
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || type == null) {
return Optional.empty();
}
Object json = singleStates(loadEnvelope(sessionKey)).get(name);
if (!(json instanceof String text) || text.isBlank()) {
return Optional.empty();
}
return Optional.of(JsonUtils.getJsonCodec().fromJson(text, type));
}
@Override
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || itemType == null) {
return List.of();
}
Object raw = listStates(loadEnvelope(sessionKey)).get(name);
if (!(raw instanceof List<?> values) || values.isEmpty()) {
return List.of();
}
List<T> result = new ArrayList<>();
for (Object value : values) {
if (value instanceof String text && !text.isBlank()) {
result.add(JsonUtils.getJsonCodec().fromJson(text, itemType));
}
}
return result;
}
@Override
public boolean exists(String sessionKey) {
if (!StringUtils.hasText(sessionKey)) {
return false;
}
if (readCache(sessionKey) != null) {
return true;
}
return findBySessionKey(sessionKey) != null;
}
@Override
public void delete(String sessionKey) {
if (!StringUtils.hasText(sessionKey)) {
return;
}
AgentSession session = findBySessionKey(sessionKey);
if (session != null) {
session.setIsDeleted(1);
session.setModified(new Date());
agentSessionMapper.update(session);
}
deleteCache(sessionKey);
}
@Override
public Set<String> listSessionKeys() {
List<AgentSession> sessions = agentSessionMapper.selectListByQuery(QueryWrapper.create()
.eq("is_deleted", 0)
.select("session_key"));
Set<String> keys = new LinkedHashSet<>();
for (AgentSession session : sessions) {
if (StringUtils.hasText(session.getSessionKey())) {
keys.add(session.getSessionKey());
}
}
return keys;
}
private void persistEnvelope(String sessionKey, Map<String, Object> envelope) {
AgentSession session = findBySessionKey(sessionKey);
Date now = new Date();
if (session == null) {
session = new AgentSession();
session.setRuntimeSessionId(sessionKey);
session.setSessionKey(sessionKey);
session.setCreated(now);
session.setVersion(0L);
session.setCacheVersion(0L);
session.setIsDeleted(0);
}
long nextVersion = session.getVersion() == null ? 1L : session.getVersion() + 1L;
session.setStateJson(envelope);
session.setVersion(nextVersion);
session.setCacheVersion(nextVersion);
session.setLastAccessAt(now);
session.setModified(now);
session.setIsDeleted(0);
// 同步写会话状态
agentSessionMapper.insertOrUpdate(session);
// 同步写缓存
writeCache(sessionKey, envelope);
}
private Map<String, Object> loadEnvelope(String sessionKey) {
Map<String, Object> cached = readCache(sessionKey);
if (cached != null) {
return cached;
}
AgentSession session = findBySessionKey(sessionKey);
if (session == null || session.getStateJson() == null || session.getStateJson().isEmpty()) {
return emptyEnvelope();
}
writeCache(sessionKey, session.getStateJson());
return deepCopy(session.getStateJson());
}
private AgentSession findBySessionKey(String sessionKey) {
return agentSessionMapper.selectOneByQuery(QueryWrapper.create()
.eq("session_key", sessionKey)
.eq("is_deleted", 0)
.limit(1));
}
private Map<String, Object> emptyEnvelope() {
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("version", ENVELOPE_VERSION);
envelope.put(SINGLE_STATES, new LinkedHashMap<String, Object>());
envelope.put(LIST_STATES, new LinkedHashMap<String, Object>());
return envelope;
}
@SuppressWarnings("unchecked")
private Map<String, Object> singleStates(Map<String, Object> envelope) {
return (Map<String, Object>) envelope.computeIfAbsent(SINGLE_STATES, key -> new LinkedHashMap<String, Object>());
}
@SuppressWarnings("unchecked")
private Map<String, Object> listStates(Map<String, Object> envelope) {
return (Map<String, Object>) envelope.computeIfAbsent(LIST_STATES, key -> new LinkedHashMap<String, Object>());
}
@SuppressWarnings("unchecked")
private Map<String, Object> readCache(String sessionKey) {
try {
String value = stringRedisTemplate.opsForValue().get(cacheKey(sessionKey));
if (!StringUtils.hasText(value)) {
return null;
}
return JsonUtils.getJsonCodec().fromJson(value, Map.class);
} catch (RuntimeException e) {
return null;
}
}
private void writeCache(String sessionKey, Map<String, Object> envelope) {
try {
long seconds = Math.max(1L, properties.getSessionCacheTtl().toSeconds());
stringRedisTemplate.opsForValue().set(cacheKey(sessionKey), JsonUtils.getJsonCodec().toJson(envelope),
seconds, TimeUnit.SECONDS);
} catch (RuntimeException e) {
// MySQL 是 AgentScope session 的真相源。Redis 只做热缓存,写缓存失败不能掩盖已完成的持久化写入。
}
}
private void deleteCache(String sessionKey) {
try {
stringRedisTemplate.delete(cacheKey(sessionKey));
} catch (RuntimeException ignored) {
// 清理缓存失败不应掩盖 MySQL 删除结果,后续 TTL 会自然回收。
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> deepCopy(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
return emptyEnvelope();
}
return JsonUtils.getJsonCodec().fromJson(JsonUtils.getJsonCodec().toJson(source), Map.class);
}
private String cacheKey(String sessionKey) {
return REDIS_PREFIX + hash(sessionKey);
}
private String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} catch (NoSuchAlgorithmException e) {
return value.replace(':', '_');
}
}
private <T> T firstNonNull(T left, T right) {
return left == null ? right : left;
}
private String firstText(String left, String right) {
return StringUtils.hasText(left) ? left : right;
}
/**
* AgentScope session 绑定的 EasyFlow 业务元信息。
*
* @param sessionKey AgentScope session key
* @param runtimeSessionId runtime session ID
* @param agentId Agent ID
* @param chatSessionId chatlog 会话 ID
* @param tenantId 租户 ID
* @param operatorId 操作人 ID
*/
public record AgentSessionMetadata(String sessionKey,
String runtimeSessionId,
BigInteger agentId,
BigInteger chatSessionId,
BigInteger tenantId,
BigInteger operatorId) {
}
}

View File

@@ -0,0 +1,25 @@
package tech.easyflow.agent.service;
import tech.easyflow.agent.entity.Agent;
import java.util.Collection;
/**
* Agent 审批状态派生服务。
*/
public interface AgentApprovalStateService {
/**
* 填充单个 Agent 的审批展示状态。
*
* @param agent Agent 资源
*/
void fillAgentApprovalState(Agent agent);
/**
* 批量填充 Agent 的审批展示状态。
*
* @param agents Agent 资源集合
*/
void fillAgentApprovalState(Collection<Agent> agents);
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.AgentCategory;
/**
* Agent 分类服务。
*/
public interface AgentCategoryService extends IService<AgentCategory> {
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Agent 知识库绑定服务。
*/
public interface AgentKnowledgeBindingService extends IService<AgentKnowledgeBinding> {
/**
* 替换 Agent 知识库绑定。
*
* @param agentId Agent ID
* @param bindings 新绑定列表
* @return 保存后的绑定列表
*/
List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings);
/**
* 查询 Agent 启用知识库绑定。
*
* @param agentId Agent ID
* @return 启用绑定列表
*/
List<AgentKnowledgeBinding> listEnabled(BigInteger agentId);
}

View File

@@ -0,0 +1,61 @@
package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.Agent;
import java.math.BigInteger;
import java.util.Map;
/**
* Agent 配置服务。
*/
public interface AgentService extends IService<Agent> {
/**
* 获取 Agent 详情。
*
* @param id Agent ID
* @return Agent 详情
*/
Agent getDetail(BigInteger id);
/**
* 保存草稿 Agent。
*
* @param agent Agent 草稿
* @return 保存后的 Agent
*/
Agent saveDraft(Agent agent);
/**
* 更新草稿 Agent。
*
* @param agent Agent 草稿
* @return 更新后的 Agent
*/
Agent updateDraft(Agent agent);
/**
* 获取已发布运行视图。
*
* @param id Agent ID
* @return 已发布运行视图
*/
Agent getPublishedView(BigInteger id);
/**
* 构建发布快照。
*
* @param agent Agent 当前草稿
* @return 发布快照
*/
Map<String, Object> buildPublishSnapshot(Agent agent);
/**
* 从快照还原运行视图。
*
* @param snapshot 发布快照
* @return Agent 运行视图
*/
Agent fromSnapshot(Map<String, Object> snapshot);
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.agent.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Agent 工具绑定服务。
*/
public interface AgentToolBindingService extends IService<AgentToolBinding> {
/**
* 替换 Agent 工具绑定。
*
* @param agentId Agent ID
* @param bindings 新绑定列表
* @return 保存后的绑定列表
*/
List<AgentToolBinding> replaceBindings(BigInteger agentId, List<AgentToolBinding> bindings);
/**
* 查询 Agent 启用工具绑定。
*
* @param agentId Agent ID
* @return 启用绑定列表
*/
List<AgentToolBinding> listEnabled(BigInteger agentId);
}

View File

@@ -0,0 +1,116 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.service.AgentApprovalStateService;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Agent 审批状态派生服务实现。
*/
@Service
public class AgentApprovalStateServiceImpl implements AgentApprovalStateService {
private final ApprovalInstanceMapper approvalInstanceMapper;
/**
* 创建 Agent 审批状态派生服务。
*
* @param approvalInstanceMapper 审批实例 Mapper
*/
public AgentApprovalStateServiceImpl(ApprovalInstanceMapper approvalInstanceMapper) {
this.approvalInstanceMapper = approvalInstanceMapper;
}
/**
* {@inheritDoc}
*/
@Override
public void fillAgentApprovalState(Agent agent) {
fillAgentApprovalState(agent == null ? List.of() : List.of(agent));
}
/**
* {@inheritDoc}
*/
@Override
public void fillAgentApprovalState(Collection<Agent> agents) {
if (CollectionUtils.isEmpty(agents)) {
return;
}
List<Agent> validAgents = agents.stream().filter(Objects::nonNull).toList();
if (validAgents.isEmpty()) {
return;
}
Map<BigInteger, ApprovalInstance> instanceMap = loadInstanceMap(validAgents, Agent::getCurrentApprovalInstanceId);
for (Agent agent : validAgents) {
fillOne(agent, instanceMap.get(agent.getCurrentApprovalInstanceId()));
}
}
private void fillOne(Agent agent, ApprovalInstance instance) {
PublishStatus currentStatus = PublishStatus.from(agent.getPublishStatus());
if (!isValidCurrentInstance(instance)) {
agent.setApprovalPending(false);
agent.setCurrentApprovalActionType(null);
agent.setDisplayPublishStatus(currentStatus.getCode());
return;
}
ApprovalInstanceStatus instanceStatus = ApprovalInstanceStatus.from(instance.getStatus());
if (instanceStatus.isFinished()) {
agent.setApprovalPending(false);
agent.setCurrentApprovalActionType(null);
agent.setDisplayPublishStatus(currentStatus.getCode());
return;
}
ApprovalActionType actionType = ApprovalActionType.from(instance.getActionType());
agent.setApprovalPending(true);
agent.setCurrentApprovalActionType(actionType.getCode());
agent.setDisplayPublishStatus(resolveDisplayStatusWithActiveInstance(currentStatus, actionType).getCode());
}
private Map<BigInteger, ApprovalInstance> loadInstanceMap(Collection<Agent> agents,
Function<Agent, BigInteger> instanceIdGetter) {
Set<BigInteger> instanceIds = agents.stream()
.map(instanceIdGetter)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (instanceIds.isEmpty()) {
return Collections.emptyMap();
}
List<ApprovalInstance> instances = approvalInstanceMapper.selectListByQuery(
QueryWrapper.create().in(ApprovalInstance::getId, instanceIds)
);
return instances.stream().collect(Collectors.toMap(ApprovalInstance::getId, Function.identity()));
}
private boolean isValidCurrentInstance(ApprovalInstance instance) {
return instance != null && ApprovalResourceType.AGENT.getCode().equals(instance.getResourceType());
}
private PublishStatus resolveDisplayStatusWithActiveInstance(PublishStatus currentStatus,
ApprovalActionType actionType) {
if (currentStatus == PublishStatus.PUBLISH_PENDING
|| currentStatus == PublishStatus.OFFLINE_PENDING
|| currentStatus == PublishStatus.DELETE_PENDING) {
return currentStatus;
}
return switch (actionType) {
case PUBLISH -> PublishStatus.PUBLISH_PENDING;
case OFFLINE -> PublishStatus.OFFLINE_PENDING;
case DELETE -> PublishStatus.DELETE_PENDING;
};
}
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.entity.AgentCategory;
import tech.easyflow.agent.mapper.AgentCategoryMapper;
import tech.easyflow.agent.service.AgentCategoryService;
/**
* Agent 分类服务实现。
*/
@Service
public class AgentCategoryServiceImpl extends ServiceImpl<AgentCategoryMapper, AgentCategory> implements AgentCategoryService {
}

View File

@@ -0,0 +1,123 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.mapper.AgentKnowledgeBindingMapper;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
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 javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* Agent 知识库绑定服务实现。
*/
@Service
public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledgeBindingMapper, AgentKnowledgeBinding>
implements AgentKnowledgeBindingService {
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
@Resource
private AgentMapper agentMapper;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ResourceAccessService resourceAccessService;
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings) {
Agent agent = requireAgent(agentId);
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
remove(QueryWrapper.create().where("agent_id = ?", agentId));
if (bindings == null || bindings.isEmpty()) {
return Collections.emptyList();
}
for (int i = 0; i < bindings.size(); i++) {
AgentKnowledgeBinding binding = bindings.get(i);
validateBinding(binding);
applyBindingDefaults(agent, binding, i);
}
saveBatch(bindings);
return listEnabled(agentId);
}
/**
* {@inheritDoc}
*/
@Override
public List<AgentKnowledgeBinding> listEnabled(BigInteger agentId) {
return list(QueryWrapper.create()
.where("agent_id = ?", agentId)
.and("enabled = ?", true)
.orderBy("sort_no asc, id asc"));
}
private Agent requireAgent(BigInteger agentId) {
Agent agent = agentMapper.selectOneById(agentId);
if (agent == null) {
throw new BusinessException("Agent 不存在");
}
return agent;
}
private void validateBinding(AgentKnowledgeBinding binding) {
if (binding == null || binding.getKnowledgeId() == null) {
throw new BusinessException("知识库绑定参数不完整");
}
DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId());
if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("绑定知识库不存在或未发布");
}
KnowledgeRetrievalModes.parse(binding.getRetrievalMode());
resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE, "无权限绑定该知识库");
}
private void applyBindingDefaults(Agent agent, AgentKnowledgeBinding binding, int index) {
LoginAccount account = requireCurrentLoginAccount();
Date now = new Date();
binding.setId(null);
binding.setTenantId(agent.getTenantId());
binding.setAgentId(agent.getId());
if (binding.getRetrievalMode() == null || binding.getRetrievalMode().isBlank()) {
binding.setRetrievalMode(DEFAULT_RETRIEVAL_MODE);
} else {
binding.setRetrievalMode(binding.getRetrievalMode().trim().toUpperCase());
}
binding.setEnabled(binding.getEnabled() == null || binding.getEnabled());
binding.setSortNo(binding.getSortNo() == null ? index : binding.getSortNo());
binding.setCreated(now);
binding.setCreatedBy(account.getId());
binding.setModified(now);
binding.setModifiedBy(account.getId());
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
}

View File

@@ -0,0 +1,391 @@
package tech.easyflow.agent.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.*;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Agent 配置服务实现。
*/
@Service
public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements AgentService {
private static final TypeReference<List<AgentToolBinding>> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<AgentKnowledgeBinding>> KNOWLEDGE_BINDING_LIST_TYPE = new TypeReference<>() {};
@Resource
private AgentToolBindingService agentToolBindingService;
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource
private ModelService modelService;
@Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private ObjectMapper objectMapper;
/**
* {@inheritDoc}
*/
@Override
public Agent getDetail(BigInteger id) {
Agent agent = requireAgent(id);
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.READ, "无权限查看该 Agent");
agent.setToolBindings(agentToolBindingService.listEnabled(id));
agent.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(id));
return agent;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Agent saveDraft(Agent agent) {
validateDraft(agent);
applyDraftDefaults(agent);
save(agent);
return getDetail(agent.getId());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Agent updateDraft(Agent agent) {
if (agent == null || agent.getId() == null) {
throw new BusinessException("Agent ID 不能为空");
}
Agent existing = requireAgent(agent.getId());
resourceAccessService.assertAccess(CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
validateDraft(agent);
applyDraftUpdate(existing, agent);
updateById(existing);
return getDetail(existing.getId());
}
/**
* {@inheritDoc}
*/
@Override
public Agent getPublishedView(BigInteger id) {
Agent agent = requireAgent(id);
PublishStatus status = PublishStatus.from(agent.getPublishStatus());
if (!status.isExternallyVisible() || agent.getPublishedSnapshotJson() == null || agent.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("Agent 未发布");
}
return fromSnapshot(agent.getPublishedSnapshotJson());
}
/**
* {@inheritDoc}
*/
@Override
public Map<String, Object> buildPublishSnapshot(Agent agent) {
Agent detail = getDetail(agent.getId());
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", detail.getId());
snapshot.put("tenantId", detail.getTenantId());
snapshot.put("deptId", detail.getDeptId());
snapshot.put("createdBy", detail.getCreatedBy());
snapshot.put("name", detail.getName());
snapshot.put("description", detail.getDescription());
snapshot.put("avatar", detail.getAvatar());
snapshot.put("categoryId", detail.getCategoryId());
snapshot.put("modelId", detail.getModelId());
snapshot.put("modelConfigJson", detail.getModelConfigJson());
snapshot.put("generationConfigJson", detail.getGenerationConfigJson());
snapshot.put("promptConfigJson", detail.getPromptConfigJson());
snapshot.put("memoryConfigJson", detail.getMemoryConfigJson());
snapshot.put("executionConfigJson", detail.getExecutionConfigJson());
snapshot.put("visibilityScope", detail.getVisibilityScope());
snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings()));
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings()));
snapshot.put("basicSummary", basicSummary(detail));
snapshot.put("modelSummary", modelSummary(detail.getModelId()));
snapshot.put("parameterSummary", parameterSummary(detail));
snapshot.put("promptSummary", promptSummary(detail));
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
snapshot.put("snapshotAt", new Date());
return snapshot;
}
/**
* {@inheritDoc}
*/
@Override
public Agent fromSnapshot(Map<String, Object> snapshot) {
if (snapshot == null || snapshot.isEmpty()) {
throw new BusinessException("Agent 发布快照为空");
}
Agent agent = objectMapper.convertValue(snapshot, Agent.class);
agent.setId(toBigInteger(snapshot.get("id")));
agent.setTenantId(toBigInteger(snapshot.get("tenantId")));
agent.setDeptId(toBigInteger(snapshot.get("deptId")));
agent.setCreatedBy(toBigInteger(snapshot.get("createdBy")));
agent.setModelId(toBigInteger(snapshot.get("modelId")));
agent.setCategoryId(toBigInteger(snapshot.get("categoryId")));
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
agent.setPublishedSnapshotJson(snapshot);
agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE));
agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE));
return agent;
}
private Agent requireAgent(BigInteger id) {
Agent agent = getById(id);
if (agent == null) {
throw new BusinessException("Agent 不存在");
}
return agent;
}
private void validateDraft(Agent agent) {
if (agent == null) {
throw new BusinessException("Agent 不能为空");
}
if (agent.getName() == null || agent.getName().isBlank()) {
throw new BusinessException("Agent 名称不能为空");
}
if (agent.getModelId() == null) {
throw new BusinessException("Agent 模型不能为空");
}
Model model = modelService.getModelInstance(agent.getModelId());
if (model == null) {
throw new BusinessException("Agent 模型不存在");
}
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
}
private void applyDraftDefaults(Agent agent) {
LoginAccount account = requireCurrentLoginAccount();
Date now = new Date();
agent.setTenantId(account.getTenantId());
agent.setDeptId(account.getDeptId());
agent.setCreated(now);
agent.setCreatedBy(account.getId());
agent.setModified(now);
agent.setModifiedBy(account.getId());
agent.setStatus(agent.getStatus() == null ? 1 : agent.getStatus());
agent.setPublishStatus(PublishStatus.DRAFT.getCode());
}
private Map<String, Object> basicSummary(Agent agent) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("id", agent.getId());
summary.put("name", agent.getName());
summary.put("description", agent.getDescription());
summary.put("avatar", agent.getAvatar());
summary.put("categoryId", agent.getCategoryId());
summary.put("status", agent.getStatus());
summary.put("publishStatus", PublishStatus.PUBLISHED.getCode());
summary.put("visibilityScope", agent.getVisibilityScope());
return summary;
}
private void applyDraftUpdate(Agent existing, Agent incoming) {
LoginAccount account = requireCurrentLoginAccount();
existing.setName(incoming.getName());
existing.setDescription(incoming.getDescription());
existing.setAvatar(incoming.getAvatar());
existing.setCategoryId(incoming.getCategoryId());
existing.setModelId(incoming.getModelId());
existing.setModelConfigJson(incoming.getModelConfigJson());
existing.setGenerationConfigJson(incoming.getGenerationConfigJson());
existing.setPromptConfigJson(incoming.getPromptConfigJson());
existing.setMemoryConfigJson(incoming.getMemoryConfigJson());
existing.setExecutionConfigJson(incoming.getExecutionConfigJson());
existing.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus());
existing.setVisibilityScope(incoming.getVisibilityScope());
existing.setModified(new Date());
existing.setModifiedBy(account.getId());
}
private Map<String, Object> modelSummary(BigInteger modelId) {
Model model = modelService.getModelInstance(modelId);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("id", model.getId());
summary.put("title", model.getTitle());
summary.put("modelName", model.getModelName());
summary.put("providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
return summary;
}
private Map<String, Object> parameterSummary(Agent agent) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("generationConfigJson", agent.getGenerationConfigJson());
summary.put("modelConfigJson", agent.getModelConfigJson());
summary.put("memoryConfigJson", agent.getMemoryConfigJson());
summary.put("executionConfigJson", agent.getExecutionConfigJson());
return summary;
}
private Map<String, Object> promptSummary(Agent agent) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("promptConfigJson", agent.getPromptConfigJson());
summary.put("systemPrompt", agent.getPromptConfigJson() == null ? null : agent.getPromptConfigJson().get("systemPrompt"));
summary.put("prompt", agent.getPromptConfigJson() == null ? null : agent.getPromptConfigJson().get("prompt"));
return summary;
}
private List<Map<String, Object>> toolSummaries(List<AgentToolBinding> bindings) {
if (bindings == null) {
return List.of();
}
return bindings.stream().map(this::toolSummary).toList();
}
private Map<String, Object> toolSummary(AgentToolBinding binding) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("bindingId", binding.getId());
summary.put("toolType", binding.getToolType());
summary.put("targetId", binding.getTargetId());
summary.put("toolName", binding.getToolName());
summary.put("enabled", Boolean.TRUE.equals(binding.getEnabled()));
summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
summary.put("hitlConfigJson", binding.getHitlConfigJson());
summary.put("sortNo", binding.getSortNo());
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
Workflow workflow = workflowService.getById(binding.getTargetId());
summary.put("title", workflow == null ? null : workflow.getTitle());
} else if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
summary.put("title", pluginItem == null ? null : pluginItem.getName());
} else {
Mcp mcp = mcpService.getById(binding.getTargetId());
summary.put("title", mcp == null ? null : mcp.getTitle());
}
return summary;
}
private List<AgentToolBinding> snapshotToolBindings(List<AgentToolBinding> bindings) {
if (bindings == null) {
return List.of();
}
return bindings.stream().map(binding -> {
AgentToolBinding snapshot = objectMapper.convertValue(binding, AgentToolBinding.class);
snapshot.setResourceSummary(toolSummary(binding));
snapshot.setResourceSnapshot(toolResourceSnapshot(binding));
return snapshot;
}).toList();
}
private Map<String, Object> toolResourceSnapshot(AgentToolBinding binding) {
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
Workflow workflow = workflowService.getPublishedById(binding.getTargetId());
if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("绑定工作流不存在或未发布");
}
return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {});
}
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
if (pluginItem == null) {
throw new BusinessException("绑定插件不存在");
}
return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {});
}
Mcp mcp = mcpService.getById(binding.getTargetId());
if (mcp == null) {
throw new BusinessException("绑定 MCP 不存在");
}
return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {});
}
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(List<AgentKnowledgeBinding> bindings) {
if (bindings == null) {
return List.of();
}
return bindings.stream().map(binding -> {
AgentKnowledgeBinding snapshot = objectMapper.convertValue(binding, AgentKnowledgeBinding.class);
snapshot.setResourceSummary(knowledgeSummary(binding));
snapshot.setResourceSnapshot(knowledgeResourceSnapshot(binding));
return snapshot;
}).toList();
}
private Map<String, Object> knowledgeResourceSnapshot(AgentKnowledgeBinding binding) {
DocumentCollection knowledge = documentCollectionService.getPublishedById(binding.getKnowledgeId());
if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("绑定知识库不存在或未发布");
}
return objectMapper.convertValue(knowledge, new TypeReference<Map<String, Object>>() {});
}
private List<Map<String, Object>> knowledgeSummaries(List<AgentKnowledgeBinding> bindings) {
if (bindings == null) {
return List.of();
}
return bindings.stream().map(this::knowledgeSummary).toList();
}
private Map<String, Object> knowledgeSummary(AgentKnowledgeBinding binding) {
DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId());
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("bindingId", binding.getId());
summary.put("knowledgeId", binding.getKnowledgeId());
summary.put("retrievalMode", binding.getRetrievalMode());
summary.put("enabled", Boolean.TRUE.equals(binding.getEnabled()));
summary.put("optionsJson", binding.getOptionsJson());
summary.put("sortNo", binding.getSortNo());
summary.put("title", knowledge == null ? null : knowledge.getTitle());
return summary;
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
private BigInteger toBigInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return BigInteger.valueOf(number.longValue());
}
return new BigInteger(String.valueOf(value));
}
}

View File

@@ -0,0 +1,139 @@
package tech.easyflow.agent.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.mapper.AgentToolBindingMapper;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
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 javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* Agent 工具绑定服务实现。
*/
@Service
public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMapper, AgentToolBinding>
implements AgentToolBindingService {
@Resource
private AgentMapper agentMapper;
@Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private ResourceAccessService resourceAccessService;
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<AgentToolBinding> replaceBindings(BigInteger agentId, List<AgentToolBinding> bindings) {
Agent agent = requireAgent(agentId);
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
remove(QueryWrapper.create().where("agent_id = ?", agentId));
if (bindings == null || bindings.isEmpty()) {
return Collections.emptyList();
}
for (int i = 0; i < bindings.size(); i++) {
AgentToolBinding binding = bindings.get(i);
validateBinding(binding);
applyBindingDefaults(agent, binding, i);
}
saveBatch(bindings);
return listEnabled(agentId);
}
/**
* {@inheritDoc}
*/
@Override
public List<AgentToolBinding> listEnabled(BigInteger agentId) {
return list(QueryWrapper.create()
.where("agent_id = ?", agentId)
.and("enabled = ?", true)
.orderBy("sort_no asc, id asc"));
}
private Agent requireAgent(BigInteger agentId) {
Agent agent = agentMapper.selectOneById(agentId);
if (agent == null) {
throw new BusinessException("Agent 不存在");
}
return agent;
}
private void validateBinding(AgentToolBinding binding) {
if (binding == null || binding.getTargetId() == null || binding.getToolType() == null) {
throw new BusinessException("工具绑定参数不完整");
}
AgentToolType type = AgentToolType.from(binding.getToolType());
if (type == AgentToolType.WORKFLOW) {
Workflow workflow = workflowService.getById(binding.getTargetId());
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("绑定工作流不存在或未发布");
}
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流");
return;
}
if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
if (pluginItem == null || pluginItem.getStatus() == null || pluginItem.getStatus() != 1) {
throw new BusinessException("绑定插件不存在或未启用");
}
return;
}
Mcp mcp = mcpService.getById(binding.getTargetId());
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
throw new BusinessException("绑定 MCP 不存在或未启用");
}
}
private void applyBindingDefaults(Agent agent, AgentToolBinding binding, int index) {
LoginAccount account = requireCurrentLoginAccount();
Date now = new Date();
binding.setId(null);
binding.setTenantId(agent.getTenantId());
binding.setAgentId(agent.getId());
binding.setEnabled(binding.getEnabled() == null || binding.getEnabled());
binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled()));
binding.setSortNo(binding.getSortNo() == null ? index : binding.getSortNo());
binding.setCreated(now);
binding.setCreatedBy(account.getId());
binding.setModified(now);
binding.setModifiedBy(account.getId());
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
}

View File

@@ -0,0 +1,59 @@
package tech.easyflow.agent.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentToolBindingService;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link AgentApprovalSubjectHandler} 单元测试。
*/
public class AgentApprovalSubjectHandlerTest {
/**
* 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。
*/
@Test
public void beforeRemoveShouldCleanAgentBindings() {
AtomicInteger toolRemoveCalls = new AtomicInteger();
AtomicInteger knowledgeRemoveCalls = new AtomicInteger();
AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls);
AgentKnowledgeBindingService knowledgeBindingService = proxy(AgentKnowledgeBindingService.class, knowledgeRemoveCalls);
AgentApprovalSubjectHandler handler = new AgentApprovalSubjectHandler(
null,
new ObjectMapper(),
null,
toolBindingService,
knowledgeBindingService,
null
);
handler.beforeRemove(BigInteger.valueOf(1001));
Assert.assertEquals(1, toolRemoveCalls.get());
Assert.assertEquals(1, knowledgeRemoveCalls.get());
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, AtomicInteger removeCalls) {
return (T) Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[]{type},
(proxy, method, args) -> {
if ("remove".equals(method.getName()) && args != null && args.length == 1) {
removeCalls.incrementAndGet();
return true;
}
if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class) {
return false;
}
return null;
}
);
}
}

View File

@@ -0,0 +1,192 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Flux;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Agent 运行态注册表测试。
*/
public class AgentRunRegistryTest {
/**
* 验证批准请求会恢复当前运行时。
*/
@Test
public void approveShouldResumeRuntime() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-1", "session-1", "user-1", runtime));
registry.registerResumeToken("request-1", "token-1");
registry.approve("request-1", "token-1", "user-1");
AgentResumeRequest request = runtime.resumeRequest.get();
Assert.assertNotNull(request);
Assert.assertTrue(request.isApproved());
Assert.assertEquals("token-1", request.getResumeToken().getValue());
}
/**
* 验证拒绝请求支持通过恢复令牌反查运行时。
*/
@Test
public void rejectShouldResolveRequestByResumeToken() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-2", "session-2", "user-1", runtime));
registry.registerResumeToken("request-2", "token-2");
registry.reject(null, "token-2", "user-1", "denied");
AgentResumeRequest request = runtime.resumeRequest.get();
Assert.assertNotNull(request);
Assert.assertFalse(request.isApproved());
Assert.assertEquals("denied", request.getRejectReason());
}
/**
* 验证运行结束后清理运行态与恢复令牌索引。
*/
@Test
public void removeShouldClearRuntimeAndResumeTokens() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-3", "session-3", "user-1", new CapturingRuntime()));
registry.registerResumeToken("request-3", "token-3");
registry.remove("request-3");
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
}
/**
* 验证运行审批只能由运行发起人处理。
*/
@Test
public void approveShouldRejectDifferentOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-4", "session-4", "user-1", runtime));
registry.registerResumeToken("request-4", "token-4");
Assert.assertThrows(BusinessException.class, () -> registry.approve("request-4", "token-4", "user-2"));
Assert.assertNull(runtime.resumeRequest.get());
registry.approve("request-4", "token-4", "user-1");
Assert.assertTrue(runtime.resumeRequest.get().isApproved());
}
/**
* 验证传入 requestId 时仍会校验恢复令牌归属,避免错误令牌打断挂起运行。
*/
@Test
public void approveShouldRejectTokenNotBelongingToRequest() {
AgentRunRegistry registry = new AgentRunRegistry();
CapturingRuntime runtime = new CapturingRuntime();
registry.register(context("request-5", "session-5", "user-1", runtime));
Assert.assertThrows(BusinessException.class,
() -> registry.approve("request-5", "wrong-token", "user-1"));
Assert.assertNull(runtime.resumeRequest.get());
}
/**
* 验证同一会话同一时刻只允许一个运行态。
*/
@Test
public void registerShouldRejectActiveRunInSameSession() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-6", "session-6", "user-1", new CapturingRuntime()));
Assert.assertThrows(BusinessException.class,
() -> registry.register(context("request-7", "session-6", "user-1", new CapturingRuntime())));
}
/**
* 验证按会话清理会取消当前运行并释放同会话运行锁。
*/
@Test
public void cancelSessionShouldRemoveActiveRun() {
AgentRunRegistry registry = new AgentRunRegistry();
AgentRunRegistry.AgentRunContext context = context("request-8", "session-8", "user-1", new CapturingRuntime());
registry.register(context);
registry.cancelSession("session-8");
Assert.assertNull(registry.get("request-8"));
registry.register(context("request-9", "session-8", "user-1", new CapturingRuntime()));
Assert.assertNotNull(registry.get("request-9"));
}
/**
* 验证按会话清理时会校验运行归属。
*/
@Test
public void cancelSessionShouldRejectDifferentOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.register(context("request-10", "session-10", "user-1", new CapturingRuntime()));
Assert.assertThrows(BusinessException.class, () -> registry.cancelSession("session-10", "user-2"));
Assert.assertNotNull(registry.get("request-10"));
}
private AgentRunRegistry.AgentRunContext context(String requestId,
String sessionId,
String userId,
AgentRuntime runtime) {
return new AgentRunRegistry.AgentRunContext(
requestId,
sessionId,
runtime,
null,
new ChatRuntimeContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
false,
new AgentRunRegistry.RunOwner("agent-1", sessionId, userId),
null,
event -> {
},
error -> {
},
() -> {
}
);
}
private static final class CapturingRuntime implements AgentRuntime {
private final AtomicReference<AgentResumeRequest> resumeRequest = new AtomicReference<>();
@Override
public void init(AgentInitRequest request) {
// 测试桩无需初始化。
}
@Override
public Flux<AgentRuntimeEvent> stream(com.easyagents.agent.runtime.message.AgentMessage userMessage) {
return Flux.empty();
}
@Override
public Flux<AgentRuntimeEvent> resume(AgentResumeRequest request) {
resumeRequest.set(request);
return Flux.empty();
}
}
}

View File

@@ -0,0 +1,660 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatEnvelope;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeManager;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Agent 草稿试用与 HITL 事件映射测试。
*/
public class AgentRunServiceDraftAndHitlTest {
/**
* 验证工具 HITL 事件会映射为显式前端载荷。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildToolHitlPayloadShouldExposeStableFieldsWithoutPrompt() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
event.setToolCallId("call-1");
event.getPayload().put("resumeToken", "token-1");
event.getPayload().put("sessionId", "session-1");
event.getPayload().put("agentId", "agent-1");
event.getPayload().put("toolName", "search");
event.getPayload().put("toolType", "PLUGIN");
event.getPayload().put("approvalPrompt", "不应透出");
event.getPayload().put("toolInput", Map.of("keyword", "EasyFlow"));
event.getPayload().put("approvalMetadata", Map.of(
"risk", "low",
"prompt", "不应透出",
"toolType", "WORKFLOW"
));
AgentToolHitlPayload payload = invoke(service, "buildToolHitlPayload",
new Class<?>[]{String.class, AgentRuntimeEvent.class}, "request-1", event);
Assert.assertEquals("request-1", payload.getRequestId());
Assert.assertEquals("token-1", payload.getResumeToken());
Assert.assertEquals("session-1", payload.getSessionId());
Assert.assertEquals("agent-1", payload.getAgentId());
Assert.assertEquals("call-1", payload.getToolCallId());
Assert.assertEquals("search", payload.getToolName());
Assert.assertEquals("PLUGIN", payload.getToolType());
Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
Assert.assertEquals("low", payload.getMetadata().get("risk"));
Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType"));
Assert.assertFalse(payload.getMetadata().containsKey("prompt"));
}
/**
* 验证工具事件发送给前端时会携带稳定工具调用 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildToolEventPayloadShouldExposeRuntimeToolCallId() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
event.setToolCallId("call-runtime");
event.getPayload().put("toolName", "search");
event.getPayload().put("text", "ok");
Map<String, Object> payload = invoke(service, "buildToolEventPayload",
new Class<?>[]{AgentRuntimeEvent.class}, event);
Assert.assertEquals("call-runtime", payload.get("toolCallId"));
Assert.assertEquals("search", payload.get("toolName"));
Assert.assertEquals("ok", payload.get("text"));
}
/**
* 验证思考事件会优先读取 reasoning 字段。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void stringPayloadShouldExposeReasoningValue() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
event.getPayload().put("reasoning", "思考中");
String reasoning = invoke(service, "stringPayload",
new Class<?>[]{AgentRuntimeEvent.class, String.class}, event, "reasoning");
String fallback = invoke(service, "firstText",
new Class<?>[]{String.class, String.class}, reasoning, "正文");
Assert.assertEquals("思考中", fallback);
}
/**
* 验证思考事件会作为增量发送给前端。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendReasoningDeltaAsThinkingEnvelope() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
event.getPayload().put("reasoning", "思考增量");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.THINKING, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("思考增量", payload.get("reasoning"));
Assert.assertEquals("思考增量", payload.get("delta"));
}
/**
* 验证正文事件会作为增量发送给前端并累计到持久化缓冲。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendMessageDeltaAndAccumulateAnswer() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
event.getPayload().put("text", "正文增量");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals("正文增量", answer.toString());
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.MESSAGE, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("正文增量", payload.get("delta"));
}
/**
* 验证自动上下文压缩事件会作为业务状态发送给前端。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendMemoryCompressionAsStatusEnvelope() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED);
event.getPayload().put("statusKey", "memory-compression");
event.getPayload().put("status", "running");
event.getPayload().put("label", "正在整理上下文");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("memory-compression", payload.get("statusKey"));
Assert.assertEquals("running", payload.get("status"));
Assert.assertEquals("正在整理上下文", payload.get("label"));
}
/**
* 验证完成事件不会再次发送正文消息,只用于最终收口。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldNotSendMessageEnvelopeOnCompleted() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder("流式正文");
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
event.getPayload().put("text", "最终正文");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals("最终正文", answer.toString());
Assert.assertTrue(emitter.envelopes.stream().noneMatch(envelope ->
envelope.getDomain() == ChatDomain.LLM && envelope.getType() == ChatType.MESSAGE));
Assert.assertTrue(emitter.envelopes.stream().anyMatch(envelope ->
envelope.getDomain() == ChatDomain.SYSTEM && envelope.getType() == ChatType.DONE));
}
/**
* 验证挂起事件后自然完成不会关闭 SSE 或清理运行态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void finishIfNeededShouldKeepRuntimeWhenSuspended() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
setField(service, "agentRunRegistry", registry);
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AtomicBoolean finished = new AtomicBoolean(false);
AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
"request-suspended",
"session-suspended",
new NoopRuntime(),
emitter,
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
false,
new AgentRunRegistry.RunOwner("agent-1", "session-suspended", "user-1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
registry.register(runContext);
runContext.markSuspended();
invoke(service, "finishIfNeeded",
new Class<?>[]{String.class, ChatSseEmitter.class, ChatRuntimeContext.class, StringBuilder.class,
ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
"request-suspended", emitter, chatContext(), new StringBuilder(),
new ChatAssistantAccumulator(), finished, false);
Assert.assertFalse(finished.get());
Assert.assertNotNull(registry.get("request-suspended"));
Assert.assertTrue(emitter.envelopes.isEmpty());
}
/**
* 验证取消事件作为业务状态收口,不按系统错误发送。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldSendCancelledAsStatusAndDone() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "chatRuntimeManager", chatRuntimeManager);
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
StringBuilder answer = new StringBuilder("取消前正文");
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
event.getPayload().put("reason", "用户拒绝执行");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
chatContext(), new AtomicBoolean(false), true);
Assert.assertEquals(2, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("agent-cancelled", payload.get("statusKey"));
Assert.assertEquals("cancelled", payload.get("status"));
Assert.assertEquals("用户拒绝执行", payload.get("message"));
Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(1).getDomain());
Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType());
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
}
/**
* 验证最终知识库引用会保留命中分片原文。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildKnowledgeCitationPayloadShouldExposeChunkContent() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "answer");
AgentKnowledgeReference reference = new AgentKnowledgeReference();
reference.setKnowledgeId("kb-1");
reference.setKnowledgeName("学生事务 FAQ");
reference.setDocumentId("faq-1");
reference.setDocumentName("faq-1.md");
reference.setChunkId("chunk-1");
reference.setChunkContent("暑假安排原文");
reference.setScore(0.91D);
reference.getMetadata().put("knowledgeType", "FAQ");
reference.getMetadata().put("faqCollection", true);
message.setKnowledgeReferences(List.of(reference));
event.setMessage(message);
List<Map<String, Object>> payload = invoke(service, "buildKnowledgeCitationPayload",
new Class<?>[]{AgentRuntimeEvent.class}, event);
Assert.assertEquals(1, payload.size());
Assert.assertEquals("学生事务 FAQ", payload.get(0).get("knowledgeName"));
Assert.assertEquals("暑假安排原文", payload.get(0).get("chunkContent"));
Assert.assertEquals("FAQ", payload.get(0).get("knowledgeType"));
Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
}
/**
* 验证未保存草稿会生成临时 Agent ID并把绑定指向该运行 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception {
AgentRunService service = new AgentRunService();
AgentDraftChatRequest request = new AgentDraftChatRequest();
Agent agent = new Agent();
agent.setModelId(BigInteger.valueOf(10));
request.setAgent(agent);
AgentToolBinding toolBinding = new AgentToolBinding();
toolBinding.setToolType("PLUGIN");
toolBinding.setTargetId(BigInteger.valueOf(20));
toolBinding.setResourceSnapshot(Map.of("name", "client-forged"));
toolBinding.setResourceSummary(Map.of("name", "client-forged"));
request.setToolBindings(List.of(toolBinding));
AgentKnowledgeBinding knowledgeBinding = new AgentKnowledgeBinding();
knowledgeBinding.setKnowledgeId(BigInteger.valueOf(30));
knowledgeBinding.setResourceSnapshot(Map.of("title", "client-forged"));
knowledgeBinding.setResourceSummary(Map.of("title", "client-forged"));
request.setKnowledgeBindings(List.of(knowledgeBinding));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.valueOf(2));
account.setDeptId(BigInteger.valueOf(3));
Agent draftAgent = invoke(service, "buildDraftAgent",
new Class<?>[]{AgentDraftChatRequest.class, LoginAccount.class}, request, account);
Assert.assertNotNull(draftAgent.getId());
Assert.assertEquals(BigInteger.valueOf(2), draftAgent.getTenantId());
Assert.assertEquals(draftAgent.getId(), draftAgent.getToolBindings().get(0).getAgentId());
Assert.assertEquals(draftAgent.getId(), draftAgent.getKnowledgeBindings().get(0).getAgentId());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getEnabled());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getEnabled());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSnapshot().isEmpty());
Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSummary().isEmpty());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSnapshot().isEmpty());
Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSummary().isEmpty());
}
/**
* 验证正式聊天在获取运行锁失败时不会提前写入用户消息。
*
* <p>运行锁是 AgentScope session 与 chatlog 的一致性入口。若同会话并发请求抢锁失败,
* 必须在 prepareSession 和 recordUserMessage 之前失败,避免 chatlog 出现没有真实运行的用户消息。</p>
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runShouldAcquireLockBeforePersistingUserMessage() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "chatRuntimeManager", chatRuntimeManager);
setField(service, "agentRunLock", new RejectingAgentRunLock());
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
ChatRuntimeContext context = chatContext();
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true));
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
}
/**
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void sendSessionCreatedShouldExposeSessionId() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
Boolean sent = invoke(service, "sendSessionCreated",
new Class<?>[]{ChatSseEmitter.class, BigInteger.class}, emitter, BigInteger.valueOf(123));
Assert.assertTrue(sent);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(0).getDomain());
Assert.assertEquals(ChatType.SESSION_CREATED, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("123", payload.get("sessionId"));
}
/**
* 验证正式聊天只有新会话首轮会自动设置默认标题。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void applyFormalSessionTitleShouldOnlyUseFirstPromptForNewSession() throws Exception {
AgentRunService service = new AgentRunService();
ChatRuntimeContext newContext = chatContext();
ChatRuntimeContext existingContext = chatContext();
ChatSessionSummary existingSession = new ChatSessionSummary();
existingSession.setId(BigInteger.valueOf(123));
existingSession.setTitle("用户改过的标题");
existingSession.setMessageCount(2);
invoke(service, "applyFormalSessionTitle",
new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
newContext, "第一句话", null);
invoke(service, "applyFormalSessionTitle",
new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
existingContext, "后续消息", existingSession);
Assert.assertEquals("第一句话", newContext.getSessionTitle());
Assert.assertNull(existingContext.getSessionTitle());
}
/**
* 验证正式聊天 SSE 断开后会取消运行并保存断开前已输出的 assistant 内容。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldCancelAndRecordFailureWhenSseDisconnected() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", registry);
setField(service, "chatRuntimeManager", chatRuntimeManager);
AtomicBoolean finished = new AtomicBoolean(false);
ChatRuntimeContext context = chatContext();
AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
"request-disconnected",
"session-disconnected",
new NoopRuntime(),
new FailingChatSseEmitter(),
context,
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
true,
new AgentRunRegistry.RunOwner("agent-1", "session-disconnected", "user-1"),
null,
ignored -> {
},
ignored -> {
},
() -> {
}
);
registry.register(runContext);
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
event.getPayload().put("text", "断连前正文");
StringBuilder answer = new StringBuilder();
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-disconnected", new FailingChatSseEmitter(), answer,
assistantAccumulator, context, finished, true);
Assert.assertTrue(finished.get());
Assert.assertNull(registry.get("request-disconnected"));
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals("断连前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
}
@SuppressWarnings("unchecked")
private <T> T invoke(Object target, String methodName, Class<?>[] parameterTypes, Object... args) throws Exception {
Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes);
method.setAccessible(true);
return (T) method.invoke(target, args);
}
private void setField(Object target, String fieldName, Object value) throws Exception {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private Class<?>[] runtimeEventParameterTypes() {
return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
ChatAssistantAccumulator.class,
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
}
private ChatRuntimeContext chatContext() {
ChatRuntimeContext context = new ChatRuntimeContext();
context.setAssistantId(BigInteger.valueOf(100));
context.setAssistantName("Agent");
context.setUserId(BigInteger.valueOf(101));
context.setUserName("用户");
return context;
}
private Throwable rootCause(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
/**
* 记录发送内容的 SSE 测试桩。
*/
private static class RecordingChatSseEmitter extends ChatSseEmitter {
private final List<ChatEnvelope<?>> envelopes = new java.util.ArrayList<>();
@Override
public boolean send(ChatEnvelope<?> envelope) {
envelopes.add(envelope);
return true;
}
@Override
public boolean sendDone(ChatEnvelope<?> envelope) {
envelopes.add(envelope);
return true;
}
}
private static class FailingChatSseEmitter extends ChatSseEmitter {
@Override
public boolean send(ChatEnvelope<?> envelope) {
return false;
}
@Override
public boolean sendDone(ChatEnvelope<?> envelope) {
return false;
}
}
private static class NoopRuntime implements com.easyagents.agent.runtime.AgentRuntime {
@Override
public void init(com.easyagents.agent.runtime.AgentInitRequest request) {
// 测试桩无需初始化。
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
return reactor.core.publisher.Flux.empty();
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
return reactor.core.publisher.Flux.empty();
}
}
/**
* 记录 chatlog 写入动作的测试桩。
*/
private static class RecordingChatRuntimeManager implements ChatRuntimeManager {
private int prepareSessionCount;
private int recordUserMessageCount;
private int recordAssistantCompletedCount;
private int recordFailureCount;
private int recordCompletedCount;
private ChatRuntimeMessage lastAssistantMessage;
@Override
public void prepareSession(ChatRuntimeContext context) {
prepareSessionCount++;
}
@Override
public void recordUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
recordUserMessageCount++;
}
@Override
public void recordAssistantDelta(ChatRuntimeContext context, ChatRuntimeMessage message) {
// 测试桩无需记录。
}
@Override
public void recordAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
recordAssistantCompletedCount++;
lastAssistantMessage = message;
}
@Override
public void recordFailure(ChatRuntimeContext context, Throwable throwable) {
recordFailureCount++;
}
@Override
public void recordCompleted(ChatRuntimeContext context) {
recordCompletedCount++;
}
@Override
public List<ChatRuntimeMessage> loadMessages(ChatRuntimeContext context, int limit) {
return List.of();
}
}
/**
* 模拟同会话运行锁已被占用的测试桩。
*/
private static class RejectingAgentRunLock implements AgentRunLock {
@Override
public Handle acquire(BigInteger agentId, String sessionId) {
throw new BusinessException("当前 Agent 会话已有运行中的请求,请稍后再试");
}
}
}

View File

@@ -45,7 +45,24 @@ public final class ChatToolNameHelper {
return buildFallbackName(fallbackPrefix, resourceId);
}
private static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) {
/**
* 判断工具名称是否满足 OpenAI-compatible function.name 约束。
*
* @param name 待检查名称
* @return 名称合法返回 true否则返回 false
*/
public static boolean isSafeToolName(String name) {
return StringUtils.hasText(name) && SAFE_TOOL_NAME_PATTERN.matcher(name).matches();
}
/**
* 构建稳定的安全兜底工具名称。
*
* @param fallbackPrefix 安全兜底名前缀
* @param resourceId 资源 ID
* @return 安全兜底工具名称
*/
public static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) {
String prefix = StringUtils.hasText(fallbackPrefix) ? fallbackPrefix : "tool";
String suffix = resourceId == null ? "unknown" : resourceId.toString();
return prefix + "_" + suffix;

View File

@@ -7,16 +7,7 @@ import com.easyagents.core.model.rerank.RerankModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.SearchWrapper;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.rag.retrieval.HitSource;
import com.easyagents.rag.retrieval.KeywordRetriever;
import com.easyagents.rag.retrieval.RagHit;
import com.easyagents.rag.retrieval.RagQuery;
import com.easyagents.rag.retrieval.RagRetrievalExecutor;
import com.easyagents.rag.retrieval.RagScoreNormalizer;
import com.easyagents.rag.retrieval.RagRetrievalResult;
import com.easyagents.rag.retrieval.RetrievalMode;
import com.easyagents.rag.retrieval.RrfFusionStrategy;
import com.easyagents.rag.retrieval.VectorRetriever;
import com.easyagents.rag.retrieval.*;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.easyagents.search.engine.service.KeywordSearchRequest;
import com.mybatisflex.core.query.QueryWrapper;
@@ -26,13 +17,13 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
@@ -50,18 +41,10 @@ import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.*;
import java.util.stream.Collectors;
import static tech.easyflow.ai.entity.DocumentCollection.KEY_DOC_RECALL_MAX_NUM;
import static tech.easyflow.ai.entity.DocumentCollection.KEY_RERANK_ENABLE;
import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOLD;
import static tech.easyflow.ai.entity.DocumentCollection.*;
/**
* 服务层实现。
@@ -76,6 +59,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
private static final int INTERNAL_RECALL_MULTIPLIER = 5;
private static final int MAX_INTERNAL_RECALL_LIMIT = 100;
private static final int LOG_TEXT_MAX_LENGTH = 300;
@Resource
private ModelService llmService;
@@ -122,6 +106,18 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
int docRecallMaxNum = resolveDocRecallMaxNum(request, documentCollection);
int internalRecallLimit = resolveInternalRecallLimit(docRecallMaxNum);
float minSimilarity = resolveMinSimilarity(request, documentCollection);
LOG.info(
"Knowledge retrieval started, callerType={}, callerId={}, knowledgeId={}, query={}, retrievalMode={}, limit={}, internalLimit={}, minSimilarity={}, faqCollection={}",
request.getCallerType(),
request.getCallerId(),
request.getKnowledgeId(),
keyword,
retrievalMode,
docRecallMaxNum,
internalRecallLimit,
minSimilarity,
documentCollection.isFaqCollection()
);
RagQuery ragQuery = new RagQuery();
ragQuery.setQuery(keyword);
@@ -136,10 +132,28 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
);
RagRetrievalResult retrievalResult = retrievalExecutor.retrieve(ragQuery);
LOG.info(
"Knowledge retrieval raw hits, callerType={}, callerId={}, knowledgeId={}, query={}, hitCount={}, hits={}",
request.getCallerType(),
request.getCallerId(),
request.getKnowledgeId(),
keyword,
retrievalResult == null || retrievalResult.getHits() == null ? 0 : retrievalResult.getHits().size(),
summarizeRagHits(retrievalResult == null ? null : retrievalResult.getHits())
);
List<Document> searchDocuments = prepareSearchDocuments(
documentCollection,
toDocuments(retrievalResult.getHits())
);
LOG.info(
"Knowledge retrieval prepared documents, callerType={}, callerId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
request.getCallerType(),
request.getCallerId(),
request.getKnowledgeId(),
keyword,
searchDocuments.size(),
summarizeDocuments(searchDocuments)
);
if (searchDocuments.isEmpty()) {
return Collections.emptyList();
}
@@ -154,6 +168,15 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
toDocuments(rerankResult.getHits())
);
reranked = true;
LOG.info(
"Knowledge retrieval reranked documents, callerType={}, callerId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
request.getCallerType(),
request.getCallerId(),
request.getKnowledgeId(),
keyword,
searchDocuments.size(),
summarizeDocuments(searchDocuments)
);
} catch (RerankException e) {
LOG.warn("Rerank failed for collectionId={}, modelId={}, fallback to retrieved results. message={}",
documentCollection.getId(), documentCollection.getRerankModelId(), e.getMessage());
@@ -161,7 +184,23 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
}
RagScoreNormalizer.normalize(searchDocuments, retrievalMode, reranked);
return formatDocuments(searchDocuments, shouldApplyMinSimilarityFilter(retrievalMode, reranked), minSimilarity, docRecallMaxNum);
List<Document> formattedDocuments = formatDocuments(
searchDocuments,
shouldApplyMinSimilarityFilter(retrievalMode, reranked),
minSimilarity,
docRecallMaxNum
);
LOG.info(
"Knowledge retrieval completed, callerType={}, callerId={}, knowledgeId={}, query={}, reranked={}, finalCount={}, documents={}",
request.getCallerType(),
request.getCallerId(),
request.getKnowledgeId(),
keyword,
reranked,
formattedDocuments.size(),
summarizeDocuments(formattedDocuments)
);
return formattedDocuments;
}
/**
@@ -260,12 +299,25 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
StoreOptions options = StoreOptions.ofCollectionName(documentCollection.getVectorStoreCollection());
options.setIndexName(documentCollection.getVectorStoreCollection());
List<Document> documents = documentStore.search(wrapper, options);
return documents == null ? Collections.<Document>emptyList() : documents;
List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
LOG.info(
"Knowledge vector search completed, knowledgeId={}, collectionName={}, query={}, limit={}, minSimilarity={}, hitCount={}, hits={}",
documentCollection.getId(),
documentCollection.getVectorStoreCollection(),
keyword,
docRecallMaxNum,
minSimilarity,
result.size(),
summarizeDocuments(result)
);
return result;
}
private List<Document> searchKeywordDocuments(DocumentCollection documentCollection, String keyword, int docRecallMaxNum) {
DocumentSearcher searcher = searcherFactory.getSearcher();
if (searcher == null) {
LOG.warn("Knowledge keyword search skipped because searcher is unavailable, knowledgeId={}, query={}",
documentCollection == null ? null : documentCollection.getId(), keyword);
return Collections.emptyList();
}
KeywordSearchRequest request = KeywordSearchRequest.of(keyword, docRecallMaxNum);
@@ -273,7 +325,16 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
? null
: documentCollection.getId().toString());
List<Document> documents = searcher.searchDocuments(request);
return documents == null ? Collections.<Document>emptyList() : documents;
List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
LOG.info(
"Knowledge keyword search completed, knowledgeId={}, query={}, limit={}, hitCount={}, hits={}",
request.getKnowledgeId(),
keyword,
docRecallMaxNum,
result.size(),
summarizeDocuments(result)
);
return result;
}
private List<RagHit> adaptDocuments(List<Document> documents, HitSource hitSource) {
@@ -392,21 +453,38 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
}
if (documentCollection.isFaqCollection()) {
fillSearchContent(documentCollection, searchDocuments);
LOG.info(
"Knowledge FAQ documents prepared, knowledgeId={}, remainingCount={}, documents={}",
documentCollection.getId(),
searchDocuments.size(),
summarizeDocuments(searchDocuments)
);
return searchDocuments;
}
DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
if (hitSnapshot.isEmpty()) {
LOG.info(
"Knowledge document hits filtered out, knowledgeId={}, reason=no_completed_source_document, originalCount={}",
documentCollection.getId(),
searchDocuments.size()
);
return Collections.emptyList();
}
return searchDocuments.stream()
List<Document> preparedDocuments = searchDocuments.stream()
.filter(Objects::nonNull)
.filter(item -> {
Object chunkId = item.getId();
String content = hitSnapshot.findChunkContent(item.getId());
if (!StringUtil.hasText(content)) {
return false;
}
item.setContent(content);
item.addMetadata("chunkId", chunkId);
Object sourceDocumentId = hitSnapshot.findSourceDocumentId(item.getId());
if (sourceDocumentId != null) {
item.addMetadata("documentId", sourceDocumentId);
}
String renderMarkdown = hitSnapshot.findChunkRenderMarkdown(item.getId());
if (StringUtil.hasText(renderMarkdown)) {
item.addMetadata("renderMarkdown", renderMarkdown);
@@ -418,6 +496,14 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return true;
})
.collect(Collectors.toList());
LOG.info(
"Knowledge document hits prepared, knowledgeId={}, originalCount={}, remainingCount={}, documents={}",
documentCollection.getId(),
searchDocuments.size(),
preparedDocuments.size(),
summarizeDocuments(preparedDocuments)
);
return preparedDocuments;
}
@Override
@@ -512,6 +598,8 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
.map(image -> image.get("url"))
.filter(Objects::nonNull)
.collect(Collectors.toList());
metadataMap.put("chunkId", item.getId());
metadataMap.put("documentId", item.getId());
metadataMap.put("imageUrls", imageUrls);
item.setMetadataMap(metadataMap);
});
@@ -606,6 +694,17 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return StringUtil.noText(documentChunk.getContent()) ? null : documentChunk.getContent();
}
private Object findSourceDocumentId(Object chunkId) {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
if (documentChunk == null || documentChunk.getDocumentId() == null) {
return null;
}
if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
return null;
}
return documentChunk.getDocumentId();
}
private String findChunkRenderMarkdown(Object chunkId) {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
if (documentChunk == null || documentChunk.getDocumentId() == null || documentChunk.getOptions() == null) {
@@ -740,4 +839,69 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
.setScale(4, RoundingMode.HALF_UP)
.doubleValue();
}
/**
* 构建 RAG 原始命中摘要,便于排查向量、关键词与融合阶段的召回情况。
*
* @param hits RAG 命中列表
* @return 命中摘要
*/
private List<Map<String, Object>> summarizeRagHits(List<RagHit> hits) {
if (hits == null || hits.isEmpty()) {
return Collections.emptyList();
}
return hits.stream()
.filter(Objects::nonNull)
.map(hit -> {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("id", hit.getDocumentId());
summary.put("title", hit.getTitle());
summary.put("source", hit.getHitSource());
summary.put("score", hit.getScore());
summary.put("vectorScore", hit.getVectorScore());
summary.put("keywordScore", hit.getKeywordScore());
summary.put("rank", hit.getRank());
summary.put("content", truncate(hit.getContent()));
summary.put("metadata", hit.getMetadata());
return summary;
})
.collect(Collectors.toList());
}
/**
* 构建文档命中摘要,避免完整知识库内容撑爆日志。
*
* @param documents 文档命中列表
* @return 文档摘要
*/
private List<Map<String, Object>> summarizeDocuments(List<Document> documents) {
if (documents == null || documents.isEmpty()) {
return Collections.emptyList();
}
return documents.stream()
.filter(Objects::nonNull)
.map(document -> {
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());
return summary;
})
.collect(Collectors.toList());
}
/**
* 截断日志文本,保留足够排查上下文。
*
* @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) + "...";
}
}

View File

@@ -11,6 +11,7 @@ import java.util.stream.Collectors;
public enum ApprovalResourceType {
BOT("BOT"),
AGENT("AGENT"),
WORKFLOW("WORKFLOW"),
KNOWLEDGE("KNOWLEDGE");

View File

@@ -16,12 +16,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
import java.util.*;
@Repository
public class MySqlChatSessionRepository {
@@ -110,6 +105,10 @@ public class MySqlChatSessionRepository {
}
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return listSessions(userId, assistantId, null, query);
}
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
String table = tableRouter.resolveSessionTable();
List<Object> params = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT * FROM `").append(table)
@@ -119,6 +118,10 @@ public class MySqlChatSessionRepository {
sql.append(" AND assistant_id=?");
params.add(assistantId);
}
if (assistantCode != null && !assistantCode.isBlank()) {
sql.append(" AND assistant_code=?");
params.add(assistantCode);
}
sql.append(" ORDER BY last_message_at DESC, id DESC LIMIT ? OFFSET ?");
params.add(query.getPageSize());
params.add(query.getOffset());
@@ -126,6 +129,10 @@ public class MySqlChatSessionRepository {
}
public long countSessions(BigInteger userId, BigInteger assistantId) {
return countSessions(userId, assistantId, null);
}
public long countSessions(BigInteger userId, BigInteger assistantId, String assistantCode) {
String table = tableRouter.resolveSessionTable();
List<Object> params = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT COUNT(1) FROM `").append(table)
@@ -135,6 +142,10 @@ public class MySqlChatSessionRepository {
sql.append(" AND assistant_id=?");
params.add(assistantId);
}
if (assistantCode != null && !assistantCode.isBlank()) {
sql.append(" AND assistant_code=?");
params.add(assistantCode);
}
Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
return count == null ? 0L : count;
}

View File

@@ -1,8 +1,8 @@
package tech.easyflow.chatlog.service;
import org.slf4j.MDC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
@@ -107,7 +107,7 @@ public class ChatPersistDispatcher {
payload.setTitle(title);
payload.setOperatorId(operatorId);
payload.setOperateAt(operateAt);
eventProducer.send(buildEvent(
ChatPersistEvent event = buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.SESSION_RENAMED,
sessionId,
@@ -115,7 +115,9 @@ public class ChatPersistDispatcher {
BigInteger.ZERO,
operateAt,
chatJsonSupport.toJson(payload)
));
);
persistImmediately(event);
eventProducer.send(event);
}
public void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId) {

View File

@@ -2,13 +2,8 @@ package tech.easyflow.chatlog.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionSummaryCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.command.*;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.domain.event.ChatPersistEventType;
import tech.easyflow.chatlog.domain.event.payload.ChatSessionDeletePayload;
import tech.easyflow.chatlog.domain.event.payload.ChatSessionRenamePayload;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
@@ -21,13 +16,7 @@ import tech.easyflow.chatlog.support.ChatJsonSupport;
import java.math.BigInteger;
import java.time.YearMonth;
import java.time.ZoneId;
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.Set;
import java.util.*;
@Service
public class ChatPersistMySqlApplyService {
@@ -231,9 +220,6 @@ public class ChatPersistMySqlApplyService {
if (isBlank(upsert.getUserAccount())) {
upsert.setUserAccount("");
}
if (isBlank(upsert.getTitle())) {
upsert.setTitle("会话-" + upsert.getSessionId());
}
if (upsert.getOperateAt() == null) {
upsert.setOperateAt(new Date());
}

View File

@@ -1,7 +1,7 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
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.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
@@ -13,10 +13,57 @@ public interface ChatSessionQueryService {
List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query);
/**
* 按助手编码查询用户会话列表。
*
* @param userId 用户 ID
* @param assistantId 助手 ID可为空
* @param assistantCode 助手编码,可为空
* @param query 分页参数
* @return 会话列表
*/
default List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
if (assistantCode != null && !assistantCode.isBlank()) {
throw new UnsupportedOperationException("当前会话查询实现不支持 assistantCode 过滤");
}
return listSessions(userId, assistantId, query);
}
long countSessions(BigInteger userId, BigInteger assistantId);
/**
* 按助手编码统计用户会话数。
*
* @param userId 用户 ID
* @param assistantId 助手 ID可为空
* @param assistantCode 助手编码,可为空
* @return 会话数
*/
default long countSessions(BigInteger userId, BigInteger assistantId, String assistantCode) {
if (assistantCode != null && !assistantCode.isBlank()) {
throw new UnsupportedOperationException("当前会话查询实现不支持 assistantCode 过滤");
}
return countSessions(userId, assistantId);
}
ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query);
/**
* 按助手编码分页查询用户会话。
*
* @param userId 用户 ID
* @param assistantId 助手 ID可为空
* @param assistantCode 助手编码,可为空
* @param query 分页参数
* @return 会话分页
*/
default ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
if (assistantCode != null && !assistantCode.isBlank()) {
throw new UnsupportedOperationException("当前会话查询实现不支持 assistantCode 过滤");
}
return pageSessions(userId, assistantId, query);
}
ChatSessionSummary getSessionSummary(BigInteger sessionId);
/**

View File

@@ -13,12 +13,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import java.math.BigInteger;
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 java.util.*;
@Service
public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
@@ -40,22 +35,37 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
@Override
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return sessionRepository.listSessions(userId, assistantId, query);
return listSessions(userId, assistantId, null, query);
}
@Override
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
return sessionRepository.listSessions(userId, assistantId, assistantCode, query);
}
@Override
public long countSessions(BigInteger userId, BigInteger assistantId) {
return sessionRepository.countSessions(userId, assistantId);
return countSessions(userId, assistantId, null);
}
@Override
public long countSessions(BigInteger userId, BigInteger assistantId, String assistantCode) {
return sessionRepository.countSessions(userId, assistantId, assistantCode);
}
@Override
public ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return pageSessions(userId, assistantId, null, query);
}
@Override
public ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
ChatSessionPage page = new ChatSessionPage();
page.setPageNumber(query.getPageNumber());
page.setPageSize(query.getPageSize());
page.setTotal(sessionRepository.countSessions(userId, assistantId));
page.setRecords(listSessions(userId, assistantId, query));
page.setTotal(sessionRepository.countSessions(userId, assistantId, assistantCode));
page.setRecords(listSessions(userId, assistantId, assistantCode, query));
return page;
}

View File

@@ -62,7 +62,7 @@ public class ChatPersistMySqlApplyServiceTest {
}
@Test
public void shouldFallbackTitleWhenMessageMetadataMissing() {
public void shouldKeepTitleBlankWhenMessageMetadataMissing() {
ChatAppendMessageCommand command = new ChatAppendMessageCommand();
command.setSessionId(BigInteger.valueOf(202));
command.setTenantId(BigInteger.ONE);
@@ -77,7 +77,7 @@ public class ChatPersistMySqlApplyServiceTest {
Assert.assertEquals(1, upserts.size());
ChatSessionUpsertCommand upsert = upserts.get(0);
Assert.assertEquals("", upsert.getUserAccount());
Assert.assertEquals("会话-202", upsert.getTitle());
Assert.assertNull(upsert.getTitle());
Assert.assertEquals(BigInteger.valueOf(7), upsert.getOperatorId());
}

View File

@@ -48,6 +48,27 @@ public class ChatSessionQueryServiceImplTest {
Assert.assertEquals(1, sessionRepository.listSessionsCalls);
}
/**
* Agent 会话查询必须把 assistantCode 过滤条件下推到 MySQL 会话表,避免混入 Bot 会话。
*/
@Test
public void pageSessionsShouldPassAssistantCodeFilterToMysqlRepository() {
FakeSessionRepository sessionRepository = new FakeSessionRepository();
sessionRepository.sessions = List.of(session(BigInteger.valueOf(1002), 2));
sessionRepository.count = 1;
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
sessionRepository,
new FakeLogRepository(),
new FakeTableManager(List.of()),
new FakeHotStateService()
);
service.pageSessions(BigInteger.valueOf(7), BigInteger.valueOf(88), "AGENT", new ChatPageQuery());
Assert.assertEquals("AGENT", sessionRepository.capturedListAssistantCode);
Assert.assertEquals("AGENT", sessionRepository.capturedCountAssistantCode);
}
/**
* 工作台消息分页必须走 MySQL 热表主线查询,并保持分页参数语义。
*/
@@ -124,6 +145,8 @@ public class ChatSessionQueryServiceImplTest {
private long count;
private int countSessionsCalls;
private int listSessionsCalls;
private String capturedListAssistantCode;
private String capturedCountAssistantCode;
private ChatSessionSummary summary;
private List<ChatSessionSummary> sessions = new ArrayList<>();
@@ -133,13 +156,25 @@ public class ChatSessionQueryServiceImplTest {
@Override
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return listSessions(userId, assistantId, null, query);
}
@Override
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) {
listSessionsCalls += 1;
capturedListAssistantCode = assistantCode;
return sessions;
}
@Override
public long countSessions(BigInteger userId, BigInteger assistantId) {
return countSessions(userId, assistantId, null);
}
@Override
public long countSessions(BigInteger userId, BigInteger assistantId, String assistantCode) {
countSessionsCalls += 1;
capturedCountAssistantCode = assistantCode;
return count;
}

View File

@@ -8,6 +8,7 @@ import java.util.stream.Collectors;
public enum CategoryResourceType {
BOT("BOT"),
AGENT("AGENT"),
PLUGIN("PLUGIN"),
WORKFLOW("WORKFLOW"),
KNOWLEDGE("KNOWLEDGE"),

View File

@@ -18,6 +18,7 @@
<module>easyflow-module-autoconfig</module>
<module>easyflow-module-chatlog</module>
<module>easyflow-module-ai</module>
<module>easyflow-module-agent</module>
<module>easyflow-module-job</module>
<module>easyflow-module-datacenter</module>
</modules>

View File

@@ -52,6 +52,10 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId>

View File

@@ -0,0 +1,83 @@
CREATE TABLE IF NOT EXISTS `tb_agent_category` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`category_name` VARCHAR(128) NOT NULL COMMENT '分类名称',
`sort_no` INT DEFAULT 0 COMMENT '排序',
`status` INT DEFAULT 1 COMMENT '状态',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_agent_category_tenant` (`tenant_id`, `status`, `sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 分类';
CREATE TABLE IF NOT EXISTS `tb_agent` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`dept_id` BIGINT NULL COMMENT '部门ID',
`name` VARCHAR(128) NOT NULL COMMENT 'Agent 名称',
`description` VARCHAR(1024) NULL COMMENT '描述',
`avatar` VARCHAR(512) NULL COMMENT '头像',
`category_id` BIGINT NULL COMMENT '分类ID',
`model_id` BIGINT NULL COMMENT '模型ID',
`model_config_json` JSON NULL COMMENT '模型配置',
`generation_config_json` JSON NULL COMMENT '生成参数配置',
`prompt_config_json` JSON NULL COMMENT '提示词配置',
`memory_config_json` JSON NULL COMMENT '记忆配置',
`execution_config_json` JSON NULL COMMENT '运行配置',
`status` INT DEFAULT 1 COMMENT '数据状态',
`visibility_scope` VARCHAR(32) NULL COMMENT '可见范围',
`publish_status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '发布状态',
`current_approval_instance_id` BIGINT NULL COMMENT '当前审批实例ID',
`published_snapshot_json` JSON NULL COMMENT '已发布快照',
`published_at` DATETIME NULL COMMENT '发布时间',
`published_by` BIGINT NULL COMMENT '发布人',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_agent_tenant_category` (`tenant_id`, `category_id`, `status`),
KEY `idx_agent_publish_status` (`publish_status`),
KEY `idx_agent_created_by` (`created_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent';
CREATE TABLE IF NOT EXISTS `tb_agent_tool_binding` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`agent_id` BIGINT NOT NULL COMMENT 'Agent ID',
`tool_type` VARCHAR(32) NOT NULL COMMENT '工具类型',
`target_id` BIGINT NOT NULL COMMENT '目标资源ID',
`tool_name` VARCHAR(128) NULL COMMENT '工具名称',
`enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
`hitl_enabled` TINYINT(1) DEFAULT 0 COMMENT '是否启用工具执行前确认',
`hitl_config_json` JSON NULL COMMENT 'HITL 配置',
`options_json` JSON NULL COMMENT '选项',
`sort_no` INT DEFAULT 0 COMMENT '排序',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_agent_tool_agent` (`agent_id`, `enabled`, `sort_no`),
KEY `idx_agent_tool_target` (`tool_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 工具绑定';
CREATE TABLE IF NOT EXISTS `tb_agent_knowledge_binding` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`agent_id` BIGINT NOT NULL COMMENT 'Agent ID',
`knowledge_id` BIGINT NOT NULL COMMENT '知识库ID',
`retrieval_mode` VARCHAR(32) NULL COMMENT '检索模式',
`enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
`options_json` JSON NULL COMMENT '选项',
`sort_no` INT DEFAULT 0 COMMENT '排序',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_agent_knowledge_agent` (`agent_id`, `enabled`, `sort_no`),
KEY `idx_agent_knowledge_target` (`knowledge_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 知识库绑定';

View File

@@ -0,0 +1,210 @@
SET NAMES utf8mb4;
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000001, 0, 0, 'menus.ai.agents', '/ai/agents', '/ai/agents/AgentList', 'lucide:bot',
1, '', 2, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, '管理端 Agent 编排菜单'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000001
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000011, 367300000000000001, 1, '查询', '', '', '',
0, '/api/v1/agent/query', 1, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-查询'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000011
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000012, 367300000000000001, 1, '详情', '', '', '',
0, '/api/v1/agent/getDetail', 2, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-详情'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000012
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000013, 367300000000000001, 1, '保存', '', '', '',
0, '/api/v1/agent/save', 3, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-保存'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000013
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000014, 367300000000000001, 1, '更新', '', '', '',
0, '/api/v1/agent/update', 4, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-更新'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000014
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000015, 367300000000000001, 1, '删除', '', '', '',
0, '/api/v1/agent/remove', 5, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-删除'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000015
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000016, 367300000000000001, 1, '发布', '', '', '',
0, '/api/v1/agent/submitPublishApproval', 6, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-发布审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000016
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000017, 367300000000000001, 1, '下线', '', '', '',
0, '/api/v1/agent/submitOfflineApproval', 7, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-下线审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000017
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000018, 367300000000000001, 1, '删除审批', '', '', '',
0, '/api/v1/agent/submitDeleteApproval', 8, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-删除审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000018
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000019, 367300000000000001, 1, '工具绑定', '', '', '',
0, '/api/v1/agent/toolBinding/update', 9, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-工具绑定更新'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000019
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367300000000000020, 367300000000000001, 1, '知识绑定', '', '', '',
0, '/api/v1/agent/knowledgeBinding/update', 10, 0, '2026-05-20 10:00:00', 1, '2026-05-20 10:00:00', 1, 'Agent-知识库绑定更新'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367300000000000020
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000101, 1, 367300000000000001
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000001
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000111, 1, 367300000000000011
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000011
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000112, 1, 367300000000000012
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000012
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000113, 1, 367300000000000013
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000013
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000114, 1, 367300000000000014
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000014
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000115, 1, 367300000000000015
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000015
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000116, 1, 367300000000000016
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000016
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000117, 1, 367300000000000017
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000017
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000118, 1, 367300000000000018
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000018
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000119, 1, 367300000000000019
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000019
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367300000000000120, 1, 367300000000000020
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367300000000000020
);

View File

@@ -0,0 +1,73 @@
CREATE TABLE IF NOT EXISTS `tb_agent_session` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`agent_id` BIGINT NULL COMMENT 'Agent ID',
`chat_session_id` BIGINT NULL COMMENT '聊天会话ID',
`runtime_session_id` VARCHAR(128) NOT NULL COMMENT '运行时会话ID',
`session_key` VARCHAR(255) NOT NULL COMMENT 'AgentScope session key',
`state_json` JSON NULL COMMENT 'AgentScope session state envelope',
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '版本号',
`cache_version` BIGINT NOT NULL DEFAULT 0 COMMENT '缓存版本号',
`last_access_at` DATETIME NULL COMMENT '最后访问时间',
`expires_at` DATETIME NULL COMMENT '过期时间',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_agent_session_key` (`agent_id`, `session_key`),
KEY `idx_agent_session_chat` (`chat_session_id`),
KEY `idx_agent_session_runtime` (`runtime_session_id`),
KEY `idx_agent_session_modified` (`modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AgentScope 会话状态';
CREATE TABLE IF NOT EXISTS `tb_agent_hitl_pending` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`agent_id` BIGINT NULL COMMENT 'Agent ID',
`chat_session_id` BIGINT NULL COMMENT '聊天会话ID',
`runtime_session_id` VARCHAR(128) NULL COMMENT '运行时会话ID',
`request_id` VARCHAR(64) NULL COMMENT '运行请求ID',
`resume_token` VARCHAR(128) NOT NULL COMMENT '恢复令牌',
`tool_call_id` VARCHAR(128) NULL COMMENT '工具调用ID',
`tool_name` VARCHAR(128) NULL COMMENT '工具名称',
`tool_input_json` JSON NULL COMMENT '工具入参',
`status` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态',
`reject_reason` VARCHAR(1024) NULL COMMENT '拒绝原因',
`expires_at` DATETIME NULL COMMENT '过期时间',
`consumed_at` DATETIME NULL COMMENT '消费时间',
`metadata_json` JSON NULL COMMENT '元数据',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_agent_hitl_resume_token` (`resume_token`),
KEY `idx_agent_hitl_session_status` (`chat_session_id`, `status`, `expires_at`),
KEY `idx_agent_hitl_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 工具审批挂起态';
CREATE TABLE IF NOT EXISTS `tb_agent_run_event` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`agent_id` BIGINT NULL COMMENT 'Agent ID',
`chat_session_id` BIGINT NULL COMMENT '聊天会话ID',
`round_id` BIGINT NULL COMMENT '聊天轮次ID',
`round_no` INT NULL COMMENT '聊天轮次序号',
`variant_index` INT NOT NULL DEFAULT 1 COMMENT '答案版本序号',
`request_id` VARCHAR(64) NULL COMMENT '运行请求ID',
`event_id` VARCHAR(64) NULL COMMENT '运行事件ID',
`event_type` VARCHAR(64) NOT NULL COMMENT '运行事件类型',
`event_phase` VARCHAR(64) NULL COMMENT '运行事件阶段',
`tool_call_id` VARCHAR(128) NULL COMMENT '工具调用ID',
`payload_json` JSON NULL COMMENT '事件载荷',
`metadata_json` JSON NULL COMMENT '事件元数据',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
PRIMARY KEY (`id`),
KEY `idx_agent_event_session_round` (`chat_session_id`, `round_id`, `created`, `id`),
KEY `idx_agent_event_request` (`request_id`, `created`, `id`),
KEY `idx_agent_event_type` (`event_type`, `created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 运行事件摘要';

View File

@@ -0,0 +1,14 @@
SET NAMES utf8mb4;
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
VALUES
(367300000000000002, 0, 0, '智能体聊天', '/ai/agent-chat', '/ai/agent-chat/index', 'lucide:message-square', 1, '', 3, 0, '2026-05-24 19:30:00', 1, '2026-05-24 19:30:00', 1, '管理端 Agent 正式聊天菜单'),
(367300000000000022, 367300000000000002, 1, '会话查询', '', '', '', 0, '/api/v1/agent/session/query', 1, 0, '2026-05-24 19:30:00', 1, '2026-05-24 19:30:00', 1, 'Agent-会话查询');
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
VALUES
(367300000000000102, 1, 367300000000000002),
(367300000000000122, 1, 367300000000000022);

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import AiConversation from './AiConversation.vue';
import AiPromptInput from './AiPromptInput.vue';
defineProps<{
approvalLoading?: boolean;
closable?: boolean;
emptyText?: string;
loading?: boolean;
messages: AiChatMessage[];
subtitle?: string;
title: string;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
close: [];
reject: [payload: AiToolApprovalPayload];
send: [text: string];
stop: [];
}>();
defineSlots<{
default?: () => any;
headerActions?: () => any;
}>();
</script>
<template>
<section class="ai-chat-panel">
<header class="ai-chat-panel__header">
<div class="ai-chat-panel__title-wrap">
<div class="ai-chat-panel__title">{{ title }}</div>
<div v-if="subtitle" class="ai-chat-panel__subtitle">
{{ subtitle }}
</div>
</div>
<div class="ai-chat-panel__actions">
<slot name="headerActions"></slot>
<ElButton
v-if="closable"
:icon="Close"
circle
text
aria-label="关闭"
@click="emit('close')"
/>
</div>
</header>
<slot>
<AiConversation
:messages="messages"
:empty-text="emptyText"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</slot>
<AiPromptInput
:loading="loading"
@send="emit('send', $event)"
@stop="emit('stop')"
/>
</section>
</template>
<style scoped>
.ai-chat-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ai-chat-panel__header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ai-chat-panel__title-wrap {
flex: 1;
min-width: 0;
}
.ai-chat-panel__actions {
display: inline-flex;
flex: 0 0 auto;
gap: 2px;
align-items: center;
}
.ai-chat-panel__actions :deep(.el-button.is-circle) {
width: 32px;
height: 32px;
}
.ai-chat-panel__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.ai-chat-panel__subtitle {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {ref, watch} from 'vue';
import {ChatDotRound} from '@element-plus/icons-vue';
import {ElEmpty} from 'element-plus';
import AiMessage from './AiMessage.vue';
import {useAiChatScroll} from './composables/useAiChatScroll';
const props = defineProps<{
approvalLoading?: boolean;
emptyText?: string;
messages: AiChatMessage[];
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
const containerRef = ref<HTMLElement>();
const { scrollToBottom } = useAiChatScroll(containerRef);
watch(
() => props.messages,
() => scrollToBottom(),
{ deep: true, immediate: true },
);
</script>
<template>
<div ref="containerRef" class="ai-conversation">
<ElEmpty
v-if="messages.length === 0"
:image-size="88"
:description="emptyText || '开始试用当前草稿'"
>
<template #image>
<div class="ai-conversation__empty-icon">
<ChatDotRound />
</div>
</template>
</ElEmpty>
<AiMessage
v-for="message in messages"
v-else
:key="message.id"
:message="message"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</div>
</template>
<style scoped>
.ai-conversation {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: auto;
}
.ai-conversation__empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-conversation__empty-icon svg {
width: 32px;
height: 32px;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import {WarningFilled} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
defineProps<{ message: string }>();
</script>
<template>
<div class="ai-error-notice">
<ElIcon><WarningFilled /></ElIcon>
<span>{{ message }}</span>
</div>
</template>
<style scoped>
.ai-error-notice {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
line-height: 20px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type {AiKnowledgeHit} from './types';
defineProps<{ items: AiKnowledgeHit[] }>();
function resolveTitle(item: AiKnowledgeHit, index: number) {
return item.title || item.source || `知识片段 ${index + 1}`;
}
</script>
<template>
<div class="ai-knowledge-card">
<div class="ai-knowledge-card__title">知识检索</div>
<div
v-for="(item, index) in items"
:key="item.id || index"
class="ai-knowledge-card__item"
>
<div class="ai-knowledge-card__item-title">
{{ resolveTitle(item, index) }}
</div>
<div v-if="item.content" class="ai-knowledge-card__content">
{{ item.content }}
</div>
<div v-if="item.score" class="ai-knowledge-card__score">
相似度 {{ item.score }}
</div>
</div>
</div>
</template>
<style scoped>
.ai-knowledge-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-knowledge-card__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ai-knowledge-card__item + .ai-knowledge-card__item {
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.ai-knowledge-card__item-title {
font-size: 13px;
font-weight: 500;
}
.ai-knowledge-card__content,
.ai-knowledge-card__score {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import AiErrorNotice from './AiErrorNotice.vue';
import AiKnowledgeCard from './AiKnowledgeCard.vue';
import AiReasoningCard from './AiReasoningCard.vue';
import AiToolApprovalCard from './AiToolApprovalCard.vue';
import AiToolCallCard from './AiToolCallCard.vue';
defineProps<{
approvalLoading?: boolean;
message: AiChatMessage;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
</script>
<template>
<div class="ai-message" :class="`ai-message--${message.role}`">
<div class="ai-message__bubble">
<template v-for="(part, index) in message.parts" :key="index">
<div v-if="part.type === 'text'" class="ai-message__text">
{{ part.text }}
</div>
<AiReasoningCard
v-else-if="part.type === 'reasoning'"
:text="part.text"
:collapsed="part.collapsed"
/>
<AiKnowledgeCard
v-else-if="part.type === 'knowledge'"
:items="part.items"
/>
<AiToolCallCard
v-else-if="part.type === 'tool_call'"
:tool-name="part.toolName"
:status="part.status"
:input="part.input"
:output="part.output"
/>
<AiToolApprovalCard
v-else-if="part.type === 'tool_approval'"
:request-id="part.requestId"
:resume-token="part.resumeToken"
:tool-name="part.toolName"
:tool-display-name="part.toolDisplayName"
:tool-call-id="part.toolCallId"
:tool-type="part.toolType"
:input="part.input"
:expires-at="part.expiresAt"
:metadata="part.metadata"
:loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<AiErrorNotice
v-else-if="part.type === 'error'"
:message="part.message"
/>
</template>
<span
v-if="message.status === 'streaming'"
class="ai-message__cursor"
></span>
</div>
</div>
</template>
<style scoped>
.ai-message {
display: flex;
width: 100%;
}
.ai-message--user {
justify-content: flex-end;
}
.ai-message--assistant,
.ai-message--system {
justify-content: flex-start;
}
.ai-message__bubble {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(88%, 640px);
padding: 12px 14px;
color: var(--el-text-color-primary);
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.ai-message--user .ai-message__bubble {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.ai-message__text {
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
}
.ai-message__cursor {
width: 6px;
height: 16px;
margin-left: 2px;
background: var(--el-color-primary);
border-radius: 2px;
animation: ai-chat-cursor 0.9s infinite;
}
@keyframes ai-chat-cursor {
50% {
opacity: 0.2;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import AiPromptInput from './AiPromptInput.vue';
describe('AiPromptInput', () => {
it('emits send when loading is false', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: false,
},
});
await wrapper.find('textarea').setValue('你好');
await wrapper.find('[aria-label="发送"]').trigger('click');
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
});
it('emits stop when loading is true', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: true,
},
});
expect(wrapper.find('[aria-label="中止"]').exists()).toBe(true);
await wrapper.find('[aria-label="中止"]').trigger('click');
expect(wrapper.emitted('stop')).toBeTruthy();
});
});

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import {Promotion} from '@element-plus/icons-vue';
import {ElButton, ElInput} from 'element-plus';
const props = defineProps<{
loading?: boolean;
placeholder?: string;
}>();
const emit = defineEmits<{
send: [text: string];
stop: [];
}>();
const text = ref('');
const canSend = computed(() => text.value.trim().length > 0 && !props.loading);
function send() {
const value = text.value.trim();
if (!value || props.loading) return;
emit('send', value);
text.value = '';
}
function stop() {
if (!props.loading) return;
emit('stop');
}
function handleKeydown(event: Event | KeyboardEvent) {
if (!(event instanceof KeyboardEvent)) return;
if (event.key !== 'Enter' || event.shiftKey) return;
event.preventDefault();
send();
}
</script>
<template>
<div class="ai-prompt-input">
<ElInput
v-model="text"
class="ai-prompt-input__textarea"
type="textarea"
resize="none"
:autosize="{ minRows: 1, maxRows: 5 }"
:disabled="loading"
:placeholder="placeholder || '输入消息'"
@keydown="handleKeydown"
/>
<ElButton
v-if="loading"
type="primary"
circle
aria-label="中止"
title="中止"
class="ai-prompt-input__action is-stop"
@click="stop"
/>
<ElButton
v-else
type="primary"
circle
:icon="Promotion"
:disabled="!canSend"
aria-label="发送"
class="ai-prompt-input__action"
@click="send"
/>
</div>
</template>
<style scoped>
.ai-prompt-input {
display: flex;
gap: 10px;
align-items: flex-end;
padding: 10px;
margin: 0 16px 16px;
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
box-shadow: var(--el-box-shadow-lighter);
}
.ai-prompt-input__textarea {
flex: 1;
}
.ai-prompt-input__textarea :deep(.el-textarea__inner) {
min-height: 36px !important;
padding: 8px 10px;
line-height: 20px;
background: transparent;
border: none;
box-shadow: none;
}
.ai-prompt-input__action {
width: 36px;
height: 36px;
}
.ai-prompt-input__action.is-stop::before {
display: block;
width: 12px;
height: 12px;
content: '';
background: var(--el-color-white);
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import {ref} from 'vue';
import {ArrowDown, ArrowRight} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<{
collapsed?: boolean;
text: string;
}>();
const open = ref(!props.collapsed);
</script>
<template>
<div class="ai-reasoning-card">
<ElButton class="ai-reasoning-card__toggle" link @click="open = !open">
<ElIcon>
<ArrowDown v-if="open" />
<ArrowRight v-else />
</ElIcon>
<span>思考</span>
</ElButton>
<div v-if="open" class="ai-reasoning-card__body">{{ text }}</div>
</div>
</template>
<style scoped>
.ai-reasoning-card {
padding: 10px 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-reasoning-card__toggle {
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
}
.ai-reasoning-card__body {
margin-top: 8px;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import type {AiToolApprovalPayload} from './types';
import {Check, Close, Key} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<AiToolApprovalPayload & { loading?: boolean }>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
function payload(): AiToolApprovalPayload {
return {
requestId: props.requestId,
resumeToken: props.resumeToken,
toolName: props.toolName,
toolDisplayName: props.toolDisplayName,
toolCallId: props.toolCallId,
toolType: props.toolType,
input: props.input,
expiresAt: props.expiresAt,
metadata: props.metadata,
};
}
</script>
<template>
<div class="ai-tool-approval">
<div class="ai-tool-approval__head">
<ElIcon><Key /></ElIcon>
<div>
<div class="ai-tool-approval__title">
{{ toolDisplayName || toolName }}
</div>
<div class="ai-tool-approval__desc">需要确认后执行</div>
</div>
</div>
<pre v-if="input" class="ai-tool-approval__payload">{{ input }}</pre>
<div class="ai-tool-approval__actions">
<ElButton
:icon="Close"
:loading="loading"
@click="emit('reject', payload())"
>
拒绝
</ElButton>
<ElButton
type="primary"
:icon="Check"
:loading="loading"
@click="emit('approve', payload())"
>
批准
</ElButton>
</div>
</div>
</template>
<style scoped>
.ai-tool-approval {
padding: 12px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-tool-approval__head {
display: flex;
gap: 8px;
align-items: center;
}
.ai-tool-approval__title {
font-size: 13px;
font-weight: 600;
}
.ai-tool-approval__desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ai-tool-approval__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
.ai-tool-approval__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {computed} from 'vue';
import {Tools} from '@element-plus/icons-vue';
import {ElIcon, ElTag} from 'element-plus';
const props = defineProps<{
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
}>();
const statusText = computed(() => props.status || '执行中');
</script>
<template>
<div class="ai-tool-card">
<div class="ai-tool-card__head">
<div class="ai-tool-card__name">
<ElIcon><Tools /></ElIcon>
<span>{{ toolName }}</span>
</div>
<ElTag size="small" effect="plain">{{ statusText }}</ElTag>
</div>
<pre v-if="input" class="ai-tool-card__payload">{{ input }}</pre>
<pre v-if="output" class="ai-tool-card__payload">{{ output }}</pre>
</div>
</template>
<style scoped>
.ai-tool-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-tool-card__head,
.ai-tool-card__name {
display: flex;
align-items: center;
}
.ai-tool-card__head {
justify-content: space-between;
}
.ai-tool-card__name {
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 600;
}
.ai-tool-card__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,13 @@
import type {Ref} from 'vue';
import {nextTick} from 'vue';
export function useAiChatScroll(containerRef: Ref<HTMLElement | undefined>) {
async function scrollToBottom() {
await nextTick();
const container = containerRef.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
}
return { scrollToBottom };
}

View File

@@ -0,0 +1,45 @@
export type AiChatMessageRole = 'assistant' | 'system' | 'user';
export type AiChatMessageStatus = 'done' | 'error' | 'pending' | 'streaming';
export interface AiKnowledgeHit {
id?: string;
title?: string;
content?: string;
score?: number | string;
source?: string;
[key: string]: any;
}
export interface AiToolApprovalPayload {
requestId: string;
resumeToken: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
toolType?: string;
input?: unknown;
expiresAt?: string;
metadata?: unknown;
}
export type AiChatMessagePart =
| (AiToolApprovalPayload & { type: 'tool_approval' })
| { collapsed?: boolean; text: string; type: 'reasoning' }
| {
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
type: 'tool_call';
}
| { items: AiKnowledgeHit[]; type: 'knowledge' }
| { message: string; type: 'error' }
| { text: string; type: 'text' };
export interface AiChatMessage {
id: string;
role: AiChatMessageRole;
status?: AiChatMessageStatus;
parts: AiChatMessagePart[];
createdAt?: number;
}

View File

@@ -1,16 +1,17 @@
<script setup lang="ts">
import type {
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeTimelineItem,
} from '@easyflow/types';
import { computed, ref } from 'vue';
import {computed, ref} from 'vue';
import { ChatThinkingBlock, ChatTimeMarkdown } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import {ChatThinkingBlock, ChatTimeMarkdown} from '@easyflow/common-ui';
import {IconifyIcon} from '@easyflow/icons';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
import {CircleCheck} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
@@ -29,8 +30,23 @@ const renderableSegments = computed(() => {
return [] as ChatTimeAssistantSegment[];
}
if (props.item.segments.length > 0) {
const textSegments = props.item.segments.filter(
(segment): segment is ChatTimeAssistantTextSegment =>
segment.type === 'text',
);
if (textSegments.length <= 1) {
return props.item.segments;
}
const mergedTextSegment: ChatTimeAssistantTextSegment = {
content: textSegments.map((segment) => segment.content).join(''),
id: `${props.item.id}-merged-text`,
type: 'text',
};
return [
...props.item.segments.filter((segment) => segment.type !== 'text'),
mergedTextSegment,
];
}
if (!props.item.content) {
return [] as ChatTimeAssistantSegment[];
}
@@ -74,7 +90,11 @@ function toggleToolExpanded() {
:status="segment.status"
class="chat-thinking-block-item"
/>
<ChatTimeMarkdown v-else :content="segment.content" />
<ChatTimeMarkdown
v-else
:content="segment.content"
:streaming="item.typing"
/>
</template>
</div>
</template>
@@ -124,7 +144,7 @@ function toggleToolExpanded() {
</div>
</template>
<ChatTimeMarkdown v-else :content="item.content" />
<ChatTimeMarkdown v-else :content="item.content" :streaming="item.typing" />
</template>
<style scoped>

View File

@@ -23,6 +23,7 @@
},
"ai": {
"chat": "Chat",
"agents": "Agent",
"bots": "ChatAssistant",
"title": "AI",
"resources": "Resources",

View File

@@ -23,6 +23,7 @@
},
"ai": {
"chat": "聊天",
"agents": "智能体",
"bots": "聊天助手",
"title": "AI能力",
"resources": "素材库",

View File

@@ -0,0 +1,30 @@
import type {RouteRecordRaw} from 'vue-router';
import {$t} from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'AgentDesigner',
path: '/ai/agents/designer/:id',
component: () => import('#/views/ai/agents/AgentDesigner.vue'),
meta: {
title: $t('menus.ai.agents'),
openInNewWindow: true,
hideInMenu: true,
activePath: '/ai/agents',
},
},
{
name: 'AgentChat',
path: '/ai/agent-chat',
component: () => import('#/views/ai/agent-chat/index.vue'),
meta: {
title: '智能体聊天',
fullPathKey: false,
hideInMenu: true,
activePath: '/ai/agents',
},
},
];
export default routes;

View File

@@ -0,0 +1,230 @@
import {describe, expect, it} from 'vitest';
import type {ChatTimelineMessageItem} from '@easyflow/common-ui';
import {
applyAgentSseEnvelope,
parseAgentSseMessage,
recordsToTimelineItems,
} from './agentTimelineAdapter';
describe('agentTimelineAdapter', () => {
it('projects history records to chat timeline items', () => {
const items = recordsToTimelineItems([
{
id: '1',
senderRole: 'user',
contentText: '帮我查一下',
roundId: 'r1',
},
{
id: '2',
senderRole: 'assistant',
contentText: '查到了',
roundId: 'r1',
contentPayload: {
agentResult: {
reasoning: '先检索',
text: '查到了',
knowledgeReferences: [
{
documentName: '手册',
chunkContent: '内容片段',
},
],
},
chains: [
{
id: 'tool-1',
name: 'search',
status: 'TOOL_RESULT',
arguments: { q: 'EasyFlow' },
result: 'ok',
},
],
},
},
]);
expect(
items.some((item) => item.type === 'message' && item.role === 'user'),
).toBe(true);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
expect(assistant?.parts.some((part) => part.type === 'thinking')).toBe(
true,
);
expect(assistant?.parts.some((part) => part.type === 'text')).toBe(true);
expect(items.some((item) => item.type === 'tool')).toBe(true);
expect(assistant?.knowledgeItems?.[0]?.documentName).toBe('手册');
});
it('keeps stable ids when history has reasoning, tools and final text', () => {
const items = recordsToTimelineItems([
{
id: '42',
senderRole: 'assistant',
contentText: '最终回答',
roundId: 'r2',
contentPayload: {
agentResult: {
text: '最终回答',
},
chains: [
{
reasoning_content: '先思考',
},
{
id: 'tool-2',
name: 'search',
status: 'TOOL_RESULT',
result: 'ok',
},
],
messageChain: [
{
role: 'assistant',
reasoningContent: '中间思考',
toolCalls: [{ id: 'tool-2', name: 'search', arguments: '{}' }],
},
{
role: 'tool',
toolCallId: 'tool-2',
content: 'ok',
},
],
},
},
]);
const ids = items.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
expect(
items.filter(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toHaveLength(2);
expect(
items.some((item) => item.type === 'tool' && item.status === 'success'),
).toBe(true);
});
it('parses raw SSE text as message delta', () => {
const envelope = parseAgentSseMessage({
data: 'hello',
event: '',
id: '',
retry: undefined,
});
expect(envelope).toMatchObject({
domain: 'LLM',
type: 'MESSAGE',
payload: { delta: 'hello' },
});
});
it('applies streaming text, HITL approval and error envelopes', () => {
const items: any[] = [];
applyAgentSseEnvelope(items, {
domain: 'LLM',
type: 'MESSAGE',
payload: { delta: '你好' },
});
applyAgentSseEnvelope(items, {
domain: 'TOOL',
type: 'FORM_REQUEST',
payload: {
requestId: 'req-1',
resumeToken: 'token-1',
toolCallId: 'tool-1',
toolName: 'workflow',
input: { name: 'demo' },
},
});
applyAgentSseEnvelope(items, {
domain: 'ERROR',
type: 'ERROR',
payload: { message: '失败' },
});
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
const tool = items.find((item) => item.type === 'tool');
const error = items.find((item) => item.type === 'error');
expect(assistant?.parts[0]?.content).toBe('你好');
expect(tool?.status).toBe('pending_approval');
expect(tool?.approval?.resumeToken).toBe('token-1');
expect(error?.message).toBe('失败');
});
it('keeps assistant text and approval card when a tool request is rejected', () => {
const items: any[] = [];
applyAgentSseEnvelope(items, {
domain: 'LLM',
type: 'MESSAGE',
payload: { delta: '正在处理' },
});
applyAgentSseEnvelope(items, {
domain: 'TOOL',
type: 'FORM_REQUEST',
payload: {
requestId: 'req-2',
resumeToken: 'token-2',
toolCallId: 'tool-2',
toolName: '审批工具',
input: { name: 'demo' },
},
});
applyAgentSseEnvelope(items, {
domain: 'TOOL',
type: 'FORM_REJECTED',
payload: {
requestId: 'req-2',
resumeToken: 'token-2',
toolCallId: 'tool-2',
reason: '用户拒绝执行',
},
});
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
const tool = items.find((item) => item.type === 'tool');
expect(assistant?.parts[0]?.content).toBe('正在处理');
expect(items).toHaveLength(2);
expect(tool?.status).toBe('rejected');
expect(tool?.rejectReason).toBe('用户拒绝执行');
});
it('applies streaming round metadata to assistant messages for action toolbar anchoring', () => {
const items: any[] = [];
applyAgentSseEnvelope(
items,
{
domain: 'LLM',
type: 'MESSAGE',
payload: { delta: '准备调用工具' },
},
{ roundId: 'runtime-round-1' },
);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
expect(assistant?.roundId).toBe('runtime-round-1');
expect(assistant?.parts[0]?.content).toBe('准备调用工具');
});
});

View File

@@ -0,0 +1,420 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type {
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import type {AgentChatMessageRecord} from '../api';
export interface AgentSseEnvelope {
domain: string;
payload: Record<string, any>;
type: string;
}
function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, any>)
: {};
}
function asArray(value: unknown): any[] {
return Array.isArray(value) ? value : [];
}
function asTimestamp(value: unknown) {
if (!value) {
return Date.now();
}
const timestamp = new Date(String(value)).getTime();
return Number.isFinite(timestamp) ? timestamp : Date.now();
}
function normalizeRole(value: unknown): 'assistant' | 'system' | 'user' {
const role = asText(value).toLowerCase();
if (role === 'assistant' || role === 'system' || role === 'user') {
return role;
}
return 'assistant';
}
function normalizeToolName(value: unknown) {
return asText(value).trim();
}
function normalizeToolCallId(payload: Record<string, any>) {
return asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id);
}
function normalizeMetadata(record: AgentChatMessageRecord) {
return {
createdAt: asTimestamp(record.created),
id: `history-${record.id || record.roundId || Date.now()}`,
roundId: asText(record.roundId),
roundNo: record.roundNo,
selectedVariantIndex: record.selectedVariantIndex,
switchable: false,
variantCount: record.variantCount,
variantIndex: record.variantIndex,
} satisfies Partial<ChatTimelineMessageItem>;
}
function assistantMetadata(
record: AgentChatMessageRecord,
suffix?: string,
): Partial<ChatTimelineMessageItem> {
const metadata = normalizeMetadata(record);
return suffix ? { ...metadata, id: `${metadata.id}-${suffix}` } : metadata;
}
function normalizeKnowledgeItems(payload: Record<string, any>) {
const rawItems =
asArray(payload.items).length > 0
? asArray(payload.items)
: asArray(payload.knowledgeReferences).length > 0
? asArray(payload.knowledgeReferences)
: asArray(payload.knowledgeCitations);
return rawItems
.map((item, index): ChatTimelineKnowledgeHit => {
const source = asRecord(item);
const metadata = asRecord(source.metadata);
const documentName = asText(
source.documentName ?? source.title ?? metadata.documentName,
);
const sourceFileName = asText(
source.sourceFileName ?? metadata.sourceFileName,
);
const chunkContent = asText(
source.chunkContent ?? source.content ?? source.text ?? source.summary,
);
return {
...source,
id: asText(source.id ?? source.chunkId ?? index),
chunkContent,
content: asText(source.content ?? source.text ?? source.summary),
documentId: asText(source.documentId ?? metadata.documentId),
documentName,
knowledgeId: asText(source.knowledgeId ?? payload.knowledgeId),
knowledgeName: asText(source.knowledgeName ?? payload.knowledgeName),
metadata,
score: source.score ?? source.similarity,
sourceFileName,
sourceUri: asText(source.sourceUri ?? metadata.sourceUri),
title: documentName || sourceFileName || asText(source.source),
};
})
.filter((item) => item.chunkContent || item.title || item.documentName);
}
function buildApprovalPayload(payload: Record<string, any>) {
return {
expiresAt: asText(payload.expiresAt),
input: payload.input,
metadata: payload.metadata,
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: normalizeToolCallId(payload),
toolDisplayName: asText(payload.toolDisplayName),
toolName: normalizeToolName(payload.toolName ?? payload.name) || '工具调用',
toolType: asText(payload.toolType),
} satisfies ChatTimelineToolApprovalPayload;
}
function appendAssistantText(
items: ChatTimelineItem[],
record: AgentChatMessageRecord,
content: unknown,
suffix?: string,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = asText(content);
if (!text) {
return;
}
ChatTimelineBuilder.appendMessageDelta(
items,
text,
{
...assistantMetadata(record, suffix),
...metadata,
},
);
}
function appendAssistantThinking(
items: ChatTimelineItem[],
record: AgentChatMessageRecord,
content: unknown,
suffix?: string,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = asText(content);
if (!text) {
return;
}
ChatTimelineBuilder.appendThinkingDelta(
items,
text,
{
...assistantMetadata(record, suffix),
...metadata,
},
);
}
function projectHistoryChain(
items: ChatTimelineItem[],
record: AgentChatMessageRecord,
) {
const payload = asRecord(record.contentPayload);
let hasAssistantText = false;
let hasAssistantThinking = false;
const displayChains = asArray(payload.displayChains ?? payload.chains);
for (const chain of displayChains) {
const item = asRecord(chain);
const reasoning = item.reasoningContent ?? item.reasoning_content;
if (reasoning) {
appendAssistantThinking(items, record, reasoning, 'thinking');
hasAssistantThinking = true;
continue;
}
const toolName = normalizeToolName(item.name ?? item.toolName);
if (toolName) {
ChatTimelineBuilder.upsertToolCall(items, {
input: item.arguments ?? item.input,
output: item.result ?? item.output,
status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running',
toolCallId: asText(item.id ?? item.toolCallId),
toolName,
});
}
}
const messageChain = asArray(payload.messageChain);
for (const chain of messageChain) {
const item = asRecord(chain);
const role = asText(item.role).toLowerCase();
if (role === 'assistant') {
appendAssistantThinking(items, record, item.reasoningContent, 'thinking');
if (item.reasoningContent) {
hasAssistantThinking = true;
}
if (!payload.agentResult && item.content) {
appendAssistantText(items, record, item.content, 'text');
hasAssistantText = true;
}
for (const toolCall of asArray(item.toolCalls)) {
const tool = asRecord(toolCall);
ChatTimelineBuilder.upsertToolCall(items, {
input: tool.arguments ?? tool.input,
status: 'running',
toolCallId: asText(tool.id ?? tool.toolCallId),
toolName: normalizeToolName(tool.name ?? tool.toolName),
});
}
continue;
}
if (role === 'tool') {
ChatTimelineBuilder.upsertToolCall(items, {
output: item.content ?? item.result,
status: 'success',
toolCallId: asText(item.toolCallId ?? item.id),
toolName: normalizeToolName(item.name ?? item.toolName) || '工具调用',
});
}
}
return {
hasAssistantText,
hasAssistantThinking,
};
}
function appendHistoryRecord(
items: ChatTimelineItem[],
record: AgentChatMessageRecord,
) {
const role = normalizeRole(record.senderRole);
const metadata = normalizeMetadata(record);
if (role === 'user') {
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
return;
}
if (role === 'system') {
ChatTimelineBuilder.appendError(items, record.contentText || '系统消息');
return;
}
const payload = asRecord(record.contentPayload);
const agentResult = asRecord(payload.agentResult);
const chainProjection = projectHistoryChain(items, record);
if (!chainProjection.hasAssistantThinking) {
appendAssistantThinking(
items,
record,
payload.reasoningContent ?? agentResult.reasoning,
'thinking',
);
}
if (!chainProjection.hasAssistantText) {
appendAssistantText(
items,
record,
agentResult.text ?? payload.content ?? record.contentText,
chainProjection.hasAssistantThinking ? 'text' : undefined,
);
}
const knowledgeItems = normalizeKnowledgeItems({
...payload,
items:
payload.knowledgeCitations ??
agentResult.knowledgeReferences ??
payload.knowledgeReferences,
});
if (knowledgeItems.length > 0) {
ChatTimelineBuilder.appendKnowledge(items, knowledgeItems);
}
ChatTimelineBuilder.finalize(items);
}
export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) {
const items: ChatTimelineItem[] = [];
for (const record of records) {
appendHistoryRecord(items, record);
}
ChatTimelineBuilder.finalize(items);
return items;
}
export function parseAgentSseMessage(message: ServerSentEventMessage) {
const raw = message.data || '';
if (!raw) {
return undefined;
}
try {
const data = JSON.parse(raw);
return {
domain: asText(
data.domain ?? data.eventDomain ?? data.typeDomain,
).toUpperCase(),
payload: asRecord(data.payload ?? data.data ?? data),
type: asText(
data.type ?? data.eventType ?? data.chatType ?? data.event,
).toUpperCase(),
} satisfies AgentSseEnvelope;
} catch {
return {
domain: 'LLM',
payload: { delta: raw },
type: 'MESSAGE',
} satisfies AgentSseEnvelope;
}
}
export function applyAgentSseEnvelope(
items: ChatTimelineItem[],
envelope: AgentSseEnvelope,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const { domain, payload, type } = envelope;
if (domain === 'LLM' && type === 'MESSAGE') {
ChatTimelineBuilder.appendMessageDelta(
items,
payload.delta ?? payload.text,
metadata,
);
return;
}
if (domain === 'LLM' && type === 'THINKING') {
ChatTimelineBuilder.appendThinkingDelta(
items,
payload.reasoning ?? payload.delta ?? payload.text,
metadata,
);
return;
}
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
ChatTimelineBuilder.appendToolApproval(
items,
buildApprovalPayload(payload),
);
return;
}
if (domain === 'TOOL' && type === 'FORM_APPROVING') {
ChatTimelineBuilder.markToolApproving(items, {
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: normalizeToolCallId(payload),
});
return;
}
if (domain === 'TOOL' && type === 'FORM_REJECTED') {
ChatTimelineBuilder.markToolRejected(items, {
reason: asText(payload.reason),
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: normalizeToolCallId(payload),
});
return;
}
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: payload.output ?? payload.result ?? payload.text,
status: type === 'TOOL_RESULT' ? 'success' : 'running',
statusKey: asText(payload.statusKey) || undefined,
toolCallId: normalizeToolCallId(payload),
toolName: normalizeToolName(
payload.toolDisplayName ?? payload.toolName ?? payload.name,
),
});
return;
}
if (domain === 'BUSINESS' && type === 'CITATIONS') {
ChatTimelineBuilder.appendKnowledge(
items,
normalizeKnowledgeItems(payload),
);
return;
}
if (domain === 'BUSINESS' && type === 'STATUS') {
if (asText(payload.statusKey) === 'memory-compression') {
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
compressed:
typeof payload.compressed === 'boolean'
? payload.compressed
: undefined,
label: asText(payload.label),
phase: asText(payload.phase),
status: asText(payload.status),
statusKey: asText(payload.statusKey),
});
return;
}
if (asText(payload.statusKey) === 'knowledge-retrieval') {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
asText(payload.status) === 'running' ? 'running' : 'done',
asText(payload.statusKey),
);
}
return;
}
if (domain === 'SYSTEM' && type === 'DONE') {
ChatTimelineBuilder.finalize(items);
return;
}
if (domain === 'ERROR' || type === 'ERROR') {
ChatTimelineBuilder.appendError(
items,
payload.message ?? payload.error ?? '请求失败',
);
}
}

View File

@@ -0,0 +1,290 @@
import type {ChatTimelineItem} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
interface RuntimeSessionState {
agentId: string;
agentName?: string;
completed: boolean;
error?: string;
items: ChatTimelineItem[];
prompt: string;
roundId: string;
sending: boolean;
sessionId: string;
updatedAt: number;
}
interface StoredRuntimeSession {
agentId: string;
agentName?: string;
completed: boolean;
error?: string;
items: ChatTimelineItem[];
prompt: string;
roundId: string;
sessionId: string;
updatedAt: number;
version: number;
}
interface StartOptions {
agentId: string;
agentName?: string;
baseItems?: ChatTimelineItem[];
prompt: string;
sessionId?: string;
}
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
const STORAGE_VERSION = 1;
const sessions = new Map<string, RuntimeSessionState>();
const listeners = new Set<() => void>();
let latestSessionId = '';
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createRoundId() {
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function storageKey(sessionId: string) {
return `${STORAGE_PREFIX}:${sessionId}`;
}
function safeSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function notify() {
for (const listener of listeners) {
listener();
}
}
function persistSession(state: RuntimeSessionState) {
const storage = safeSessionStorage();
if (!storage) {
return;
}
const snapshot: StoredRuntimeSession = {
agentId: state.agentId,
agentName: state.agentName,
completed: state.completed,
error: state.error,
items: clone(state.items),
prompt: state.prompt,
roundId: state.roundId,
sessionId: state.sessionId,
updatedAt: state.updatedAt,
version: STORAGE_VERSION,
};
try {
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
} catch {
// 缓存失败不影响正式聊天主流程。
}
}
function restoreSession(sessionId: string) {
const existing = sessions.get(sessionId);
if (existing) {
return existing;
}
const storage = safeSessionStorage();
if (!storage) {
return undefined;
}
try {
const raw = storage.getItem(storageKey(sessionId));
if (!raw) {
return undefined;
}
const parsed = JSON.parse(raw) as StoredRuntimeSession;
if (parsed.version !== STORAGE_VERSION || parsed.sessionId !== sessionId) {
return undefined;
}
const restored: RuntimeSessionState = {
agentId: parsed.agentId,
agentName: parsed.agentName,
completed: parsed.completed,
error: parsed.error,
items: Array.isArray(parsed.items) ? parsed.items : [],
prompt: parsed.prompt,
roundId: parsed.roundId,
sending: false,
sessionId,
updatedAt: parsed.updatedAt,
};
sessions.set(sessionId, restored);
return restored;
} catch {
return undefined;
}
}
function upsertState(state: RuntimeSessionState) {
state.updatedAt = Date.now();
latestSessionId = state.sessionId;
sessions.set(state.sessionId, state);
persistSession(state);
notify();
}
function runningSession() {
return [...sessions.values()].find((session) => session.sending);
}
function restoreLatestSession() {
const running = runningSession();
if (running) {
return running;
}
const storage = safeSessionStorage();
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
const sessionId = latestSessionId || storedSessionId;
return sessionId ? restoreSession(sessionId) : undefined;
}
async function resolveSessionId(sessionId?: string) {
if (sessionId) {
return sessionId;
}
const res = await generateAgentSessionId();
if (res.errorCode !== 0 || !res.data) {
throw new Error(res.message || '会话创建失败');
}
return String(res.data);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '发送失败,请稍后再试';
}
export const agentChatRuntimeManager = {
getSnapshot(sessionId?: string) {
if (!sessionId) {
return undefined;
}
const state = restoreSession(sessionId);
return state ? clone(state) : undefined;
},
getLatestSnapshot() {
const state = restoreLatestSession();
return state ? clone(state) : undefined;
},
hasRunning() {
return Boolean(runningSession());
},
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
const state = restoreSession(sessionId);
if (!state) {
return;
}
state.items = clone(items);
upsertState(state);
},
async start(options: StartOptions) {
const active = runningSession();
if (active) {
throw new Error('当前回复完成后再发送新消息');
}
const sessionId = await resolveSessionId(options.sessionId);
const roundId = createRoundId();
const state: RuntimeSessionState = {
agentId: options.agentId,
agentName: options.agentName,
completed: false,
items: clone(options.baseItems || []),
prompt: options.prompt,
roundId,
sending: true,
sessionId,
updatedAt: Date.now(),
};
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
roundId,
});
upsertState(state);
void sendAgentChat(
{
agentId: options.agentId,
prompt: options.prompt,
sessionId,
},
{
onError(error) {
const current = sessions.get(sessionId);
if (!current || !current.sending) {
return;
}
current.error = errorMessage(error);
current.sending = false;
current.completed = true;
ChatTimelineBuilder.appendError(current.items, current.error);
ChatTimelineBuilder.finalize(current.items);
upsertState(current);
},
onFinished() {
const current = sessions.get(sessionId);
if (!current) {
return;
}
current.sending = false;
current.completed = true;
ChatTimelineBuilder.finalize(current.items);
upsertState(current);
},
onMessage(message) {
const current = sessions.get(sessionId);
if (!current || !current.sending) {
return;
}
const envelope = parseAgentSseMessage(message);
if (!envelope) {
return;
}
applyAgentSseEnvelope(current.items, envelope, { roundId });
upsertState(current);
},
},
);
return sessionId;
},
stop(sessionId?: string) {
const state = sessionId ? restoreSession(sessionId) : runningSession();
if (!state || !state.sending) {
return;
}
stopAgentChatStream();
state.sending = false;
state.completed = true;
ChatTimelineBuilder.finalize(state.items);
upsertState(state);
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};

View File

@@ -0,0 +1,146 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type {AgentInfo} from '../agents/types';
import {api, SseClient} from '#/api/request';
const agentChatSseClient = new SseClient();
export interface RequestResult<T = any> {
data: T;
errorCode: number;
message?: string;
}
export interface AgentChatSessionView {
accessAt?: string;
assistantCode?: string;
assistantId?: number | string;
assistantName?: string;
continuable?: boolean;
lastMessageAt?: string;
lastMessagePreview?: string;
messageCount?: number;
readOnlyReason?: unknown;
sessionId?: number | string;
title?: string;
}
export interface AgentChatSessionPage {
pageNumber?: number;
pageSize?: number;
records?: AgentChatSessionView[];
total?: number;
}
export interface AgentChatMessageRecord {
assistantId?: number | string;
contentPayload?: Record<string, any>;
contentText?: string;
contentType?: string;
created?: string;
id?: number | string;
messageKind?: string;
roundId?: number | string;
roundNo?: number;
selectedVariantIndex?: number;
senderName?: string;
senderRole?: string;
sessionId?: number | string;
switchable?: boolean;
variantCount?: number;
variantIndex?: number;
}
export interface AgentChatConversationView {
records?: AgentChatMessageRecord[];
total?: number;
variantsByRound?: Record<string, AgentChatMessageRecord[]>;
}
export function getPublishedAgents() {
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
params: { publishedOnly: true },
});
}
export function generateAgentSessionId() {
return api.get<RequestResult<string>>('/api/v1/agent/session/generateId');
}
export function getAgentSession(sessionId: number | string) {
return api.get<RequestResult<AgentChatSessionView>>(
`/api/v1/agent/session/${sessionId}`,
);
}
export function getAgentSessions(params?: {
agentId?: number | string;
pageNumber?: number;
pageSize?: number;
}) {
return api.get<RequestResult<AgentChatSessionPage>>(
'/api/v1/agent/session/list',
{
params: {
pageNumber: params?.pageNumber ?? 1,
pageSize: params?.pageSize ?? 50,
...(params?.agentId ? { agentId: params.agentId } : {}),
},
},
);
}
export function getAgentConversation(sessionId: number | string) {
return api.get<RequestResult<AgentChatConversationView>>(
`/api/v1/agent/session/${sessionId}/conversation`,
);
}
export function renameAgentSession(sessionId: number | string, title: string) {
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/rename`, {
title,
});
}
export function deleteAgentSession(sessionId: number | string) {
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/delete`);
}
export function approveAgentRun(requestId: string, resumeToken: string) {
return api.post<RequestResult>('/api/v1/agent/run/approve', {
requestId,
resumeToken,
});
}
export function rejectAgentRun(
requestId: string,
resumeToken: string,
reason?: string,
) {
return api.post<RequestResult>('/api/v1/agent/run/reject', {
requestId,
resumeToken,
reason,
});
}
export function sendAgentChat(
data: {
agentId: number | string;
prompt: string;
sessionId?: number | string;
},
options: {
onError?: (error: unknown) => void;
onFinished?: () => void;
onMessage?: (message: ServerSentEventMessage) => void;
},
) {
return agentChatSseClient.post('/api/v1/agent/chat', data, options);
}
export function stopAgentChatStream() {
agentChatSseClient.abort();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
/* cspell:ignore tryit */
import type {AgentCapabilityKind, AgentOption, AgentValidationIssue,} from './types';
import {computed, onMounted, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import {ElMessage, ElMessageBox} from 'element-plus';
import {tryit} from 'radash';
import {api} from '#/api/request';
import {
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
} from '#/views/ai/shared/publish-status';
import {
getAgentDetail,
getAgentModels,
getPublishedKnowledgeList,
saveAgent,
submitAgentOfflineApproval,
submitAgentPublishApproval,
updateAgent,
updateAgentKnowledgeBindings,
updateAgentToolBindings,
} from './api';
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
import AgentCommandBar from './components/AgentCommandBar.vue';
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
import {useAgentDesignerState} from './composables/useAgentDesignerState';
const route = useRoute();
const router = useRouter();
const {
state,
addKnowledgeNode,
addToolNode,
buildKnowledgePayload,
buildPayloadAgent,
buildToolPayload,
markDirty,
openTryout,
removeSelectedCapability,
reset,
selectBase,
selectNode,
validate,
} = useAgentDesignerState();
const pageLoading = ref(false);
const saveLoading = ref(false);
const publishLoading = ref(false);
const issues = ref<AgentValidationIssue[]>([]);
const categories = ref<AgentOption[]>([]);
const models = ref<AgentOption[]>([]);
const knowledges = ref<AgentOption[]>([]);
const workflows = ref<AgentOption[]>([]);
const pluginTools = ref<AgentOption[]>([]);
const isNew = computed(() => String(route.params.id || '') === 'new');
const publishText = computed(() => {
if (
canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return '下线';
}
if (
canAiResourceRepublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return '重新发布';
}
return '发布';
});
const publishDisabled = computed(() => {
if (!state.agent.id) return true;
if (
isAiResourceApprovalPending(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return true;
}
return !(
canAiResourcePublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
) ||
canAiResourceRepublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
) ||
canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
);
});
onMounted(async () => {
pageLoading.value = true;
try {
await Promise.all([loadOptions(), loadAgent()]);
} finally {
pageLoading.value = false;
}
});
async function loadAgent() {
if (isNew.value) {
reset();
return;
}
const [, res] = await tryit(getAgentDetail)(String(route.params.id));
if (res?.errorCode === 0) {
reset(res.data);
}
}
async function loadOptions() {
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
await Promise.all([
api.get('/api/v1/agentCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
getAgentModels(),
getPublishedKnowledgeList(),
api.get('/api/v1/workflow/page', {
params: { pageNumber: 1, pageSize: 200 },
}),
api.get('/api/v1/plugin/pageByCategory', {
params: { pageNumber: 1, pageSize: 200, category: 0 },
}),
]);
categories.value = (categoryRes.data || []).map((item: any) => ({
label: item.categoryName || item.name,
value: String(item.id),
raw: item,
}));
models.value = (modelRes.data || []).map((item: any) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
knowledges.value = (knowledgeRes.data || []).map((item: any) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
workflows.value = (
(workflowRes.data?.records || workflowRes.data || []) as any[]
).map((item) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
pluginTools.value = flattenPluginTools(
pluginRes.data?.records || pluginRes.data || [],
);
}
function flattenPluginTools(list: any[]): AgentOption[] {
const result: AgentOption[] = [];
list.forEach((plugin) => {
const tools = Array.isArray(plugin.tools) ? plugin.tools : [];
tools.forEach((tool: any) => {
result.push({
label: tool.name || tool.title,
value: String(tool.id),
raw: { ...tool, pluginName: plugin.name || plugin.title },
});
});
});
return result;
}
async function handleAdd(kind: AgentCapabilityKind) {
if (kind === 'knowledge') {
addKnowledgeNode();
return;
}
addToolNode(kind);
}
function handleSelectNode(nodeId: string) {
selectNode(nodeId);
}
function handleSelectIssue(nodeId: string) {
selectNode(nodeId);
}
function runValidation() {
issues.value = validate();
if (issues.value.length > 0) {
selectNode(issues.value[0]!.nodeId);
ElMessage.warning('请先完成必要配置');
return false;
}
return true;
}
async function handleSave(showMessage = true) {
if (!runValidation()) return false;
saveLoading.value = true;
try {
const agentPayload = buildPayloadAgent();
const agentRes = state.agent.id
? await updateAgent(agentPayload)
: await saveAgent(agentPayload);
if (agentRes.errorCode !== 0 || !agentRes.data?.id) {
return false;
}
const id = agentRes.data.id;
const toolBindingRes = await updateAgentToolBindings(
id,
buildToolPayload(id),
);
if (toolBindingRes.errorCode !== 0) {
return false;
}
const knowledgeBindingRes = await updateAgentKnowledgeBindings(
id,
buildKnowledgePayload(id),
);
if (knowledgeBindingRes.errorCode !== 0) {
return false;
}
state.agent = {
...state.agent,
...agentRes.data,
id,
};
state.dirty = false;
if (isNew.value) {
await router.replace(`/ai/agents/designer/${id}`);
}
if (showMessage) {
ElMessage.success('已保存');
}
return true;
} finally {
saveLoading.value = false;
}
}
async function handlePublish() {
if (!state.agent.id) return;
const saved = await handleSave(false);
if (!saved) return;
const offline = canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
);
try {
await ElMessageBox.confirm(
offline ? '确认提交下线审批?' : '确认提交发布审批?',
'提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: offline ? 'warning' : 'info',
},
);
} catch {
return;
}
publishLoading.value = true;
try {
const res = offline
? await submitAgentOfflineApproval(String(state.agent.id))
: await submitAgentPublishApproval(String(state.agent.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '已提交');
await loadAgent();
}
} finally {
publishLoading.value = false;
}
}
function handleTryout() {
if (!runValidation()) return;
openTryout();
}
function handleCloseTryout() {
selectBase();
}
</script>
<template>
<div v-loading="pageLoading" class="agent-designer">
<AgentStudioCanvas
:state="state"
:knowledge-options="knowledges"
:selected-node-id="state.selectedNodeId"
@select="handleSelectNode"
/>
<AgentInspectorPanel
:state="state"
:models="models"
:categories="categories"
:knowledges="knowledges"
:workflows="workflows"
:plugin-tools="pluginTools"
:issues="issues"
@change="markDirty"
@remove-capability="removeSelectedCapability"
@close-tryout="handleCloseTryout"
@select-issue="handleSelectIssue"
/>
<AgentCommandBar
:save-loading="saveLoading"
:publish-loading="publishLoading"
:publish-disabled="publishDisabled"
:publish-text="publishText"
@add="handleAdd"
@save="handleSave()"
@publish="handlePublish"
@tryout="handleTryout"
/>
</div>
</template>
<style scoped>
.agent-designer {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background:
radial-gradient(
circle at 50% 42%,
var(--el-color-primary-light-9),
transparent 32%
),
var(--el-fill-color-extra-light);
}
</style>

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