diff --git a/easyflow-api/easyflow-api-admin/pom.xml b/easyflow-api/easyflow-api-admin/pom.xml index 621bed7..8ab3cbd 100644 --- a/easyflow-api/easyflow-api-admin/pom.xml +++ b/easyflow-api/easyflow-api-admin/pom.xml @@ -20,6 +20,10 @@ tech.easyflow easyflow-module-ai + + tech.easyflow + easyflow-module-agent + tech.easyflow easyflow-module-chatlog diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentCategoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentCategoryController.java new file mode 100644 index 0000000..38905ba --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentCategoryController.java @@ -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 { + + @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> 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 ids) { + for (Serializable id : ids) { + QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id); + List agents = agentMapper.selectListByQuery(queryWrapper); + if (!agents.isEmpty()) { + throw new BusinessException("请先删除该分类下的所有 Agent"); + } + } + return super.onRemoveBefore(ids); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java new file mode 100644 index 0000000..4fb9efa --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java @@ -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 { + + @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 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 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 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 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 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 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> updateToolBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId, + @JsonBody("bindings") List 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> updateKnowledgeBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId, + @JsonBody("bindings") List bindings) { + return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings)); + } + + /** + * 提交发布审批。 + * + * @param id Agent ID + * @return 审批实例 ID + */ + @PostMapping("/submitPublishApproval") + @SaCheckPermission("/api/v1/agent/save") + public Result 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 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 submitDeleteApproval(@JsonBody("id") BigInteger id) { + return buildApprovalActionResult(agentPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除"); + } + + @Override + protected Result onRemoveBefore(Collection 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 queryPage(Page page, QueryWrapper queryWrapper) { + if (!applyCategoryPermission(queryWrapper)) { + return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L); + } + applyPublishedOnlyFilter(queryWrapper); + Page 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 buildApprovalActionResult(ApprovalActionResult actionResult, + String approvalMessage, + String directMessage) { + return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId()); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java new file mode 100644 index 0000000..24d3b39 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java @@ -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 generateId() { + long nextId = new SnowFlakeIDKeyGenerator().nextId(); + return Result.ok(String.valueOf(nextId)); + } + + /** + * 查询 Agent 会话分页。 + * + * @param agentId Agent ID,可为空 + * @param query 分页参数 + * @return 会话分页 + */ + @GetMapping("/list") + public Result list(BigInteger agentId, ChatPageQuery query) { + return Result.ok(agentSessionService.queryCurrentUserSessions(currentAccount(), agentId, query)); + } + + /** + * 查询 Agent 会话详情。 + * + * @param sessionId 会话 ID + * @return 会话详情 + */ + @GetMapping("/{sessionId}") + public Result detail(@PathVariable BigInteger sessionId) { + return Result.ok(agentSessionService.getCurrentUserSession(currentAccount(), sessionId)); + } + + /** + * 查询 Agent 会话消息。 + * + * @param sessionId 会话 ID + * @param query 分页参数 + * @return 消息分页 + */ + @GetMapping("/{sessionId}/messages") + public Result messages(@PathVariable BigInteger sessionId, ChatPageQuery query) { + return Result.ok(agentSessionService.queryCurrentUserMessages(currentAccount(), sessionId, query)); + } + + /** + * 查询 Agent 完整会话。 + * + * @param sessionId 会话 ID + * @return 完整会话 + */ + @GetMapping("/{sessionId}/conversation") + public Result conversation(@PathVariable BigInteger sessionId) { + return Result.ok(agentSessionService.getCurrentUserConversation(currentAccount(), sessionId)); + } + + /** + * 重命名 Agent 会话。 + * + * @param sessionId 会话 ID + * @param title 新标题 + * @return 操作结果 + */ + @PostMapping("/{sessionId}/rename") + public Result 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 delete(@PathVariable BigInteger sessionId) { + agentSessionService.deleteCurrentUserSession(currentAccount(), sessionId); + return Result.ok(); + } + + private LoginAccount currentAccount() { + return SaTokenUtil.getLoginAccount(); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java index dcd2dcb..99cb715 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java @@ -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 agents) { + fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName); + } + /** * 通用的创建人名称填充逻辑。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java new file mode 100644 index 0000000..7a0a6cc --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java @@ -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 availabilityMap = resolveAgentAvailability(page.getRecords()); + ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage(); + result.setTotal(page.getTotal()); + result.setPageNumber(page.getPageNumber()); + result.setPageSize(page.getPageSize()); + List 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 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 resolveAgentAvailability(List sessions) { + Map result = new LinkedHashMap<>(); + if (sessions == null || sessions.isEmpty()) { + return result; + } + Set agentIds = new LinkedHashSet<>(); + for (ChatSessionSummary session : sessions) { + if (session != null && session.getAssistantId() != null) { + agentIds.add(session.getAssistantId()); + } + } + if (agentIds.isEmpty()) { + return result; + } + List agents = agentService.list(QueryWrapper.create().in("id", agentIds)); + Map 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 resolveBoundKnowledges(Agent displayAgent) { + if (displayAgent == null || displayAgent.getKnowledgeBindings() == null || displayAgent.getKnowledgeBindings().isEmpty()) { + return List.of(); + } + List knowledgeIds = displayAgent.getKnowledgeBindings().stream() + .map(binding -> binding.getKnowledgeId()) + .filter(Objects::nonNull) + .toList(); + if (knowledgeIds.isEmpty()) { + return List.of(); + } + List collections = documentCollectionService.listByIds(knowledgeIds); + Map collectionMap = new LinkedHashMap<>(); + for (DocumentCollection collection : collections) { + collectionMap.put(collection.getId(), collection); + } + List 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) { + } +} diff --git a/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/RedisLockExecutor.java b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/RedisLockExecutor.java index c0d6bfa..78e0ff0 100644 --- a/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/RedisLockExecutor.java +++ b/easyflow-commons/easyflow-common-cache/src/main/java/tech/easyflow/common/cache/RedisLockExecutor.java @@ -20,6 +20,7 @@ public class RedisLockExecutor { private static final long RETRY_INTERVAL_MILLIS = 50L; private static final DefaultRedisScript RELEASE_LOCK_SCRIPT; + private static final DefaultRedisScript 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 executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Supplier task) { + LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout); + try { + return task.get(); + } finally { + handle.release(); + } + } + + /** + * 获取显式释放的分布式锁句柄。 + * + *

长连接、SSE 或异步任务不能使用 callback 型锁,否则 callback 返回后锁会被提前释放。 + * 该方法返回 owner token 绑定的句柄,由调用方在运行完成、失败或取消时显式释放。

+ * + * @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(); + } + } } diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/ChatType.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/ChatType.java index 79807a9..7ee9f94 100644 --- a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/ChatType.java +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/ChatType.java @@ -6,6 +6,8 @@ public enum ChatType { TOOL_CALL, TOOL_RESULT, STATUS, + CITATIONS, + SESSION_CREATED, ERROR, FORM_REQUEST, FORM_CANCEL, diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java index ccb3a57..f1c3e43 100644 --- a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java @@ -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; + } } diff --git a/easyflow-modules/easyflow-module-agent/pom.xml b/easyflow-modules/easyflow-module-agent/pom.xml new file mode 100644 index 0000000..9fed6cb --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + tech.easyflow + easyflow-modules + ${revision} + + + easyflow-module-agent + easyflow-module-agent + + + + tech.easyflow + easyflow-module-ai + + + tech.easyflow + easyflow-module-chatlog + + + tech.easyflow + easyflow-module-approval + + + tech.easyflow + easyflow-module-system + + + tech.easyflow + easyflow-common-chat-protocol + + + tech.easyflow + easyflow-common-cache + + + tech.easyflow + easyflow-common-web + + + tech.easyflow + easyflow-common-satoken + + + com.mybatis-flex + mybatis-flex-spring-boot3-starter + + + com.easyagents + easy-agents-agent-runtime + + + org.springframework.boot + spring-boot-starter-web + + + junit + junit + ${junit.version} + test + + + diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java new file mode 100644 index 0000000..a913a3e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentRuntimeProperties.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentRuntimeProperties.java new file mode 100644 index 0000000..350194c --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentRuntimeProperties.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java new file mode 100644 index 0000000..e4db2af --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java @@ -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 modelConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map generationConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map promptConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map memoryConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map executionConfigJson = new LinkedHashMap<>(); + private Integer status; + private String visibilityScope; + private String publishStatus; + private BigInteger currentApprovalInstanceId; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map 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 toolBindings; + @Column(ignore = true) + private List 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 getModelConfigJson() { return modelConfigJson; } + public void setModelConfigJson(Map modelConfigJson) { this.modelConfigJson = modelConfigJson == null ? new LinkedHashMap<>() : modelConfigJson; } + public Map getGenerationConfigJson() { return generationConfigJson; } + public void setGenerationConfigJson(Map generationConfigJson) { this.generationConfigJson = generationConfigJson == null ? new LinkedHashMap<>() : generationConfigJson; } + public Map getPromptConfigJson() { return promptConfigJson; } + public void setPromptConfigJson(Map promptConfigJson) { this.promptConfigJson = promptConfigJson == null ? new LinkedHashMap<>() : promptConfigJson; } + public Map getMemoryConfigJson() { return memoryConfigJson; } + public void setMemoryConfigJson(Map memoryConfigJson) { this.memoryConfigJson = memoryConfigJson == null ? new LinkedHashMap<>() : memoryConfigJson; } + public Map getExecutionConfigJson() { return executionConfigJson; } + public void setExecutionConfigJson(Map 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 getPublishedSnapshotJson() { return publishedSnapshotJson; } + public void setPublishedSnapshotJson(Map 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 getToolBindings() { return toolBindings; } + public void setToolBindings(List toolBindings) { this.toolBindings = toolBindings; } + public List getKnowledgeBindings() { return knowledgeBindings; } + public void setKnowledgeBindings(List knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentCategory.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentCategory.java new file mode 100644 index 0000000..0d0ff1c --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentCategory.java @@ -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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentHitlPending.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentHitlPending.java new file mode 100644 index 0000000..675e91c --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentHitlPending.java @@ -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 toolInputJson = new LinkedHashMap<>(); + private String status; + private String rejectReason; + private Date expiresAt; + private Date consumedAt; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map 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 getToolInputJson() { return toolInputJson; } + public void setToolInputJson(Map 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 getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map 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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentKnowledgeBinding.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentKnowledgeBinding.java new file mode 100644 index 0000000..d3a391c --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentKnowledgeBinding.java @@ -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 optionsJson = new LinkedHashMap<>(); + @Column(ignore = true) + private Map resourceSnapshot = new LinkedHashMap<>(); + @Column(ignore = true) + private Map 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 getOptionsJson() { return optionsJson; } + public void setOptionsJson(Map optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; } + public Map getResourceSnapshot() { return resourceSnapshot; } + public void setResourceSnapshot(Map resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; } + public Map getResourceSummary() { return resourceSummary; } + public void setResourceSummary(Map 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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentRunEventRecord.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentRunEventRecord.java new file mode 100644 index 0000000..3f579bb --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentRunEventRecord.java @@ -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 payloadJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map 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 getPayloadJson() { return payloadJson; } + public void setPayloadJson(Map payloadJson) { this.payloadJson = payloadJson == null ? new LinkedHashMap<>() : payloadJson; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map 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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSession.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSession.java new file mode 100644 index 0000000..9124d1d --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSession.java @@ -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 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 getStateJson() { return stateJson; } + public void setStateJson(Map 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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentToolBinding.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentToolBinding.java new file mode 100644 index 0000000..d187201 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentToolBinding.java @@ -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 hitlConfigJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map optionsJson = new LinkedHashMap<>(); + @Column(ignore = true) + private Map resourceSnapshot = new LinkedHashMap<>(); + @Column(ignore = true) + private Map 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 getHitlConfigJson() { return hitlConfigJson; } + public void setHitlConfigJson(Map hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; } + public Map getOptionsJson() { return optionsJson; } + public void setOptionsJson(Map optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; } + public Map getResourceSnapshot() { return resourceSnapshot; } + public void setResourceSnapshot(Map resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; } + public Map getResourceSummary() { return resourceSummary; } + public void setResourceSummary(Map 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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/enums/AgentToolType.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/enums/AgentToolType.java new file mode 100644 index 0000000..d0e27a8 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/enums/AgentToolType.java @@ -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)); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentCategoryMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentCategoryMapper.java new file mode 100644 index 0000000..cced060 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentCategoryMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentHitlPendingMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentHitlPendingMapper.java new file mode 100644 index 0000000..69d926a --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentHitlPendingMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentKnowledgeBindingMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentKnowledgeBindingMapper.java new file mode 100644 index 0000000..cb7b814 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentKnowledgeBindingMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentMapper.java new file mode 100644 index 0000000..79e30fe --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentRunEventRecordMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentRunEventRecordMapper.java new file mode 100644 index 0000000..01f551b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentRunEventRecordMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSessionMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSessionMapper.java new file mode 100644 index 0000000..13a481e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSessionMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentToolBindingMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentToolBindingMapper.java new file mode 100644 index 0000000..a406729 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentToolBindingMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java new file mode 100644 index 0000000..eb56432 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java @@ -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 { + + 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 getPublishedSnapshot(Agent resource) { + return resource.getPublishedSnapshotJson(); + } + + @Override + protected Map 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 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"; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java new file mode 100644 index 0000000..6765052 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java @@ -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() + ); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatRequest.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatRequest.java new file mode 100644 index 0000000..ca1871e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatRequest.java @@ -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; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDefinitionCompiler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDefinitionCompiler.java new file mode 100644 index 0000000..df5e6ed --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDefinitionCompiler.java @@ -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 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 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 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 config) { + AgentMemoryPolicy policy = new AgentMemoryPolicy(); + policy.setType(memoryTypeValue(config, "type")); + Map 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 specs = new ArrayList<>(); + Map 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 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 sanitizedHitlMetadata(Map config) { + Map 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 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 specs = new ArrayList<>(); + Map 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 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 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> summarizeDocuments(List documents) { + List> summaries = new ArrayList<>(); + if (documents == null) { + return summaries; + } + for (Document document : documents) { + Map 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 metadata, String key) { + Object value = metadata == null ? null : metadata.get(key); + return value == null ? null : String.valueOf(value); + } + + private Map toSchema(Parameter[] parameters) { + Map schema = new LinkedHashMap<>(); + Map properties = new LinkedHashMap<>(); + List 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 parameterSchema(Parameter parameter) { + Map 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 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 firstArrayItemSchema(List 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 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 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 map, String key, String defaultValue) { + Object value = map == null ? null : map.get(key); + return value == null ? defaultValue : String.valueOf(value); + } + + private Integer intValue(Map 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 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 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 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 mapValue(Map map, String key) { + Object value = map == null ? null : map.get(key); + if (value == null) { + return new LinkedHashMap<>(); + } + if (value instanceof Map rawMap) { + Map result = new LinkedHashMap<>(); + rawMap.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue)); + return result; + } + throw new BusinessException("Agent 配置字段必须是对象:" + key); + } + + private Map stringMapValue(Map map, String key) { + Map rawMap = mapValue(map, key); + Map result = new LinkedHashMap<>(); + rawMap.forEach((rawKey, rawValue) -> { + if (rawValue != null) { + result.put(rawKey, String.valueOf(rawValue)); + } + }); + return result; + } + +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java new file mode 100644 index 0000000..4e74003 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java @@ -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 toolBindings; + private List 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 getToolBindings() { + return toolBindings; + } + + /** + * 设置工具绑定快照。 + * + * @param toolBindings 工具绑定快照 + */ + public void setToolBindings(List toolBindings) { + this.toolBindings = toolBindings; + } + + /** + * 获取知识库绑定快照。 + * + * @return 知识库绑定快照 + */ + public List getKnowledgeBindings() { + return knowledgeBindings; + } + + /** + * 设置知识库绑定快照。 + * + * @param knowledgeBindings 知识库绑定快照 + */ + public void setKnowledgeBindings(List 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; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java new file mode 100644 index 0000000..932a5f7 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java @@ -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 runs = new ConcurrentHashMap<>(); + private final Map sessionRuns = new ConcurrentHashMap<>(); + private final Map resumeTokenIndex = new ConcurrentHashMap<>(); + private final Map> requestTokens = new ConcurrentHashMap<>(); + private final Map 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); + } + + /** + * 取消并移除指定会话当前活跃运行。 + * + *

草稿试运行清理会删除 AgentScope session。若同一会话仍有 SSE 运行中, + * 必须先取消 runtime 订阅并关闭 SSE,避免旧运行继续向已清空的会话写入状态。

+ * + * @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 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 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 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 eventConsumer; + private final Consumer errorConsumer; + private final Runnable completionHandler; + private final AtomicBoolean suspended = new AtomicBoolean(false); + private final AtomicReference 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 eventConsumer, + Consumer 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 eventConsumer() { + return eventConsumer; + } + + /** + * 获取错误处理器。 + * + * @return 错误处理器 + */ + public Consumer 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。 + * + *

该方法用于调用方主动清理会话的场景。它不通过 runtime 事件链发送取消事件, + * 因为调用方此时已经明确要求丢弃当前草稿会话。

+ */ + 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); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java new file mode 100644 index 0000000..3d2a24b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java @@ -0,0 +1,1236 @@ +package tech.easyflow.agent.runtime; + +import com.easyagents.agent.runtime.AgentInitRequest; +import com.easyagents.agent.runtime.AgentRuntime; +import com.easyagents.agent.runtime.AgentRuntimeContext; +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.message.AgentKnowledgeReference; +import com.easyagents.agent.runtime.message.AgentMessage; +import com.easyagents.agent.runtime.message.AgentMessageRole; +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.enums.AgentToolType; +import tech.easyflow.agent.runtime.event.AgentRunEventRecorder; +import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; +import tech.easyflow.agent.runtime.lock.AgentRunLock; +import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.ai.entity.DocumentCollection; +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.rag.KnowledgeRetrievalModes; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +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.*; +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.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Agent 管理端运行服务。 + */ +@Service +public class AgentRunService { + + private static final Logger LOG = LoggerFactory.getLogger(AgentRunService.class); + private static final String ASSISTANT_CODE = "AGENT"; + private static final String DRAFT_ASSISTANT_CODE = "AGENT_DRAFT"; + + @Resource + private AgentService agentService; + @Resource + private AgentDefinitionCompiler agentDefinitionCompiler; + @Resource + private AgentRuntimeFactory agentRuntimeFactory; + @Resource + private AgentSessionStore agentSessionStore; + @Resource + private EasyFlowAgentSessionStore easyFlowAgentSessionStore; + @Resource + private AgentRunRegistry agentRunRegistry; + @Resource + private AgentRunLock agentRunLock; + @Resource + private AgentHitlPendingService agentHitlPendingService; + @Resource + private AgentRunEventRecorder agentRunEventRecorder; + @Resource + private ChatRuntimeManager chatRuntimeManager; + @Resource + private ChatSessionQueryService chatSessionQueryService; + @Resource(name = "sseThreadPool") + private ThreadPoolTaskExecutor threadPoolTaskExecutor; + @Resource + private ResourceAccessService resourceAccessService; + @Resource + private WorkflowService workflowService; + @Resource + private PluginItemService pluginItemService; + @Resource + private McpService mcpService; + @Resource + private DocumentCollectionService documentCollectionService; + + /** + * 启动 Agent 聊天。 + * + * @param chatRequest 聊天请求 + * @return SSE Emitter + */ + public SseEmitter chat(AgentChatRequest chatRequest) { + // 判定Agent是否对当前用户可用 + validateChatRequest(chatRequest); + LoginAccount account = requireCurrentLoginAccount(); + Agent liveAgent = agentService.getById(chatRequest.getAgentId()); + if (liveAgent == null) { + throw new BusinessException("Agent 不存在"); + } + resourceAccessService.assertAccess(CategoryResourceType.AGENT, liveAgent, ResourceAction.USE, "无权限运行该 Agent"); + assertAgentRunnable(liveAgent); + // 创建会话 ID + BigInteger sessionId = chatRequest.getSessionId() == null + ? BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId()) + : chatRequest.getSessionId(); + ChatSessionSummary existingSession = resolveExistingSession(account, sessionId, chatRequest.getAgentId()); + // 获取 Agent 发布快照 + Agent agent = agentService.getPublishedView(chatRequest.getAgentId()); + String requestId = UUID.randomUUID().toString(); + String traceId = UUID.randomUUID().toString(); + // 组建会话上下文必要信息 + ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, sessionId, chatRequest.getPrompt(), account); + applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession); + // 执行对话 + return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(), + ASSISTANT_CODE, chatContext, true); + } + + /** + * 启动 Agent 草稿态纯文本试用。 + * + * @param draftRequest 草稿试用请求 + * @return SSE Emitter + */ + public SseEmitter chatDraft(AgentDraftChatRequest draftRequest) { + validateDraftChatRequest(draftRequest); + LoginAccount account = requireCurrentLoginAccount(); + Agent agent = buildDraftAgent(draftRequest, account); + validateDraftResources(agent); + + String runtimeSessionId = draftRequest.getSessionId(); + if (runtimeSessionId == null || runtimeSessionId.isBlank()) { + runtimeSessionId = "agent-draft-" + new SnowFlakeIDKeyGenerator().nextId(); + } + BigInteger chatSessionId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId()); + String requestId = UUID.randomUUID().toString(); + String traceId = UUID.randomUUID().toString(); + ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE); + return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId, + DRAFT_ASSISTANT_CODE, chatContext, false); + } + + private SseEmitter run(Agent agent, + String prompt, + String requestId, + String traceId, + String runtimeSessionId, + String assistantCode, + ChatRuntimeContext chatContext, + boolean persistChatlog) { + ChatSseEmitter chatSseEmitter = new ChatSseEmitter(); + // 获取会话锁 + AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId); + boolean submitted = false; + try { + if (persistChatlog) { + // 持久化会话初始信息 + chatRuntimeManager.prepareSession(chatContext); + if (!sendSessionCreated(chatSseEmitter, chatContext.getSessionId())) { + chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消")); + return chatSseEmitter.getEmitter(); + } + chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt)); + } + threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId, + assistantCode, chatContext, chatSseEmitter, persistChatlog, lockHandle)); + submitted = true; + return chatSseEmitter.getEmitter(); + } finally { + // 释放锁 + if (!submitted && lockHandle != null) { + lockHandle.release(); + } + } + } + + /** + * 清理草稿试运行会话。 + * + * @param sessionId 草稿会话 ID + */ + public void clearDraftSession(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + throw new BusinessException("Agent 草稿会话 ID 不能为空"); + } + if (!sessionId.startsWith("agent-draft-")) { + throw new BusinessException("仅允许清理 Agent 草稿试运行会话"); + } + LoginAccount account = requireCurrentLoginAccount(); + agentRunRegistry.cancelSession(sessionId, account.getId() == null ? null : account.getId().toString()); + agentSessionStore.delete(sessionId); + if (agentHitlPendingService != null) { + agentHitlPendingService.deleteByRuntimeSessionId(sessionId); + } + } + + /** + * 批准工具执行。 + * + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + */ + public void approve(String requestId, String resumeToken) { + LoginAccount account = requireCurrentLoginAccount(); + String userId = account.getId() == null ? null : account.getId().toString(); + agentRunRegistry.approve(requestId, resumeToken, userId, + () -> agentHitlPendingService.approve(resumeToken, account.getId())); + } + + /** + * 拒绝工具执行。 + * + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param reason 拒绝原因 + */ + public void reject(String requestId, String resumeToken, String reason) { + LoginAccount account = requireCurrentLoginAccount(); + String userId = account.getId() == null ? null : account.getId().toString(); + agentRunRegistry.reject(requestId, resumeToken, userId, reason, + () -> agentHitlPendingService.reject(resumeToken, account.getId(), reason)); + } + + private void startRuntime(Agent agent, + String prompt, + String requestId, + String traceId, + String runtimeSessionId, + String assistantCode, + ChatRuntimeContext chatContext, + ChatSseEmitter chatSseEmitter, + boolean persistChatlog, + AgentRunLock.Handle initialLockHandle) { + AtomicBoolean finished = new AtomicBoolean(false); + StringBuilder answer = new StringBuilder(); + ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator(); + // 注册 emit 服务 + registerEmitterCancellation(requestId, chatSseEmitter, chatContext, answer, + assistantAccumulator, finished, persistChatlog); + AgentRunLock.Handle lockHandle = initialLockHandle; + try { + bindAgentSession(agent, runtimeSessionId, chatContext); + AgentRuntimeBundle bundle = agentDefinitionCompiler.compile(agent); + AgentRuntime runtime = agentRuntimeFactory.create(); + // 会话初始化请求 + AgentInitRequest request = new AgentInitRequest(); + request.setSessionId(runtimeSessionId); + request.setAgentDefinition(bundle.getDefinition()); + request.setRuntimeContext(buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId)); + request.setToolInvokers(bundle.getToolInvokers()); + request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers()); + request.setSessionStore(agentSessionStore); + request.getMetadata().put("assistantCode", assistantCode); + runtime.init(request); + // 注册会话运行时管理 + AgentRunRegistry.RunOwner owner = new AgentRunRegistry.RunOwner( + agent.getId() == null ? null : agent.getId().toString(), + runtimeSessionId, + chatContext.getUserId() == null ? null : chatContext.getUserId().toString() + ); + AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext( + requestId, + runtimeSessionId, + runtime, + chatSseEmitter, + chatContext, + answer, + assistantAccumulator, + finished, + persistChatlog, + owner, + lockHandle, + event -> handleRuntimeEvent(event, requestId, chatSseEmitter, answer, + assistantAccumulator, chatContext, finished, persistChatlog), + error -> handleRuntimeError(error, requestId, chatSseEmitter, chatContext, finished, persistChatlog), + () -> finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + assistantAccumulator, finished, persistChatlog) + ); + agentRunRegistry.register(runContext); + lockHandle = null; + if (finished.get()) { + runContext.cancel(); + agentRunRegistry.remove(requestId); + return; + } + agentRunRegistry.bindSubscription(requestId, + runtime.stream(AgentMessage.text(AgentMessageRole.USER, prompt)).subscribe( + runContext.eventConsumer(), + runContext.errorConsumer(), + runContext.completionHandler() + )); + } catch (Exception e) { + AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); + if (runContext != null) { + runContext.cancel(); + } + agentRunRegistry.remove(requestId); + if (lockHandle != null) { + lockHandle.release(); + } + handleRuntimeError(e, requestId, chatSseEmitter, chatContext, finished, persistChatlog); + } + } + + private void bindAgentSession(Agent agent, String runtimeSessionId, ChatRuntimeContext chatContext) { + if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) { + return; + } + easyFlowAgentSessionStore.bindSession(new EasyFlowAgentSessionStore.AgentSessionMetadata( + runtimeSessionId, + runtimeSessionId, + agent == null ? null : agent.getId(), + chatContext == null ? null : chatContext.getSessionId(), + chatContext == null ? null : chatContext.getTenantId(), + chatContext == null ? null : chatContext.getUserId() + )); + } + + private AgentRunLock.Handle acquireRunLock(Agent agent, String runtimeSessionId) { + if (agentRunLock == null) { + return null; + } + return agentRunLock.acquire(agent == null ? null : agent.getId(), runtimeSessionId); + } + + private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) { + if (agentRunEventRecorder != null) { + agentRunEventRecorder.record(requestId, chatContext, event); + } + } + + private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) { + if (agentHitlPendingService != null) { + agentHitlPendingService.recordApprovalRequired(requestId, chatContext, event); + } + } + + private void cancelPending(String requestId, String reason) { + if (agentHitlPendingService != null) { + agentHitlPendingService.cancelByRequestId(requestId, reason); + } + } + + private void registerEmitterCancellation(String requestId, + ChatSseEmitter chatSseEmitter, + ChatRuntimeContext chatContext, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + AtomicBoolean finished, + boolean persistChatlog) { + Runnable cancelTask = () -> cancelDisconnectedRun(requestId, chatContext, answer, + assistantAccumulator, finished, persistChatlog); + SseEmitter emitter = chatSseEmitter.getEmitter(); + emitter.onCompletion(cancelTask); + emitter.onTimeout(cancelTask); + emitter.onError(error -> cancelTask.run()); + } + + private void cancelDisconnectedRun(String requestId, + ChatRuntimeContext chatContext, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + AtomicBoolean finished, + boolean persistChatlog) { + if (!finished.compareAndSet(false, true)) { + return; + } + AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); + if (runContext != null) { + try { + runContext.cancel(); + } catch (Exception e) { + LOG.warn("Cancel disconnected Agent run failed, requestId={}, message={}", requestId, e.getMessage(), e); + } + } + agentRunRegistry.remove(requestId); + cancelPending(requestId, "客户端连接已断开,Agent 运行已取消"); + if (!persistChatlog) { + return; + } + try { + recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, "客户端连接已断开,Agent 运行已取消"); + chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消")); + } catch (Exception e) { + LOG.warn("Record disconnected Agent run failure failed, requestId={}, message={}", requestId, e.getMessage(), e); + } + } + + private void handleRuntimeEvent(AgentRuntimeEvent event, + String requestId, + ChatSseEmitter chatSseEmitter, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + ChatRuntimeContext chatContext, + AtomicBoolean finished, + boolean persistChatlog) { + if (event == null || event.getEventType() == null) { + return; + } + recordRuntimeEvent(requestId, chatContext, event); + if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) { + String text = stringPayload(event, "text"); + if (text != null) { + answer.append(text); + assistantAccumulator.appendContent(text); + LOG.debug("Agent runtime message delta, requestId={}, deltaLength={}, answerLength={}, delta={}", + requestId, text.length(), answer.length(), toVisibleLogText(text)); + if (!sendEnvelope(chatSseEmitter, ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", text, "role", "assistant"))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) { + Map payload = new LinkedHashMap<>(); + String reasoning = firstText(stringPayload(event, "reasoning"), stringPayload(event, "text")); + assistantAccumulator.appendReasoning(reasoning); + payload.put("reasoning", reasoning); + payload.put("delta", reasoning); + if (!sendEnvelope(chatSseEmitter, ChatDomain.LLM, ChatType.THINKING, payload)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { + String resumeToken = stringPayload(event, "resumeToken"); + agentRunRegistry.registerResumeToken(requestId, resumeToken); + recordApprovalRequired(requestId, chatContext, event); + if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) { + LOG.info("Agent runtime tool call, requestId={}, toolCallId={}, payload={}, metadata={}", + requestId, event.getToolCallId(), event.getPayload(), event.getMetadata()); + assistantAccumulator.appendToolCall( + firstText(event.getToolCallId(), stringPayload(event, "toolCallId")), + firstText(stringPayload(event, "toolName"), stringPayload(event, "name")), + firstNonNull(event.getPayload().get("input"), event.getPayload().get("toolInput")) + ); + if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, buildToolEventPayload(event))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) { + LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, payload={}, metadata={}", + requestId, event.getToolCallId(), event.getPayload(), event.getMetadata()); + assistantAccumulator.appendToolResult( + firstText(event.getToolCallId(), stringPayload(event, "toolCallId")), + firstText(stringPayload(event, "toolName"), stringPayload(event, "name")), + firstNonNull(firstNonNull(event.getPayload().get("output"), event.getPayload().get("result")), + event.getPayload().get("text")) + ); + if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_RESULT, buildToolEventPayload(event))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) { + LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}", + requestId, event.getPayload(), event.getMetadata()); + if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED + || event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) { + LOG.info("Agent runtime memory compression, requestId={}, eventType={}, payload={}, metadata={}", + requestId, event.getEventType(), event.getPayload(), event.getMetadata()); + if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, event.getPayload())) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.SUSPENDED) { + LOG.info("Agent runtime suspended, requestId={}, payload={}, metadata={}", + requestId, event.getPayload(), event.getMetadata()); + if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, buildSuspendedStatusPayload(event))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + return; + } + AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); + if (runContext != null) { + runContext.markSuspended(); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.COMPLETED) { + String finalText = stringPayload(event, "text"); + if (finalText != null && !finalText.isBlank()) { + answer.setLength(0); + answer.append(finalText); + } + List> citations = buildKnowledgeCitationPayload(event); + if (!citations.isEmpty()) { + if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog); + return; + } + } + finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + assistantAccumulator, finished, persistChatlog, citations); + return; + } + if (event.getEventType() == AgentRuntimeEventType.CANCELLED) { + handleRuntimeCancelled(event, requestId, chatSseEmitter, chatContext, answer, + assistantAccumulator, finished, persistChatlog); + return; + } + if (event.getEventType() == AgentRuntimeEventType.FAILED) { + handleRuntimeError(new BusinessException(errorMessage(event)), requestId, chatSseEmitter, chatContext, finished, persistChatlog); + } + } + + private void finishIfNeeded(String requestId, + ChatSseEmitter chatSseEmitter, + ChatRuntimeContext chatContext, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + AtomicBoolean finished, + boolean persistChatlog) { + finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + assistantAccumulator, finished, persistChatlog, List.of()); + } + + private void finishIfNeeded(String requestId, + ChatSseEmitter chatSseEmitter, + ChatRuntimeContext chatContext, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + AtomicBoolean finished, + boolean persistChatlog, + List> citations) { + AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); + if (runContext != null && runContext.isSuspended()) { + LOG.info("Agent runtime stream suspended, keep SSE and runtime active, requestId={}", requestId); + return; + } + if (!finished.compareAndSet(false, true)) { + return; + } + agentRunRegistry.remove(requestId); + String finalAnswer = answer.toString(); + LOG.info("Agent runtime final answer, requestId={}, sessionId={}, answerLength={}, answer={}", + requestId, chatContext.getSessionId(), finalAnswer.length(), toVisibleLogText(finalAnswer)); + if (persistChatlog) { + chatRuntimeManager.recordAssistantCompleted(chatContext, + buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations)); + chatRuntimeManager.recordCompleted(chatContext); + } + sendDone(chatSseEmitter); + } + + private void handleRuntimeError(Throwable error, + String requestId, + ChatSseEmitter chatSseEmitter, + ChatRuntimeContext chatContext, + AtomicBoolean finished, + boolean persistChatlog) { + if (!finished.compareAndSet(false, true)) { + return; + } + agentRunRegistry.remove(requestId); + cancelPending(requestId, safeErrorMessage(error)); + Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error; + LOG.error("Agent run failed, requestId={}, message={}, exception={}", requestId, + safeError.getMessage(), safeError.toString(), safeError); + if (persistChatlog) { + chatRuntimeManager.recordFailure(chatContext, safeError); + } + Map payload = new LinkedHashMap<>(); + payload.put("message", safeError.getMessage() == null ? "Agent 运行失败" : safeError.getMessage()); + payload.put("code", "AGENT_RUN_FAILED"); + sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.ERROR, payload); + chatSseEmitter.complete(); + } + + private String safeErrorMessage(Throwable error) { + if (error == null || error.getMessage() == null || error.getMessage().isBlank()) { + return "Agent 运行失败"; + } + return error.getMessage(); + } + + private void handleRuntimeCancelled(AgentRuntimeEvent event, + String requestId, + ChatSseEmitter chatSseEmitter, + ChatRuntimeContext chatContext, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + AtomicBoolean finished, + boolean persistChatlog) { + if (!finished.compareAndSet(false, true)) { + return; + } + agentRunRegistry.remove(requestId); + String reason = errorMessage(event); + cancelPending(requestId, reason); + LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason); + if (persistChatlog) { + recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, reason); + chatRuntimeManager.recordFailure(chatContext, new BusinessException(reason)); + } + Map payload = new LinkedHashMap<>(); + payload.put("statusKey", "agent-cancelled"); + payload.put("status", "cancelled"); + payload.put("label", "已取消"); + payload.put("message", reason); + sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, payload); + sendDone(chatSseEmitter); + } + + /** + * 保存 Agent 取消前已经生成的 assistant 内容。 + * + *

用户主动中止或连接断开时,运行不会进入正常 COMPLETED 分支,但前端历史恢复仍需要看到 + * 中止前已经流出的模型输出。没有正文、reasoning 或工具链内容时不写空 assistant 消息。

+ * + * @param context 聊天运行上下文 + * @param answer 已累计的 assistant 正文 + * @param assistantAccumulator assistant 结构化累计器 + * @param reason 取消原因 + */ + private void recordPartialAssistantIfPresent(ChatRuntimeContext context, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + String reason) { + String partialAnswer = answer == null ? "" : answer.toString(); + if (partialAnswer.isBlank() && !hasAssistantPayload(assistantAccumulator)) { + return; + } + chatRuntimeManager.recordAssistantCompleted(context, + buildAssistantRuntimeMessage(context, partialAnswer, assistantAccumulator, List.of())); + LOG.info("Agent partial answer persisted after cancellation, sessionId={}, answerLength={}, reason={}", + context == null ? null : context.getSessionId(), partialAnswer.length(), reason); + } + + /** + * 判断 assistant 累计器中是否存在可恢复的展示内容。 + * + * @param assistantAccumulator assistant 结构化累计器 + * @return true 表示存在正文、思考过程或工具链内容 + */ + private boolean hasAssistantPayload(ChatAssistantAccumulator assistantAccumulator) { + if (assistantAccumulator == null) { + return false; + } + Map payload = assistantAccumulator.buildPayload(""); + if (payload == null || payload.isEmpty()) { + return false; + } + return payload.values().stream().anyMatch(value -> { + if (value instanceof List list) { + return !list.isEmpty(); + } + if (value instanceof Map map) { + return !map.isEmpty(); + } + return value != null && !String.valueOf(value).isBlank(); + }); + } + + private ChatRuntimeContext buildChatRuntimeContext(Agent agent, BigInteger sessionId, String prompt, LoginAccount account) { + return buildChatRuntimeContext(agent, sessionId, prompt, account, ASSISTANT_CODE); + } + + /** + * 为正式 Agent 聊天设置会话默认标题。 + * + *

正式聊天只在新会话首轮使用用户输入作为默认标题,后续轮次不再自动覆盖,避免用户已重命名的标题被后续消息改写。

+ * + * @param context 聊天运行上下文 + * @param prompt 当前用户输入 + * @param existingSession 已存在的会话摘要 + */ + private void applyFormalSessionTitle(ChatRuntimeContext context, String prompt, ChatSessionSummary existingSession) { + if (context == null) { + return; + } + if (existingSession != null) { + context.setSessionTitle(null); + return; + } + context.setSessionTitle(toSessionTitle(prompt)); + } + + private ChatRuntimeContext buildChatRuntimeContext(Agent agent, + BigInteger sessionId, + String prompt, + LoginAccount account, + String assistantCode) { + ChatRuntimeContext context = new ChatRuntimeContext(); + context.setChannel(ChatChannel.ADMIN); + context.setSessionId(sessionId); + context.setTenantId(account.getTenantId()); + context.setDeptId(account.getDeptId()); + context.setUserId(account.getId()); + context.setUserAccount(account.getLoginName()); + context.setUserName(account.getNickname() == null || account.getNickname().isBlank() ? account.getLoginName() : account.getNickname()); + context.setAssistantId(agent.getId()); + context.setAssistantCode(assistantCode); + context.setAssistantName(agent.getName()); + context.setSessionTitle(toSessionTitle(prompt)); + return context; + } + + /** + * 将用户输入裁剪为会话标题。 + * + * @param prompt 用户输入内容 + * @return 最长 200 字符的会话标题 + */ + private String toSessionTitle(String prompt) { + if (prompt == null) { + return null; + } + return prompt.length() > 200 ? prompt.substring(0, 200) : prompt; + } + + private AgentRuntimeContext buildAgentRuntimeContext(ChatRuntimeContext chatContext, String traceId, String sessionId) { + AgentRuntimeContext context = new AgentRuntimeContext(); + context.setTenantId(chatContext.getTenantId() == null ? null : chatContext.getTenantId().toString()); + context.setUserId(chatContext.getUserId() == null ? null : chatContext.getUserId().toString()); + context.setUserName(chatContext.getUserName()); + context.setSessionId(sessionId); + context.setTraceId(traceId); + return context; + } + + private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context, String prompt) { + ChatRuntimeMessage message = new ChatRuntimeMessage(); + message.setRole("user"); + message.setContentType("TEXT"); + message.setContentText(prompt); + message.setCreatedAt(new Date()); + message.setSenderId(context.getUserId()); + message.setSenderName(context.getUserName()); + return message; + } + + private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, String content) { + return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of()); + } + + private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, + String content, + ChatAssistantAccumulator assistantAccumulator, + List> citations) { + ChatRuntimeMessage message = new ChatRuntimeMessage(); + message.setRole("assistant"); + message.setContentType("TEXT"); + message.setContentText(content); + Map contentPayload = assistantAccumulator == null + ? new LinkedHashMap<>() + : assistantAccumulator.buildPayload(content); + if (citations != null && !citations.isEmpty()) { + contentPayload.put("knowledgeCitations", citations); + } + Map agentResult = new LinkedHashMap<>(); + agentResult.put("text", content); + agentResult.put("reasoning", contentPayload.get("reasoningContent")); + agentResult.put("knowledgeReferences", citations == null ? List.of() : citations); + contentPayload.put("agentResult", agentResult); + message.setContentPayload(contentPayload); + message.setCreatedAt(new Date()); + message.setSenderId(context.getAssistantId()); + message.setSenderName(context.getAssistantName()); + return message; + } + + private boolean sendEnvelope(ChatSseEmitter chatSseEmitter, ChatDomain domain, ChatType type, Object payload) { + ChatEnvelope envelope = new ChatEnvelope<>(); + envelope.setDomain(domain); + envelope.setType(type); + envelope.setPayload(payload); + return chatSseEmitter.send(envelope); + } + + private boolean sendDone(ChatSseEmitter chatSseEmitter) { + ChatEnvelope> envelope = new ChatEnvelope<>(); + envelope.setDomain(ChatDomain.SYSTEM); + envelope.setType(ChatType.DONE); + return chatSseEmitter.sendDone(envelope); + } + + private boolean sendSessionCreated(ChatSseEmitter chatSseEmitter, BigInteger sessionId) { + if (sessionId == null) { + return true; + } + return sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.SESSION_CREATED, + Map.of("sessionId", sessionId.toString())); + } + + private void validateChatRequest(AgentChatRequest request) { + if (request == null || request.getAgentId() == null) { + throw new BusinessException("Agent ID 不能为空"); + } + if (request.getPrompt() == null || request.getPrompt().isBlank()) { + throw new BusinessException("Agent 输入不能为空"); + } + } + + private void validateDraftChatRequest(AgentDraftChatRequest request) { + if (request == null || request.getAgent() == null) { + throw new BusinessException("Agent 草稿不能为空"); + } + if (request.getAgent().getModelId() == null) { + throw new BusinessException("Agent 模型不能为空"); + } + if (request.getPrompt() == null || request.getPrompt().isBlank()) { + throw new BusinessException("Agent 输入不能为空"); + } + } + + private LoginAccount requireCurrentLoginAccount() { + try { + return SaTokenUtil.getLoginAccount(); + } catch (Exception e) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + } + + /** + * 解析并校验当前用户可继续的 Agent 会话。 + * + *

前端可能先生成稳定 sessionId 再发送首条消息。若该 ID 尚未落库,视为新会话并返回 null;若已落库,则校验归属与 Agent 绑定。

+ * + * @param account 当前登录账号 + * @param sessionId 会话 ID + * @param agentId Agent ID + * @return 已存在的会话摘要;不存在时返回 null + */ + private ChatSessionSummary resolveExistingSession(LoginAccount account, BigInteger sessionId, BigInteger agentId) { + if (sessionId == null) { + return null; + } + ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId); + if (summary == null) { + return null; + } + if (Integer.valueOf(1).equals(summary.getIsDeleted())) { + throw new BusinessException("Agent 会话不存在或已删除"); + } + if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) { + throw new BusinessException("当前会话不是 Agent 会话"); + } + if (!Objects.equals(summary.getUserId(), account.getId())) { + throw new BusinessException("无权访问该 Agent 会话"); + } + if (agentId != null && summary.getAssistantId() != null && !Objects.equals(summary.getAssistantId(), agentId)) { + throw new BusinessException("当前会话与所选 Agent 不匹配"); + } + return summary; + } + + private void assertAgentRunnable(Agent agent) { + if (!Integer.valueOf(1).equals(agent.getStatus()) + || PublishStatus.from(agent.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("当前 Agent 已下线或不可继续会话"); + } + } + + private Agent buildDraftAgent(AgentDraftChatRequest request, LoginAccount account) { + Agent incoming = request.getAgent(); + Agent agent = new Agent(); + agent.setId(incoming.getId()); + agent.setTenantId(account.getTenantId()); + agent.setDeptId(account.getDeptId()); + agent.setCreatedBy(account.getId()); + agent.setName(incoming.getName() == null || incoming.getName().isBlank() ? "草稿 Agent" : incoming.getName()); + agent.setDescription(incoming.getDescription()); + agent.setAvatar(incoming.getAvatar()); + agent.setCategoryId(incoming.getCategoryId()); + agent.setModelId(incoming.getModelId()); + agent.setModelConfigJson(incoming.getModelConfigJson()); + agent.setGenerationConfigJson(incoming.getGenerationConfigJson()); + agent.setPromptConfigJson(incoming.getPromptConfigJson()); + agent.setMemoryConfigJson(incoming.getMemoryConfigJson()); + agent.setExecutionConfigJson(incoming.getExecutionConfigJson()); + agent.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus()); + agent.setVisibilityScope(incoming.getVisibilityScope()); + agent.setPublishStatus(incoming.getPublishStatus()); + if (agent.getId() == null) { + agent.setId(BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId())); + } else { + Agent liveAgent = agentService.getById(agent.getId()); + if (liveAgent == null) { + throw new BusinessException("Agent 不存在"); + } + resourceAccessService.assertAccess(CategoryResourceType.AGENT, liveAgent, ResourceAction.MANAGE, "无权限试用该 Agent 草稿"); + } + agent.setToolBindings(copyDraftToolBindings(request.getToolBindings(), agent, account)); + agent.setKnowledgeBindings(copyDraftKnowledgeBindings(request.getKnowledgeBindings(), agent, account)); + return agent; + } + + private List copyDraftToolBindings(List bindings, Agent agent, LoginAccount account) { + List result = new ArrayList<>(); + if (bindings == null || bindings.isEmpty()) { + return result; + } + for (int i = 0; i < bindings.size(); i++) { + AgentToolBinding incoming = bindings.get(i); + if (incoming == null) { + continue; + } + AgentToolBinding binding = new AgentToolBinding(); + binding.setId(incoming.getId()); + binding.setTenantId(account.getTenantId()); + binding.setAgentId(agent.getId()); + binding.setToolType(incoming.getToolType()); + binding.setTargetId(incoming.getTargetId()); + binding.setToolName(incoming.getToolName()); + binding.setEnabled(incoming.getEnabled() == null || incoming.getEnabled()); + binding.setHitlEnabled(Boolean.TRUE.equals(incoming.getHitlEnabled())); + binding.setHitlConfigJson(incoming.getHitlConfigJson()); + binding.setOptionsJson(incoming.getOptionsJson()); + binding.setResourceSnapshot(new LinkedHashMap<>()); + binding.setResourceSummary(new LinkedHashMap<>()); + binding.setSortNo(incoming.getSortNo() == null ? i : incoming.getSortNo()); + binding.setCreatedBy(account.getId()); + binding.setModifiedBy(account.getId()); + result.add(binding); + } + return result; + } + + private List copyDraftKnowledgeBindings(List bindings, Agent agent, LoginAccount account) { + List result = new ArrayList<>(); + if (bindings == null || bindings.isEmpty()) { + return result; + } + for (int i = 0; i < bindings.size(); i++) { + AgentKnowledgeBinding incoming = bindings.get(i); + if (incoming == null) { + continue; + } + AgentKnowledgeBinding binding = new AgentKnowledgeBinding(); + binding.setId(incoming.getId()); + binding.setTenantId(account.getTenantId()); + binding.setAgentId(agent.getId()); + binding.setKnowledgeId(incoming.getKnowledgeId()); + binding.setRetrievalMode(incoming.getRetrievalMode()); + binding.setEnabled(incoming.getEnabled() == null || incoming.getEnabled()); + binding.setOptionsJson(incoming.getOptionsJson()); + binding.setResourceSnapshot(new LinkedHashMap<>()); + binding.setResourceSummary(new LinkedHashMap<>()); + binding.setSortNo(incoming.getSortNo() == null ? i : incoming.getSortNo()); + binding.setCreatedBy(account.getId()); + binding.setModifiedBy(account.getId()); + result.add(binding); + } + return result; + } + + private void validateDraftResources(Agent agent) { + if (agent.getToolBindings() != null) { + for (AgentToolBinding binding : agent.getToolBindings()) { + if (binding != null && Boolean.TRUE.equals(binding.getEnabled())) { + validateDraftToolBinding(binding); + } + } + } + if (agent.getKnowledgeBindings() != null) { + for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) { + if (binding != null && Boolean.TRUE.equals(binding.getEnabled())) { + validateDraftKnowledgeBinding(binding); + } + } + } + } + + private void validateDraftToolBinding(AgentToolBinding binding) { + if (binding.getTargetId() == null || binding.getToolType() == null) { + throw new BusinessException("工具绑定参数不完整"); + } + AgentToolType type = parseToolType(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 validateDraftKnowledgeBinding(AgentKnowledgeBinding binding) { + if (binding.getKnowledgeId() == null) { + throw new BusinessException("知识库绑定参数不完整"); + } + KnowledgeRetrievalModes.parse(binding.getRetrievalMode()); + DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId()); + if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("绑定知识库不存在或未发布"); + } + resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE, "无权限试用该知识库"); + } + + private AgentToolType parseToolType(String toolType) { + try { + return AgentToolType.from(toolType); + } catch (IllegalArgumentException e) { + throw new BusinessException("不支持的工具绑定类型:" + (toolType == null ? "" : toolType)); + } + } + + private AgentToolHitlPayload buildToolHitlPayload(String requestId, AgentRuntimeEvent event) { + Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); + AgentToolHitlPayload payload = new AgentToolHitlPayload(); + payload.setRequestId(requestId); + payload.setResumeToken(stringValue(rawPayload, "resumeToken")); + payload.setSessionId(stringValue(rawPayload, "sessionId")); + payload.setAgentId(stringValue(rawPayload, "agentId")); + payload.setToolCallId(firstText(stringValue(rawPayload, "toolCallId"), event.getToolCallId())); + payload.setToolName(stringValue(rawPayload, "toolName")); + payload.setToolDisplayName(firstText(stringValue(rawPayload, "toolDisplayName"), stringValue(rawPayload, "toolName"))); + payload.setInput(mapPayload(rawPayload.get("toolInput"))); + if (payload.getInput().isEmpty()) { + payload.setInput(mapPayload(rawPayload.get("input"))); + } + payload.setExpiresAt(stringValue(rawPayload, "expiresAt")); + Map metadata = buildHitlMetadata(rawPayload); + String toolType = firstText(stringValue(rawPayload, "toolType"), stringValue(metadata, "toolType")); + payload.setToolType(toolType); + if (toolType != null && !toolType.isBlank()) { + metadata.put("toolType", toolType); + } + payload.setMetadata(metadata); + return payload; + } + + /** + * 构建发送给聊天时间线的工具事件载荷。 + * + * @param event 运行时工具事件 + * @return 包含稳定工具调用 ID 的前端载荷 + */ + private Map buildToolEventPayload(AgentRuntimeEvent event) { + Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + String toolCallId = firstText(event.getToolCallId(), stringValue(payload, "toolCallId")); + if (toolCallId != null && !toolCallId.isBlank()) { + payload.put("toolCallId", toolCallId); + } + return payload; + } + + /** + * 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。 + * + * @param event 知识库检索运行时事件 + * @return 知识库检索状态载荷 + */ + private Map buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) { + Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + payload.put("statusKey", "knowledge-retrieval"); + payload.put("status", "done"); + payload.put("label", "已检索知识库"); + return payload; + } + + /** + * 构建挂起状态载荷。 + * + * @param event 挂起事件 + * @return 前端状态载荷 + */ + private Map buildSuspendedStatusPayload(AgentRuntimeEvent event) { + Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + payload.put("statusKey", "agent-suspended"); + payload.put("status", "waiting"); + payload.put("label", "等待人工确认"); + return payload; + } + + /** + * 构建发送给前端的最终知识库引用载荷。 + * + * @param event 运行完成事件 + * @return 知识库引用列表 + */ + private List> buildKnowledgeCitationPayload(AgentRuntimeEvent event) { + AgentMessage message = event == null ? null : event.getMessage(); + if (message == null || message.getKnowledgeReferences() == null || message.getKnowledgeReferences().isEmpty()) { + return List.of(); + } + List> citations = new ArrayList<>(); + for (AgentKnowledgeReference reference : message.getKnowledgeReferences()) { + if (reference == null) { + continue; + } + Map item = new LinkedHashMap<>(); + putIfPresent(item, "knowledgeId", reference.getKnowledgeId()); + putIfPresent(item, "knowledgeName", reference.getKnowledgeName()); + putIfPresent(item, "documentId", reference.getDocumentId()); + putIfPresent(item, "documentName", reference.getDocumentName()); + putIfPresent(item, "chunkId", reference.getChunkId()); + putIfPresent(item, "chunkContent", reference.getChunkContent()); + putIfPresent(item, "sourceUri", reference.getSourceUri()); + putIfPresent(item, "score", reference.getScore()); + if (reference.getMetadata() != null && !reference.getMetadata().isEmpty()) { + item.put("metadata", new LinkedHashMap<>(reference.getMetadata())); + putIfPresent(item, "sourceFileName", reference.getMetadata().get("sourceFileName")); + putIfPresent(item, "knowledgeType", reference.getMetadata().get("knowledgeType")); + putIfPresent(item, "faqCollection", reference.getMetadata().get("faqCollection")); + } + citations.add(item); + } + return citations; + } + + private void putIfPresent(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } + + private Map buildHitlMetadata(Map rawPayload) { + Map metadata = new LinkedHashMap<>(); + mapPayload(rawPayload.get("approvalMetadata")).forEach((key, value) -> { + if (!isHitlPromptKey(key)) { + metadata.put(key, value); + } + }); + for (String key : List.of("toolType", "skillId", "skillName", "bindingId", "targetId")) { + Object value = rawPayload.get(key); + if (value != null) { + 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 Map mapPayload(Object value) { + if (!(value instanceof Map rawMap)) { + return new LinkedHashMap<>(); + } + Map result = new LinkedHashMap<>(); + rawMap.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue)); + return result; + } + + private String stringValue(Map payload, String key) { + Object value = payload == null ? null : payload.get(key); + return value == null ? null : String.valueOf(value); + } + + /** + * 将模型输出转换为适合日志排查的可见文本。 + * + * @param value 原始模型输出 + * @return 已转义换行、制表符与控制字符的日志文本 + */ + private String toVisibleLogText(String value) { + if (value == null) { + return ""; + } + StringBuilder builder = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current == '\n') { + builder.append("\\n"); + } else if (current == '\r') { + builder.append("\\r"); + } else if (current == '\t') { + builder.append("\\t"); + } else if (Character.isISOControl(current)) { + builder.append(String.format("\\u%04x", (int) current)); + } else { + builder.append(current); + } + } + return builder.toString(); + } + + private String firstText(String first, String second) { + return first == null || first.isBlank() ? second : first; + } + + private Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + private String stringPayload(AgentRuntimeEvent event, String key) { + Object value = event.getPayload() == null ? null : event.getPayload().get(key); + return value == null ? null : String.valueOf(value); + } + + private String errorMessage(AgentRuntimeEvent event) { + String message = stringPayload(event, "message"); + if (message != null && !message.isBlank()) { + return message; + } + String reason = stringPayload(event, "reason"); + return reason == null || reason.isBlank() ? "Agent 运行失败" : reason; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java new file mode 100644 index 0000000..12ed0fb --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java @@ -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 toolInvokers = new LinkedHashMap<>(); + private Map 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 getToolInvokers() { + return toolInvokers; + } + + /** + * 设置工具调用器。 + * + * @param toolInvokers 工具调用器 + */ + public void setToolInvokers(Map toolInvokers) { + this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers; + } + + /** + * 获取知识库检索器。 + * + * @return 知识库检索器 + */ + public Map getKnowledgeRetrievers() { + return knowledgeRetrievers; + } + + /** + * 设置知识库检索器。 + * + * @param knowledgeRetrievers 知识库检索器 + */ + public void setKnowledgeRetrievers(Map knowledgeRetrievers) { + this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeFactory.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeFactory.java new file mode 100644 index 0000000..307c830 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeFactory.java @@ -0,0 +1,16 @@ +package tech.easyflow.agent.runtime; + +import com.easyagents.agent.runtime.AgentRuntime; + +/** + * Agent 运行时工厂 + */ +public interface AgentRuntimeFactory { + + /** + * 创建新的有状态 Agent 运行时实例。 + * + * @return Agent 运行时实例 + */ + AgentRuntime create(); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeStateCleanupService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeStateCleanupService.java new file mode 100644 index 0000000..0626c66 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeStateCleanupService.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentScopeRuntimeFactory.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentScopeRuntimeFactory.java new file mode 100644 index 0000000..0806018 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentScopeRuntimeFactory.java @@ -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(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java new file mode 100644 index 0000000..deb52c2 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java @@ -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 input = new LinkedHashMap<>(); + private String expiresAt; + private Map 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 getInput() { + return input; + } + + /** + * 设置工具入参。 + * + * @param input 工具入参 + */ + public void setInput(Map 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 getMetadata() { + return metadata; + } + + /** + * 设置扩展元数据。 + * + * @param metadata 扩展元数据 + */ + public void setMetadata(Map metadata) { + this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/AgentRunEventRecorder.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/AgentRunEventRecorder.java new file mode 100644 index 0000000..beb4654 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/AgentRunEventRecorder.java @@ -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); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java new file mode 100644 index 0000000..17e0e0b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingExpirationTask.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingExpirationTask.java new file mode 100644 index 0000000..e48230b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingExpirationTask.java @@ -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 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); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java new file mode 100644 index 0000000..85d43fe --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java @@ -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 expirePending(int limit); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java new file mode 100644 index 0000000..e2c7d48 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java @@ -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 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 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 records = pendingMapper.selectListByQuery(QueryWrapper.create() + .eq("runtime_session_id", runtimeSessionId) + .eq("is_deleted", 0)); + softDelete(records); + } + + private void softDelete(List 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 expirePending(int limit) { + List 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 metadata(AgentRuntimeEvent event) { + Map 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 mapValue(Object value) { + if (value instanceof Map map) { + Map 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); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingStatus.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingStatus.java new file mode 100644 index 0000000..3afb813 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingStatus.java @@ -0,0 +1,31 @@ +package tech.easyflow.agent.runtime.hitl; + +/** + * Agent HITL pending 状态。 + */ +public enum AgentHitlPendingStatus { + /** + * 等待审批。 + */ + PENDING, + + /** + * 已批准。 + */ + APPROVED, + + /** + * 已拒绝。 + */ + REJECTED, + + /** + * 已过期。 + */ + EXPIRED, + + /** + * 已取消。 + */ + CANCELLED +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java new file mode 100644 index 0000000..2cf5164 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java @@ -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(); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java new file mode 100644 index 0000000..7ad9ae8 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java @@ -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(); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/session/EasyFlowAgentSessionStore.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/session/EasyFlowAgentSessionStore.java new file mode 100644 index 0000000..b5a32a2 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/session/EasyFlowAgentSessionStore.java @@ -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; + } + + /** + * 绑定业务会话元信息。 + * + *

AgentScope Session API 不会把 EasyFlow 的 {@code agentId/chatSessionId/tenantId} 传入 + * {@code save(...)},因此运行入口必须先调用本方法建立或刷新元信息。后续 state 写入会复用 + * 这些字段,避免表里只剩裸 sessionKey。

+ * + * @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 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 envelope = loadEnvelope(sessionKey); + singleStates(envelope).put(name, JsonUtils.getJsonCodec().toJson(state)); + persistEnvelope(sessionKey, envelope); + } + + @Override + public void saveList(String sessionKey, String name, List states) { + if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name)) { + return; + } + List values = new ArrayList<>(); + if (states != null) { + for (State state : states) { + values.add(JsonUtils.getJsonCodec().toJson(state)); + } + } + Map envelope = loadEnvelope(sessionKey); + listStates(envelope).put(name, values); + persistEnvelope(sessionKey, envelope); + } + + @Override + public Optional get(String sessionKey, String name, Class 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 List getList(String sessionKey, String name, Class 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 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 listSessionKeys() { + List sessions = agentSessionMapper.selectListByQuery(QueryWrapper.create() + .eq("is_deleted", 0) + .select("session_key")); + Set keys = new LinkedHashSet<>(); + for (AgentSession session : sessions) { + if (StringUtils.hasText(session.getSessionKey())) { + keys.add(session.getSessionKey()); + } + } + return keys; + } + + private void persistEnvelope(String sessionKey, Map 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 loadEnvelope(String sessionKey) { + Map 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 emptyEnvelope() { + Map envelope = new LinkedHashMap<>(); + envelope.put("version", ENVELOPE_VERSION); + envelope.put(SINGLE_STATES, new LinkedHashMap()); + envelope.put(LIST_STATES, new LinkedHashMap()); + return envelope; + } + + @SuppressWarnings("unchecked") + private Map singleStates(Map envelope) { + return (Map) envelope.computeIfAbsent(SINGLE_STATES, key -> new LinkedHashMap()); + } + + @SuppressWarnings("unchecked") + private Map listStates(Map envelope) { + return (Map) envelope.computeIfAbsent(LIST_STATES, key -> new LinkedHashMap()); + } + + @SuppressWarnings("unchecked") + private Map 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 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 deepCopy(Map 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 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) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentApprovalStateService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentApprovalStateService.java new file mode 100644 index 0000000..d3505a5 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentApprovalStateService.java @@ -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 agents); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentCategoryService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentCategoryService.java new file mode 100644 index 0000000..b9707bb --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentCategoryService.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentKnowledgeBindingService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentKnowledgeBindingService.java new file mode 100644 index 0000000..50df90f --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentKnowledgeBindingService.java @@ -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 { + + /** + * 替换 Agent 知识库绑定。 + * + * @param agentId Agent ID + * @param bindings 新绑定列表 + * @return 保存后的绑定列表 + */ + List replaceBindings(BigInteger agentId, List bindings); + + /** + * 查询 Agent 启用知识库绑定。 + * + * @param agentId Agent ID + * @return 启用绑定列表 + */ + List listEnabled(BigInteger agentId); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java new file mode 100644 index 0000000..bd68868 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java @@ -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 详情。 + * + * @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 buildPublishSnapshot(Agent agent); + + /** + * 从快照还原运行视图。 + * + * @param snapshot 发布快照 + * @return Agent 运行视图 + */ + Agent fromSnapshot(Map snapshot); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentToolBindingService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentToolBindingService.java new file mode 100644 index 0000000..462072d --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentToolBindingService.java @@ -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 { + + /** + * 替换 Agent 工具绑定。 + * + * @param agentId Agent ID + * @param bindings 新绑定列表 + * @return 保存后的绑定列表 + */ + List replaceBindings(BigInteger agentId, List bindings); + + /** + * 查询 Agent 启用工具绑定。 + * + * @param agentId Agent ID + * @return 启用绑定列表 + */ + List listEnabled(BigInteger agentId); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentApprovalStateServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentApprovalStateServiceImpl.java new file mode 100644 index 0000000..1705b63 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentApprovalStateServiceImpl.java @@ -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 agents) { + if (CollectionUtils.isEmpty(agents)) { + return; + } + List validAgents = agents.stream().filter(Objects::nonNull).toList(); + if (validAgents.isEmpty()) { + return; + } + Map 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 loadInstanceMap(Collection agents, + Function instanceIdGetter) { + Set instanceIds = agents.stream() + .map(instanceIdGetter) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (instanceIds.isEmpty()) { + return Collections.emptyMap(); + } + List 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; + }; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentCategoryServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentCategoryServiceImpl.java new file mode 100644 index 0000000..9aa7de5 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentCategoryServiceImpl.java @@ -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 implements AgentCategoryService { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java new file mode 100644 index 0000000..2b3196d --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java @@ -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 + 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 replaceBindings(BigInteger agentId, List 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 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("当前登录状态失效,请重新登录后再试"); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java new file mode 100644 index 0000000..6a3cb6e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java @@ -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 implements AgentService { + + private static final TypeReference> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> 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 buildPublishSnapshot(Agent agent) { + Agent detail = getDetail(agent.getId()); + Map 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 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 basicSummary(Agent agent) { + Map 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 modelSummary(BigInteger modelId) { + Model model = modelService.getModelInstance(modelId); + Map 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 parameterSummary(Agent agent) { + Map 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 promptSummary(Agent agent) { + Map 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> toolSummaries(List bindings) { + if (bindings == null) { + return List.of(); + } + return bindings.stream().map(this::toolSummary).toList(); + } + + private Map toolSummary(AgentToolBinding binding) { + Map 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 snapshotToolBindings(List 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 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>() {}); + } + if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) { + PluginItem pluginItem = pluginItemService.getById(binding.getTargetId()); + if (pluginItem == null) { + throw new BusinessException("绑定插件不存在"); + } + return objectMapper.convertValue(pluginItem, new TypeReference>() {}); + } + Mcp mcp = mcpService.getById(binding.getTargetId()); + if (mcp == null) { + throw new BusinessException("绑定 MCP 不存在"); + } + return objectMapper.convertValue(mcp, new TypeReference>() {}); + } + + private List snapshotKnowledgeBindings(List 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 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>() {}); + } + + private List> knowledgeSummaries(List bindings) { + if (bindings == null) { + return List.of(); + } + return bindings.stream().map(this::knowledgeSummary).toList(); + } + + private Map knowledgeSummary(AgentKnowledgeBinding binding) { + DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId()); + Map 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)); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java new file mode 100644 index 0000000..b6409c1 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java @@ -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 + 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 replaceBindings(BigInteger agentId, List 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 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("当前登录状态失效,请重新登录后再试"); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java new file mode 100644 index 0000000..27fc9b6 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java @@ -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 proxy(Class 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; + } + ); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunRegistryTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunRegistryTest.java new file mode 100644 index 0000000..39ce921 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunRegistryTest.java @@ -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 resumeRequest = new AtomicReference<>(); + + @Override + public void init(AgentInitRequest request) { + // 测试桩无需初始化。 + } + + @Override + public Flux stream(com.easyagents.agent.runtime.message.AgentMessage userMessage) { + return Flux.empty(); + } + + @Override + public Flux resume(AgentResumeRequest request) { + resumeRequest.set(request); + return Flux.empty(); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java new file mode 100644 index 0000000..744f685 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java @@ -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 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 payload = (Map) 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 payload = (Map) 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 payload = (Map) 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 payload = (Map) 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> 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()); + } + + /** + * 验证正式聊天在获取运行锁失败时不会提前写入用户消息。 + * + *

运行锁是 AgentScope session 与 chatlog 的一致性入口。若同会话并发请求抢锁失败, + * 必须在 prepareSession 和 recordUserMessage 之前失败,避免 chatlog 出现没有真实运行的用户消息。

+ * + * @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 payload = (Map) 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 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> 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 stream(AgentMessage userMessage) { + return reactor.core.publisher.Flux.empty(); + } + + @Override + public reactor.core.publisher.Flux 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 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 会话已有运行中的请求,请稍后再试"); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java index 7aeb02b..a6b174c 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java @@ -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; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java index 8bdd4b2..f0694d9 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java @@ -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 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 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 documents = documentStore.search(wrapper, options); - return documents == null ? Collections.emptyList() : documents; + List result = documents == null ? Collections.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 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 documents = searcher.searchDocuments(request); - return documents == null ? Collections.emptyList() : documents; + List result = documents == null ? Collections.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 adaptDocuments(List documents, HitSource hitSource) { @@ -392,21 +453,38 @@ public class DocumentCollectionServiceImpl extends ServiceImpl 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 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> summarizeRagHits(List hits) { + if (hits == null || hits.isEmpty()) { + return Collections.emptyList(); + } + return hits.stream() + .filter(Objects::nonNull) + .map(hit -> { + Map 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> summarizeDocuments(List documents) { + if (documents == null || documents.isEmpty()) { + return Collections.emptyList(); + } + return documents.stream() + .filter(Objects::nonNull) + .map(document -> { + Map 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) + "..."; + } } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java index 1a1e117..0a64bd4 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java @@ -11,6 +11,7 @@ import java.util.stream.Collectors; public enum ApprovalResourceType { BOT("BOT"), + AGENT("AGENT"), WORKFLOW("WORKFLOW"), KNOWLEDGE("KNOWLEDGE"); diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/mysql/MySqlChatSessionRepository.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/mysql/MySqlChatSessionRepository.java index 577c71a..887db8a 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/mysql/MySqlChatSessionRepository.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/mysql/MySqlChatSessionRepository.java @@ -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 listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { + return listSessions(userId, assistantId, null, query); + } + + public List listSessions(BigInteger userId, BigInteger assistantId, String assistantCode, ChatPageQuery query) { String table = tableRouter.resolveSessionTable(); List 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 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; } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java index 59318e4..e790038 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java @@ -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) { diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyService.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyService.java index 288a6e5..e5f9e89 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyService.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyService.java @@ -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()); } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionQueryService.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionQueryService.java index 397b45e..cf57341 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionQueryService.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionQueryService.java @@ -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 listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query); + /** + * 按助手编码查询用户会话列表。 + * + * @param userId 用户 ID + * @param assistantId 助手 ID,可为空 + * @param assistantCode 助手编码,可为空 + * @param query 分页参数 + * @return 会话列表 + */ + default List 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); /** diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java index b81759b..d27d151 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java @@ -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 listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { - return sessionRepository.listSessions(userId, assistantId, query); + return listSessions(userId, assistantId, null, query); + } + + @Override + public List 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; } diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyServiceTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyServiceTest.java index 53d9390..c01b2a2 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyServiceTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistMySqlApplyServiceTest.java @@ -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()); } diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java index 10bc784..01e6f34 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java @@ -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 sessions = new ArrayList<>(); @@ -133,13 +156,25 @@ public class ChatSessionQueryServiceImplTest { @Override public List listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { + return listSessions(userId, assistantId, null, query); + } + + @Override + public List 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; } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java index d6c7160..de05a65 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java @@ -8,6 +8,7 @@ import java.util.stream.Collectors; public enum CategoryResourceType { BOT("BOT"), + AGENT("AGENT"), PLUGIN("PLUGIN"), WORKFLOW("WORKFLOW"), KNOWLEDGE("KNOWLEDGE"), diff --git a/easyflow-modules/pom.xml b/easyflow-modules/pom.xml index 44a1c2b..6e6c50f 100644 --- a/easyflow-modules/pom.xml +++ b/easyflow-modules/pom.xml @@ -18,6 +18,7 @@ easyflow-module-autoconfig easyflow-module-chatlog easyflow-module-ai + easyflow-module-agent easyflow-module-job easyflow-module-datacenter diff --git a/easyflow-starter/easyflow-starter-all/pom.xml b/easyflow-starter/easyflow-starter-all/pom.xml index 1b6cea3..0699a1b 100644 --- a/easyflow-starter/easyflow-starter-all/pom.xml +++ b/easyflow-starter/easyflow-starter-all/pom.xml @@ -52,6 +52,10 @@ + + tech.easyflow + easyflow-module-agent + tech.easyflow easyflow-module-auth diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V19__mysql_agent_schema.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V19__mysql_agent_schema.sql new file mode 100644 index 0000000..50f10ce --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V19__mysql_agent_schema.sql @@ -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 知识库绑定'; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V20__mysql_agent_menu.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V20__mysql_agent_menu.sql new file mode 100644 index 0000000..972fe2e --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V20__mysql_agent_menu.sql @@ -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 +); diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V21__mysql_agent_runtime_state.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V21__mysql_agent_runtime_state.sql new file mode 100644 index 0000000..5aa75d9 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V21__mysql_agent_runtime_state.sql @@ -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 运行事件摘要'; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V22__mysql_agent_chat_menu.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V22__mysql_agent_chat_menu.sql new file mode 100644 index 0000000..dbd1b55 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V22__mysql_agent_chat_menu.sql @@ -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); diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue new file mode 100644 index 0000000..8f745d9 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiConversation.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiConversation.vue new file mode 100644 index 0000000..9c00744 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiConversation.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiErrorNotice.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiErrorNotice.vue new file mode 100644 index 0000000..c950825 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiErrorNotice.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiKnowledgeCard.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiKnowledgeCard.vue new file mode 100644 index 0000000..1f1904a --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiKnowledgeCard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue new file mode 100644 index 0000000..efe30dd --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.test.ts b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.test.ts new file mode 100644 index 0000000..8afd229 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.test.ts @@ -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(); + }); +}); diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue new file mode 100644 index 0000000..4dfcd2b --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiReasoningCard.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiReasoningCard.vue new file mode 100644 index 0000000..e061759 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiReasoningCard.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiToolApprovalCard.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiToolApprovalCard.vue new file mode 100644 index 0000000..ba66321 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiToolApprovalCard.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiToolCallCard.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiToolCallCard.vue new file mode 100644 index 0000000..20a9c0c --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiToolCallCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/ai-chat/composables/useAiChatScroll.ts b/easyflow-ui-admin/app/src/components/ai-chat/composables/useAiChatScroll.ts new file mode 100644 index 0000000..54d487b --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/composables/useAiChatScroll.ts @@ -0,0 +1,13 @@ +import type {Ref} from 'vue'; +import {nextTick} from 'vue'; + +export function useAiChatScroll(containerRef: Ref) { + async function scrollToBottom() { + await nextTick(); + const container = containerRef.value; + if (!container) return; + container.scrollTop = container.scrollHeight; + } + + return { scrollToBottom }; +} diff --git a/easyflow-ui-admin/app/src/components/ai-chat/types.ts b/easyflow-ui-admin/app/src/components/ai-chat/types.ts new file mode 100644 index 0000000..cac11e6 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/ai-chat/types.ts @@ -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; +} diff --git a/easyflow-ui-admin/app/src/components/chat/ChatTimeMessageContent.vue b/easyflow-ui-admin/app/src/components/chat/ChatTimeMessageContent.vue index 3099799..39b4a55 100644 --- a/easyflow-ui-admin/app/src/components/chat/ChatTimeMessageContent.vue +++ b/easyflow-ui-admin/app/src/components/chat/ChatTimeMessageContent.vue @@ -1,16 +1,17 @@ + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue new file mode 100644 index 0000000..02bb24e --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue new file mode 100644 index 0000000..9964995 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/api.ts b/easyflow-ui-admin/app/src/views/ai/agents/api.ts new file mode 100644 index 0000000..0532368 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/api.ts @@ -0,0 +1,111 @@ +import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from './types'; + +import {api} from '#/api/request'; + +export interface RequestResult { + data: T; + errorCode: number; + message?: string; +} + +export function getAgentDetail(id: number | string) { + return api.get>('/api/v1/agent/getDetail', { + params: { id }, + }); +} + +export function saveAgent(agent: AgentInfo) { + return api.post>('/api/v1/agent/save', agent); +} + +export function updateAgent(agent: AgentInfo) { + return api.post>('/api/v1/agent/update', agent); +} + +export function removeAgent(id: number | string) { + return api.post('/api/v1/agent/remove', { id }); +} + +export function updateAgentToolBindings( + agentId: number | string, + bindings: AgentToolBinding[], +) { + return api.post>( + '/api/v1/agent/toolBinding/update', + { agentId, bindings }, + ); +} + +export function updateAgentKnowledgeBindings( + agentId: number | string, + bindings: AgentKnowledgeBinding[], +) { + return api.post>( + '/api/v1/agent/knowledgeBinding/update', + { agentId, bindings }, + ); +} + +export function submitAgentPublishApproval(id: number | string) { + return api.post>( + '/api/v1/agent/submitPublishApproval', + { id }, + ); +} + +export function submitAgentOfflineApproval(id: number | string) { + return api.post>( + '/api/v1/agent/submitOfflineApproval', + { id }, + ); +} + +export function submitAgentDeleteApproval(id: number | string) { + return api.post>( + '/api/v1/agent/submitDeleteApproval', + { id }, + ); +} + +export function approveAgentRun(requestId: string, resumeToken: string) { + return api.post('/api/v1/agent/run/approve', { + requestId, + resumeToken, + }); +} + +export function rejectAgentRun( + requestId: string, + resumeToken: string, + reason?: string, +) { + return api.post('/api/v1/agent/run/reject', { + requestId, + resumeToken, + reason, + }); +} + +export function clearAgentDraftSession(sessionId: string) { + return api.post('/api/v1/agent/chat/draft/clear', { + sessionId, + }); +} + +export function getAgentCategories() { + return api.get>('/api/v1/agentCategory/visibleList', { + params: { sortKey: 'sortNo', sortType: 'asc' }, + }); +} + +export function getAgentModels() { + return api.get>('/api/v1/model/list', { + params: { modelType: 'chatModel', added: true }, + }); +} + +export function getPublishedKnowledgeList() { + return api.get>('/api/v1/documentCollection/list', { + params: { publishedOnly: true }, + }); +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue new file mode 100644 index 0000000..3bc4cdb --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentCommandBar.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentCommandBar.vue new file mode 100644 index 0000000..8f191dd --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentCommandBar.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentInspectorPanel.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentInspectorPanel.vue new file mode 100644 index 0000000..7963a4f --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentInspectorPanel.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentKnowledgeForm.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentKnowledgeForm.vue new file mode 100644 index 0000000..3f8d991 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentKnowledgeForm.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentToolForm.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentToolForm.vue new file mode 100644 index 0000000..c0a3447 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentToolForm.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue new file mode 100644 index 0000000..cda921f --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue @@ -0,0 +1,212 @@ + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioBaseNode.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioBaseNode.vue new file mode 100644 index 0000000..c0ded49 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioBaseNode.vue @@ -0,0 +1,11 @@ + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCanvas.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCanvas.vue new file mode 100644 index 0000000..00ebd9b --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCanvas.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCapabilityNode.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCapabilityNode.vue new file mode 100644 index 0000000..c0ded49 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCapabilityNode.vue @@ -0,0 +1,11 @@ + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioEdgeLayer.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioEdgeLayer.vue new file mode 100644 index 0000000..1ff69f4 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioEdgeLayer.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioNode.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioNode.vue new file mode 100644 index 0000000..6ddcf33 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioNode.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts new file mode 100644 index 0000000..af46b05 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts @@ -0,0 +1,59 @@ +import type {AgentCapabilityKind} from '../../types'; + +export type AgentStudioNodeKind = 'base' | AgentCapabilityKind; + +export interface AgentStudioNodeData { + badge?: string; + detail?: string; + iconKey: AgentStudioNodeKind; + id: string; + kind: AgentStudioNodeKind; + selected: boolean; + title: string; +} + +export interface AgentStudioNodeRenderProps { + data: AgentStudioNodeData; +} + +export interface AgentStudioNodeView { + id: string; + position: { + x: number; + y: number; + }; + height?: number; + type: string; + width?: number; + data: AgentStudioNodeData; +} + +export interface AgentStudioEdgeView { + id: string; + source: string; + target: string; +} + +export interface AgentStudioConnectionView { + active?: boolean; + id: string; + path: string; + sourceId: string; + targetId: string; +} + +export interface AgentStudioViewport { + x: number; + y: number; + zoom: number; +} + +export interface AgentStudioCanvasSize { + height: number; + width: number; +} + +export interface AgentStudioLayoutSnapshot { + nodePositions: Record; + viewport?: AgentStudioViewport; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioLayout.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioLayout.ts new file mode 100644 index 0000000..336a444 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioLayout.ts @@ -0,0 +1,161 @@ +import type { + AgentStudioLayoutSnapshot, + AgentStudioNodeView, + AgentStudioViewport, +} from '../../components/agent-studio/types'; +import type {AgentDraftState} from '../../types'; + +import {computed, onBeforeUnmount, reactive, toRaw, watch} from 'vue'; + +const STORAGE_PREFIX = 'agent-studio-layout'; +const WRITE_DELAY = 260; + +function readLayout(key: string): AgentStudioLayoutSnapshot { + if (typeof window === 'undefined') { + return { nodePositions: {} }; + } + try { + const raw = window.localStorage.getItem(key); + if (!raw) return { nodePositions: {} }; + const parsed = JSON.parse(raw) as AgentStudioLayoutSnapshot; + return { + nodePositions: parsed.nodePositions || {}, + viewport: parsed.viewport, + }; + } catch { + return { nodePositions: {} }; + } +} + +function isFinitePosition( + position: unknown, +): position is { x: number; y: number } { + return ( + typeof position === 'object' && + position !== null && + Number.isFinite((position as { x?: number }).x) && + Number.isFinite((position as { y?: number }).y) + ); +} + +function isFiniteViewport(viewport: unknown): viewport is AgentStudioViewport { + return ( + isFinitePosition(viewport) && + Number.isFinite((viewport as { zoom?: number }).zoom) + ); +} + +export function useAgentStudioLayout(state: AgentDraftState) { + const storageKey = computed(() => { + const agentId = state.agent.id ? String(state.agent.id) : 'new'; + return `${STORAGE_PREFIX}:${agentId}`; + }); + const snapshot = reactive({ + nodePositions: {}, + }); + let writeTimer: number | undefined; + + function load() { + const next = readLayout(storageKey.value); + snapshot.nodePositions = { ...next.nodePositions }; + snapshot.viewport = next.viewport; + } + + function hasCapturedLayout() { + return ( + Object.keys(snapshot.nodePositions).length > 0 || + isFiniteViewport(snapshot.viewport) + ); + } + + function persistSnapshot() { + if (typeof window === 'undefined') return; + window.localStorage.setItem( + storageKey.value, + JSON.stringify(toRaw(snapshot)), + ); + } + + function scheduleWrite() { + if (typeof window === 'undefined') return; + if (writeTimer) { + window.clearTimeout(writeTimer); + } + writeTimer = window.setTimeout(() => { + persistSnapshot(); + writeTimer = undefined; + }, WRITE_DELAY); + } + + function capture(data: { + nodes?: AgentStudioNodeView[]; + viewport?: AgentStudioViewport; + }) { + let changed = false; + if (Array.isArray(data.nodes)) { + const activeIds = new Set(data.nodes.map((node) => node.id)); + const nextPositions: AgentStudioLayoutSnapshot['nodePositions'] = {}; + for (const [key, position] of Object.entries(snapshot.nodePositions)) { + if (activeIds.has(key)) nextPositions[key] = position; + else changed = true; + } + data.nodes.forEach((node) => { + if (isFinitePosition(node.position)) { + const current = nextPositions[node.id]; + if ( + !current || + current.x !== node.position.x || + current.y !== node.position.y + ) { + nextPositions[node.id] = { ...node.position }; + changed = true; + } + } + }); + snapshot.nodePositions = nextPositions; + } + if ( + isFiniteViewport(data.viewport) && + (!snapshot.viewport || + snapshot.viewport.x !== data.viewport.x || + snapshot.viewport.y !== data.viewport.y || + snapshot.viewport.zoom !== data.viewport.zoom) + ) { + snapshot.viewport = { ...data.viewport }; + changed = true; + } + if (changed) { + scheduleWrite(); + } + } + + watch( + storageKey, + (nextKey, previousKey) => { + const persisted = readLayout(nextKey); + const shouldCarryNewDraftLayout = + previousKey?.endsWith(':new') && + !persisted.viewport && + Object.keys(persisted.nodePositions).length === 0 && + hasCapturedLayout(); + if (shouldCarryNewDraftLayout) { + persistSnapshot(); + return; + } + load(); + }, + { immediate: true }, + ); + + onBeforeUnmount(() => { + if (writeTimer && typeof window !== 'undefined') { + window.clearTimeout(writeTimer); + persistSnapshot(); + } + }); + + return { + capture, + layout: snapshot, + }; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts new file mode 100644 index 0000000..cc9f23b --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts @@ -0,0 +1,59 @@ +import {describe, expect, it} from 'vitest'; + +import {resolveCapabilityNodePosition} from './useAgentStudioModel'; + +describe('resolveCapabilityNodePosition', () => { + it('无视口信息时沿用默认左侧列位置', () => { + expect( + resolveCapabilityNodePosition({ + fallbackIndex: 1, + nodeId: 'knowledge:new', + }), + ).toEqual({ x: 70, y: 252 }); + }); + + it('有视口信息时投放到当前可见区域左侧', () => { + expect( + resolveCapabilityNodePosition({ + canvasSize: { height: 720, width: 1280 }, + fallbackIndex: 0, + layout: { + nodePositions: {}, + viewport: { x: 250, y: 100, zoom: 1 }, + }, + nodeId: 'tool:new', + }), + ).toEqual({ x: -202, y: -4 }); + }); + + it('新增位置与已有能力节点冲突时向下避让', () => { + expect( + resolveCapabilityNodePosition({ + canvasSize: { height: 720, width: 1280 }, + fallbackIndex: 0, + layout: { + nodePositions: {}, + viewport: { x: 250, y: 100, zoom: 1 }, + }, + nodeId: 'tool:new', + occupiedPositions: [{ x: -202, y: -4 }], + }), + ).toEqual({ x: -202, y: 100 }); + }); + + it('已有持久化位置时优先保留用户拖拽后的布局', () => { + expect( + resolveCapabilityNodePosition({ + canvasSize: { height: 720, width: 1280 }, + fallbackIndex: 0, + layout: { + nodePositions: { + 'tool:existing': { x: 128, y: 256 }, + }, + viewport: { x: 250, y: 100, zoom: 1 }, + }, + nodeId: 'tool:existing', + }), + ).toEqual({ x: 128, y: 256 }); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts new file mode 100644 index 0000000..66ced28 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts @@ -0,0 +1,259 @@ +import type { + AgentStudioCanvasSize, + AgentStudioEdgeView, + AgentStudioLayoutSnapshot, + AgentStudioNodeData, + AgentStudioNodeView, +} from '../../components/agent-studio/types'; +import type { + AgentDraftState, + AgentKnowledgeBinding, + AgentOption, + AgentToolBinding, +} from '../../types'; + +import {computed} from 'vue'; + +const BASE_NODE_ID = 'agent-base'; +const BASE_POSITION = { x: 430, y: 260 }; +const CAPABILITY_GAP = 104; +const CAPABILITY_OFFSET_X = 360; +const CAPABILITY_START_Y = 148; +const CAPABILITY_NODE_HEIGHT = 78; +const CAPABILITY_NODE_WIDTH = 212; +const LEFT_SCREEN_PADDING = 48; +const TOP_SCREEN_PADDING = 96; +const INSPECTOR_RESERVED_WIDTH = 468; +const MIN_VISIBLE_WORKSPACE_WIDTH = 520; + +function firstText(...values: unknown[]) { + const matched = values.find((value) => String(value || '').trim()); + return matched ? String(matched).trim() : ''; +} + +function buildKnowledgeTitle( + binding: AgentKnowledgeBinding, + options: AgentOption[], +) { + const matchedOption = options.find( + (item) => String(item.value) === String(binding.knowledgeId), + ); + return firstText( + binding.resourceSummary?.title, + binding.resourceSummary?.name, + binding.resourceSummary?.label, + binding.resourceSummary?.displayName, + binding.resourceSnapshot?.title, + binding.resourceSnapshot?.name, + binding.resourceSnapshot?.label, + binding.resourceSnapshot?.displayName, + binding.title, + binding.name, + binding.knowledgeName, + binding.collectionName, + matchedOption?.label, + matchedOption?.raw?.title, + matchedOption?.raw?.name, + ); +} + +function buildToolTitle(binding: AgentToolBinding) { + return firstText( + binding.resourceSummary?.title, + binding.resourceSummary?.name, + binding.resourceSnapshot?.title, + binding.resourceSnapshot?.name, + binding.toolName, + ); +} + +function buildToolDetail(binding: AgentToolBinding, fallback: string) { + const toolName = firstText(binding.toolName); + const resourceName = buildToolTitle(binding); + if (toolName && resourceName && toolName !== resourceName) { + return `${resourceName} / ${toolName}`; + } + return resourceName || toolName || fallback; +} + +function toFlowPoint( + point: { x: number; y: number }, + viewport: NonNullable, +) { + return { + x: (point.x - viewport.x) / viewport.zoom, + y: (point.y - viewport.y) / viewport.zoom, + }; +} + +function resolveVisibleLeftX( + layout?: AgentStudioLayoutSnapshot, + canvasSize?: AgentStudioCanvasSize, +) { + const viewport = layout?.viewport; + if (!viewport || !canvasSize?.width) { + return BASE_POSITION.x - CAPABILITY_OFFSET_X; + } + + const availableWidth = Math.max( + MIN_VISIBLE_WORKSPACE_WIDTH, + canvasSize.width - INSPECTOR_RESERVED_WIDTH, + ); + const screenX = Math.min( + LEFT_SCREEN_PADDING, + Math.max(24, availableWidth - CAPABILITY_NODE_WIDTH - LEFT_SCREEN_PADDING), + ); + return toFlowPoint({ x: screenX, y: 0 }, viewport).x; +} + +function resolveVisibleTopY( + layout?: AgentStudioLayoutSnapshot, + canvasSize?: AgentStudioCanvasSize, +) { + const viewport = layout?.viewport; + if (!viewport || !canvasSize?.height) { + return CAPABILITY_START_Y; + } + return toFlowPoint({ x: 0, y: TOP_SCREEN_PADDING }, viewport).y; +} + +function hasVerticalOverlap( + position: { x: number; y: number }, + occupied: Array<{ x: number; y: number }>, +) { + return occupied.some( + (item) => + Math.abs(item.x - position.x) < CAPABILITY_NODE_WIDTH && + Math.abs(item.y - position.y) < CAPABILITY_NODE_HEIGHT, + ); +} + +export function resolveCapabilityNodePosition(params: { + canvasSize?: AgentStudioCanvasSize; + fallbackIndex: number; + layout?: AgentStudioLayoutSnapshot; + nodeId: string; + occupiedPositions?: Array<{ x: number; y: number }>; +}) { + const persisted = params.layout?.nodePositions?.[params.nodeId]; + if (persisted) return persisted; + + const occupied = params.occupiedPositions || []; + const x = resolveVisibleLeftX(params.layout, params.canvasSize); + let y = + resolveVisibleTopY(params.layout, params.canvasSize) + + params.fallbackIndex * CAPABILITY_GAP; + + while (hasVerticalOverlap({ x, y }, occupied)) { + y += CAPABILITY_GAP; + } + + return { x, y }; +} + +export function useAgentStudioModel( + state: AgentDraftState, + selectedNodeId: () => string, + layout?: AgentStudioLayoutSnapshot, + canvasSize?: () => AgentStudioCanvasSize | undefined, + knowledgeOptions?: () => AgentOption[], +) { + return computed(() => { + const positionOf = (nodeId: string, fallback: { x: number; y: number }) => + layout?.nodePositions?.[nodeId] || fallback; + const size = canvasSize?.(); + const occupiedPositions: Array<{ x: number; y: number }> = Object.values( + layout?.nodePositions || {}, + ); + const nodes: AgentStudioNodeView[] = [ + { + id: BASE_NODE_ID, + type: 'agentStudioBase', + position: positionOf(BASE_NODE_ID, BASE_POSITION), + width: 268, + height: 104, + data: { + badge: '基座', + detail: firstText(state.agent.description, '等待配置核心提示词'), + iconKey: 'base', + id: BASE_NODE_ID, + kind: 'base', + selected: selectedNodeId() === BASE_NODE_ID, + title: firstText(state.agent.name, '未命名智能体'), + } satisfies AgentStudioNodeData, + }, + ]; + + const knowledgeNodes = state.knowledgeBindings.map((binding, index) => { + const nodeId = `knowledge:${binding.localId}`; + const title = buildKnowledgeTitle(binding, knowledgeOptions?.() || []); + const position = resolveCapabilityNodePosition({ + canvasSize: size, + fallbackIndex: index, + layout, + nodeId, + occupiedPositions, + }); + occupiedPositions.push(position); + return { + id: nodeId, + type: 'agentStudioCapability', + position, + width: CAPABILITY_NODE_WIDTH, + height: CAPABILITY_NODE_HEIGHT, + data: { + badge: '知识库', + detail: title || '待选择知识库', + iconKey: 'knowledge', + id: nodeId, + kind: 'knowledge', + selected: selectedNodeId() === nodeId, + title: title || '知识库', + } satisfies AgentStudioNodeData, + }; + }); + const toolNodes = state.toolBindings.map((binding, index) => { + const nodeId = `tool:${binding.localId}`; + const isWorkflow = + String(binding.toolType || '').toUpperCase() === 'WORKFLOW'; + const fallback = isWorkflow ? '待选择工作流' : '待选择插件工具'; + const detail = buildToolDetail(binding, fallback); + const position = resolveCapabilityNodePosition({ + canvasSize: size, + fallbackIndex: state.knowledgeBindings.length + index, + layout, + nodeId, + occupiedPositions, + }); + occupiedPositions.push(position); + return { + id: nodeId, + type: 'agentStudioCapability', + position, + width: CAPABILITY_NODE_WIDTH, + height: CAPABILITY_NODE_HEIGHT, + data: { + badge: isWorkflow ? '工作流' : '插件', + detail, + iconKey: isWorkflow ? 'workflow' : 'plugin', + id: nodeId, + kind: isWorkflow ? 'workflow' : 'plugin', + selected: selectedNodeId() === nodeId, + title: + detail === fallback ? (isWorkflow ? '工作流' : '插件') : detail, + } satisfies AgentStudioNodeData, + }; + }); + + const capabilityNodes = [...knowledgeNodes, ...toolNodes]; + + const edges: AgentStudioEdgeView[] = capabilityNodes.map((node) => ({ + id: `edge:${node.id}`, + source: BASE_NODE_ID, + target: node.id, + })); + + nodes.push(...capabilityNodes); + return { edges, nodes }; + }); +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts new file mode 100644 index 0000000..39de68c --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts @@ -0,0 +1,371 @@ +import type { + AgentCapabilityKind, + AgentDraftState, + AgentInfo, + AgentKnowledgeBinding, + AgentToolBinding, + AgentValidationIssue, +} from '../types'; + +import {computed, reactive} from 'vue'; + +const BASE_NODE_ID = 'agent-base'; +const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + +function createLocalId(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function isSafeToolName(name?: string) { + return SAFE_TOOL_NAME_PATTERN.test(String(name || '')); +} + +function buildFallbackToolName(prefix: string, resource?: Record) { + const id = resource?.id ? String(resource.id) : createLocalId(prefix); + return `${prefix}_${id}`; +} + +function resolveToolName( + kind: Exclude, + resource?: Record, +) { + if (isSafeToolName(resource?.englishName)) { + return String(resource?.englishName); + } + if (isSafeToolName(resource?.name)) { + return String(resource?.name); + } + return buildFallbackToolName( + kind === 'workflow' ? 'workflow' : 'plugin', + resource, + ); +} + +function normalizeBindingToolName(binding: AgentToolBinding) { + if (isSafeToolName(binding.toolName)) { + return String(binding.toolName); + } + const kind = + String(binding.toolType || '').toUpperCase() === 'WORKFLOW' + ? 'workflow' + : 'plugin'; + const resource = { + ...(binding.resourceSnapshot || {}), + ...(binding.resourceSummary || {}), + id: + binding.targetId || + binding.resourceSummary?.id || + binding.resourceSnapshot?.id, + }; + return resolveToolName(kind, resource); +} + +export function createEmptyAgent(): AgentInfo { + return { + name: '未命名智能体', + description: '', + avatar: '', + categoryId: '', + modelId: '', + promptConfigJson: { systemPrompt: '' }, + memoryConfigJson: { + compressionParameter: { + enabled: true, + msgThreshold: 12, + lastKeep: 8, + minCompressionTokenThreshold: 6000, + }, + }, + status: 1, + }; +} + +function normalizeAgent(agent?: AgentInfo): AgentInfo { + const source = agent || {}; + const memoryConfig = source.memoryConfigJson || {}; + const compressionParameter = memoryConfig.compressionParameter || {}; + const defaultCompressionParameter = + createEmptyAgent().memoryConfigJson?.compressionParameter || {}; + const legacyMsgThreshold = memoryConfig.maxAttachedMessageCount; + return { + ...createEmptyAgent(), + ...source, + promptConfigJson: { + systemPrompt: '', + ...source.promptConfigJson, + }, + memoryConfigJson: { + ...memoryConfig, + compressionParameter: { + enabled: true, + ...compressionParameter, + msgThreshold: + compressionParameter.msgThreshold ?? + legacyMsgThreshold ?? + defaultCompressionParameter.msgThreshold, + lastKeep: + compressionParameter.lastKeep ?? defaultCompressionParameter.lastKeep, + minCompressionTokenThreshold: + compressionParameter.minCompressionTokenThreshold ?? + defaultCompressionParameter.minCompressionTokenThreshold, + }, + }, + }; +} + +function normalizeKnowledgeBinding( + binding: AgentKnowledgeBinding, + index: number, +): AgentKnowledgeBinding { + return { + ...binding, + enabled: binding.enabled !== false, + localId: + binding.localId || String(binding.id || createLocalId('knowledge')), + optionsJson: { + limit: 5, + scoreThreshold: 0.5, + ...binding.optionsJson, + }, + retrievalMode: binding.retrievalMode || 'HYBRID', + sortNo: binding.sortNo ?? index + 1, + }; +} + +function normalizeToolBinding( + binding: AgentToolBinding, + index: number, +): AgentToolBinding { + return { + ...binding, + enabled: binding.enabled !== false, + hitlEnabled: Boolean(binding.hitlEnabled), + localId: + binding.localId || + String( + binding.id || + createLocalId(String(binding.toolType || 'tool').toLowerCase()), + ), + toolName: normalizeBindingToolName(binding), + optionsJson: binding.optionsJson || {}, + sortNo: binding.sortNo ?? index + 1, + }; +} + +export function useAgentDesignerState() { + const state = reactive({ + agent: createEmptyAgent(), + knowledgeBindings: [], + toolBindings: [], + selectedNodeId: BASE_NODE_ID, + panelMode: 'base', + dirty: false, + }); + + const selectedCapability = computed(() => { + if (state.selectedNodeId.startsWith('knowledge:')) { + const localId = state.selectedNodeId.slice('knowledge:'.length); + return { + kind: 'knowledge' as AgentCapabilityKind, + binding: state.knowledgeBindings.find( + (item) => item.localId === localId, + ), + }; + } + if (state.selectedNodeId.startsWith('tool:')) { + const localId = state.selectedNodeId.slice('tool:'.length); + const binding = state.toolBindings.find( + (item) => item.localId === localId, + ); + return { + kind: + String(binding?.toolType || '').toUpperCase() === 'WORKFLOW' + ? ('workflow' as AgentCapabilityKind) + : ('plugin' as AgentCapabilityKind), + binding, + }; + } + return undefined; + }); + + function markDirty() { + state.dirty = true; + } + + function reset(agent?: AgentInfo) { + state.agent = normalizeAgent(agent); + state.knowledgeBindings = (agent?.knowledgeBindings || []).map( + (binding, index) => normalizeKnowledgeBinding(binding, index), + ); + state.toolBindings = (agent?.toolBindings || []).map((binding, index) => + normalizeToolBinding(binding, index), + ); + state.selectedNodeId = BASE_NODE_ID; + state.panelMode = 'base'; + state.dirty = false; + } + + function selectBase() { + state.selectedNodeId = BASE_NODE_ID; + state.panelMode = 'base'; + } + + function selectNode(nodeId: string) { + state.selectedNodeId = nodeId; + state.panelMode = nodeId === BASE_NODE_ID ? 'base' : 'capability'; + } + + function openTryout() { + state.panelMode = 'tryout'; + } + + function addKnowledgeNode(resource?: Record) { + const binding = normalizeKnowledgeBinding( + { + knowledgeId: resource?.id ? String(resource.id) : '', + resourceSummary: resource || {}, + }, + state.knowledgeBindings.length, + ); + state.knowledgeBindings.push(binding); + state.selectedNodeId = `knowledge:${binding.localId}`; + state.panelMode = 'capability'; + markDirty(); + } + + function addToolNode( + kind: Exclude, + resource?: Record, + ) { + const toolType = kind === 'workflow' ? 'WORKFLOW' : 'PLUGIN'; + const binding = normalizeToolBinding( + { + toolType, + targetId: resource?.id ? String(resource.id) : '', + toolName: resolveToolName(kind, resource), + resourceSummary: resource || {}, + }, + state.toolBindings.length, + ); + state.toolBindings.push(binding); + state.selectedNodeId = `tool:${binding.localId}`; + state.panelMode = 'capability'; + markDirty(); + } + + function removeSelectedCapability() { + const selected = selectedCapability.value; + if (!selected?.binding?.localId) return; + if (selected.kind === 'knowledge') { + state.knowledgeBindings = state.knowledgeBindings.filter( + (item) => item.localId !== selected.binding?.localId, + ); + } else { + state.toolBindings = state.toolBindings.filter( + (item) => item.localId !== selected.binding?.localId, + ); + } + selectBase(); + markDirty(); + } + + function validate(): AgentValidationIssue[] { + const issues: AgentValidationIssue[] = []; + if (!String(state.agent.name || '').trim()) { + issues.push({ + nodeId: BASE_NODE_ID, + field: 'name', + message: '请填写 Agent 名称', + }); + } + if (!state.agent.modelId) { + issues.push({ + nodeId: BASE_NODE_ID, + field: 'modelId', + message: '请选择模型', + }); + } + state.knowledgeBindings.forEach((binding) => { + if (!binding.knowledgeId) { + issues.push({ + nodeId: `knowledge:${binding.localId}`, + field: 'knowledgeId', + message: '请选择知识库', + }); + } + }); + state.toolBindings.forEach((binding) => { + const nodeId = `tool:${binding.localId}`; + if (!binding.targetId) { + issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' }); + } + if (!String(binding.toolName || '').trim()) { + issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' }); + } else if (!isSafeToolName(binding.toolName)) { + issues.push({ + nodeId, + field: 'toolName', + message: '工具名称只能包含英文、数字、下划线或中划线', + }); + } + }); + return issues; + } + + function buildPayloadAgent(): AgentInfo { + const memoryConfigJson = state.agent.memoryConfigJson || {}; + const compressionParameter = memoryConfigJson.compressionParameter || {}; + const { maxAttachedMessageCount, ...restMemoryConfigJson } = + memoryConfigJson; + return { + ...state.agent, + memoryConfigJson: { + ...restMemoryConfigJson, + compressionParameter: { + ...compressionParameter, + enabled: true, + }, + }, + status: state.agent.status ?? 1, + }; + } + + function buildKnowledgePayload(agentId?: number | string) { + return state.knowledgeBindings.map((binding, index) => ({ + ...binding, + agentId, + enabled: binding.enabled !== false, + sortNo: index + 1, + })); + } + + function buildToolPayload(agentId?: number | string) { + return state.toolBindings.map((binding, index) => ({ + ...binding, + agentId, + enabled: binding.enabled !== false, + hitlEnabled: Boolean(binding.hitlEnabled), + sortNo: index + 1, + })); + } + + reset(); + + return { + BASE_NODE_ID, + state, + selectedCapability, + addKnowledgeNode, + addToolNode, + buildKnowledgePayload, + buildPayloadAgent, + buildToolPayload, + markDirty, + openTryout, + removeSelectedCapability, + reset, + selectBase, + selectNode, + validate, + }; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts new file mode 100644 index 0000000..ae105e8 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts @@ -0,0 +1,213 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds'; + +describe('useAgentTryoutRawRounds', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('按原始事件顺序生成 timeline', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-1', + }); + const roundId = store.createRound('上一轮问题'); + + store.recordEvent(roundId, { + domain: 'LLM', + payload: { reasoning: '先思考' }, + type: 'THINKING', + }); + store.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '上一轮回答' }, + type: 'MESSAGE', + }); + store.completeRound(roundId); + + expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ + 'message', + 'message', + ]); + }); + + it('业务引用只用于展示', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-2', + }); + const roundId = store.createRound('查知识库'); + + store.recordEvent(roundId, { + domain: 'BUSINESS', + payload: { + items: [ + { + chunkContent: '知识库原文', + chunkId: 'chunk-1', + documentId: 'doc-1', + knowledgeId: 'kb-1', + }, + ], + }, + type: 'CITATIONS', + }); + store.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '引用后的回答' }, + type: 'MESSAGE', + }); + store.completeRound(roundId); + + expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ + 'message', + 'knowledge', + 'message', + ]); + }); + + it('AgentScope fragment 工具事件不进入页面时间线', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-fragment', + }); + const roundId = store.createRound('调用内部片段'); + + store.recordEvent(roundId, { + domain: 'TOOL', + payload: { + input: { text: 'fragment' }, + toolCallId: 'fragment-1', + toolName: '__fragment__', + }, + type: 'TOOL_CALL', + }); + store.recordEvent(roundId, { + domain: 'TOOL', + payload: { + output: 'internal', + toolCallId: 'fragment-1', + toolName: '__fragment__', + }, + type: 'TOOL_RESULT', + }); + + expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ + 'message', + ]); + }); + + it('刷新后能从 raw rounds 恢复 timeline', () => { + const first = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-5', + }); + const roundId = first.createRound('问题'); + first.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '回答' }, + type: 'MESSAGE', + }); + first.completeRound(roundId); + + const restored = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-5', + }); + + expect(restored.buildTimelineItems().map((item) => item.type)).toEqual([ + 'message', + 'message', + ]); + }); + + it('错误轮次不会被 completeRound 覆盖为成功状态', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-error', + }); + const roundId = store.createRound('会失败的问题'); + store.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '半截回答' }, + type: 'MESSAGE', + }); + store.recordEvent(roundId, { + domain: 'SYSTEM', + payload: { message: '调用失败' }, + type: 'ERROR', + }); + + store.completeRound(roundId); + + const assistant = store + .buildTimelineItems() + .find((item) => item.type === 'message' && item.role === 'assistant'); + expect(assistant).toMatchObject({ status: 'error' }); + }); + + it('流式重建 timeline 时保持稳定 item id', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-stable-id', + }); + const roundId = store.createRound('问题'); + store.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '你' }, + type: 'MESSAGE', + }); + const firstIds = store.buildTimelineItems().map((item) => item.id); + + store.recordEvent(roundId, { + domain: 'LLM', + payload: { delta: '好' }, + type: 'MESSAGE', + }); + const secondIds = store.buildTimelineItems().map((item) => item.id); + + expect(secondIds).toEqual(firstIds); + }); + + it('审批状态作为展示事件缓存并可刷新恢复', () => { + const first = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-approval', + }); + const roundId = first.createRound('审批工具'); + first.recordEvent(roundId, { + domain: 'TOOL', + payload: { + requestId: 'req-1', + resumeToken: 'resume-1', + toolCallId: 'call-approval', + toolName: 'dangerous_tool', + }, + type: 'FORM_REQUEST', + }); + first.recordEvent(roundId, { + domain: 'TOOL', + payload: { + requestId: 'req-1', + resumeToken: 'resume-1', + toolCallId: 'call-approval', + }, + type: 'FORM_APPROVING', + }); + first.flush(); + + const restored = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'session-raw-approval', + }); + const tool = restored + .buildTimelineItems() + .find((item) => item.type === 'tool'); + + expect(tool).toMatchObject({ + status: 'approving', + toolCallId: 'call-approval', + }); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts new file mode 100644 index 0000000..65910f5 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts @@ -0,0 +1,796 @@ +import type { + ChatTimelineItem, + ChatTimelineKnowledgeHit, + ChatTimelineMessageItem, +} from '@easyflow/common-ui'; +import {ChatTimelineBuilder} from '@easyflow/common-ui'; + +interface AgentTryoutRuntimeEvent { + createdAt: number; + domain: string; + payload: Record; + type: string; +} + +type AgentTryoutRoundStatus = 'completed' | 'error' | 'running'; + +interface AgentTryoutRawVariant { + createdAt: number; + runtimeEvents: AgentTryoutRuntimeEvent[]; + status: AgentTryoutRoundStatus; + updatedAt: number; + variantIndex: number; +} + +interface AgentTryoutRawRound { + createdAt: number; + prompt: string; + roundId: string; + selectedVariantIndex: number; + status: AgentTryoutRoundStatus; + updatedAt: number; + variants: AgentTryoutRawVariant[]; +} + +interface AgentTryoutRawSessionRecord { + rounds: AgentTryoutRawRound[]; + sessionId: string; + version: number; +} + +const STORAGE_VERSION = 1; +const MAX_ROUNDS = 50; +const MAX_VARIANTS = 10; +const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds'; +const PERSIST_DEBOUNCE_MS = 300; +const memorySessions = new Map(); + +function createRoundId() { + return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function asText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asBoolean(value: unknown) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.toLowerCase() === 'true'; + } + return Boolean(value); +} + +function normalizeToolName(value: unknown) { + return asText(value).trim().toLowerCase(); +} + +function isHiddenToolName(value: unknown) { + const normalizedName = normalizeToolName(value); + return normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__'; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function storageKey(mode: string, sessionId: string) { + return `${STORAGE_PREFIX}:${mode}:${sessionId}`; +} + +function safeSessionStorage() { + try { + return globalThis.sessionStorage; + } catch { + return undefined; + } +} + +function createVariant(variantIndex: number): AgentTryoutRawVariant { + const now = Date.now(); + return { + createdAt: now, + runtimeEvents: [], + status: 'running', + updatedAt: now, + variantIndex, + }; +} + +function normalizeRuntimeEvent(value: any): AgentTryoutRuntimeEvent | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const domain = asText(value.domain).toUpperCase(); + const type = asText(value.type).toUpperCase(); + if (!domain || !type) { + return undefined; + } + return { + createdAt: Number(value.createdAt || Date.now()), + domain, + payload: asRecord(value.payload), + type, + }; +} + +function normalizeVariant(value: any, index: number) { + if (!value || typeof value !== 'object') { + return createVariant(index); + } + const runtimeEvents = Array.isArray(value.runtimeEvents) + ? value.runtimeEvents + .map((item: any) => normalizeRuntimeEvent(item)) + .filter( + (item: AgentTryoutRuntimeEvent | undefined): item is AgentTryoutRuntimeEvent => + Boolean(item), + ) + : []; + return { + createdAt: Number(value.createdAt || Date.now()), + runtimeEvents, + status: + value.status === 'completed' || value.status === 'error' + ? value.status + : 'running', + updatedAt: Number(value.updatedAt || value.createdAt || Date.now()), + variantIndex: index, + }; +} + +function normalizeRound(value: any): AgentTryoutRawRound | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const prompt = asText(value.prompt); + const roundId = asText(value.roundId); + if (!prompt || !roundId) { + return undefined; + } + const variants = Array.isArray(value.variants) + ? value.variants + .slice(-MAX_VARIANTS) + .map((item: any, index: number) => normalizeVariant(item, index + 1)) + : []; + if (variants.length === 0) { + variants.push(createVariant(1)); + } + const selectedVariantIndex = Math.min( + Math.max(Number(value.selectedVariantIndex || variants.length), 1), + variants.length, + ); + return { + createdAt: Number(value.createdAt || Date.now()), + prompt, + roundId, + selectedVariantIndex, + status: + value.status === 'completed' || value.status === 'error' + ? value.status + : 'running', + updatedAt: Number(value.updatedAt || value.createdAt || Date.now()), + variants, + }; +} + +function restoreSession(mode: string, sessionId: string) { + const key = storageKey(mode, sessionId); + const memoryRecords = memorySessions.get(key); + if (memoryRecords) { + return memoryRecords.map((item) => clone(item)); + } + const storage = safeSessionStorage(); + if (!storage) { + return []; + } + try { + const raw = storage.getItem(key); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as AgentTryoutRawSessionRecord; + if (parsed.sessionId !== sessionId || parsed.version !== STORAGE_VERSION) { + return []; + } + const rounds = Array.isArray(parsed.rounds) + ? parsed.rounds + .map((item) => normalizeRound(item)) + .filter((item): item is AgentTryoutRawRound => Boolean(item)) + : []; + memorySessions.set(key, rounds.map((item) => clone(item))); + return rounds; + } catch { + return []; + } +} + +function persistSession( + mode: string, + sessionId: string, + rounds: AgentTryoutRawRound[], +) { + const key = storageKey(mode, sessionId); + const snapshot: AgentTryoutRawSessionRecord = { + rounds: rounds.slice(-MAX_ROUNDS).map((item) => clone(item)), + sessionId, + version: STORAGE_VERSION, + }; + memorySessions.set(key, snapshot.rounds.map((item) => clone(item))); + const storage = safeSessionStorage(); + if (!storage) { + return; + } + try { + storage.setItem(key, JSON.stringify(snapshot)); + } catch { + // 试运行缓存失败不影响当前聊天主流程。 + } +} + +function removeStoredSession(mode: string, sessionId: string) { + const key = storageKey(mode, sessionId); + memorySessions.delete(key); + const storage = safeSessionStorage(); + if (!storage) { + return; + } + try { + storage.removeItem(key); + } catch { + // 清理缓存失败不影响界面重置。 + } +} + +function selectedVariant(round: AgentTryoutRawRound) { + return ( + round.variants.find( + (variant) => variant.variantIndex === round.selectedVariantIndex, + ) || round.variants.at(-1) + ); +} + +function visibleText(item: ChatTimelineMessageItem) { + return item.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join(''); +} + +function isUserMessage(item: ChatTimelineItem): item is ChatTimelineMessageItem { + return item.type === 'message' && item.role === 'user'; +} + +function isAssistantMessage( + item: ChatTimelineItem, +): item is ChatTimelineMessageItem { + return item.type === 'message' && item.role === 'assistant'; +} + +function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) { + const userIndex = items.findIndex( + (item) => isUserMessage(item) && item.roundId === roundId, + ); + if (userIndex < 0) { + return undefined; + } + const nextUserIndex = items.findIndex( + (item, index) => index > userIndex && isUserMessage(item), + ); + return { + end: nextUserIndex >= 0 ? nextUserIndex : items.length, + start: userIndex + 1, + }; +} + +function assistantSegmentIndex(items: ChatTimelineItem[], roundId: string) { + return items.filter( + (item) => isAssistantMessage(item) && item.roundId === roundId, + ).length; +} + +function nextAssistantId( + items: ChatTimelineItem[], + roundId: string, + variantIndex: number, +) { + const last = items[items.length - 1]; + if ( + last && + isAssistantMessage(last) && + last.roundId === roundId && + last.status !== 'done' + ) { + return last.id; + } + return `assistant-${roundId}-${variantIndex}-${assistantSegmentIndex(items, roundId) + 1}`; +} + +function normalizeAssistantPartIds( + items: ChatTimelineItem[], + roundId: string, + variantIndex: number, +) { + const segment = assistantSegmentIndex(items, roundId); + const latest = [...items] + .reverse() + .find((item): item is ChatTimelineMessageItem => isAssistantMessage(item) && item.roundId === roundId); + if (!latest) { + return; + } + latest.id = `assistant-${roundId}-${variantIndex}-${segment}`; + latest.parts.forEach((part, index) => { + part.id = `${part.type}-${roundId}-${variantIndex}-${segment}-${index + 1}`; + }); +} + +function normalizeLatestItemId( + items: ChatTimelineItem[], + prefix: string, + roundId: string, + variantIndex: number, +) { + const last = items[items.length - 1]; + if (!last) { + return; + } + last.id = `${prefix}-${roundId}-${variantIndex}`; +} + +function markRoundCompleted( + items: ChatTimelineItem[], + roundId: string, + variant: AgentTryoutRawVariant, + variantCount: number, + selectedVariantIndex: number, +) { + const range = findRoundResponseRange(items, roundId); + const source = range ? items.slice(range.start, range.end) : items; + const latest = [...source] + .reverse() + .find((item): item is ChatTimelineMessageItem => isAssistantMessage(item)); + if (!latest) { + return; + } + latest.roundCompleted = true; + latest.status = latest.status === 'error' ? 'error' : 'done'; + latest.regenerable = true; + latest.switchable = variantCount > 1; + latest.variantCount = variantCount; + latest.variantIndex = variant.variantIndex; + latest.selectedVariantIndex = selectedVariantIndex; +} + +function normalizeKnowledgeItems(payload: Record) { + const source = + payload.items || + payload.hits || + payload.documents || + payload.knowledgeResults || + []; + if (!Array.isArray(source)) { + return []; + } + const topLevelKnowledgeType = asText(payload.knowledgeType); + const topLevelFaqCollection = + payload.faqCollection === undefined + ? topLevelKnowledgeType.toUpperCase() === 'FAQ' + : asBoolean(payload.faqCollection); + return source.map((item: any) => { + const metadata = asRecord(item.metadata); + const sourceFileName = asText( + item.sourceFileName ?? metadata.sourceFileName, + ); + const documentName = asText( + item.documentName ?? item.documentTitle ?? item.title, + ); + const chunkId = asText(item.chunkId ?? metadata.chunkId ?? item.id); + const documentId = asText( + item.documentId ?? metadata.documentId ?? item.id, + ); + return { + ...item, + id: asText(item.id || chunkId || documentId), + knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId), + knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName), + knowledgeType: asText(item.knowledgeType ?? payload.knowledgeType), + faqCollection: + item.faqCollection === undefined + ? topLevelFaqCollection + : asBoolean(item.faqCollection), + documentId, + documentName, + chunkId, + score: item.score ?? item.similarity, + source: item.source, + sourceFileName, + sourceUri: asText(item.sourceUri ?? metadata.sourceUri), + metadata, + chunkContent: asText( + item.chunkContent ?? item.content ?? item.text ?? item.summary, + ), + content: asText(item.content ?? item.text ?? item.summary), + title: documentName || sourceFileName || item.source, + } satisfies ChatTimelineKnowledgeHit; + }); +} + +function statusKeyForProjection( + payload: Record, + roundId: string, + variantIndex: number, + fallback = 'status', +) { + const statusKey = asText(payload.statusKey) || fallback; + return `${statusKey}:${roundId}:${variantIndex}`; +} + +function projectEventToTimeline( + items: ChatTimelineItem[], + event: AgentTryoutRuntimeEvent, + roundId: string, + variantIndex: number, +) { + const { domain, payload, type } = event; + if (domain === 'LLM' && type === 'MESSAGE') { + ChatTimelineBuilder.appendMessageDelta(items, payload.delta, { + id: nextAssistantId(items, roundId, variantIndex), + roundId, + }); + normalizeAssistantPartIds(items, roundId, variantIndex); + return; + } + if (domain === 'LLM' && type === 'THINKING') { + const text = asText(payload.reasoning ?? payload.delta ?? payload.text); + ChatTimelineBuilder.appendThinkingDelta(items, text, { + id: nextAssistantId(items, roundId, variantIndex), + roundId, + }); + normalizeAssistantPartIds(items, roundId, variantIndex); + return; + } + if (domain === 'TOOL' && type === 'FORM_REQUEST') { + ChatTimelineBuilder.appendToolApproval(items, { + expiresAt: asText(payload.expiresAt), + input: payload.input, + metadata: payload.metadata, + requestId: asText(payload.requestId), + resumeToken: asText(payload.resumeToken), + toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id), + toolDisplayName: asText(payload.toolDisplayName), + toolName: asText(payload.toolName), + toolType: asText(payload.toolType), + }); + if (items[items.length - 1]?.type === 'tool') { + normalizeLatestItemId(items, 'tool-approval', roundId, variantIndex); + } + return; + } + if (domain === 'TOOL' && type === 'FORM_APPROVING') { + ChatTimelineBuilder.markToolApproving(items, { + requestId: asText(payload.requestId), + resumeToken: asText(payload.resumeToken), + toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id), + }); + return; + } + if (domain === 'TOOL' && type === 'FORM_REJECTED') { + ChatTimelineBuilder.markToolRejected(items, { + reason: asText(payload.reason), + requestId: asText(payload.requestId), + resumeToken: asText(payload.resumeToken), + toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id), + }); + return; + } + if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { + const rawToolName = asText(payload.toolName ?? payload.name); + const normalizedToolName = normalizeToolName(rawToolName); + if (!normalizedToolName && type === 'TOOL_CALL') { + return; + } + const displayToolName = asText( + payload.toolDisplayName ?? rawToolName ?? '工具', + ); + ChatTimelineBuilder.upsertToolCall(items, { + input: payload.input ?? payload.toolInput, + output: payload.output ?? payload.result ?? payload.text, + status: type === 'TOOL_RESULT' ? 'success' : 'running', + statusKey: statusKeyForProjection( + payload, + roundId, + variantIndex, + 'knowledge-retrieval', + ), + toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id), + toolName: isHiddenToolName(rawToolName) ? rawToolName : displayToolName, + }); + return; + } + if (domain === 'BUSINESS' && type === 'CITATIONS') { + const itemsToAppend = normalizeKnowledgeItems(payload); + if (itemsToAppend.length > 0) { + ChatTimelineBuilder.appendKnowledge(items, itemsToAppend); + if (items[items.length - 1]?.type === 'knowledge') { + normalizeLatestItemId(items, 'knowledge', roundId, variantIndex); + } + } + 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: statusKeyForProjection(payload, roundId, variantIndex), + }); + return; + } + if (asText(payload.statusKey) === 'knowledge-retrieval') { + ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( + items, + asText(payload.status) === 'running' ? 'running' : 'done', + statusKeyForProjection(payload, roundId, variantIndex), + ); + } + return; + } + if (type === 'ERROR' || domain === 'ERROR') { + ChatTimelineBuilder.appendError( + items, + payload.message ?? payload.error ?? '试运行失败', + ); + normalizeLatestItemId(items, 'error', roundId, variantIndex); + } +} + +function sortedRounds(rounds: Map) { + return [...rounds.values()].sort( + (first, second) => + (first.createdAt || first.updatedAt) - + (second.createdAt || second.updatedAt), + ); +} + +export function useAgentTryoutRawRounds(options: { + mode: 'draft'; + sessionId: string; +}) { + const rounds = new Map(); + let persistTimer: ReturnType | undefined; + + for (const round of restoreSession(options.mode, options.sessionId)) { + rounds.set(round.roundId, round); + } + + function persistNow() { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = undefined; + } + const overflow = rounds.size - MAX_ROUNDS; + if (overflow > 0) { + for (const round of sortedRounds(rounds).slice(0, overflow)) { + rounds.delete(round.roundId); + } + } + persistSession(options.mode, options.sessionId, [...rounds.values()]); + } + + function schedulePersist() { + const key = storageKey(options.mode, options.sessionId); + memorySessions.set(key, [...rounds.values()].map((item) => clone(item))); + if (persistTimer) { + return; + } + persistTimer = setTimeout(() => { + persistNow(); + }, PERSIST_DEBOUNCE_MS); + } + + function clear() { + rounds.clear(); + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = undefined; + } + removeStoredSession(options.mode, options.sessionId); + } + + function createRound(prompt: string) { + const now = Date.now(); + const roundId = createRoundId(); + rounds.set(roundId, { + createdAt: now, + prompt, + roundId, + selectedVariantIndex: 1, + status: 'running', + updatedAt: now, + variants: [createVariant(1)], + }); + persistNow(); + return roundId; + } + + function regenerateRound(roundId: string) { + const round = rounds.get(roundId); + if (!round) { + return undefined; + } + const nextVariantIndex = Math.min(round.variants.length + 1, MAX_VARIANTS); + round.variants.push(createVariant(round.variants.length + 1)); + if (round.variants.length > MAX_VARIANTS) { + round.variants.splice(0, round.variants.length - MAX_VARIANTS); + round.variants.forEach((variant, index) => { + variant.variantIndex = index + 1; + }); + } + round.selectedVariantIndex = nextVariantIndex; + round.status = 'running'; + round.updatedAt = Date.now(); + persistNow(); + return round.roundId; + } + + function getPrompt(roundId?: string) { + return roundId ? rounds.get(roundId)?.prompt || '' : ''; + } + + function currentVariant(roundId: string) { + const round = rounds.get(roundId); + return round ? selectedVariant(round) : undefined; + } + + function recordEvent( + roundId: string, + event: { + domain: string; + payload?: Record; + type: string; + }, + ) { + const round = rounds.get(roundId); + const variant = round && selectedVariant(round); + if (!round || !variant) { + return; + } + const runtimeEvent: AgentTryoutRuntimeEvent = { + createdAt: Date.now(), + domain: event.domain.toUpperCase(), + payload: event.payload || {}, + type: event.type.toUpperCase(), + }; + variant.runtimeEvents.push(runtimeEvent); + if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') { + variant.status = 'completed'; + round.status = 'completed'; + } + if (runtimeEvent.type === 'ERROR' || runtimeEvent.domain === 'ERROR') { + variant.status = 'error'; + round.status = 'error'; + } + variant.updatedAt = Date.now(); + round.updatedAt = variant.updatedAt; + if ( + (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') || + runtimeEvent.type === 'ERROR' || + runtimeEvent.domain === 'ERROR' + ) { + persistNow(); + return; + } + schedulePersist(); + } + + function completeRound(roundId: string) { + const round = rounds.get(roundId); + const variant = round && selectedVariant(round); + if (!round || !variant) { + return; + } + if (variant.status === 'error' || round.status === 'error') { + persistNow(); + return; + } + variant.status = 'completed'; + round.status = 'completed'; + variant.updatedAt = Date.now(); + round.updatedAt = variant.updatedAt; + persistNow(); + } + + function buildTimelineItems() { + const items: ChatTimelineItem[] = []; + for (const round of sortedRounds(rounds)) { + ChatTimelineBuilder.appendUserMessage(items, round.prompt, { + id: `user-${round.roundId}`, + roundId: round.roundId, + }); + const variant = selectedVariant(round); + if (!variant) { + continue; + } + for (const event of variant.runtimeEvents) { + projectEventToTimeline(items, event, round.roundId, variant.variantIndex); + } + if (variant.status === 'completed' || variant.status === 'error') { + ChatTimelineBuilder.finalize(items); + markRoundCompleted( + items, + round.roundId, + variant, + round.variants.length, + round.selectedVariantIndex, + ); + } + } + return items; + } + + function selectVariant( + roundId: string, + direction: 'next' | 'previous', + ) { + const round = rounds.get(roundId); + if (!round) { + return; + } + const next = + direction === 'previous' + ? round.selectedVariantIndex - 1 + : round.selectedVariantIndex + 1; + if (next < 1 || next > round.variants.length) { + return; + } + round.selectedVariantIndex = next; + round.updatedAt = Date.now(); + persistNow(); + } + + function canSwitch( + item: ChatTimelineMessageItem, + direction: 'next' | 'previous', + ) { + const current = Number(item.selectedVariantIndex || item.variantIndex || 1); + const total = Number(item.variantCount || 1); + return direction === 'previous' ? current > 1 : current < total; + } + + function copyText(item: ChatTimelineMessageItem) { + return visibleText(item); + } + + return { + buildTimelineItems, + canSwitch, + clear, + completeRound, + copyText, + createRound, + currentVariant, + getPrompt, + recordEvent, + regenerateRound, + selectVariant, + flush: persistNow, + }; +} + +export type { + AgentTryoutRawRound, + AgentTryoutRawVariant, + AgentTryoutRuntimeEvent, +}; diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts new file mode 100644 index 0000000..fb23cca --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts @@ -0,0 +1,324 @@ +import type {ServerSentEventMessage} from 'fetch-event-stream'; + +import type { + ChatTimelineItem as ChatTimelineItemType, + ChatTimelineMessageItem, +} from '@easyflow/common-ui'; +import {ChatTimelineBuilder} from '@easyflow/common-ui'; + +import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types'; + +import {ref} from 'vue'; + +import {sseClient} from '#/api/request'; + +import {clearAgentDraftSession} from '../api'; +import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds'; + +function resolveDraftSessionId(agent: AgentInfo) { + return `agent-draft-${agent.id || agent.localId || 'unsaved'}`; +} + +function parseEventData(message: ServerSentEventMessage) { + const raw = message.data || ''; + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return { payload: { delta: raw } }; + } +} + +function resolveEnvelope(data: any) { + return { + domain: data.domain || data.eventDomain || data.typeDomain, + type: data.type || data.eventType || data.chatType || data.event, + payload: data.payload ?? data.data ?? data, + }; +} + +function asText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function isEndOfRoundEvent(domain: string, type: string) { + return domain === 'SYSTEM' && type === 'DONE'; +} + +interface DraftRuntimeContext { + agent: AgentInfo; + knowledgeBindings: AgentKnowledgeBinding[]; + toolBindings: AgentToolBinding[]; +} + +export function useAgentTryoutStream() { + const timelineItems = ref([]); + const loading = ref(false); + let rawRounds: ReturnType | undefined; + let activeRoundId = ''; + let activeSessionId = ''; + let userStopped = false; + + function errorMessageOf(error: unknown) { + if (error instanceof Error) { + return `${error.name} ${error.message}`.trim(); + } + if (typeof error === 'string') { + return error; + } + const value = asRecord(error); + const nested = asRecord(value.cause); + return [value.name, value.message, value.error, nested.name, nested.message] + .map((item) => asText(item).trim()) + .filter(Boolean) + .join(' '); + } + + function isAbortError(error: unknown) { + const message = errorMessageOf(error).toLowerCase(); + return message.includes('abort'); + } + + function shouldIgnoreStoppedError(error: unknown) { + return userStopped && isAbortError(error); + } + + function finishStoppedRun() { + finishAssistant(); + rawRounds?.flush(); + loading.value = false; + } + + function rebuildTimeline() { + timelineItems.value = rawRounds?.buildTimelineItems() || []; + } + + function syncDraftContext(payload: DraftRuntimeContext, restore = false) { + const sessionId = resolveDraftSessionId(payload.agent); + const sessionChanged = activeSessionId !== sessionId; + activeSessionId = sessionId; + if (!rawRounds || sessionChanged) { + rawRounds = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId, + }); + activeRoundId = ''; + } + if (restore && sessionChanged && !loading.value) { + rebuildTimeline(); + } + } + + function markRoundCompleted(roundId: string) { + if (!roundId) { + return; + } + rawRounds?.completeRound(roundId); + rebuildTimeline(); + } + + function finishAssistant() { + ChatTimelineBuilder.finalize(timelineItems.value); + } + + function markToolApproving(payload: { + requestId?: string; + resumeToken?: string; + toolCallId?: string; + }) { + rawRounds?.recordEvent(activeRoundId, { + domain: 'TOOL', + payload, + type: 'FORM_APPROVING', + }); + rawRounds?.flush(); + rebuildTimeline(); + } + + function markToolRejected(payload: { + reason?: string; + requestId?: string; + resumeToken?: string; + toolCallId?: string; + }) { + rawRounds?.recordEvent(activeRoundId, { + domain: 'TOOL', + payload, + type: 'FORM_REJECTED', + }); + rawRounds?.flush(); + rebuildTimeline(); + } + + function handleMessage(message: ServerSentEventMessage) { + const data = parseEventData(message); + const envelope = resolveEnvelope(data); + const domain = String(envelope.domain || '').toUpperCase(); + const type = String(envelope.type || '').toUpperCase(); + const payload = envelope.payload || {}; + + if (activeRoundId) { + rawRounds?.recordEvent(activeRoundId, { + domain, + payload, + type, + }); + rebuildTimeline(); + } + + if (domain === 'LLM' && type === 'MESSAGE') { + return; + } + if (domain === 'LLM' && type === 'THINKING') { + const text = asText(payload.reasoning ?? payload.delta ?? payload.text); + if (!text) return; + return; + } + if (domain === 'TOOL' && type === 'FORM_REQUEST') { + return; + } + if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { + return; + } + if (domain === 'BUSINESS' && type === 'CITATIONS') { + return; + } + if (domain === 'BUSINESS' && type === 'STATUS') { + return; + } + if (isEndOfRoundEvent(domain, type)) { + markRoundCompleted(activeRoundId); + return; + } + if (type === 'ERROR' || domain === 'ERROR') { + const message = payload.message ?? payload.error ?? '试运行失败'; + if (shouldIgnoreStoppedError(message)) { + return; + } + } + } + + async function runDraft(payload: { + agent: AgentInfo; + knowledgeBindings: AgentKnowledgeBinding[]; + prompt: string; + toolBindings: AgentToolBinding[]; + }) { + syncDraftContext(payload); + if (!rawRounds) { + return; + } + activeRoundId = rawRounds.createRound(payload.prompt); + rebuildTimeline(); + loading.value = true; + userStopped = false; + await sseClient.post( + '/api/v1/agent/chat/draft', + { + ...payload, + sessionId: activeSessionId, + }, + { + onMessage: handleMessage, + onError: (error) => { + if (shouldIgnoreStoppedError(error)) { + return; + } + rawRounds?.recordEvent(activeRoundId, { + domain: 'SYSTEM', + payload: { + message: error?.message ?? '试运行失败,请稍后再试', + }, + type: 'ERROR', + }); + rebuildTimeline(); + finishAssistant(); + rawRounds?.flush(); + loading.value = false; + }, + onFinished: () => { + if (userStopped) { + return; + } + finishAssistant(); + markRoundCompleted(activeRoundId); + rawRounds?.flush(); + loading.value = false; + }, + }, + ); + } + + async function sendDraft(payload: { + agent: AgentInfo; + knowledgeBindings: AgentKnowledgeBinding[]; + prompt: string; + toolBindings: AgentToolBinding[]; + }) { + await runDraft(payload); + } + + async function regenerateDraft(item: ChatTimelineMessageItem) { + // 有状态 runtime 的历史重生成需要后端 session fork/rollback,第一版先禁用入口。 + void item; + } + + function selectVariant( + item: ChatTimelineMessageItem, + direction: 'next' | 'previous', + ) { + if (!item.roundId || !rawRounds?.canSwitch(item, direction)) { + return; + } + rawRounds.selectVariant(item.roundId, direction); + rebuildTimeline(); + } + + function copyMessageText(item: ChatTimelineMessageItem) { + return rawRounds?.copyText(item) || ''; + } + + async function clearDraftSession() { + if (loading.value) { + userStopped = true; + sseClient.abort(); + loading.value = false; + } + const sessionId = activeSessionId; + rawRounds?.clear(); + timelineItems.value = []; + activeRoundId = ''; + if (sessionId) { + await clearAgentDraftSession(sessionId); + } + } + + function stop() { + if (!loading.value) { + return; + } + userStopped = true; + sseClient.abort(); + finishStoppedRun(); + } + + return { + loading, + clearDraftSession, + markToolApproving, + markToolRejected, + copyMessageText, + regenerateDraft, + selectVariant, + syncDraftContext, + timelineItems, + sendDraft, + stop, + }; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/types.ts new file mode 100644 index 0000000..e1e0ed2 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/types.ts @@ -0,0 +1,83 @@ +/* cspell:ignore hitl */ + +export type AgentPanelMode = 'base' | 'capability' | 'tryout'; +export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow'; + +export interface AgentInfo { + id?: number | string; + name?: string; + description?: string; + avatar?: string; + categoryId?: number | string; + modelId?: number | string; + modelConfigJson?: Record; + generationConfigJson?: Record; + promptConfigJson?: Record; + memoryConfigJson?: Record; + executionConfigJson?: Record; + status?: number; + visibilityScope?: string; + publishStatus?: string; + displayPublishStatus?: string; + approvalPending?: boolean; + currentApprovalActionType?: string; + currentApprovalInstanceId?: number | string; + publishedSnapshotJson?: Record; + toolBindings?: AgentToolBinding[]; + knowledgeBindings?: AgentKnowledgeBinding[]; + created?: string; + createdByName?: string; + [key: string]: any; +} + +export interface AgentToolBinding { + id?: number | string; + agentId?: number | string; + toolType: 'PLUGIN' | 'WORKFLOW' | string; + targetId?: number | string; + toolName?: string; + enabled?: boolean; + hitlEnabled?: boolean; + hitlConfigJson?: Record; + optionsJson?: Record; + resourceSnapshot?: Record; + resourceSummary?: Record; + sortNo?: number; + localId?: string; + [key: string]: any; +} + +export interface AgentKnowledgeBinding { + id?: number | string; + agentId?: number | string; + knowledgeId?: number | string; + retrievalMode?: string; + enabled?: boolean; + optionsJson?: Record; + resourceSnapshot?: Record; + resourceSummary?: Record; + sortNo?: number; + localId?: string; + [key: string]: any; +} + +export interface AgentDraftState { + agent: AgentInfo; + toolBindings: AgentToolBinding[]; + knowledgeBindings: AgentKnowledgeBinding[]; + selectedNodeId: string; + panelMode: AgentPanelMode; + dirty: boolean; +} + +export interface AgentOption { + label: string; + value: string; + raw?: Record; +} + +export interface AgentValidationIssue { + nodeId: string; + message: string; + field?: string; +} diff --git a/easyflow-ui-admin/app/src/views/ai/chat/index.vue b/easyflow-ui-admin/app/src/views/ai/chat/index.vue index f56effb..40a0b85 100644 --- a/easyflow-ui-admin/app/src/views/ai/chat/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/chat/index.vue @@ -1,25 +1,17 @@ diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatEventLabel.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatEventLabel.vue new file mode 100644 index 0000000..13c6ad3 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatEventLabel.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatShimmerText.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatShimmerText.vue new file mode 100644 index 0000000..f3d8b8a --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/ChatShimmerText.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/__tests__/ChatShimmerText.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/__tests__/ChatShimmerText.test.ts new file mode 100644 index 0000000..9956349 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/__tests__/ChatShimmerText.test.ts @@ -0,0 +1,23 @@ +import {mount} from '@vue/test-utils'; + +import {describe, expect, it} from 'vitest'; + +import ChatShimmerText from '../ChatShimmerText.vue'; + +describe('ChatShimmerText', () => { + it('renders text and toggles active shimmer class', async () => { + const wrapper = mount(ChatShimmerText, { + props: { + active: true, + text: '正在检索知识库', + }, + }); + + expect(wrapper.text()).toBe('正在检索知识库'); + expect(wrapper.classes()).toContain('is-active'); + + await wrapper.setProps({ active: false }); + + expect(wrapper.classes()).not.toContain('is-active'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/index.ts new file mode 100644 index 0000000..96629f7 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-status/index.ts @@ -0,0 +1,2 @@ +export { default as ChatShimmerText } from './ChatShimmerText.vue'; +export { default as ChatEventLabel } from './ChatEventLabel.vue'; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/ChatThinkingBlock.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/ChatThinkingBlock.vue index 9c00464..f7409dc 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/ChatThinkingBlock.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/ChatThinkingBlock.vue @@ -1,7 +1,9 @@ + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatKnowledgeCard.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatKnowledgeCard.vue new file mode 100644 index 0000000..467f7cc --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatKnowledgeCard.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatMessageToolbar.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatMessageToolbar.vue new file mode 100644 index 0000000..818b808 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatMessageToolbar.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTextBlock.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTextBlock.vue new file mode 100644 index 0000000..1c7eddf --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTextBlock.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue new file mode 100644 index 0000000..f9288d4 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue new file mode 100644 index 0000000..34e3f2c --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineStatusRow.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineStatusRow.vue new file mode 100644 index 0000000..981bb4d --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineStatusRow.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolApprovalCard.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolApprovalCard.vue new file mode 100644 index 0000000..b25b7b6 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolApprovalCard.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolCard.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolCard.vue new file mode 100644 index 0000000..e2f6155 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatToolCard.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatVariantNavigator.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatVariantNavigator.vue new file mode 100644 index 0000000..83e3430 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatVariantNavigator.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatKnowledgeCard.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatKnowledgeCard.test.ts new file mode 100644 index 0000000..e6947d7 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatKnowledgeCard.test.ts @@ -0,0 +1,119 @@ +import {mount} from '@vue/test-utils'; + +import {describe, expect, it} from 'vitest'; + +import ChatKnowledgeCard from '../ChatKnowledgeCard.vue'; + +describe('ChatKnowledgeCard', () => { + it('renders FAQ citation with knowledge name', () => { + const wrapper = mount(ChatKnowledgeCard, { + props: { + items: [ + { + id: 'faq-1', + chunkContent: '暑假安排原文', + faqCollection: true, + knowledgeId: 'kb-faq', + knowledgeName: '学生事务 FAQ', + knowledgeType: 'FAQ', + }, + ], + }, + }); + + expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain( + '学生事务 FAQ', + ); + }); + + it('renders document citation with document name', () => { + const wrapper = mount(ChatKnowledgeCard, { + props: { + items: [ + { + chunkContent: '文档 chunk 原文', + documentId: 'doc-1', + documentName: '数据引接与治理.pdf', + knowledgeName: '治理知识库', + knowledgeType: 'DOCUMENT', + }, + ], + }, + }); + + expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain( + '数据引接与治理.pdf', + ); + }); + + it('falls back to sourceFileName for document citation', () => { + const wrapper = mount(ChatKnowledgeCard, { + props: { + items: [ + { + chunkContent: '文档 chunk 原文', + documentId: 'doc-1', + knowledgeName: '治理知识库', + knowledgeType: 'DOCUMENT', + metadata: { + sourceFileName: '治理方案.docx', + }, + }, + ], + }, + }); + + expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain( + '治理方案.docx', + ); + }); + + it('aggregates multiple chunks from the same source', () => { + const wrapper = mount(ChatKnowledgeCard, { + props: { + items: [ + { + chunkContent: '第一段', + chunkId: 'chunk-1', + documentId: 'doc-1', + documentName: '治理方案.docx', + knowledgeType: 'DOCUMENT', + }, + { + chunkContent: '第二段', + chunkId: 'chunk-2', + documentId: 'doc-1', + documentName: '治理方案.docx', + knowledgeType: 'DOCUMENT', + }, + ], + }, + }); + + expect(wrapper.findAll('.chat-knowledge-card__pill')).toHaveLength(1); + expect(wrapper.find('.chat-knowledge-card__count').text()).toContain('2'); + }); + + it('shows raw chunk content after clicking citation', async () => { + const wrapper = mount(ChatKnowledgeCard, { + props: { + items: [ + { + chunkContent: '这里是命中片段原文', + chunkId: 'chunk-1', + documentId: 'doc-1', + documentName: '治理方案.docx', + knowledgeName: '治理知识库', + score: 0.86, + }, + ], + }, + }); + + await wrapper.find('.chat-knowledge-card__pill').trigger('click'); + + expect(wrapper.find('.chat-knowledge-card__popover').exists()).toBe(true); + expect(wrapper.text()).toContain('这里是命中片段原文'); + expect(wrapper.text()).toContain('86%'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts new file mode 100644 index 0000000..a215fed --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts @@ -0,0 +1,76 @@ +import type {ChatTimelineStatusItem} from '../types'; + +import {mount} from '@vue/test-utils'; + +import {describe, expect, it} from 'vitest'; + +import ChatTimelineStatusRow from '../ChatTimelineStatusRow.vue'; + +describe('ChatTimelineStatusRow', () => { + it('uses shimmer text while running and static text after done', async () => { + const item: ChatTimelineStatusItem = { + id: 'knowledge-retrieval', + label: '正在检索知识库', + status: 'running', + statusKey: 'knowledge-retrieval', + type: 'status', + }; + const wrapper = mount(ChatTimelineStatusRow, { + props: { item }, + }); + + expect(wrapper.text()).toContain('正在检索知识库'); + expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false); + expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); + expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active'); + + await wrapper.setProps({ + item: { + ...item, + label: '已检索知识库', + status: 'done', + }, + }); + + expect(wrapper.text()).toContain('已检索知识库'); + expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active'); + }); + + it('renders memory compression status as a separator row', () => { + const item: ChatTimelineStatusItem = { + id: 'memory-compression', + label: '正在整理上下文', + presentation: 'separator', + status: 'running', + statusKey: 'memory-compression', + type: 'status', + }; + const wrapper = mount(ChatTimelineStatusRow, { + props: { item }, + }); + + expect(wrapper.classes()).toContain('is-separator'); + expect(wrapper.text()).toContain('正在整理上下文'); + expect(wrapper.findAll('.chat-timeline-status-row__line')).toHaveLength(2); + expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(true); + expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); + expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active'); + }); + + it('uses the shared event label style', () => { + const item: ChatTimelineStatusItem = { + id: 'knowledge-retrieval', + label: '已检索知识库', + status: 'done', + statusKey: 'knowledge-retrieval', + type: 'status', + }; + const wrapper = mount(ChatTimelineStatusRow, { + props: { item }, + }); + + expect(wrapper.find('.chat-event-label').exists()).toBe(true); + expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); + expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts new file mode 100644 index 0000000..43c3296 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts @@ -0,0 +1,190 @@ +import type {ChatTimelineItem} from '../types'; + +import {mount} from '@vue/test-utils'; + +import {describe, expect, it} from 'vitest'; + +import ChatTimeline from '../ChatTimeline.vue'; + +function textMessage( + role: 'assistant' | 'user', + content: string, + extra: Partial> = {}, +): Extract { + return { + id: `${role}-${content}`, + parts: [ + { + id: `text-${content}`, + content, + type: 'text', + }, + ], + role, + status: 'done', + type: 'message', + ...extra, + }; +} + +describe('ChatTimeline toolbar', () => { + it('shows copy button for user messages and emits copy-message', async () => { + const userMessage = textMessage('user', '用户问题'); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => true, + items: [userMessage], + }, + }); + + const copyButton = wrapper.find('[aria-label="复制消息"]'); + expect(copyButton.exists()).toBe(true); + + await copyButton.trigger('click'); + + expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage); + }); + + it('shows copy and regenerate buttons for assistant messages', async () => { + const assistantMessage = textMessage('assistant', '助手回答', { + regenerable: true, + roundId: 'round-1', + roundCompleted: true, + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => true, + items: [assistantMessage], + regenerable: () => true, + }, + }); + + expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(true); + const regenerateButton = wrapper.find('[aria-label="重新生成"]'); + expect(regenerateButton.exists()).toBe(true); + + await regenerateButton.trigger('click'); + + expect(wrapper.emitted('regenerateMessage')?.[0]?.[0]).toEqual( + assistantMessage, + ); + }); + + it('renders variant navigator and disables boundary buttons', async () => { + const assistantMessage = textMessage('assistant', '助手回答', { + roundId: 'round-1', + selectedVariantIndex: 1, + roundCompleted: true, + switchable: true, + variantCount: 2, + variantIndex: 1, + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => true, + items: [assistantMessage], + regenerable: () => true, + }, + }); + + expect(wrapper.text()).toContain('1/2'); + const buttons = wrapper.findAll('.chat-variant-navigator__button'); + expect(buttons[0]?.attributes('disabled')).toBeDefined(); + expect(buttons[1]?.attributes('disabled')).toBeUndefined(); + + await buttons[1]?.trigger('click'); + + expect(wrapper.emitted('selectNextVariant')?.[0]?.[0]).toEqual( + assistantMessage, + ); + }); + + it('disables regenerate while streaming or globally disabled', () => { + const assistantMessage = textMessage('assistant', '助手回答', { + roundId: 'round-1', + status: 'streaming', + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => true, + items: [assistantMessage], + regenerable: () => false, + regenerateDisabled: true, + }, + }); + + expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false); + }); + + it('does not show message toolbar before streaming completes', () => { + const assistantMessage = textMessage('assistant', '助手回答', { + roundId: 'round-1', + selectedVariantIndex: 1, + status: 'streaming', + roundCompleted: false, + switchable: true, + variantCount: 2, + variantIndex: 1, + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: (item) => item.status === 'done', + items: [assistantMessage], + regenerable: (item) => item.status === 'done', + }, + }); + + expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(false); + expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false); + expect(wrapper.find('.chat-variant-navigator').exists()).toBe(false); + }); + + it('keeps toolbar hidden for unfinished rounds even after a partial done segment', () => { + const assistantMessage = textMessage('assistant', '助手回答', { + roundId: 'round-1', + status: 'done', + roundCompleted: false, + switchable: true, + variantCount: 2, + variantIndex: 1, + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => false, + items: [assistantMessage], + regenerable: () => false, + }, + }); + + expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(false); + expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false); + }); + + it('only shows action toolbar on the last assistant segment of the round', () => { + const assistantHead = textMessage('assistant', '前半段', { + roundId: 'round-1', + status: 'done', + roundCompleted: true, + }); + const assistantTail = textMessage('assistant', '后半段', { + roundId: 'round-1', + status: 'done', + roundCompleted: true, + }); + const wrapper = mount(ChatTimeline, { + props: { + copyable: () => true, + items: [assistantHead, assistantTail], + regenerable: () => true, + }, + }); + + const copyButtons = wrapper.findAll('[aria-label="复制消息"]'); + const regenerateButtons = wrapper.findAll('[aria-label="重新生成"]'); + + expect(copyButtons).toHaveLength(1); + expect(regenerateButtons).toHaveLength(1); + expect(wrapper.text()).toContain('前半段'); + expect(wrapper.text()).toContain('后半段'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts new file mode 100644 index 0000000..d1ea51c --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts @@ -0,0 +1,574 @@ +import type {ChatTimelineItem} from '../types'; + +import {describe, expect, it} from 'vitest'; + +import {ChatTimelineBuilder} from '../builder'; + +describe('chat timeline builder', () => { + it('keeps streamed thinking, text, tool and following text in timeline order', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '先思考'); + ChatTimelineBuilder.appendMessageDelta(items, '正文 A'); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '查询工具', + input: { keyword: 'EasyFlow' }, + }); + ChatTimelineBuilder.appendMessageDelta(items, '正文 B'); + + expect(items).toHaveLength(3); + expect(items[0]?.type).toBe('message'); + expect(items[1]?.type).toBe('tool'); + expect(items[2]?.type).toBe('message'); + + const firstMessage = items[0]; + expect(firstMessage?.type).toBe('message'); + if (firstMessage?.type === 'message') { + expect(firstMessage.parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(firstMessage.parts[0]?.content).toBe('先思考'); + expect(firstMessage.parts[1]?.content).toBe('正文 A'); + } + + const secondMessage = items[2]; + expect(secondMessage?.type).toBe('message'); + if (secondMessage?.type === 'message') { + expect(secondMessage.parts.map((part) => part.type)).toEqual(['text']); + expect(secondMessage.parts[0]?.content).toBe('正文 B'); + } + }); + + it('keeps round metadata on user and assistant messages', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendUserMessage(items, '问题', { + roundId: 'round-1', + }); + ChatTimelineBuilder.appendMessageDelta(items, '回答', { + roundId: 'round-1', + variantIndex: 1, + }); + ChatTimelineBuilder.finalize(items); + + expect(items).toHaveLength(2); + expect(items[0]?.type).toBe('message'); + expect(items[1]?.type).toBe('message'); + if (items[0]?.type === 'message' && items[1]?.type === 'message') { + expect(items[0].roundId).toBe('round-1'); + expect(items[1].roundId).toBe('round-1'); + expect(items[1].variantIndex).toBe(1); + expect(items[1].status).toBe('done'); + } + }); + + it('updates tool result by toolCallId instead of adding another card', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '查询工具', + input: { keyword: 'EasyFlow' }, + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '查询工具', + output: { result: 'ok' }, + status: 'success', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('tool'); + if (items[0]?.type === 'tool') { + expect(items[0].status).toBe('success'); + expect(items[0].input).toEqual({ keyword: 'EasyFlow' }); + expect(items[0].output).toEqual({ result: 'ok' }); + } + }); + + it('shows built-in knowledge retrieval as a lightweight status row', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge', + toolName: 'retrieve_knowledge', + input: { query: '请假安排' }, + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('status'); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('正在检索知识库'); + expect(items[0].status).toBe('running'); + } + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge', + toolName: 'retrieve_knowledge', + output: { result: 'ok' }, + status: 'success', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('status'); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('已检索知识库'); + expect(items[0].status).toBe('done'); + } + }); + + it('shows memory compression status as a lightweight status row', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + label: '正在整理上下文', + phase: 'started', + status: 'running', + statusKey: 'memory-compression', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('status'); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('正在整理上下文'); + expect(items[0].presentation).toBe('separator'); + expect(items[0].status).toBe('running'); + expect(items[0].statusKey).toBe('memory-compression'); + } + + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + label: '已整理上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + compressed: true, + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('status'); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('已整理上下文'); + expect(items[0].presentation).toBe('separator'); + expect(items[0].status).toBe('done'); + expect(items[0].statusKey).toBe('memory-compression'); + } + }); + + it('shows no compression needed when memory compression produced no compressed event', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + compressed: false, + label: '已整理上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('status'); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('无需压缩上下文'); + expect(items[0].presentation).toBe('separator'); + expect(items[0].status).toBe('done'); + expect(items[0].statusKey).toBe('memory-compression'); + } + }); + + it('ends current thinking before showing knowledge retrieval status', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '需要先检索知识库'); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge', + toolName: 'retrieve_knowledge', + }); + ChatTimelineBuilder.appendThinkingDelta(items, '开始分析检索结果'); + + expect(items).toHaveLength(3); + expect(items[0]?.type).toBe('message'); + expect(items[1]?.type).toBe('status'); + expect(items[2]?.type).toBe('message'); + if (items[0]?.type === 'message' && items[2]?.type === 'message') { + expect(items[0].status).toBe('done'); + expect(items[0].roundCompleted).not.toBe(true); + expect(items[0].parts[0]).toMatchObject({ + content: '需要先检索知识库', + expanded: false, + status: 'end', + type: 'thinking', + }); + expect(items[2].parts[0]).toMatchObject({ + content: '开始分析检索结果', + expanded: true, + status: 'thinking', + type: 'thinking', + }); + } + }); + + it('keeps AgentScope internal fragment hidden without showing knowledge status', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-fragment', + toolName: '__fragment__', + input: { query: '暑假安排' }, + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-fragment', + toolName: '__fragment__', + output: { result: 'ok' }, + status: 'success', + }); + + expect(items).toHaveLength(0); + }); + + it('ignores anonymous tool call events instead of rendering a fallback card', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-fragment', + input: { arguments: '{"query":"test"}' }, + }); + + expect(items).toHaveLength(0); + }); + + it('does not let hidden tool events overwrite a visible tool card', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '查询工具', + input: { keyword: 'EasyFlow' }, + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '__fragment__', + input: { arguments: '{"keyword":"EasyFlow"}' }, + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + toolCallId: 'call-1', + toolName: '查询工具', + type: 'tool', + }); + }); + + it('keeps knowledge retrieval events in one timeline status row', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge-1', + toolName: 'retrieve_knowledge', + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge-2', + toolName: 'retrieve_knowledge', + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-knowledge-1', + toolName: 'retrieve_knowledge', + status: 'success', + }); + ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( + items, + 'done', + 'knowledge-retrieval', + ); + + expect(items).toHaveLength(1); + expect(items.every((item) => item.type === 'status')).toBe(true); + if (items[0]?.type === 'status') { + expect(items[0].label).toBe('已检索知识库'); + expect(items[0].status).toBe('done'); + expect(items[0].statusKey).toBe('knowledge-retrieval'); + } + }); + + it('attaches final knowledge citations to assistant message', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendMessageDelta(items, '暑假安排如下'); + ChatTimelineBuilder.appendKnowledge(items, [ + { + chunkContent: '暑假安排原文', + chunkId: 'faq-1', + faqCollection: true, + knowledgeName: '学生事务 FAQ', + }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('message'); + if (items[0]?.type === 'message') { + expect(items[0].knowledgeItems?.[0]?.chunkContent).toBe('暑假安排原文'); + expect(items[0].knowledgeItems?.[0]?.knowledgeName).toBe('学生事务 FAQ'); + } + }); + + it('keeps knowledge citations as fallback item without assistant message', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendKnowledge(items, [ + { + chunkContent: '第一段', + chunkId: 'chunk-1', + documentId: 'doc-1', + documentName: '治理方案.docx', + }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('knowledge'); + if (items[0]?.type === 'knowledge') { + expect(items[0].items).toHaveLength(1); + } + }); + + it('keeps approval tool lifecycle in one card', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendToolApproval(items, { + requestId: 'request-1', + resumeToken: 'resume-1', + toolCallId: 'call-1', + toolName: '审批工具', + input: { keyword: 'EasyFlow' }, + }); + ChatTimelineBuilder.markToolApproving(items, { + requestId: 'request-1', + resumeToken: 'resume-1', + toolCallId: 'call-1', + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '审批工具', + status: 'running', + }); + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + output: { result: 'ok' }, + status: 'success', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('tool'); + if (items[0]?.type === 'tool') { + expect(items[0].mode).toBe('approval'); + expect(items[0].status).toBe('success'); + expect(items[0].approval?.requestId).toBe('request-1'); + expect(items[0].input).toEqual({ keyword: 'EasyFlow' }); + expect(items[0].output).toEqual({ result: 'ok' }); + } + }); + + it('marks approval tool rejected in the same card', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendToolApproval(items, { + requestId: 'request-1', + resumeToken: 'resume-1', + toolCallId: 'call-1', + toolName: '审批工具', + input: { keyword: 'EasyFlow' }, + }); + ChatTimelineBuilder.markToolRejected(items, { + requestId: 'request-1', + toolCallId: 'call-1', + reason: '用户拒绝执行', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('tool'); + if (items[0]?.type === 'tool') { + expect(items[0].mode).toBe('approval'); + expect(items[0].status).toBe('rejected'); + expect(items[0].rejectReason).toBe('用户拒绝执行'); + } + }); + + it('keeps approval and auto updates separate when toolCallId differs', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertToolCall(items, { + toolCallId: 'call-1', + toolName: '查询工具', + input: { keyword: 'before approval' }, + }); + ChatTimelineBuilder.appendToolApproval(items, { + requestId: 'request-1', + resumeToken: 'resume-1', + toolCallId: 'call-2', + toolName: '查询工具', + input: { keyword: 'approval' }, + }); + + expect(items).toHaveLength(2); + expect(items[0]?.type).toBe('tool'); + expect(items[1]?.type).toBe('tool'); + if (items[0]?.type === 'tool' && items[1]?.type === 'tool') { + expect(items[0].toolCallId).toBe('call-1'); + expect(items[0].mode).toBe('auto'); + expect(items[1].toolCallId).toBe('call-2'); + expect(items[1].mode).toBe('approval'); + expect(items[1].status).toBe('pending_approval'); + expect(items[1].approval?.requestId).toBe('request-1'); + expect(items[1].input).toEqual({ keyword: 'approval' }); + } + }); + + it('streams thinking content in a thinking part', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, 'A'); + ChatTimelineBuilder.appendThinkingDelta(items, 'B'); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe('message'); + if (items[0]?.type === 'message') { + expect(items[0].parts).toHaveLength(1); + expect(items[0].parts[0]).toMatchObject({ + content: 'AB', + expanded: true, + status: 'thinking', + type: 'thinking', + }); + } + }); + + it('keeps reasoning content separate from normal text payloads', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '思考中'); + ChatTimelineBuilder.appendMessageDelta(items, '正文'); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[0]?.content).toBe('思考中'); + expect(items[0].parts[0]).toMatchObject({ + expanded: false, + status: 'end', + }); + expect(items[0].parts[1]?.content).toBe('正文'); + } + }); + + it('appends duplicated text deltas without filtering model output', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '思考中'); + ChatTimelineBuilder.appendMessageDelta(items, '你好啊'); + ChatTimelineBuilder.appendMessageDelta(items, '你好啊'); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[1]?.content).toBe('你好啊你好啊'); + } + }); + + it('appends accumulated-looking text without guessing protocol semantics', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '思考中'); + ChatTimelineBuilder.appendMessageDelta(items, '你好啊!很高兴见到'); + ChatTimelineBuilder.appendMessageDelta( + items, + '你好啊!很高兴见到你!有什么我可以帮助你的吗?', + ); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[1]?.content).toBe( + '你好啊!很高兴见到你好啊!很高兴见到你!有什么我可以帮助你的吗?', + ); + } + }); + + it('keeps markdown and code text exactly as streamed', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '思考中'); + ChatTimelineBuilder.appendMessageDelta(items, '## 标题\n'); + ChatTimelineBuilder.appendMessageDelta(items, '| 模型 | 说明 |\n'); + ChatTimelineBuilder.appendMessageDelta(items, '| --- | --- |\n'); + ChatTimelineBuilder.appendMessageDelta(items, '| ACL | 访问控制列表 |\n'); + ChatTimelineBuilder.appendMessageDelta( + items, + 'Final Answer: ```echartsoption', + ); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].status).toBe('streaming'); + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[1]?.content).toBe( + '## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption', + ); + } + }); + + it('appends thinking delta without accumulated snapshot replacement', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta( + items, + '用户问的是“暑假安排是什么”。', + ); + ChatTimelineBuilder.appendThinkingDelta( + items, + '用户问的是“暑假安排是什么”。我需要先检索知识库,看看有没有相关文档。', + ); + ChatTimelineBuilder.appendMessageDelta(items, '正文'); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[0]?.content).toBe( + '用户问的是“暑假安排是什么”。用户问的是“暑假安排是什么”。我需要先检索知识库,看看有没有相关文档。', + ); + expect(items[0].parts[1]?.content).toBe('正文'); + } + }); + + it('ignores late thinking delta after text started in the same assistant message', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.appendThinkingDelta(items, '思考 A'); + ChatTimelineBuilder.appendMessageDelta(items, '正文'); + ChatTimelineBuilder.appendThinkingDelta(items, '思考 B'); + + expect(items).toHaveLength(1); + if (items[0]?.type === 'message') { + expect(items[0].parts.map((part) => part.type)).toEqual([ + 'thinking', + 'text', + ]); + expect(items[0].parts[0]?.content).toBe('思考 A'); + expect(items[0].parts[1]?.content).toBe('正文'); + } + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts new file mode 100644 index 0000000..9a11c0e --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts @@ -0,0 +1,572 @@ +import type { + ChatTimelineItem, + ChatTimelineKnowledgeHit, + ChatTimelineMessageItem, + ChatTimelineMessagePart, + ChatTimelineStatusItem, + ChatTimelineStatusStatus, + ChatTimelineStatusTone, + ChatTimelineThinkingStatus, + ChatTimelineToolApprovalPayload, + ChatTimelineToolItem, + ChatTimelineToolMode, + ChatTimelineToolStatus, +} from './types'; + +function createId(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function normalizeText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function normalizePayloadValue(value: unknown) { + if (value === null || value === undefined || value === '') { + return undefined; + } + if (typeof value === 'string') { + return value; + } + return value; +} + +function normalizeToolName(value?: string) { + return normalizeText(value).trim().toLowerCase(); +} + +function isHiddenToolName(toolName?: string) { + const normalizedName = normalizeToolName(toolName); + return ( + normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__' + ); +} + +function isKnowledgeRetrievalToolName(toolName?: string) { + return normalizeToolName(toolName) === 'retrieve_knowledge'; +} + +function isBlankToolName(toolName?: string) { + return !normalizeToolName(toolName); +} + +function knowledgeRetrievalStatusKey(statusKey?: string) { + return normalizeText(statusKey).trim() || 'knowledge-retrieval'; +} + +function ensureMessageTail( + items: ChatTimelineItem[], + role: ChatTimelineMessageItem['role'], + status: ChatTimelineMessageItem['status'] = 'streaming', + metadata?: Partial, +) { + const last = items[items.length - 1]; + if ( + last?.type === 'message' && + last.role === role && + last.status !== 'done' && + (!metadata?.roundId || last.roundId === metadata.roundId) + ) { + last.status = status; + Object.assign(last, metadata); + return last; + } + const item: ChatTimelineMessageItem = { + id: createId(role), + role, + status, + createdAt: Date.now(), + parts: [], + type: 'message', + ...metadata, + }; + items.push(item); + return item; +} + +function appendMessagePart( + message: ChatTimelineMessageItem, + part: ChatTimelineMessagePart, +) { + const tail = message.parts[message.parts.length - 1]; + if (tail?.type === part.type) { + tail.content += part.content; + if (tail.type === 'thinking' && part.type === 'thinking') { + tail.status = part.status; + } + return; + } + message.parts.push(part); +} + +function appendThinkingPart( + message: ChatTimelineMessageItem, + part: Extract, +) { + appendMessagePart(message, part); +} + +function appendTextPart(message: ChatTimelineMessageItem, content: string) { + appendMessagePart(message, { + id: createId('text'), + content, + type: 'text', + }); +} + +function replaceTextPart(message: ChatTimelineMessageItem, content: string) { + message.parts = [ + ...message.parts.filter((part) => part.type !== 'text'), + { + id: createId('text'), + content, + type: 'text' as const, + }, + ]; +} + +function updateThinkingStatus( + message: ChatTimelineMessageItem, + status: ChatTimelineThinkingStatus, +) { + message.parts = message.parts.map((part) => + part.type === 'thinking' && part.status === 'thinking' + ? { ...part, expanded: status === 'thinking', status } + : part, + ); +} + +function finishLastAssistantMessage(items: ChatTimelineItem[]) { + finishAssistantMessage(items, true); +} + +function finishAssistantMessage( + items: ChatTimelineItem[], + roundCompleted: boolean, +) { + const lastMessage = [...items] + .reverse() + .find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && item.role === 'assistant', + ); + if (!lastMessage) { + return; + } + updateThinkingStatus(lastMessage, 'end'); + lastMessage.status = lastMessage.status === 'error' ? 'error' : 'done'; + if (lastMessage.status === 'done' && roundCompleted) { + lastMessage.roundCompleted = true; + } +} + +function findToolItem(items: ChatTimelineItem[], toolCallId?: string) { + const identity = normalizeText(toolCallId).trim(); + if (!identity) { + return undefined; + } + return items.find( + (item): item is ChatTimelineToolItem => + item.type === 'tool' && item.toolCallId === identity, + ); +} + +function findStatusItem(items: ChatTimelineItem[], statusKey: string) { + return items.find( + (item): item is ChatTimelineStatusItem => + item.type === 'status' && item.statusKey === statusKey, + ); +} + +function doneStatusLabel(item: ChatTimelineStatusItem) { + if (item.statusKey === 'knowledge-retrieval') { + return '已检索知识库'; + } + if (item.statusKey === 'memory-compression') { + return '已整理上下文'; + } + return item.label.replace(/^正在/, '已'); +} + +function finishRunningStatusItems(items: ChatTimelineItem[]) { + items.forEach((item) => { + if (item.type !== 'status' || item.status !== 'running') { + return; + } + item.status = 'done'; + item.label = doneStatusLabel(item); + }); +} + +function upsertStatus( + items: ChatTimelineItem[], + payload: { + label: string; + presentation?: ChatTimelineStatusItem['presentation']; + status: ChatTimelineStatusStatus; + statusKey: string; + tone?: ChatTimelineStatusTone; + }, +) { + const found = findStatusItem(items, payload.statusKey); + if (found) { + found.label = payload.label; + found.presentation = payload.presentation ?? found.presentation; + found.status = payload.status; + found.tone = payload.tone ?? found.tone; + return found; + } + const item: ChatTimelineStatusItem = { + id: payload.statusKey, + createdAt: Date.now(), + label: payload.label, + presentation: payload.presentation, + status: payload.status, + statusKey: payload.statusKey, + tone: payload.tone ?? 'muted', + type: 'status', + }; + items.push(item); + return item; +} + +function upsertTool( + items: ChatTimelineItem[], + payload: { + approval?: ChatTimelineToolApprovalPayload; + input?: unknown; + mode?: ChatTimelineToolMode; + output?: unknown; + rejectReason?: string; + requestId?: string; + resumeToken?: string; + status?: ChatTimelineToolStatus; + toolCallId?: string; + toolName?: string; + }, +) { + const toolCallId = normalizeText( + payload.toolCallId ?? payload.approval?.toolCallId, + ).trim(); + const found = findToolItem(items, toolCallId); + const approval = payload.approval ?? found?.approval; + const mode = + payload.mode === 'approval' + ? 'approval' + : (found?.mode ?? payload.mode ?? (approval ? 'approval' : 'auto')); + const toolName = + payload.toolName || + approval?.toolDisplayName || + approval?.toolName || + found?.toolName; + if (isHiddenToolName(toolName)) { + return found; + } + if (!found && isBlankToolName(toolName)) { + return undefined; + } + + if (found) { + found.approval = approval; + found.input = + normalizePayloadValue(payload.input) ?? + normalizePayloadValue(approval?.input) ?? + found.input; + found.mode = mode; + found.output = normalizePayloadValue(payload.output) ?? found.output; + found.rejectReason = payload.rejectReason ?? found.rejectReason; + found.status = payload.status || found.status; + found.toolCallId = toolCallId || found.toolCallId; + found.toolName = toolName || found.toolName; + return found; + } + + const toolItem: ChatTimelineToolItem = { + id: toolCallId || createId('tool'), + approval, + createdAt: Date.now(), + input: + normalizePayloadValue(payload.input) ?? + normalizePayloadValue(approval?.input), + mode, + output: normalizePayloadValue(payload.output), + rejectReason: payload.rejectReason, + status: + payload.status || (mode === 'approval' ? 'pending_approval' : 'running'), + toolCallId, + toolName: toolName || '工具调用', + type: 'tool', + }; + items.push(toolItem); + return toolItem; +} + +export const ChatTimelineBuilder = { + appendUserMessage( + items: ChatTimelineItem[], + content?: unknown, + metadata?: Partial, + ) { + const text = normalizeText(content); + if (!text) { + return; + } + const item: ChatTimelineMessageItem = { + id: createId('user'), + role: 'user', + status: 'done', + createdAt: Date.now(), + parts: [ + { + id: createId('text'), + content: text, + type: 'text', + }, + ], + type: 'message', + ...metadata, + }; + items.push(item); + }, + + appendThinkingDelta( + items: ChatTimelineItem[], + delta?: unknown, + metadata?: Partial, + ) { + const text = normalizeText(delta); + if (!text) { + return; + } + const message = ensureMessageTail( + items, + 'assistant', + 'streaming', + metadata, + ); + if (message.parts.some((part) => part.type === 'text')) { + return; + } + appendThinkingPart(message, { + id: createId('thinking'), + content: text, + expanded: true, + status: 'thinking', + type: 'thinking', + }); + }, + + appendMessageDelta( + items: ChatTimelineItem[], + delta?: unknown, + metadata?: Partial, + ) { + const text = normalizeText(delta); + if (!text) { + return; + } + const message = ensureMessageTail( + items, + 'assistant', + 'streaming', + metadata, + ); + updateThinkingStatus(message, 'end'); + appendTextPart(message, text); + }, + + replaceMessageContent(items: ChatTimelineItem[], content?: unknown) { + const text = normalizeText(content); + if (!text) { + return; + } + const message = ensureMessageTail(items, 'assistant', 'done'); + updateThinkingStatus(message, 'end'); + replaceTextPart(message, text); + }, + + appendToolApproval( + items: ChatTimelineItem[], + payload: ChatTimelineToolApprovalPayload, + ) { + upsertTool(items, { + approval: payload, + input: payload.input, + mode: 'approval', + status: 'pending_approval', + toolCallId: payload.toolCallId, + toolName: payload.toolDisplayName || payload.toolName, + }); + }, + + upsertToolCall( + items: ChatTimelineItem[], + payload: { + input?: unknown; + output?: unknown; + status?: ChatTimelineToolStatus; + statusKey?: string; + toolCallId?: string; + toolName?: string; + }, + ) { + if (isKnowledgeRetrievalToolName(payload.toolName)) { + ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( + items, + payload.status === 'success' ? 'done' : 'running', + payload.statusKey, + ); + return; + } + upsertTool(items, { + ...payload, + mode: 'auto', + status: payload.status || 'running', + }); + }, + + upsertKnowledgeRetrievalStatus( + items: ChatTimelineItem[], + status: ChatTimelineStatusStatus, + statusKey?: string, + ) { + finishAssistantMessage(items, false); + upsertStatus(items, { + label: status === 'running' ? '正在检索知识库' : '已检索知识库', + status, + statusKey: knowledgeRetrievalStatusKey(statusKey), + tone: 'muted', + }); + }, + + upsertMemoryCompressionStatus( + items: ChatTimelineItem[], + payload?: { + compressed?: boolean; + label?: string; + phase?: string; + status?: string; + statusKey?: string; + }, + ) { + const status = + payload?.status === 'done' || payload?.phase === 'completed' + ? 'done' + : 'running'; + finishAssistantMessage(items, false); + const label = + status === 'running' + ? payload?.label || '正在整理上下文' + : payload?.compressed === false + ? '无需压缩上下文' + : payload?.label || '已整理上下文'; + upsertStatus(items, { + label, + status, + statusKey: payload?.statusKey || 'memory-compression', + presentation: 'separator', + tone: 'muted', + }); + }, + + markToolApproving( + items: ChatTimelineItem[], + payload: { + requestId?: string; + resumeToken?: string; + toolCallId?: string; + }, + ) { + upsertTool(items, { + ...payload, + mode: 'approval', + status: 'approving', + }); + }, + + markToolRejected( + items: ChatTimelineItem[], + payload: { + reason?: string; + requestId?: string; + resumeToken?: string; + toolCallId?: string; + }, + ) { + upsertTool(items, { + ...payload, + mode: 'approval', + rejectReason: payload.reason, + status: 'rejected', + }); + }, + + appendKnowledge( + items: ChatTimelineItem[], + knowledgeItems: ChatTimelineKnowledgeHit[], + ) { + if (knowledgeItems.length === 0) { + return; + } + const lastAssistantMessage = [...items] + .reverse() + .find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && item.role === 'assistant', + ); + if (lastAssistantMessage) { + lastAssistantMessage.knowledgeItems = [ + ...(lastAssistantMessage.knowledgeItems || []), + ...knowledgeItems, + ]; + return; + } + const last = items[items.length - 1]; + if (last?.type === 'knowledge') { + last.items.push(...knowledgeItems); + return; + } + items.push({ + id: createId('knowledge'), + createdAt: Date.now(), + items: knowledgeItems, + type: 'knowledge', + }); + }, + + appendError(items: ChatTimelineItem[], message?: unknown) { + const text = normalizeText(message) || '请求失败'; + const last = items[items.length - 1]; + if (last?.type === 'message' && last.role === 'assistant') { + updateThinkingStatus(last, 'error'); + last.status = 'error'; + } + items.push({ + id: createId('error'), + createdAt: Date.now(), + message: text, + type: 'error', + }); + }, + + finalize(items: ChatTimelineItem[]) { + finishRunningStatusItems(items); + finishLastAssistantMessage(items); + }, + + replaceRoundAssistant( + items: ChatTimelineItem[], + roundId: string, + message: ChatTimelineMessageItem, + ) { + const targetIndex = items.findIndex( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && + item.role === 'assistant' && + item.roundId === roundId, + ); + if (targetIndex >= 0) { + items.splice(targetIndex, 1, message); + } + }, +}; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts new file mode 100644 index 0000000..7213147 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts @@ -0,0 +1,29 @@ +export { ChatTimelineBuilder } from './builder'; +export { default as ChatErrorNotice } from './ChatErrorNotice.vue'; +export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue'; +export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue'; +export { default as ChatTextBlock } from './ChatTextBlock.vue'; +export { default as ChatTimeline } from './ChatTimeline.vue'; +export { default as ChatTimelineItemView } from './ChatTimelineItem.vue'; +export { default as ChatTimelineStatusRow } from './ChatTimelineStatusRow.vue'; +export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue'; +export { default as ChatToolCard } from './ChatToolCard.vue'; +export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue'; +export type { + ChatTimelineErrorItem, + ChatTimelineItem, + ChatTimelineItemStatus, + ChatTimelineKnowledgeHit, + ChatTimelineKnowledgeItem, + ChatTimelineMessageItem, + ChatTimelineMessagePart, + ChatTimelineRole, + ChatTimelineThinkingStatus, + ChatTimelineStatusItem, + ChatTimelineStatusStatus, + ChatTimelineStatusTone, + ChatTimelineToolApprovalItem, + ChatTimelineToolApprovalPayload, + ChatTimelineToolItem, + ChatTimelineToolStatus, +} from './types'; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts new file mode 100644 index 0000000..3f1f8d5 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts @@ -0,0 +1,127 @@ +export type ChatTimelineRole = 'assistant' | 'system' | 'user'; +export type ChatTimelineItemStatus = 'done' | 'error' | 'pending' | 'streaming'; +export type ChatTimelineThinkingStatus = 'end' | 'error' | 'thinking'; +export type ChatTimelineToolMode = 'approval' | 'auto'; +export type ChatTimelineToolStatus = + | 'approving' + | 'error' + | 'pending_approval' + | 'rejected' + | 'running' + | 'success'; +export type ChatTimelineStatusStatus = 'done' | 'running'; +export type ChatTimelineStatusTone = 'muted'; + +export interface ChatTimelineToolApprovalPayload { + requestId: string; + resumeToken: string; + toolName: string; + toolDisplayName?: string; + toolCallId?: string; + toolType?: string; + input?: unknown; + expiresAt?: string; + metadata?: unknown; +} + +export interface ChatTimelineKnowledgeHit { + chunkContent?: string; + chunkId?: string; + id?: string; + documentId?: string; + documentName?: string; + faqCollection?: boolean; + knowledgeId?: string; + knowledgeName?: string; + knowledgeType?: string; + metadata?: Record; + source?: string; + sourceFileName?: string; + sourceUri?: string; + score?: number | string; + title?: string; + content?: string; + [key: string]: any; +} + +export interface ChatTimelineItemBase { + createdAt?: number; + id: string; +} + +export interface ChatTimelineMessageItem extends ChatTimelineItemBase { + knowledgeItems?: ChatTimelineKnowledgeHit[]; + parts: ChatTimelineMessagePart[]; + regenerable?: boolean; + role: ChatTimelineRole; + roundId?: string; + roundCompleted?: boolean; + roundNo?: number; + status?: ChatTimelineItemStatus; + selectedVariantIndex?: number; + switchable?: boolean; + type: 'message'; + variantCount?: number; + variantIndex?: number; +} + +export interface ChatTimelineToolItem extends ChatTimelineItemBase { + approval?: ChatTimelineToolApprovalPayload; + input?: unknown; + mode: ChatTimelineToolMode; + output?: unknown; + rejectReason?: string; + status: ChatTimelineToolStatus; + toolCallId?: string; + toolName: string; + type: 'tool'; +} + +/** + * @deprecated 工具审批已聚合到 ChatTimelineToolItem,保留类型用于旧调用方过渡。 + */ +export interface ChatTimelineToolApprovalItem extends ChatTimelineItemBase { + payload: ChatTimelineToolApprovalPayload; + type: 'tool_approval'; +} + +export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase { + items: ChatTimelineKnowledgeHit[]; + type: 'knowledge'; +} + +export interface ChatTimelineStatusItem extends ChatTimelineItemBase { + label: string; + presentation?: 'inline' | 'separator'; + status: ChatTimelineStatusStatus; + statusKey: string; + tone?: ChatTimelineStatusTone; + type: 'status'; +} + +export interface ChatTimelineErrorItem extends ChatTimelineItemBase { + message: string; + type: 'error'; +} + +export type ChatTimelineItem = + | ChatTimelineErrorItem + | ChatTimelineKnowledgeItem + | ChatTimelineMessageItem + | ChatTimelineStatusItem + | ChatTimelineToolApprovalItem + | ChatTimelineToolItem; + +export type ChatTimelineMessagePart = + | { + content: string; + id: string; + type: 'text'; + } + | { + content: string; + expanded?: boolean; + id: string; + status: ChatTimelineThinkingStatus; + type: 'thinking'; + }; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts index caae19d..e3e0d94 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts @@ -1,7 +1,9 @@ export * from './api-component'; export * from './captcha'; export * from './chat-markdown'; +export * from './chat-status'; export * from './chat-thinking'; +export * from './chat-timeline'; export * from './col-page'; export * from './count-to'; export * from './ellipsis-text'; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts index 89eff08..2e9ffff 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/Tinyflow.ts @@ -1,6 +1,7 @@ -import type { useSvelteFlow } from '@xyflow/svelte'; -import { componentName } from './consts'; -import type { TinyflowData, TinyflowOptions, TinyflowTheme } from './types'; +import type {useSvelteFlow} from '@xyflow/svelte'; +import {componentName} from './consts'; +import {store} from './store/stores.svelte'; +import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types'; type FlowInstance = ReturnType; @@ -93,6 +94,37 @@ export class Tinyflow { return flow.toObject(); } + updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) { + const flow = this._getFlowInstance(); + if (!flow) { + return false; + } + + const currentViewport = flow.getViewport(); + const currentNodes = flow.getNodes(); + const currentNodePositions = new Map( + currentNodes.map((node) => [node.id, node.position]), + ); + const nextNodes = + options?.preserveViewport === true + ? (data.nodes || currentNodes).map((node) => { + const currentPosition = currentNodePositions.get(node.id); + return currentPosition + ? { ...node, position: { ...currentPosition } } + : node; + }) + : data.nodes || currentNodes; + store.setNodes(nextNodes); + store.setEdges(data.edges || flow.getEdges()); + + if (data.viewport && options?.preserveViewport !== true) { + flow.setViewport(data.viewport, { duration: 0 }); + } else { + flow.setViewport(currentViewport, { duration: 0 }); + } + return true; + } + async focusNode( nodeId: string, options?: { duration?: number; zoom?: number }, diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/TinyflowCore.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/TinyflowCore.svelte index e7345a8..c8730d3 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/TinyflowCore.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/TinyflowCore.svelte @@ -65,6 +65,14 @@ const readonly = options.readonly === true; let canvasLocked = $state(readonly); const hideBottomDock = options.hideBottomDock === true; + const hideEdgePanel = options.hideEdgePanel === true; + const hideMiniMap = options.hideMiniMap === true; + const hideNodePicker = options.hideNodePicker === true; + const nodesDraggable = options.nodesDraggable ?? !readonly; + const nodesConnectable = options.nodesConnectable ?? !readonly; + const elementsSelectable = options.elementsSelectable ?? !readonly; + const dropEnabled = options.dropEnabled ?? !readonly; + const connectionEnabled = nodesConnectable && !readonly; const availableNodes = getAvailableNodes(options); const onRunTest = options.onRunTest; @@ -779,22 +787,22 @@ bind:nodes={store.getNodes, store.setNodes} bind:edges={store.getEdges, store.setEdges} bind:viewport={store.getViewport, store.setViewport} - nodesDraggable={!canvasLocked} - nodesConnectable={!canvasLocked} - elementsSelectable={!canvasLocked} + nodesDraggable={nodesDraggable && !canvasLocked} + nodesConnectable={nodesConnectable && !canvasLocked} + elementsSelectable={elementsSelectable && !canvasLocked} panOnDrag={readonly ? true : !canvasLocked} zoomOnScroll={readonly ? true : !canvasLocked} zoomOnDoubleClick={readonly ? true : !canvasLocked} - ondrop={readonly ? undefined : onDrop} - ondragover={readonly ? undefined : onDragOver} + ondrop={dropEnabled ? onDrop : undefined} + ondragover={dropEnabled ? onDragOver : undefined} isValidConnection={isValidConnection} - onconnectend={readonly ? undefined : onconnectend} - onconnectstart={readonly ? undefined : onconnectstart} - onconnect={readonly ? undefined : onconnect} + onconnectend={connectionEnabled ? onconnectend : undefined} + onconnectstart={connectionEnabled ? onconnectstart : undefined} + onconnect={connectionEnabled ? onconnect : undefined} connectionRadius={50} connectionLineComponent={FlowConnectionLine} onedgeclick={(e) => { - if (readonly) { + if (readonly || hideEdgePanel) { return; } showEdgePanel = true; @@ -803,7 +811,7 @@ onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)} ondelete={readonly ? undefined : onDelete} onclick={(e) => { - if (readonly) { + if (readonly || hideEdgePanel) { return; } const el = e.target as HTMLElement; @@ -825,7 +833,9 @@ }} > - + {#if !hideMiniMap} + + {/if} {#if showEdgePanel} @@ -889,7 +899,7 @@ {/if} - {#if nodePickerVisible} + {#if nodePickerVisible && !hideNodePicker} {#if pendingConnectionLine} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/FlowEdge.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/FlowEdge.svelte index 2d54397..c349912 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/FlowEdge.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/FlowEdge.svelte @@ -1,6 +1,7 @@ - + -{#if interactionWidth > 0} +{#if resolvedInteractionWidth > 0} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte index eb276ae..578a551 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte @@ -8,8 +8,7 @@ useUpdateNodeInternals } from '@xyflow/svelte'; import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base'; - import {type Snippet} from 'svelte'; - import {onDestroy, onMount} from 'svelte'; + import {onDestroy, onMount, type Snippet} from 'svelte'; import {useDeleteNode} from '../utils/useDeleteNode.svelte'; import {useCopyNode} from '../utils/useCopyNode.svelte'; import {getOptions} from '../utils/NodeUtils'; @@ -70,6 +69,13 @@ const { copyNode } = useCopyNode(); const options = getOptions(); + const toolbarHidden = options.hideNodeToolbar === true; + const nodeSettingHidden = options.hideNodeSetting === true; + const handlesHidden = options.hideNodeHandles === true; + const toolbarDeleteEnabled = $derived(allowDelete && !toolbarHidden); + const toolbarCopyEnabled = $derived(allowCopy && !toolbarHidden); + const toolbarExecuteEnabled = $derived(allowExecute && !toolbarHidden); + const toolbarSettingEnabled = $derived(allowSetting && !toolbarHidden && !nodeSettingHidden); const executeNode = () => { options.onNodeExecute?.(getNode(id)!); @@ -111,10 +117,10 @@ -{#if allowExecute || allowCopy || allowDelete} +{#if toolbarExecuteEnabled || toolbarCopyEnabled || toolbarDeleteEnabled || toolbarSettingEnabled}
- {#if allowDelete} + {#if toolbarDeleteEnabled} {/if} - {#if allowCopy} + {#if toolbarCopyEnabled} {/if} - {#if allowExecute} + {#if toolbarExecuteEnabled} {/if} - {#if allowSetting} + {#if toolbarSettingEnabled}
-{#if showTargetHandle} +{#if showTargetHandle && !handlesHidden} {/if} -{#if showSourceHandle} +{#if showSourceHandle && !handlesHidden} {/if} {@render handle?.()} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CustomNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CustomNode.svelte index 57970d7..2100d8f 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CustomNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CustomNode.svelte @@ -2,7 +2,7 @@ +{#if customNode.presentation === 'plain'} +
+{:else} + - - - {#snippet icon()} - {@html customNode.icon} - {/snippet} + {#snippet icon()} + {@html customNode.icon} + {/snippet} {#if customNode.parametersEnable !== false}
@@ -251,7 +253,8 @@ {/if} - + +{/if} diff --git a/easyflow-ui-usercenter/packages/utils/src/helpers/__tests__/chat-time.test.ts b/easyflow-ui-usercenter/packages/utils/src/helpers/__tests__/chat-time.test.ts index 37c3bd7..b34e209 100644 --- a/easyflow-ui-usercenter/packages/utils/src/helpers/__tests__/chat-time.test.ts +++ b/easyflow-ui-usercenter/packages/utils/src/helpers/__tests__/chat-time.test.ts @@ -1,9 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import {describe, expect, it} from 'vitest'; -import { - ChatTimeHistoryMapper, - ChatTimeTimelineBuilder, -} from '../chat-time'; +import {ChatTimeHistoryMapper, ChatTimeTimelineBuilder,} from '../chat-time'; describe('chat-time timeline builder', () => { it('builds assistant thinking and message in the same assistant item', () => { @@ -29,6 +26,37 @@ describe('chat-time timeline builder', () => { ]); }); + it('appends markdown deltas without altering repeated symbols', () => { + const items: any[] = []; + + ChatTimeTimelineBuilder.appendThinkingDelta(items, '先想一下', 1); + ChatTimeTimelineBuilder.appendMessageDelta(items, '## 标题\n', 2); + ChatTimeTimelineBuilder.appendMessageDelta(items, '| 模型 | 说明 |\n', 3); + ChatTimeTimelineBuilder.appendMessageDelta(items, '| --- | --- |\n', 4); + ChatTimeTimelineBuilder.appendMessageDelta(items, '| ACL | 访问控制列表 |\n', 5); + ChatTimeTimelineBuilder.appendMessageDelta( + items, + 'Final Answer: ```echartsoption', + 6, + ); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + content: + '## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption', + role: 'assistant', + typing: true, + }); + expect(items[0].segments).toMatchObject([ + { content: '先想一下', status: 'end', type: 'thinking' }, + { + content: + '## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption', + type: 'text', + }, + ]); + }); + it('creates a new assistant item after tool result', () => { const items: any[] = []; diff --git a/easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts b/easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts index d1439bf..ff4c111 100644 --- a/easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts +++ b/easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts @@ -10,7 +10,7 @@ import type { ChatTimeToolStatus, } from '../../../types/src/chat-time'; -import { uuid } from './uuid'; +import {uuid} from './uuid'; type ChatTimeToolMeta = { arguments?: string; @@ -159,6 +159,35 @@ class ChatTimeTimelineBuilder { assistant.typing = true; } + /** + * 用最终完整回答替换当前 assistant 文本。 + */ + static replaceMessageContent( + items: ChatTimeTimelineItem[], + content?: string, + created?: number | string, + meta?: ChatTimeRoundMeta, + ) { + const normalizedContent = normalizeAssistantText(content); + if (!normalizedContent) { + return; + } + prepareRoundVariant(items, meta); + const assistant = ensureAssistantTail(items, created, meta); + stopThinkingForAssistant(assistant); + assistant.content = normalizedContent; + assistant.segments = [ + ...assistant.segments.filter((segment) => segment.type !== 'text'), + { + content: normalizedContent, + id: uuid(), + type: 'text' as const, + }, + ]; + assistant.loading = false; + assistant.typing = false; + } + /** * 停止当前 assistant 的思考态。 */ @@ -1003,9 +1032,7 @@ function normalizePositiveInteger(value: any) { } function normalizeAssistantText(value: any) { - return normalizePlainText(value) - .replace(/^Final Answer:\s*/i, '') - .replaceAll('```echartsoption', '```echarts\noption'); + return normalizePlainText(value); } function normalizePayloadValue(value: any) { diff --git a/easyflow-ui-usercenter/pnpm-lock.yaml b/easyflow-ui-usercenter/pnpm-lock.yaml index 9961d9c..aa1d344 100644 --- a/easyflow-ui-usercenter/pnpm-lock.yaml +++ b/easyflow-ui-usercenter/pnpm-lock.yaml @@ -490,6 +490,12 @@ importers: .: devDependencies: + '@changesets/changelog-github': + specifier: 'catalog:' + version: 0.5.1 + '@changesets/cli': + specifier: 'catalog:' + version: 2.29.7(@types/node@24.10.1) '@easyflow/commitlint-config': specifier: workspace:* version: link:internal/lint-configs/commitlint-config @@ -517,12 +523,6 @@ importers: '@easyflow/vsh': specifier: workspace:* version: link:scripts/vsh - '@changesets/changelog-github': - specifier: 'catalog:' - version: 0.5.1 - '@changesets/cli': - specifier: 'catalog:' - version: 2.29.7(@types/node@24.10.1) '@playwright/test': specifier: 'catalog:' version: 1.56.1 @@ -531,10 +531,10 @@ importers: version: 24.10.1 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) + version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) + version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.6 @@ -576,10 +576,10 @@ importers: version: 3.6.1(sass@1.94.0)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.24(typescript@5.9.3)) vite: specifier: 'catalog:' - version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + version: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) vitest: specifier: 'catalog:' - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) vue: specifier: ^3.5.17 version: 3.5.24(typescript@5.9.3) @@ -674,15 +674,15 @@ importers: internal/lint-configs/commitlint-config: dependencies: - '@easyflow/node-utils': - specifier: workspace:* - version: link:../../node-utils '@commitlint/cli': specifier: 'catalog:' version: 19.8.1(@types/node@24.10.1)(typescript@5.9.3) '@commitlint/config-conventional': specifier: 'catalog:' version: 19.8.1 + '@easyflow/node-utils': + specifier: workspace:* + version: link:../../node-utils commitlint-plugin-function-rules: specifier: 'catalog:' version: 4.1.1(@commitlint/lint@19.8.1) @@ -1324,6 +1324,12 @@ importers: '@easyflow/types': specifier: workspace:* version: link:../../types + '@incremark/theme': + specifier: 1.0.2 + version: 1.0.2 + '@incremark/vue': + specifier: 1.0.2 + version: 1.0.2(katex@0.16.25)(vue@3.5.24(typescript@5.9.3)) '@vueuse/core': specifier: 'catalog:' version: 13.9.0(vue@3.5.24(typescript@5.9.3)) @@ -1342,9 +1348,6 @@ importers: vue: specifier: ^3.5.17 version: 3.5.24(typescript@5.9.3) - vue-element-plus-x: - specifier: 'catalog:' - version: 1.3.7(rollup@4.53.2)(vue@3.5.24(typescript@5.9.3)) vue-json-viewer: specifier: 'catalog:' version: 3.0.4(vue@3.5.24(typescript@5.9.3)) @@ -1602,18 +1605,21 @@ importers: '@easyflow-core/typings': specifier: workspace:* version: link:../@core/base/typings + '@easyflow/types': + specifier: workspace:* + version: link:../types vue-router: specifier: 'catalog:' version: 4.6.3(vue@3.5.24(typescript@5.9.3)) scripts/turbo-run: dependencies: - '@easyflow/node-utils': - specifier: workspace:* - version: link:../../internal/node-utils '@clack/prompts': specifier: 'catalog:' version: 0.10.1 + '@easyflow/node-utils': + specifier: workspace:* + version: link:../../internal/node-utils cac: specifier: 'catalog:' version: 6.7.14 @@ -1645,6 +1651,9 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@antfu/utils@9.3.0': + resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} + '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -3163,6 +3172,40 @@ packages: peerDependencies: vue: ^3.5.17 + '@incremark/colors@1.0.2': + resolution: {integrity: sha512-WYj1ITAnkvLFYSioTk1W2/7HFo+eXwSwVpBZuH/IUyf1UExaRo3/xc+GhZwcuet7X8rZCu36qeD3xBg5XGtlyA==} + + '@incremark/core@1.0.2': + resolution: {integrity: sha512-87adubRCGpnV60O9sr6yYYPhvRePT7zxw63gqoFXkcsTkbsUGjUlvgyj/hnpv4g7nZqmmRlLd/Ln9EnIO+05Lg==} + + '@incremark/devtools@1.0.2': + resolution: {integrity: sha512-JUkiLGirATiWbAU/8y24MiA20gJZ9UItF7BMSNlKiqBxeX6ILRdoT/PWTNIPF6mKnTYNAxCUBZyin5xDo/WmGw==} + peerDependencies: + '@incremark/core': 1.0.2 + + '@incremark/icons@1.0.2': + resolution: {integrity: sha512-GNlDFk3GRFl0GBje6naqU9foToEknaFiZL+NwLkZJ8epHomswNjLq53CSx2StxUSGv9Y2Ap5tgGMtGxa+qcCIg==} + + '@incremark/shared@1.0.2': + resolution: {integrity: sha512-BsfZXx9nmXANBlFUGNoM1GpGKG9J8bEhzabp23GMxDvmYnLIlpUZb7QrmqNAwWJgG//z4Rg6fL5V7tlZgH7ToQ==} + peerDependencies: + '@incremark/core': 1.0.2 + + '@incremark/theme@1.0.2': + resolution: {integrity: sha512-Mc8E6fmd+wRGzxQcHg2gmaLWjjc5MUhfgrLiLJ3m1olnVm3VNc4R6fTLGx/1ht5e2EyOAvpLbMfdLhuLINYDgQ==} + + '@incremark/vue@1.0.2': + resolution: {integrity: sha512-SxHq/IbsknPwKOsg+9DPUWfhDCMZ9R44k1l6W2y/JJapwfSkyQ5lExPVAmr2du24RntaYc4O43IivLMqMLzTfA==} + peerDependencies: + katex: ^0.16.0 + mermaid: ^10.0.0 || ^11.0.0 + vue: ^3.5.17 + peerDependenciesMeta: + katex: + optional: true + mermaid: + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -3776,24 +3819,42 @@ packages: '@shikijs/core@3.15.0': resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + '@shikijs/engine-javascript@3.15.0': resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==} + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/engine-oniguruma@3.15.0': resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/langs@3.15.0': resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/themes@3.15.0': resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/transformers@3.15.0': resolution: {integrity: sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A==} '@shikijs/types@3.15.0': resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -3958,6 +4019,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -4803,6 +4867,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -6481,6 +6548,12 @@ packages: iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -6532,6 +6605,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -6570,6 +6646,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@1.0.0: resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} engines: {node: '>=18'} @@ -6804,6 +6883,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-formatter-js@2.5.23: + resolution: {integrity: sha512-Cbm8wHXjo/C56aCePP1VuKvjxoMEmL7g7Ckss1oWFFlCsvOEEbye1kTeaNNaqba1Cl6YpIOYAnK65pUQ8mDIUQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -6992,6 +7074,9 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash-unified@1.0.3: resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} peerDependencies: @@ -7103,6 +7188,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7110,6 +7200,9 @@ packages: mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -7182,6 +7275,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -7676,6 +7772,9 @@ packages: resolution: {integrity: sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==} engines: {node: '>=8'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-imports-exports@0.2.4: resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} @@ -8808,9 +8907,26 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki-stream@0.1.4: + resolution: {integrity: sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==} + peerDependencies: + react: ^19.0.0 + solid-js: ^1.9.0 + vue: ^3.5.17 + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + vue: + optional: true + shiki@3.15.0: resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + short-tree@3.0.0: resolution: {integrity: sha512-Yd9NFs/o9QSoH4/wTjxk4Xe0+CIzitDRN1Qg7iBeTSejKjlCg/3PbgiRwDUVuaIxD0RRdv7Iz9jKr7e0HljtUg==} engines: {node: ^14.13.1 || >=16.0.0} @@ -10164,6 +10280,8 @@ snapshots: '@antfu/utils@0.7.10': {} + '@antfu/utils@9.3.0': {} + '@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)': dependencies: ajv: 8.17.1 @@ -11927,6 +12045,69 @@ snapshots: '@iconify/types': 2.0.0 vue: 3.5.24(typescript@5.9.3) + '@incremark/colors@1.0.2': {} + + '@incremark/core@1.0.2': + dependencies: + '@types/lodash-es': 4.17.12 + '@types/mdast': 4.0.4 + lodash-es: 4.18.1 + marked: 17.0.6 + mdast-util-directive: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm: 3.1.0 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-math: 3.0.0 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-math: 3.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@incremark/devtools@1.0.2(@incremark/core@1.0.2)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@incremark/core': 1.0.2 + json-formatter-js: 2.5.23 + + '@incremark/icons@1.0.2': {} + + '@incremark/shared@1.0.2(@incremark/core@1.0.2)': + dependencies: + '@incremark/core': 1.0.2 + + '@incremark/theme@1.0.2': + dependencies: + '@incremark/colors': 1.0.2 + + '@incremark/vue@1.0.2(katex@0.16.25)(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@antfu/utils': 9.3.0 + '@incremark/core': 1.0.2 + '@incremark/devtools': 1.0.2(@incremark/core@1.0.2) + '@incremark/icons': 1.0.2 + '@incremark/shared': 1.0.2(@incremark/core@1.0.2) + '@incremark/theme': 1.0.2 + shiki: 3.23.0 + shiki-stream: 0.1.4(vue@3.5.24(typescript@5.9.3)) + vue: 3.5.24(typescript@5.9.3) + optionalDependencies: + katex: 0.16.25 + transitivePeerDependencies: + - react + - solid-js + - supports-color + '@inquirer/external-editor@1.0.3(@types/node@24.10.1)': dependencies: chardet: 2.1.1 @@ -12586,25 +12767,51 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@3.15.0': dependencies: '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + '@shikijs/engine-oniguruma@3.15.0': dependencies: '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.15.0': dependencies: '@shikijs/types': 3.15.0 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@3.15.0': dependencies: '@shikijs/types': 3.15.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/transformers@3.15.0': dependencies: '@shikijs/core': 3.15.0 @@ -12615,6 +12822,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sindresorhus/is@7.1.1': {} @@ -12780,6 +12992,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.16': {} @@ -13005,6 +13219,18 @@ snapshots: - rollup - supports-color + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.50 + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) + vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vue: 3.5.24(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 @@ -13017,6 +13243,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.29 + vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vue: 3.5.24(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 @@ -13031,13 +13263,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -13741,6 +13973,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} chatarea@5.9.3: {} @@ -15703,6 +15937,13 @@ snapshots: iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -15759,6 +16000,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -15789,6 +16032,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ci@1.0.0: {} is-inside-container@1.0.0: @@ -15974,6 +16219,8 @@ snapshots: json-buffer@3.0.1: {} + json-formatter-js@2.5.23: {} + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} @@ -16164,6 +16411,8 @@ snapshots: lodash-es@4.17.21: {} + lodash-es@4.18.1: {} + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): dependencies: '@types/lodash-es': 4.17.12 @@ -16263,10 +16512,26 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.6: {} + math-intrinsics@1.1.0: {} mathml-tag-names@2.1.3: {} + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -16433,6 +16698,16 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -17100,6 +17375,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.2.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-imports-exports@0.2.4: dependencies: parse-statements: 1.0.11 @@ -18254,6 +18539,12 @@ snapshots: shebang-regex@3.0.0: {} + shiki-stream@0.1.4(vue@3.5.24(typescript@5.9.3)): + dependencies: + '@shikijs/core': 3.15.0 + optionalDependencies: + vue: 3.5.24(typescript@5.9.3) + shiki@3.15.0: dependencies: '@shikijs/core': 3.15.0 @@ -18265,6 +18556,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + short-tree@3.0.0: dependencies: '@types/bintrees': 1.0.6 @@ -19248,6 +19550,27 @@ snapshots: dependencies: vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vite-node@3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -19268,6 +19591,7 @@ snapshots: - terser - tsx - yaml + optional: true vite-plugin-compression@0.5.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)): dependencies: @@ -19378,6 +19702,23 @@ snapshots: transitivePeerDependencies: - supports-color + vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): + dependencies: + esbuild: 0.25.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 1.21.7 + less: 4.4.2 + sass: 1.94.0 + terser: 5.44.1 + yaml: 2.8.1 + vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.25.3 @@ -19395,11 +19736,54 @@ snapshots: terser: 5.44.1 yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.10.1 + happy-dom: 17.6.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -19437,6 +19821,7 @@ snapshots: - terser - tsx - yaml + optional: true vscode-languageserver-textdocument@1.0.12: {} diff --git a/pom.xml b/pom.xml index 4aa8a80..4f783e5 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ 1.11.6 0.0.1 4.9.3 + 1.8.22 3.5.9 10.20.1 10.20.0 @@ -198,6 +199,11 @@ easy-agents-spring-boot-starter ${easy-agents.version} + + com.easyagents + easy-agents-agent-runtime + ${easy-agents.version} + com.squareup.okhttp3 @@ -260,6 +266,36 @@ jackson-databind ${jackson.version} + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk7 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-common + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + org.springframework.boot spring-boot-starter-actuator @@ -408,6 +444,11 @@ easyflow-module-ai ${revision} + + tech.easyflow + easyflow-module-agent + ${revision} + tech.easyflow easyflow-module-auth