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 index 38905ba..d2a1235 100644 --- 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 @@ -78,7 +78,7 @@ public class AgentCategoryController extends BaseCurdController agents = agentMapper.selectListByQuery(queryWrapper); - if (!agents.isEmpty()) { + if (agents != null && !agents.isEmpty()) { throw new BusinessException("请先删除该分类下的所有 Agent"); } } 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 index 24d3b39..7259de1 100644 --- 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 @@ -14,6 +14,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.jsonbody.JsonBody; import java.math.BigInteger; +import java.util.List; /** * Agent 管理端会话控制器。 @@ -104,6 +105,19 @@ public class AgentSessionController { return Result.ok(); } + /** + * 保存 Agent 会话临时知识库。 + * + * @param sessionId 会话 ID + * @param knowledgeIds 临时知识库 ID + * @return 操作结果 + */ + @PostMapping("/{sessionId}/extraKnowledges") + public Result saveExtraKnowledges(@PathVariable BigInteger sessionId, + @JsonBody(value = "knowledgeIds") List knowledgeIds) { + return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds)); + } + /** * 删除 Agent 会话。 * 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 index 7a0a6cc..4e46180 100644 --- 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 @@ -10,13 +10,12 @@ 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.command.ChatSessionUpsertCommand; +import tech.easyflow.chatlog.domain.dto.*; import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.service.ChatSessionCommandService; import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.chatlog.support.ChatJsonSupport; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.enums.CategoryResourceType; @@ -40,6 +39,7 @@ public class AgentSessionService { private final DocumentCollectionService documentCollectionService; private final ResourceAccessService resourceAccessService; private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService; + private final ChatJsonSupport chatJsonSupport; /** * 创建 Agent 管理端会话服务。 @@ -50,19 +50,22 @@ public class AgentSessionService { * @param documentCollectionService 知识库服务 * @param resourceAccessService 资源访问服务 * @param agentRuntimeStateCleanupService Agent 运行态清理服务 + * @param chatJsonSupport 聊天 JSON 工具 */ public AgentSessionService(ChatSessionQueryService chatSessionQueryService, ChatSessionCommandService chatSessionCommandService, AgentService agentService, DocumentCollectionService documentCollectionService, ResourceAccessService resourceAccessService, - AgentRuntimeStateCleanupService agentRuntimeStateCleanupService) { + AgentRuntimeStateCleanupService agentRuntimeStateCleanupService, + ChatJsonSupport chatJsonSupport) { this.chatSessionQueryService = chatSessionQueryService; this.chatSessionCommandService = chatSessionCommandService; this.agentService = agentService; this.documentCollectionService = documentCollectionService; this.resourceAccessService = resourceAccessService; this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService; + this.chatJsonSupport = chatJsonSupport; } /** @@ -103,6 +106,12 @@ public class AgentSessionService { Agent displayAgent = availability == null ? null : availability.displayAgent(); detail.setAssistant(toAssistantView(displayAgent, summary)); detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent)); + ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary); + detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges()); + detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames()); + if (extraKnowledgeResolution.shouldSync()) { + syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId()); + } return detail; } @@ -150,6 +159,26 @@ public class AgentSessionService { chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId()); } + /** + * 保存当前用户 Agent 会话的临时知识库。 + * + * @param account 当前登录账号 + * @param sessionId 会话 ID + * @param knowledgeIds 临时知识库 ID + * @return 更新后的会话详情 + */ + public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account, + BigInteger sessionId, + List knowledgeIds) { + ChatSessionSummary summary = requireUserAgentSession(account, sessionId); + ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds)); + if (!resolution.removedNames().isEmpty()) { + throw new BusinessException("所选知识库已失效或无权限使用"); + } + syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId()); + return getCurrentUserSession(account, sessionId); + } + /** * 删除当前用户的 Agent 会话。 * @@ -295,8 +324,97 @@ public class AgentSessionService { return view; } + private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) { + ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class); + List extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds(); + return resolveVisibleKnowledgeViews(extraKnowledgeIds); + } + + private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List knowledgeIds) { + if (knowledgeIds == null || knowledgeIds.isEmpty()) { + return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false); + } + List normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds); + if (normalizedIds.isEmpty()) { + return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false); + } + List collections = documentCollectionService.listByIds(normalizedIds); + Map collectionMap = new LinkedHashMap<>(); + for (DocumentCollection collection : collections) { + collectionMap.put(collection.getId(), collection); + } + List validKnowledges = new ArrayList<>(); + List validKnowledgeIds = new ArrayList<>(); + List removedNames = new ArrayList<>(); + boolean changed = false; + for (BigInteger knowledgeId : normalizedIds) { + DocumentCollection current = collectionMap.get(knowledgeId); + if (current == null) { + removedNames.add("知识库#" + knowledgeId); + changed = true; + continue; + } + if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) { + removedNames.add(current.getTitle()); + changed = true; + continue; + } + if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) { + removedNames.add(current.getTitle()); + changed = true; + continue; + } + validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current))); + validKnowledgeIds.add(current.getId()); + } + if (!Objects.equals(normalizedIds, validKnowledgeIds)) { + changed = true; + } + return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed); + } + + private List normalizeExtraKnowledgeIds(List knowledgeIds) { + if (knowledgeIds == null || knowledgeIds.isEmpty()) { + return List.of(); + } + List normalizedIds = new ArrayList<>(); + for (BigInteger knowledgeId : knowledgeIds) { + if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) { + normalizedIds.add(knowledgeId); + } + } + if (normalizedIds.size() > 3) { + throw new BusinessException("临时知识库最多选择 3 个"); + } + return normalizedIds; + } + + private void syncSessionExtraKnowledges(ChatSessionSummary summary, List validKnowledgeIds, BigInteger operatorId) { + ChatSessionExtPayload payload = new ChatSessionExtPayload(); + payload.setExtraKnowledgeIds(validKnowledgeIds); + ChatSessionUpsertCommand command = new ChatSessionUpsertCommand(); + command.setSessionId(summary.getId()); + command.setTenantId(summary.getTenantId()); + command.setDeptId(summary.getDeptId()); + command.setUserId(summary.getUserId()); + command.setUserAccount(summary.getUserAccount()); + command.setAssistantId(summary.getAssistantId()); + command.setAssistantCode(summary.getAssistantCode()); + command.setAssistantName(summary.getAssistantName()); + command.setTitle(summary.getTitle()); + command.setExtJson(chatJsonSupport.toJson(payload)); + command.setOperatorId(operatorId); + chatSessionCommandService.createOrTouchSession(command); + } + private record AgentAvailability(boolean continuable, ChatWorkspaceReadOnlyReason reason, Agent displayAgent) { } + + private record ExtraKnowledgeResolution(List validKnowledges, + List validKnowledgeIds, + List removedNames, + boolean shouldSync) { + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapability.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapability.java new file mode 100644 index 0000000..79dd183 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapability.java @@ -0,0 +1,53 @@ +package tech.easyflow.agent.runtime; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * Agent 聊天临时能力请求项。 + */ +public class AgentChatCapability implements Serializable { + + private static final long serialVersionUID = 1L; + + private String type; + private List resourceIds = new ArrayList<>(); + + /** + * 获取能力类型。 + * + * @return 能力类型 + */ + public String getType() { + return type; + } + + /** + * 设置能力类型。 + * + * @param type 能力类型 + */ + public void setType(String type) { + this.type = type; + } + + /** + * 获取资源 ID 列表。 + * + * @return 资源 ID 列表 + */ + public List getResourceIds() { + return resourceIds; + } + + /** + * 设置资源 ID 列表。 + * + * @param resourceIds 资源 ID 列表 + */ + public void setResourceIds(List resourceIds) { + this.resourceIds = resourceIds == null ? new ArrayList<>() : new ArrayList<>(resourceIds); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapabilityService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapabilityService.java new file mode 100644 index 0000000..bc9222f --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentChatCapabilityService.java @@ -0,0 +1,171 @@ +package tech.easyflow.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.*; + +/** + * Agent 聊天临时能力编排服务。 + */ +@Service +public class AgentChatCapabilityService { + + private static final String KNOWLEDGE_CAPABILITY_TYPE = "KNOWLEDGE"; + private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID"; + private static final int MAX_EXTRA_KNOWLEDGE_COUNT = 3; + + private final DocumentCollectionService documentCollectionService; + private final ResourceAccessService resourceAccessService; + private final ObjectMapper objectMapper; + + /** + * 创建 Agent 聊天临时能力编排服务。 + * + * @param documentCollectionService 知识库服务 + * @param resourceAccessService 资源访问服务 + * @param objectMapper 对象复制工具 + */ + public AgentChatCapabilityService(DocumentCollectionService documentCollectionService, + ResourceAccessService resourceAccessService, + ObjectMapper objectMapper) { + this.documentCollectionService = documentCollectionService; + this.resourceAccessService = resourceAccessService; + this.objectMapper = objectMapper; + } + + /** + * 将临时聊天能力合并到运行时 Agent 定义中。 + * + * @param agent 已发布 Agent 运行视图 + * @param capabilities 临时能力请求 + * @param account 当前登录账号 + * @return 能力解析结果 + */ + public AgentChatCapabilityResolution apply(Agent agent, + List capabilities, + LoginAccount account) { + List extraKnowledgeIds = resolveKnowledgeIds(capabilities); + boolean knowledgeCapabilityProvided = hasKnowledgeCapability(capabilities); + if (agent == null || extraKnowledgeIds.isEmpty()) { + return new AgentChatCapabilityResolution(agent, extraKnowledgeIds, knowledgeCapabilityProvided); + } + Agent runtimeAgent = objectMapper.convertValue(agent, Agent.class); + List mergedBindings = new ArrayList<>(); + Set existingKnowledgeIds = new LinkedHashSet<>(); + if (runtimeAgent.getKnowledgeBindings() != null) { + for (AgentKnowledgeBinding binding : runtimeAgent.getKnowledgeBindings()) { + if (binding == null) { + continue; + } + mergedBindings.add(binding); + if (Boolean.TRUE.equals(binding.getEnabled()) && binding.getKnowledgeId() != null) { + existingKnowledgeIds.add(binding.getKnowledgeId()); + } + } + } + int sortNo = mergedBindings.size(); + for (BigInteger knowledgeId : extraKnowledgeIds) { + if (existingKnowledgeIds.contains(knowledgeId)) { + continue; + } + DocumentCollection knowledge = documentCollectionService.getById(knowledgeId); + validateKnowledge(knowledge); + resourceAccessService.assertAccess( + CategoryResourceType.KNOWLEDGE, + knowledge, + ResourceAction.USE, + "无权限使用所选知识库" + ); + AgentKnowledgeBinding binding = new AgentKnowledgeBinding(); + binding.setTenantId(account == null ? runtimeAgent.getTenantId() : account.getTenantId()); + binding.setAgentId(runtimeAgent.getId()); + binding.setKnowledgeId(knowledgeId); + binding.setRetrievalMode(DEFAULT_RETRIEVAL_MODE); + binding.setEnabled(true); + binding.setSortNo(sortNo++); + mergedBindings.add(binding); + existingKnowledgeIds.add(knowledgeId); + } + runtimeAgent.setKnowledgeBindings(mergedBindings); + return new AgentChatCapabilityResolution(runtimeAgent, extraKnowledgeIds, knowledgeCapabilityProvided); + } + + /** + * 从能力列表提取知识库 ID。 + * + * @param capabilities 临时能力列表 + * @return 已去重知识库 ID + */ + public List resolveKnowledgeIds(List capabilities) { + if (capabilities == null || capabilities.isEmpty()) { + return List.of(); + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (AgentChatCapability capability : capabilities) { + if (capability == null || !isKnowledgeCapability(capability.getType())) { + continue; + } + if (capability.getResourceIds() == null) { + continue; + } + for (BigInteger resourceId : capability.getResourceIds()) { + if (resourceId != null) { + ids.add(resourceId); + } + } + } + if (ids.size() > MAX_EXTRA_KNOWLEDGE_COUNT) { + throw new BusinessException("临时知识库最多选择 3 个"); + } + return new ArrayList<>(ids); + } + + private boolean isKnowledgeCapability(String type) { + return Objects.equals(KNOWLEDGE_CAPABILITY_TYPE, type == null ? null : type.trim().toUpperCase()); + } + + private boolean hasKnowledgeCapability(List capabilities) { + if (capabilities == null || capabilities.isEmpty()) { + return false; + } + for (AgentChatCapability capability : capabilities) { + if (capability != null && isKnowledgeCapability(capability.getType())) { + return true; + } + } + return false; + } + + private void validateKnowledge(DocumentCollection knowledge) { + if (knowledge == null) { + throw new BusinessException("所选知识库不存在"); + } + if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("所选知识库未发布,无法用于聊天"); + } + } + + /** + * Agent 聊天临时能力解析结果。 + * + * @param agent 合并临时能力后的运行时 Agent + * @param extraKnowledgeIds 本次选择的临时知识库 ID + * @param knowledgeCapabilityProvided 请求是否显式传入知识库能力 + */ + public record AgentChatCapabilityResolution(Agent agent, + List extraKnowledgeIds, + boolean knowledgeCapabilityProvided) { + } +} 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 index ca1871e..7eb703d 100644 --- 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 @@ -1,6 +1,8 @@ package tech.easyflow.agent.runtime; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; /** * Agent 管理端运行请求。 @@ -10,6 +12,7 @@ public class AgentChatRequest { private BigInteger agentId; private BigInteger sessionId; private String prompt; + private List capabilities = new ArrayList<>(); /** * 获取 Agent ID。 @@ -52,4 +55,22 @@ public class AgentChatRequest { * @param prompt 用户输入 */ public void setPrompt(String prompt) { this.prompt = prompt; } + + /** + * 获取本次聊天启用的临时能力。 + * + * @return 临时能力列表 + */ + public List getCapabilities() { + return capabilities; + } + + /** + * 设置本次聊天启用的临时能力。 + * + * @param capabilities 临时能力列表 + */ + public void setCapabilities(List capabilities) { + this.capabilities = capabilities == null ? new ArrayList<>() : new ArrayList<>(capabilities); + } } 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 index 3d2a24b..d7d56fd 100644 --- 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 @@ -70,6 +70,8 @@ public class AgentRunService { @Resource private AgentRuntimeFactory agentRuntimeFactory; @Resource + private AgentChatCapabilityService agentChatCapabilityService; + @Resource private AgentSessionStore agentSessionStore; @Resource private EasyFlowAgentSessionStore easyFlowAgentSessionStore; @@ -121,10 +123,16 @@ public class AgentRunService { ChatSessionSummary existingSession = resolveExistingSession(account, sessionId, chatRequest.getAgentId()); // 获取 Agent 发布快照 Agent agent = agentService.getPublishedView(chatRequest.getAgentId()); + AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution = + agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account); + agent = capabilityResolution.agent(); String requestId = UUID.randomUUID().toString(); String traceId = UUID.randomUUID().toString(); // 组建会话上下文必要信息 ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, sessionId, chatRequest.getPrompt(), account); + if (capabilityResolution.knowledgeCapabilityProvided()) { + chatContext.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, capabilityResolution.extraKnowledgeIds()); + } applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession); // 执行对话 return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(), diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentChatCapabilityServiceTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentChatCapabilityServiceTest.java new file mode 100644 index 0000000..8dcb89d --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentChatCapabilityServiceTest.java @@ -0,0 +1,187 @@ +package tech.easyflow.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.permission.resource.VisibilityResource; +import tech.easyflow.system.service.ResourceAccessService; + +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Agent 聊天临时能力编排服务测试。 + */ +public class AgentChatCapabilityServiceTest { + + @Test + public void applyShouldAppendPublishedKnowledgeAndSkipBoundDuplicate() { + DocumentCollectionService documentService = documentService( + knowledge(1, PublishStatus.PUBLISHED), + knowledge(2, PublishStatus.PUBLISHED) + ); + AgentChatCapabilityService service = new AgentChatCapabilityService( + documentService, + new AllowResourceAccessService(), + new ObjectMapper() + ); + Agent agent = agentWithBoundKnowledge(1); + + AgentChatCapabilityService.AgentChatCapabilityResolution resolution = service.apply( + agent, + List.of(capability(1, 2, 2)), + account() + ); + + List bindings = resolution.agent().getKnowledgeBindings(); + Assert.assertEquals(List.of(BigInteger.ONE, BigInteger.valueOf(2)), resolution.extraKnowledgeIds()); + Assert.assertEquals(2, bindings.size()); + Assert.assertEquals(BigInteger.ONE, bindings.get(0).getKnowledgeId()); + Assert.assertEquals(BigInteger.valueOf(2), bindings.get(1).getKnowledgeId()); + Assert.assertEquals("HYBRID", bindings.get(1).getRetrievalMode()); + Assert.assertTrue(bindings.get(1).getEnabled()); + } + + @Test + public void applyShouldAppendWhenBoundKnowledgeIsDisabled() { + DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.PUBLISHED)); + AgentChatCapabilityService service = new AgentChatCapabilityService( + documentService, + new AllowResourceAccessService(), + new ObjectMapper() + ); + Agent agent = agentWithBoundKnowledge(2); + agent.getKnowledgeBindings().get(0).setEnabled(false); + + AgentChatCapabilityService.AgentChatCapabilityResolution resolution = service.apply( + agent, + List.of(capability(2)), + account() + ); + + List bindings = resolution.agent().getKnowledgeBindings(); + Assert.assertEquals(2, bindings.size()); + Assert.assertFalse(bindings.get(0).getEnabled()); + Assert.assertTrue(bindings.get(1).getEnabled()); + Assert.assertEquals(BigInteger.valueOf(2), bindings.get(1).getKnowledgeId()); + } + + @Test + public void resolveKnowledgeIdsShouldRejectTooManyItems() { + AgentChatCapabilityService service = new AgentChatCapabilityService( + documentService(), + new AllowResourceAccessService(), + new ObjectMapper() + ); + + Assert.assertThrows(BusinessException.class, + () -> service.resolveKnowledgeIds(List.of(capability(1, 2, 3, 4)))); + } + + @Test + public void applyShouldRejectUnpublishedKnowledge() { + DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.OFFLINE)); + AgentChatCapabilityService service = new AgentChatCapabilityService( + documentService, + new AllowResourceAccessService(), + new ObjectMapper() + ); + + Assert.assertThrows(BusinessException.class, + () -> service.apply(agentWithBoundKnowledge(1), List.of(capability(2)), account())); + } + + @Test + public void applyShouldRejectUnauthorizedKnowledge() { + DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.PUBLISHED)); + AgentChatCapabilityService service = new AgentChatCapabilityService( + documentService, + new DenyResourceAccessService(), + new ObjectMapper() + ); + + Assert.assertThrows(BusinessException.class, + () -> service.apply(agentWithBoundKnowledge(1), List.of(capability(2)), account())); + } + + private static Agent agentWithBoundKnowledge(int knowledgeId) { + Agent agent = new Agent(); + agent.setId(BigInteger.TEN); + agent.setTenantId(BigInteger.ONE); + AgentKnowledgeBinding binding = new AgentKnowledgeBinding(); + binding.setAgentId(agent.getId()); + binding.setKnowledgeId(BigInteger.valueOf(knowledgeId)); + binding.setRetrievalMode("HYBRID"); + binding.setEnabled(true); + agent.setKnowledgeBindings(List.of(binding)); + return agent; + } + + private static AgentChatCapability capability(int... ids) { + AgentChatCapability capability = new AgentChatCapability(); + capability.setType("KNOWLEDGE"); + capability.setResourceIds(java.util.Arrays.stream(ids) + .mapToObj(BigInteger::valueOf) + .toList()); + return capability; + } + + private static DocumentCollection knowledge(int id, PublishStatus status) { + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.valueOf(id)); + collection.setTitle("知识库" + id); + collection.setPublishStatus(status.name()); + return collection; + } + + private static LoginAccount account() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(100)); + account.setTenantId(BigInteger.ONE); + return account; + } + + private static DocumentCollectionService documentService(DocumentCollection... collections) { + Map collectionMap = new HashMap<>(); + for (DocumentCollection collection : collections) { + collectionMap.put(collection.getId(), collection); + } + return (DocumentCollectionService) Proxy.newProxyInstance( + AgentChatCapabilityServiceTest.class.getClassLoader(), + new Class[]{DocumentCollectionService.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getById" -> collectionMap.get(new BigInteger(String.valueOf(args[0]))); + case "toPublishedView" -> args[0]; + case "listByIds" -> ((java.util.Collection) args[0]).stream() + .map(id -> collectionMap.get(new BigInteger(String.valueOf(id)))) + .filter(java.util.Objects::nonNull) + .toList(); + default -> throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private static class AllowResourceAccessService implements ResourceAccessService { + @Override public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { return true; } + @Override public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { return true; } + @Override public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {} + } + + private static class DenyResourceAccessService extends AllowResourceAccessService { + @Override public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) { + throw new BusinessException(message); + } + } +} diff --git a/easyflow-ui-admin/app/src/components/chat-workspace/ChatCapabilityMenu.vue b/easyflow-ui-admin/app/src/components/chat-workspace/ChatCapabilityMenu.vue new file mode 100644 index 0000000..ca4faf7 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/chat-workspace/ChatCapabilityMenu.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/chat-workspace/ChatInputTriggerPanel.vue b/easyflow-ui-admin/app/src/components/chat-workspace/ChatInputTriggerPanel.vue new file mode 100644 index 0000000..f690d5e --- /dev/null +++ b/easyflow-ui-admin/app/src/components/chat-workspace/ChatInputTriggerPanel.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/types.ts b/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/types.ts new file mode 100644 index 0000000..88a0813 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/types.ts @@ -0,0 +1,23 @@ +import type {Component} from 'vue'; + +export type ChatInputTriggerSymbol = '#' | '$' | '/' | '@'; + +export interface ChatInputTriggerItem { + description?: string; + disabled?: boolean; + icon?: Component; + id: string; + label: string; +} + +export interface ChatInputTriggerGroup { + items: ChatInputTriggerItem[]; + label: string; + symbol: ChatInputTriggerSymbol; +} + +export interface ChatInputTriggerMatch { + keyword: string; + start: number; + symbol: ChatInputTriggerSymbol; +} diff --git a/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/useChatInputTrigger.ts b/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/useChatInputTrigger.ts new file mode 100644 index 0000000..0c16bac --- /dev/null +++ b/easyflow-ui-admin/app/src/components/chat-workspace/input-triggers/useChatInputTrigger.ts @@ -0,0 +1,166 @@ +import type {Ref} from 'vue'; +import {computed, nextTick, ref} from 'vue'; + +import type {ChatInputTriggerGroup, ChatInputTriggerMatch, ChatInputTriggerSymbol,} from './types'; + +const TRIGGER_SYMBOLS = new Set(['#', '$', '/', '@']); +const TRIGGER_BOUNDARY = /[\s,。!?;:,.!?;:()[\]{}]/; + +export interface ChatInputTriggerOptions { + disabled: Ref; + groups: Ref; + inputRef: Ref; + text: Ref; +} + +/** + * 管理聊天输入框快捷触发状态。 + * + * @param options 触发器依赖 + * @return 输入触发状态与操作方法 + */ +export function useChatInputTrigger(options: ChatInputTriggerOptions) { + const activeMatch = ref(); + const activeIndex = ref(0); + + const activeGroup = computed(() => { + if (!activeMatch.value) { + return undefined; + } + return options.groups.value.find( + (group) => group.symbol === activeMatch.value?.symbol, + ); + }); + + const visibleItems = computed(() => { + const group = activeGroup.value; + if (!group) { + return []; + } + const keyword = activeMatch.value?.keyword.trim().toLowerCase() || ''; + if (!keyword) { + return group.items; + } + return group.items.filter((item) => { + const label = item.label.toLowerCase(); + const description = item.description?.toLowerCase() || ''; + return label.includes(keyword) || description.includes(keyword); + }); + }); + + const activePanel = computed(() => { + const group = activeGroup.value; + if (!activeMatch.value || !group) { + return undefined; + } + return { + groupLabel: group.label, + keyword: activeMatch.value.keyword, + symbol: activeMatch.value.symbol, + }; + }); + + function getTextareaElement() { + const rawRef = options.inputRef.value as + | undefined + | { textarea?: HTMLTextAreaElement }; + return rawRef?.textarea; + } + + function close() { + activeMatch.value = undefined; + activeIndex.value = 0; + } + + function sync() { + if (options.disabled.value) { + close(); + return; + } + const textarea = getTextareaElement(); + const cursor = textarea?.selectionStart ?? options.text.value.length; + const match = findTriggerMatch(options.text.value, cursor); + if (!match) { + close(); + return; + } + activeMatch.value = match; + activeIndex.value = Math.min( + activeIndex.value, + Math.max(visibleItems.value.length - 1, 0), + ); + } + + async function replaceTriggerText(replacement = '') { + const match = activeMatch.value; + if (!match) { + return; + } + const textarea = getTextareaElement(); + const cursor = textarea?.selectionStart ?? options.text.value.length; + const before = options.text.value.slice(0, match.start); + const after = options.text.value.slice(cursor); + const nextText = `${before}${replacement}${after}`; + const nextCursor = before.length + replacement.length; + options.text.value = nextText; + close(); + await nextTick(); + textarea?.focus(); + textarea?.setSelectionRange(nextCursor, nextCursor); + } + + function move(delta: number) { + const total = visibleItems.value.length; + if (!activeMatch.value || total === 0) { + return; + } + activeIndex.value = (activeIndex.value + delta + total) % total; + } + + function setActiveIndex(index: number) { + if (index < 0 || index >= visibleItems.value.length) { + return; + } + activeIndex.value = index; + } + + function findTriggerMatch( + text: string, + cursor: number, + ): ChatInputTriggerMatch | undefined { + const beforeCursor = text.slice(0, cursor); + for (let index = beforeCursor.length - 1; index >= 0; index--) { + const char = beforeCursor[index]; + if (!char) { + continue; + } + if (TRIGGER_BOUNDARY.test(char)) { + return undefined; + } + if (!TRIGGER_SYMBOLS.has(char as ChatInputTriggerSymbol)) { + continue; + } + const previous = index === 0 ? '' : beforeCursor[index - 1]; + if (previous && !TRIGGER_BOUNDARY.test(previous)) { + return undefined; + } + return { + keyword: beforeCursor.slice(index + 1), + start: index, + symbol: char as ChatInputTriggerSymbol, + }; + } + return undefined; + } + + return { + activeIndex, + activePanel, + close, + move, + replaceTriggerText, + setActiveIndex, + sync, + visibleItems, + }; +} diff --git a/easyflow-ui-admin/app/src/router/routes/modules/agent.ts b/easyflow-ui-admin/app/src/router/routes/modules/agent.ts index e853ee4..1664887 100644 --- a/easyflow-ui-admin/app/src/router/routes/modules/agent.ts +++ b/easyflow-ui-admin/app/src/router/routes/modules/agent.ts @@ -22,7 +22,7 @@ const routes: RouteRecordRaw[] = [ title: '智能体聊天', fullPathKey: false, hideInMenu: true, - activePath: '/ai/agents', + activePath: '/ai/agent-chat', }, }, ]; diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts index b0773c9..9cb885b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts @@ -111,6 +111,135 @@ describe('agentTimelineAdapter', () => { ).toBe(true); }); + it('hides knowledge retrieval cards when restoring agent chat history', () => { + const items = recordsToTimelineItems([ + { + id: '417197643647811584', + senderRole: 'assistant', + contentText: + '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', + roundId: '417197622424633344', + contentPayload: { + chains: [ + { + id: 'call_0fc660e9d203416983ccca7e', + name: 'retrieve_knowledge', + result: 'Retrieved 2 relevant document(s)', + status: 'TOOL_RESULT', + arguments: { + query: '暑假时间', + }, + }, + ], + agentResult: { + text: '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', + knowledgeReferences: [ + { + chunkContent: + '问题:2026 年暑假安排\n答案:2026 年7 月 1 日到 8 月 15 日', + documentId: '411358369563336704', + knowledgeName: 'faq', + knowledgeType: 'FAQ', + }, + ], + }, + messageChain: [ + { + role: 'assistant', + toolCalls: [ + { + id: 'call_0fc660e9d203416983ccca7e', + name: 'retrieve_knowledge', + arguments: '{query=暑假时间}', + }, + ], + }, + { + role: 'tool', + content: 'Retrieved 2 relevant document(s)', + toolCallId: 'call_0fc660e9d203416983ccca7e', + }, + { + role: 'assistant', + content: + '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', + }, + ], + }, + }, + ]); + + expect(items.some((item) => item.type === 'tool')).toBe(false); + expect( + items.some( + (item) => item.type === 'status' && item.label === '已检索知识库', + ), + ).toBe(true); + expect( + items.some((item) => item.type === 'message' && item.role === 'assistant'), + ).toBe(true); + const assistant = items.find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && item.role === 'assistant', + ); + expect(assistant?.knowledgeItems?.[0]?.knowledgeName).toBe('faq'); + }); + + it('hides internal fragment and context reload tools from history', () => { + const items = recordsToTimelineItems([ + { + id: 'internal-tools', + senderRole: 'assistant', + contentText: '已处理', + roundId: 'round-internal', + contentPayload: { + chains: [ + { + id: 'fragment-1', + name: '__fragment__', + status: 'TOOL_RESULT', + result: 'fragment', + }, + { + id: 'context-1', + name: 'context_reload', + status: 'TOOL_RESULT', + result: 'reload', + }, + ], + agentResult: { + text: '已处理', + }, + messageChain: [ + { + role: 'assistant', + toolCalls: [ + { id: 'fragment-1', name: '__fragment__' }, + { id: 'context-1', name: 'context_reload' }, + ], + }, + { + role: 'tool', + toolCallId: 'fragment-1', + content: 'fragment', + }, + { + role: 'tool', + toolCallId: 'context-1', + content: 'reload', + }, + ], + }, + }, + ]); + + expect(items.some((item) => item.type === 'tool')).toBe(false); + expect(items.some((item) => item.type === 'status')).toBe(false); + expect( + items.some((item) => item.type === 'message' && item.role === 'assistant'), + ).toBe(true); + }); + it('parses raw SSE text as message delta', () => { const envelope = parseAgentSseMessage({ data: 'hello', @@ -227,4 +356,123 @@ describe('agentTimelineAdapter', () => { expect(assistant?.roundId).toBe('runtime-round-1'); expect(assistant?.parts[0]?.content).toBe('准备调用工具'); }); + + it('updates memory compression status within the current round', () => { + const items: any[] = []; + + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + label: '正在整理上下文', + phase: 'started', + status: 'running', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-a' }, + ); + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + compressed: true, + label: '已整理上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-a' }, + ); + + const statuses = items.filter((item) => item.type === 'status'); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.label).toBe('已整理上下文'); + expect(statuses[0]?.status).toBe('done'); + expect(statuses[0]?.statusKey).toBe('memory-compression:round-a'); + }); + + it('keeps memory compression statuses isolated by round', () => { + const items: any[] = []; + + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + compressed: true, + label: '已整理上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-a' }, + ); + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + compressed: false, + label: '无需压缩上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-b' }, + ); + + const statuses = items.filter((item) => item.type === 'status'); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.statusKey).toBe('memory-compression:round-a'); + expect(statuses[0]?.label).toBe('已整理上下文'); + }); + + it('does not show no-compression status before a later compression run', () => { + const items: any[] = []; + + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + compressed: false, + label: '无需压缩上下文', + phase: 'completed', + status: 'done', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-a' }, + ); + applyAgentSseEnvelope( + items, + { + domain: 'BUSINESS', + type: 'STATUS', + payload: { + label: '正在整理上下文', + phase: 'started', + status: 'running', + statusKey: 'memory-compression', + }, + }, + { roundId: 'round-a' }, + ); + + const statuses = items.filter((item) => item.type === 'status'); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.label).toBe('正在整理上下文'); + expect(statuses[0]?.status).toBe('running'); + }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts index 52edde5..a2996b4 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts @@ -54,6 +54,38 @@ function normalizeToolCallId(payload: Record) { return asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id); } +function isBlankToolName(value: unknown) { + return !normalizeToolName(value); +} + +function shouldSkipToolProjection(value: unknown) { + const normalizedName = normalizeToolName(value).toLowerCase(); + return ( + normalizedName === 'context_reload' || + normalizedName === '__fragment__' + ); +} + +function normalizeToolCallName(payload: Record) { + const fn = asRecord(payload.function); + return normalizeToolName(payload.name ?? payload.toolName ?? fn.name); +} + +function normalizeToolCallInput(payload: Record) { + const fn = asRecord(payload.function); + return payload.arguments ?? payload.input ?? fn.arguments; +} + +function statusKeyForProjection( + payload: Record, + metadata?: Partial, + fallback = 'status', +) { + const statusKey = asText(payload.statusKey) || fallback; + const roundId = asText(metadata?.roundId); + return roundId ? `${statusKey}:${roundId}` : statusKey; +} + function normalizeMetadata(record: AgentChatMessageRecord) { return { createdAt: asTimestamp(record.created), @@ -139,14 +171,10 @@ function appendAssistantText( if (!text) { return; } - ChatTimelineBuilder.appendMessageDelta( - items, - text, - { - ...assistantMetadata(record, suffix), - ...metadata, - }, - ); + ChatTimelineBuilder.appendMessageDelta(items, text, { + ...assistantMetadata(record, suffix), + ...metadata, + }); } function appendAssistantThinking( @@ -160,14 +188,10 @@ function appendAssistantThinking( if (!text) { return; } - ChatTimelineBuilder.appendThinkingDelta( - items, - text, - { - ...assistantMetadata(record, suffix), - ...metadata, - }, - ); + ChatTimelineBuilder.appendThinkingDelta(items, text, { + ...assistantMetadata(record, suffix), + ...metadata, + }); } function projectHistoryChain( @@ -177,6 +201,7 @@ function projectHistoryChain( const payload = asRecord(record.contentPayload); let hasAssistantText = false; let hasAssistantThinking = false; + const toolNameByCallId = new Map(); const displayChains = asArray(payload.displayChains ?? payload.chains); for (const chain of displayChains) { const item = asRecord(chain); @@ -187,12 +212,21 @@ function projectHistoryChain( continue; } const toolName = normalizeToolName(item.name ?? item.toolName); - if (toolName) { + const toolCallId = normalizeToolCallId(item); + if (toolCallId && toolName) { + toolNameByCallId.set(toolCallId, toolName); + } + if (toolName && !shouldSkipToolProjection(toolName)) { ChatTimelineBuilder.upsertToolCall(items, { input: item.arguments ?? item.input, output: item.result ?? item.output, status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running', - toolCallId: asText(item.id ?? item.toolCallId), + statusKey: statusKeyForProjection( + item, + normalizeMetadata(record), + 'knowledge-retrieval', + ), + toolCallId, toolName, }); } @@ -213,21 +247,45 @@ function projectHistoryChain( } for (const toolCall of asArray(item.toolCalls)) { const tool = asRecord(toolCall); + const toolCallId = normalizeToolCallId(tool); + const toolName = normalizeToolCallName(tool); + if (toolCallId && toolName) { + toolNameByCallId.set(toolCallId, toolName); + } + if (isBlankToolName(toolName) || shouldSkipToolProjection(toolName)) { + continue; + } ChatTimelineBuilder.upsertToolCall(items, { - input: tool.arguments ?? tool.input, + input: normalizeToolCallInput(tool), status: 'running', - toolCallId: asText(tool.id ?? tool.toolCallId), - toolName: normalizeToolName(tool.name ?? tool.toolName), + statusKey: statusKeyForProjection( + tool, + normalizeMetadata(record), + 'knowledge-retrieval', + ), + toolCallId, + toolName, }); } continue; } if (role === 'tool') { + const toolCallId = normalizeToolCallId(item); + const toolName = + normalizeToolCallName(item) || toolNameByCallId.get(toolCallId) || ''; + if (isBlankToolName(toolName) || shouldSkipToolProjection(toolName)) { + continue; + } ChatTimelineBuilder.upsertToolCall(items, { output: item.content ?? item.result, status: 'success', - toolCallId: asText(item.toolCallId ?? item.id), - toolName: normalizeToolName(item.name ?? item.toolName) || '工具调用', + statusKey: statusKeyForProjection( + item, + normalizeMetadata(record), + 'knowledge-retrieval', + ), + toolCallId, + toolName, }); } } @@ -369,7 +427,11 @@ export function applyAgentSseEnvelope( input: payload.input ?? payload.toolInput, output: payload.output ?? payload.result ?? payload.text, status: type === 'TOOL_RESULT' ? 'success' : 'running', - statusKey: asText(payload.statusKey) || undefined, + statusKey: statusKeyForProjection( + payload, + metadata, + 'knowledge-retrieval', + ), toolCallId: normalizeToolCallId(payload), toolName: normalizeToolName( payload.toolDisplayName ?? payload.toolName ?? payload.name, @@ -394,7 +456,11 @@ export function applyAgentSseEnvelope( label: asText(payload.label), phase: asText(payload.phase), status: asText(payload.status), - statusKey: asText(payload.statusKey), + statusKey: statusKeyForProjection( + payload, + metadata, + 'memory-compression', + ), }); return; } @@ -402,7 +468,7 @@ export function applyAgentSseEnvelope( ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( items, asText(payload.status) === 'running' ? 'running' : 'done', - asText(payload.statusKey), + statusKeyForProjection(payload, metadata, 'knowledge-retrieval'), ); } return; diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts index b0b36f9..eb738b6 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts @@ -1,7 +1,9 @@ import type {ChatTimelineItem} from '@easyflow/common-ui'; import {ChatTimelineBuilder} from '@easyflow/common-ui'; +import type {AgentChatCapabilityPayload} from './api'; import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api'; + import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter'; interface RuntimeSessionState { @@ -34,6 +36,7 @@ interface StartOptions { agentId: string; agentName?: string; baseItems?: ChatTimelineItem[]; + capabilities?: AgentChatCapabilityPayload[]; prompt: string; sessionId?: string; } @@ -47,7 +50,8 @@ const listeners = new Set<() => void>(); let latestSessionId = ''; function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; + const serialized = JSON.stringify(value); + return JSON.parse(serialized) as T; } function createRoundId() { @@ -225,6 +229,7 @@ export const agentChatRuntimeManager = { void sendAgentChat( { agentId: options.agentId, + capabilities: options.capabilities, prompt: options.prompt, sessionId, }, diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts index d33d90c..348a714 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts @@ -26,6 +26,20 @@ export interface AgentChatSessionView { title?: string; } +export interface AgentChatKnowledgeView { + alias?: string; + description?: string; + icon?: string; + id?: number | string; + title?: string; +} + +export interface AgentChatSessionDetailView extends AgentChatSessionView { + boundKnowledges?: AgentChatKnowledgeView[]; + extraKnowledges?: AgentChatKnowledgeView[]; + removedExtraKnowledgeNames?: string[]; +} + export interface AgentChatSessionPage { pageNumber?: number; pageSize?: number; @@ -58,6 +72,11 @@ export interface AgentChatConversationView { variantsByRound?: Record; } +export interface AgentChatCapabilityPayload { + resourceIds: Array; + type: 'KNOWLEDGE'; +} + export function getPublishedAgents() { return api.get>('/api/v1/agent/list', { params: { publishedOnly: true }, @@ -69,11 +88,20 @@ export function generateAgentSessionId() { } export function getAgentSession(sessionId: number | string) { - return api.get>( + return api.get>( `/api/v1/agent/session/${sessionId}`, ); } +export function getPublishedKnowledges() { + return api.get>( + '/api/v1/documentCollection/list', + { + params: { publishedOnly: true }, + }, + ); +} + export function getAgentSessions(params?: { agentId?: number | string; pageNumber?: number; @@ -103,6 +131,18 @@ export function renameAgentSession(sessionId: number | string, title: string) { }); } +export function saveAgentSessionExtraKnowledges( + sessionId: number | string, + knowledgeIds: Array, +) { + return api.post>( + `/api/v1/agent/session/${sessionId}/extraKnowledges`, + { + knowledgeIds, + }, + ); +} + export function deleteAgentSession(sessionId: number | string) { return api.post(`/api/v1/agent/session/${sessionId}/delete`); } @@ -129,6 +169,7 @@ export function rejectAgentRun( export function sendAgentChat( data: { agentId: number | string; + capabilities?: AgentChatCapabilityPayload[]; prompt: string; sessionId?: number | string; }, diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/components/AgentChatWelcomeState.vue b/easyflow-ui-admin/app/src/views/ai/agent-chat/components/AgentChatWelcomeState.vue new file mode 100644 index 0000000..591ce6d --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/components/AgentChatWelcomeState.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue index 6f5116e..a6a85cf 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue @@ -15,14 +15,21 @@ import { getAgentSession, getAgentSessions, getPublishedAgents, + getPublishedKnowledges, rejectAgentRun, renameAgentSession, + saveAgentSessionExtraKnowledges, } from './api'; +import type { + ChatInputTriggerGroup, + ChatInputTriggerItem, +} from '#/components/chat-workspace/input-triggers/types'; + import {computed, onBeforeUnmount, onMounted, ref} from 'vue'; import {useRoute, useRouter} from 'vue-router'; -import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue'; +import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue'; import { ElButton, ElDropdown, @@ -35,25 +42,49 @@ import { ElOption, ElSelect, } from 'element-plus'; + +import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue'; +import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue'; +import {useChatInputTrigger} from '#/components/chat-workspace/input-triggers/useChatInputTrigger'; + import {recordsToTimelineItems} from './adapters/agentTimelineAdapter'; import {agentChatRuntimeManager} from './agentChatRuntimeManager'; +import AgentChatWelcomeState from './components/AgentChatWelcomeState.vue'; const route = useRoute(); const router = useRouter(); +const WELCOME_TITLES = [ + '我们应该做些什么', + '让协作发生', + '今天想推进什么', + '把想法变成行动', + '让智能体开始工作', + '从一个问题开始', + '一起把事情理清楚', + '把下一步交给协作', +]; + const agents = ref([]); const sessions = ref([]); const timelineItems = ref([]); const selectedAgentId = ref(''); const currentSessionId = ref(''); const promptText = ref(''); +const promptInputRef = ref(); const loadingAgents = ref(false); const loadingSessions = ref(false); const loadingConversation = ref(false); +const loadingKnowledges = ref(false); +const savingExtraKnowledges = ref(false); const sending = ref(false); const runtimeRunning = ref(false); const approvalLoadingKey = ref(''); +const knowledgeOptions = ref<{ label: string; value: string }[]>([]); +const knowledgeMap = ref(new Map()); +const extraKnowledgeIds = ref([]); const runtimeSendingState = new Map(); +const MAX_EXTRA_KNOWLEDGE_COUNT = 3; let runtimeUnsubscribe: (() => void) | undefined; const selectedAgent = computed(() => @@ -75,13 +106,66 @@ const canSend = computed( const composerPlaceholder = computed(() => selectedAgent.value ? '输入消息' : '请选择智能体', ); -const agentSelectWidth = computed(() => { - const name = selectedAgent.value?.name || '选择智能体'; - const textWidth = Array.from(name).reduce( - (total, char) => total + (/[\u4E00-\u9FFF]/.test(char) ? 14 : 8), +const selectedExtraKnowledges = computed(() => { + const knowledges: { id: string; title: string }[] = []; + for (const id of extraKnowledgeIds.value) { + const knowledge = knowledgeMap.value.get(String(id)); + if (knowledge) { + knowledges.push(knowledge); + } + } + return knowledges; +}); +const capabilityDisabled = computed( + () => + sending.value || + runtimeRunning.value || + savingExtraKnowledges.value || + !selectedAgentId.value, +); +const isWelcomeState = computed( + () => + !loadingConversation.value && + !currentSessionId.value && + timelineItems.value.length === 0, +); +const welcomeTitle = computed(() => { + const agentKey = selectedAgentId.value || selectedAgent.value?.name || ''; + const index = [...agentKey].reduce( + (total, char) => total + char.charCodeAt(0), 0, ); - return `${Math.min(Math.max(textWidth + 36, 92), 240)}px`; + return WELCOME_TITLES[index % WELCOME_TITLES.length] || '我们应该做些什么'; +}); +const agentSelectWidth = computed(() => { + const name = selectedAgent.value?.name || '选择智能体'; + const textWidth = [...name].reduce( + (total, char) => total + (/[\u4E00-\u9FFF]/.test(char) ? 15 : 8), + 0, + ); + return `${Math.min(Math.max(textWidth + 36, 116), 320)}px`; +}); +const triggerGroups = computed(() => [ + { + items: knowledgeOptions.value.map((item) => { + const selected = extraKnowledgeIds.value.includes(String(item.value)); + return { + disabled: + !selected && + extraKnowledgeIds.value.length >= MAX_EXTRA_KNOWLEDGE_COUNT, + id: item.value, + label: item.label, + }; + }), + label: '知识库', + symbol: '@', + }, +]); +const chatInputTrigger = useChatInputTrigger({ + disabled: capabilityDisabled, + groups: triggerGroups, + inputRef: promptInputRef, + text: promptText, }); function formatDate(value?: string) { @@ -171,8 +255,42 @@ async function loadSessions() { } } +async function loadKnowledges() { + loadingKnowledges.value = true; + try { + const res = await getPublishedKnowledges(); + if (res.errorCode !== 0) { + throw new Error(res.message || '知识库加载失败'); + } + const records = Array.isArray(res.data) ? res.data : []; + knowledgeOptions.value = records + .filter((item) => item?.id) + .map((item) => ({ + label: item.title || item.alias || String(item.id), + value: String(item.id), + })); + knowledgeMap.value = new Map( + records + .filter((item) => item?.id) + .map((item) => [ + String(item.id), + { + id: String(item.id), + title: item.title || item.alias || String(item.id), + }, + ]), + ); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '知识库加载失败'); + } finally { + loadingKnowledges.value = false; + } +} + function resolveSessionSortTime(session: AgentChatSessionView) { - const time = new Date(session.lastMessageAt || session.accessAt || '').getTime(); + const time = new Date( + session.lastMessageAt || session.accessAt || '', + ).getTime(); return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY; } @@ -195,14 +313,14 @@ function upsertSessionRecord(session: AgentChatSessionView) { const currentIndex = next.findIndex( (item) => String(item.sessionId) === sessionId, ); - if (currentIndex >= 0) { - next.splice(currentIndex, 1, { - ...next[currentIndex], + if (currentIndex === -1) { + next.push({ ...session, sessionId, }); } else { - next.push({ + next.splice(currentIndex, 1, { + ...next[currentIndex], ...session, sessionId, }); @@ -318,7 +436,8 @@ async function loadConversation(sessionId: string) { try { const detailRes = await getAgentSession(sessionId); const res = await getAgentConversation(sessionId); - const latestRuntimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId); + const latestRuntimeSnapshot = + agentChatRuntimeManager.getSnapshot(sessionId); if (latestRuntimeSnapshot?.sending) { syncRuntimeSnapshot(sessionId); await syncSessionRoute(sessionId); @@ -335,6 +454,24 @@ async function loadConversation(sessionId: string) { if (session?.assistantId) { selectedAgentId.value = String(session.assistantId); } + if (detailRes.errorCode === 0 && detailRes.data) { + extraKnowledgeIds.value = (detailRes.data.extraKnowledges || []) + .map((item) => String(item.id || '')) + .filter(Boolean); + for (const item of detailRes.data.extraKnowledges || []) { + if (!item.id) { + continue; + } + knowledgeMap.value.set(String(item.id), { + id: String(item.id), + title: item.title || item.alias || String(item.id), + }); + } + if ((detailRes.data.removedExtraKnowledgeNames || []).length > 0) { + const removedNames = detailRes.data.removedExtraKnowledgeNames || []; + ElMessage.warning(`以下知识库已失效并移除:${removedNames.join('、')}`); + } + } currentSessionId.value = sessionId; sending.value = false; await syncSessionRoute(sessionId); @@ -349,6 +486,7 @@ async function createNewSession() { currentSessionId.value = ''; timelineItems.value = []; promptText.value = ''; + extraKnowledgeIds.value = []; sending.value = false; await syncSessionRoute(); } @@ -362,20 +500,77 @@ async function bindCreatedSession(sessionId: string, prompt: string) { (session) => String(session.sessionId) === sessionId, ); const nextSession = buildOptimisticSession(sessionId, prompt); - if (existingIndex >= 0) { - upsertSessionRecord(nextSession); - } else { + if (existingIndex === -1) { sessions.value = [nextSession, ...sessions.value]; + } else { + upsertSessionRecord(nextSession); } await syncSessionRoute(sessionId); } function handleAgentChange() { + extraKnowledgeIds.value = []; if (timelineItems.value.length > 0 || currentSessionId.value) { void createNewSession(); } } +async function handleExtraKnowledgeIdsChange(value: string[]) { + const previousIds = [...extraKnowledgeIds.value]; + const nextIds = value.map(String); + extraKnowledgeIds.value = nextIds; + if (!currentSessionId.value) { + return; + } + savingExtraKnowledges.value = true; + try { + const res = await saveAgentSessionExtraKnowledges( + currentSessionId.value, + nextIds, + ); + if (res.errorCode !== 0 || !res.data) { + throw new Error(res.message || '知识库保存失败'); + } + extraKnowledgeIds.value = (res.data.extraKnowledges || []) + .map((item) => String(item.id || '')) + .filter(Boolean); + upsertSessionRecord({ + ...res.data, + sessionId: currentSessionId.value, + }); + } catch (error) { + extraKnowledgeIds.value = previousIds; + ElMessage.error(error instanceof Error ? error.message : '知识库保存失败'); + } finally { + savingExtraKnowledges.value = false; + } +} + +async function handleTriggerSelect(item: ChatInputTriggerItem) { + if (item.disabled) { + return; + } + if (chatInputTrigger.activePanel.value?.symbol !== '@') { + await chatInputTrigger.replaceTriggerText(''); + return; + } + const nextIds = extraKnowledgeIds.value.map(String); + if (!nextIds.includes(String(item.id))) { + nextIds.push(String(item.id)); + await handleExtraKnowledgeIdsChange(nextIds); + } + await chatInputTrigger.replaceTriggerText(''); +} + +function buildCapabilities() { + return [ + { + resourceIds: [...extraKnowledgeIds.value], + type: 'KNOWLEDGE' as const, + }, + ]; +} + async function handleSend() { const content = promptText.value.trim(); if (!content || !selectedAgentId.value || sending.value) { @@ -392,6 +587,7 @@ async function handleSend() { agentId: selectedAgentId.value, agentName: selectedAgent.value?.name, baseItems: timelineItems.value, + capabilities: buildCapabilities(), prompt: content, sessionId: currentSessionId.value, }); @@ -406,6 +602,55 @@ async function handleSend() { } } +function handlePromptInput() { + chatInputTrigger.sync(); +} + +function handlePromptKeyup() { + chatInputTrigger.sync(); +} + +function handlePromptClick() { + chatInputTrigger.sync(); +} + +function handlePromptKeydown(event: Event | KeyboardEvent) { + if (!(event instanceof KeyboardEvent)) { + return; + } + if (chatInputTrigger.activePanel.value) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + chatInputTrigger.move(1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + chatInputTrigger.move(-1); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + chatInputTrigger.close(); + return; + } + if (event.key === 'Enter' && !event.shiftKey) { + const item = + chatInputTrigger.visibleItems.value[chatInputTrigger.activeIndex.value]; + if (item) { + event.preventDefault(); + void handleTriggerSelect(item); + return; + } + } + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSend(); + } +} + function handleStop() { if (!canStopRuntime.value) { return; @@ -539,7 +784,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) { } async function bootstrap() { - await Promise.all([loadAgents(), loadSessions()]); + await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]); const routeSessionId = String(route.query.sessionId || ''); if (routeSessionId) { await loadConversation(routeSessionId); @@ -645,10 +890,17 @@ onBeforeUnmount(() => { -
+
加载中
+ { />
-
+
+ + { resize="none" :placeholder="composerPlaceholder" :disabled="sending || runtimeRunning || !selectedAgentId" - @keydown.enter.exact.prevent="handleSend" + @click="handlePromptClick" + @input="handlePromptInput" + @keydown="handlePromptKeydown" + @keyup="handlePromptKeyup" />
{ class="agent-chat__send-button is-stop" @click="handleStop" > - + { box-sizing: border-box; } +.agent-chat__timeline-wrap.is-welcome { + justify-content: center; + padding: 0 min(8vw, 96px) 252px; +} + .agent-chat__timeline-wrap :deep(.chat-timeline) { height: 100%; min-height: 0; @@ -886,6 +1180,20 @@ onBeforeUnmount(() => { box-shadow: var(--el-box-shadow-light); } +.agent-chat__composer.is-welcome { + top: calc(50% + 40px); + bottom: auto; + transform: translateY(-50%); +} + +.agent-chat__trigger-panel { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 5; + width: 100%; +} + .agent-chat__composer-input :deep(.el-textarea__inner) { min-height: 48px !important; padding: 0; @@ -902,13 +1210,25 @@ onBeforeUnmount(() => { justify-content: space-between; } +.agent-chat__composer-tools { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 4px; + max-width: calc(100% - 64px); +} + +.agent-chat__capability-entry { + flex: none; +} + .agent-chat__agent-select { - max-width: min(240px, 58%); + max-width: min(320px, calc(100vw - 240px)); } .agent-chat__agent-select :deep(.el-select__wrapper) { min-height: 36px; - padding: 0; + padding: 0 4px 0 0; background: transparent; border: 0; box-shadow: none; @@ -921,7 +1241,7 @@ onBeforeUnmount(() => { .agent-chat__agent-select :deep(.el-select__placeholder), .agent-chat__agent-select :deep(.el-select__selected-item) { min-width: 0; - max-width: 184px; + max-width: none; overflow: hidden; font-size: 14px; font-weight: 400; @@ -932,6 +1252,7 @@ onBeforeUnmount(() => { .agent-chat__agent-select :deep(.el-select__caret) { color: var(--el-color-primary); + margin-left: 6px; } .agent-chat__composer-actions { @@ -1002,6 +1323,10 @@ onBeforeUnmount(() => { padding-bottom: 184px; } + .agent-chat__timeline-wrap.is-welcome { + padding: 0 16px 244px; + } + .agent-chat__timeline-wrap :deep(.chat-timeline) { padding: 16px; } @@ -1012,12 +1337,18 @@ onBeforeUnmount(() => { left: 16px; } + .agent-chat__composer.is-welcome { + top: calc(50% + 52px); + bottom: auto; + } + .agent-chat__composer-footer { align-items: flex-end; } .agent-chat__agent-select { - width: min(220px, calc(100% - 58px)); + width: min(280px, calc(100% - 58px)); + max-width: calc(100vw - 128px); } .agent-chat__composer-actions { diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue index 02bb24e..0c1d188 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue @@ -34,6 +34,8 @@ import {useAgentDesignerState} from './composables/useAgentDesignerState'; const route = useRoute(); const router = useRouter(); +const AGENT_TAB_PAGE_KEY = '/ai/agents'; +const DEFAULT_AGENT_TITLE = '未命名智能体'; const { state, addKnowledgeNode, @@ -52,6 +54,7 @@ const { const pageLoading = ref(false); const saveLoading = ref(false); +const offlineLoading = ref(false); const publishLoading = ref(false); const issues = ref([]); const categories = ref([]); @@ -62,14 +65,6 @@ const pluginTools = ref([]); const isNew = computed(() => String(route.params.id || '') === 'new'); const publishText = computed(() => { - if ( - canAiResourceOffline( - state.agent.displayPublishStatus, - state.agent.publishStatus, - ) - ) { - return '下线'; - } if ( canAiResourceRepublish( state.agent.displayPublishStatus, @@ -81,6 +76,13 @@ const publishText = computed(() => { return '发布'; }); +const offlineVisible = computed(() => + canAiResourceOffline( + state.agent.displayPublishStatus, + state.agent.publishStatus, + ), +); + const publishDisabled = computed(() => { if (!state.agent.id) return true; if ( @@ -99,14 +101,15 @@ const publishDisabled = computed(() => { canAiResourceRepublish( state.agent.displayPublishStatus, state.agent.publishStatus, - ) || - canAiResourceOffline( - state.agent.displayPublishStatus, - state.agent.publishStatus, ) ); }); +const offlineDisabled = computed(() => { + if (!state.agent.id) return true; + return !offlineVisible.value; +}); + onMounted(async () => { pageLoading.value = true; try { @@ -119,14 +122,55 @@ onMounted(async () => { async function loadAgent() { if (isNew.value) { reset(); + syncNavTitle(DEFAULT_AGENT_TITLE, { force: true }); return; } const [, res] = await tryit(getAgentDetail)(String(route.params.id)); if (res?.errorCode === 0) { reset(res.data); + syncNavTitle(resolveAgentTitle(res.data), { force: !hasNavTitle() }); } } +function hasNavTitle() { + const navTitle = Array.isArray(route.query.navTitle) + ? route.query.navTitle[0] + : route.query.navTitle; + return typeof navTitle === 'string' && navTitle.trim(); +} + +function resolveAgentTitle(agent = state.agent) { + return String(agent.name || '').trim() || DEFAULT_AGENT_TITLE; +} + +function syncNavTitle(title: string, options: { force?: boolean } = {}) { + const normalizedTitle = String(title || '').trim() || DEFAULT_AGENT_TITLE; + const query = route.query as Record; + const currentNavTitle = Array.isArray(query.navTitle) + ? query.navTitle[0] + : query.navTitle; + const currentPageKey = Array.isArray(query.pageKey) + ? query.pageKey[0] + : query.pageKey; + + if ( + !options.force && + currentNavTitle === normalizedTitle && + currentPageKey === AGENT_TAB_PAGE_KEY + ) { + return; + } + + router.replace({ + path: route.path, + query: { + ...query, + pageKey: AGENT_TAB_PAGE_KEY, + navTitle: normalizedTitle, + }, + }); +} + async function loadOptions() { const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] = await Promise.all([ @@ -245,8 +289,18 @@ async function handleSave(showMessage = true) { id, }; state.dirty = false; + const title = resolveAgentTitle(); if (isNew.value) { - await router.replace(`/ai/agents/designer/${id}`); + await router.replace({ + path: `/ai/agents/designer/${id}`, + query: { + ...route.query, + pageKey: AGENT_TAB_PAGE_KEY, + navTitle: title, + }, + }); + } else { + syncNavTitle(title, { force: true }); } if (showMessage) { ElMessage.success('已保存'); @@ -262,29 +316,19 @@ async function handlePublish() { const saved = await handleSave(false); if (!saved) return; - const offline = canAiResourceOffline( - state.agent.displayPublishStatus, - state.agent.publishStatus, - ); try { - await ElMessageBox.confirm( - offline ? '确认提交下线审批?' : '确认提交发布审批?', - '提示', - { - confirmButtonText: '确认', - cancelButtonText: '取消', - type: offline ? 'warning' : 'info', - }, - ); + await ElMessageBox.confirm('确认提交发布审批?', '提示', { + confirmButtonText: '确认', + cancelButtonText: '取消', + type: 'info', + }); } catch { return; } publishLoading.value = true; try { - const res = offline - ? await submitAgentOfflineApproval(String(state.agent.id)) - : await submitAgentPublishApproval(String(state.agent.id)); + const res = await submitAgentPublishApproval(String(state.agent.id)); if (res.errorCode === 0) { ElMessage.success(res.message || '已提交'); await loadAgent(); @@ -294,6 +338,33 @@ async function handlePublish() { } } +async function handleOffline() { + if (!state.agent.id) return; + const saved = await handleSave(false); + if (!saved) return; + + try { + await ElMessageBox.confirm('确认提交下线审批?', '提示', { + confirmButtonText: '确认', + cancelButtonText: '取消', + type: 'warning', + }); + } catch { + return; + } + + offlineLoading.value = true; + try { + const res = await submitAgentOfflineApproval(String(state.agent.id)); + if (res.errorCode === 0) { + ElMessage.success(res.message || '已提交'); + await loadAgent(); + } + } finally { + offlineLoading.value = false; + } +} + function handleTryout() { if (!runValidation()) return; openTryout(); @@ -330,8 +401,12 @@ function handleCloseTryout() { :publish-loading="publishLoading" :publish-disabled="publishDisabled" :publish-text="publishText" + :offline-disabled="offlineDisabled" + :offline-loading="offlineLoading" + :offline-visible="offlineVisible" @add="handleAdd" @save="handleSave()" + @offline="handleOffline" @publish="handlePublish" @tryout="handleTryout" /> diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue index 9964995..0cd663b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue @@ -36,6 +36,8 @@ import { const router = useRouter(); const pageDataRef = ref(); const sideList = ref([]); +const AGENT_TAB_PAGE_KEY = '/ai/agents'; +const DEFAULT_AGENT_TITLE = '未命名智能体'; const headerButtons = [ { @@ -53,7 +55,13 @@ const primaryAction: CardPrimaryAction = { text: '编排', permission: '/api/v1/agent/update', onClick(row: AgentInfo) { - router.push(`/ai/agents/designer/${row.id}`); + router.push({ + path: `/ai/agents/designer/${row.id}`, + query: { + pageKey: AGENT_TAB_PAGE_KEY, + navTitle: resolveNavTitle(row), + }, + }); }, }; @@ -106,10 +114,20 @@ function handleSearch(keyword: string) { function handleButtonClick(payload: any) { if (payload?.key === 'create' || payload?.data?.action === 'create') { - router.push('/ai/agents/designer/new'); + router.push({ + path: '/ai/agents/designer/new', + query: { + pageKey: AGENT_TAB_PAGE_KEY, + navTitle: DEFAULT_AGENT_TITLE, + }, + }); } } +function resolveNavTitle(row: AgentInfo) { + return String(row.name || '').trim() || DEFAULT_AGENT_TITLE; +} + function changeCategory(category: any) { pageDataRef.value?.setQuery({ categoryId: category.id }); } 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 index 8f191dd..735f501 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentCommandBar.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentCommandBar.vue @@ -1,19 +1,14 @@