feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的创建人名称填充逻辑。
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum ChatType {
|
||||
TOOL_CALL,
|
||||
TOOL_RESULT,
|
||||
STATUS,
|
||||
CITATIONS,
|
||||
SESSION_CREATED,
|
||||
ERROR,
|
||||
FORM_REQUEST,
|
||||
FORM_CANCEL,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
67
easyflow-modules/easyflow-module-agent/pom.xml
Normal file
67
easyflow-modules/easyflow-module-agent/pom.xml
Normal 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>
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntime;
|
||||
|
||||
/**
|
||||
* Agent 运行时工厂
|
||||
*/
|
||||
public interface AgentRuntimeFactory {
|
||||
|
||||
/**
|
||||
* 创建新的有状态 Agent 运行时实例。
|
||||
*
|
||||
* @return Agent 运行时实例
|
||||
*/
|
||||
AgentRuntime create();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.agent.runtime.hitl;
|
||||
|
||||
/**
|
||||
* Agent HITL pending 状态。
|
||||
*/
|
||||
public enum AgentHitlPendingStatus {
|
||||
/**
|
||||
* 等待审批。
|
||||
*/
|
||||
PENDING,
|
||||
|
||||
/**
|
||||
* 已批准。
|
||||
*/
|
||||
APPROVED,
|
||||
|
||||
/**
|
||||
* 已拒绝。
|
||||
*/
|
||||
REJECTED,
|
||||
|
||||
/**
|
||||
* 已过期。
|
||||
*/
|
||||
EXPIRED,
|
||||
|
||||
/**
|
||||
* 已取消。
|
||||
*/
|
||||
CANCELLED
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 会话已有运行中的请求,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.stream.Collectors;
|
||||
public enum ApprovalResourceType {
|
||||
|
||||
BOT("BOT"),
|
||||
AGENT("AGENT"),
|
||||
WORKFLOW("WORKFLOW"),
|
||||
KNOWLEDGE("KNOWLEDGE");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.stream.Collectors;
|
||||
public enum CategoryResourceType {
|
||||
|
||||
BOT("BOT"),
|
||||
AGENT("AGENT"),
|
||||
PLUGIN("PLUGIN"),
|
||||
WORKFLOW("WORKFLOW"),
|
||||
KNOWLEDGE("KNOWLEDGE"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 知识库绑定';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 运行事件摘要';
|
||||
@@ -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);
|
||||
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal file
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal file
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal file
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal file
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,7 +30,22 @@ const renderableSegments = computed(() => {
|
||||
return [] as ChatTimeAssistantSegment[];
|
||||
}
|
||||
if (props.item.segments.length > 0) {
|
||||
return props.item.segments;
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"chat": "Chat",
|
||||
"agents": "Agent",
|
||||
"bots": "ChatAssistant",
|
||||
"title": "AI",
|
||||
"resources": "Resources",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"chat": "聊天",
|
||||
"agents": "智能体",
|
||||
"bots": "聊天助手",
|
||||
"title": "AI能力",
|
||||
"resources": "素材库",
|
||||
|
||||
30
easyflow-ui-admin/app/src/router/routes/modules/agent.ts
Normal file
30
easyflow-ui-admin/app/src/router/routes/modules/agent.ts
Normal 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;
|
||||
@@ -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('准备调用工具');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '请求失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal file
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal 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();
|
||||
}
|
||||
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
356
easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue
Normal file
356
easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue
Normal 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
Reference in New Issue
Block a user