From 705e0faab64f3c45e5461d38462c4d10e9ba2011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Tue, 14 Jul 2026 21:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E5=AF=B9=E8=AF=9D=E4=BD=93=E9=AA=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持欢迎语、猜你想问和输入提示的编辑、草稿预览与发布态展示 - 补充配置校验、发布快照持久化和发布后回显修复 --- .../config/AgentInteractionConfigSupport.java | 115 ++++++ .../tech/easyflow/agent/entity/Agent.java | 15 + .../publish/AgentApprovalSubjectHandler.java | 38 +- .../agent/service/impl/AgentServiceImpl.java | 5 + .../AgentInteractionConfigSupportTest.java | 93 +++++ .../AgentApprovalSubjectHandlerTest.java | 120 +++++++ .../V30__mysql_agent_interaction_config.sql | 2 + .../src/components/ai-chat/AiChatPanel.vue | 8 +- .../components/AgentChatWelcomeState.vue | 30 -- .../app/src/views/ai/agent-chat/index.vue | 176 ++++++---- .../agents/components/AgentInspectorPanel.vue | 77 +++- .../components/AgentInteractionForm.vue | 328 ++++++++++++++++++ .../ai/agents/components/AgentTryoutPanel.vue | 91 +++-- .../agents/components/AgentWelcomeState.vue | 222 ++++++++++++ .../composables/useAgentDesignerState.ts | 19 +- .../ai/agents/interaction-config.test.ts | 45 +++ .../src/views/ai/agents/interaction-config.ts | 120 +++++++ .../app/src/views/ai/agents/types.ts | 7 + 18 files changed, 1351 insertions(+), 160 deletions(-) create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql delete mode 100644 easyflow-ui-admin/app/src/views/ai/agent-chat/components/AgentChatWelcomeState.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentInteractionForm.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentWelcomeState.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java new file mode 100644 index 00000000..a938e231 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java @@ -0,0 +1,115 @@ +package tech.easyflow.agent.config; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Agent 对话体验配置的规范化与校验工具。 + */ +public final class AgentInteractionConfigSupport { + + /** 欢迎语最大字符数。 */ + public static final int MAX_WELCOME_MESSAGE_LENGTH = 300; + /** 猜你想问最大数量。 */ + public static final int MAX_SUGGESTED_QUESTION_COUNT = 6; + /** 单条猜你想问最大字符数。 */ + public static final int MAX_SUGGESTED_QUESTION_LENGTH = 80; + /** 输入提示最大字符数。 */ + public static final int MAX_INPUT_PLACEHOLDER_LENGTH = 40; + + private AgentInteractionConfigSupport() { + } + + /** + * 规范化并校验对话体验配置。 + * + * @param source 原始配置 + * @return 仅包含受支持字段的稳定配置 + * @throws BusinessException 配置字段类型或内容不合法时抛出 + */ + public static Map normalize(Map source) { + Map config = source == null ? Map.of() : source; + String welcomeMessage = optionalString(config.get("welcomeMessage"), "欢迎语"); + if (welcomeMessage.length() > MAX_WELCOME_MESSAGE_LENGTH) { + throw new BusinessException("欢迎语不能超过 300 个字符"); + } + + String inputPlaceholder = optionalString(config.get("inputPlaceholder"), "输入提示"); + if (inputPlaceholder.contains("\n") || inputPlaceholder.contains("\r")) { + throw new BusinessException("输入提示仅支持单行文本"); + } + if (inputPlaceholder.length() > MAX_INPUT_PLACEHOLDER_LENGTH) { + throw new BusinessException("输入提示不能超过 40 个字符"); + } + + List suggestedQuestions = normalizeSuggestedQuestions(config.get("suggestedQuestions")); + Map normalized = new LinkedHashMap<>(); + normalized.put("welcomeMessage", welcomeMessage); + normalized.put("suggestedQuestions", suggestedQuestions); + normalized.put("inputPlaceholder", inputPlaceholder); + return normalized; + } + + /** + * 规范化猜你想问列表,过滤空项并保留原有顺序。 + * + * @param value 原始列表值 + * @return 规范化后的问题列表 + * @throws BusinessException 列表类型、数量、长度或重复性不合法时抛出 + */ + private static List normalizeSuggestedQuestions(Object value) { + if (value == null) { + return List.of(); + } + if (!(value instanceof Collection values)) { + throw new BusinessException("猜你想问格式不正确"); + } + List normalized = new ArrayList<>(); + Set uniqueQuestions = new LinkedHashSet<>(); + for (Object item : values) { + if (!(item instanceof String question)) { + throw new BusinessException("猜你想问仅支持文本内容"); + } + String trimmedQuestion = question.trim(); + if (trimmedQuestion.isEmpty()) { + continue; + } + if (trimmedQuestion.length() > MAX_SUGGESTED_QUESTION_LENGTH) { + throw new BusinessException("单条猜你想问不能超过 80 个字符"); + } + if (!uniqueQuestions.add(trimmedQuestion)) { + throw new BusinessException("猜你想问不能重复"); + } + normalized.add(trimmedQuestion); + } + if (normalized.size() > MAX_SUGGESTED_QUESTION_COUNT) { + throw new BusinessException("猜你想问最多配置 6 条"); + } + return normalized; + } + + /** + * 将可选字段转换为去除首尾空白的文本。 + * + * @param value 原始字段值 + * @param fieldName 字段名称 + * @return 规范化后的文本 + * @throws BusinessException 字段不是文本时抛出 + */ + private static String optionalString(Object value, String fieldName) { + if (value == null) { + return ""; + } + if (!(value instanceof String text)) { + throw new BusinessException(fieldName + "格式不正确"); + } + return text.trim(); + } +} 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 index e4db2afe..a342e383 100644 --- 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 @@ -43,6 +43,9 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl private Map memoryConfigJson = new LinkedHashMap<>(); @Column(typeHandler = FastjsonTypeHandler.class) private Map executionConfigJson = new LinkedHashMap<>(); + /** 对话欢迎语、猜你想问和输入提示配置。 */ + @Column(typeHandler = FastjsonTypeHandler.class) + private Map interactionConfigJson = new LinkedHashMap<>(); private Integer status; private String visibilityScope; private String publishStatus; @@ -95,6 +98,18 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl 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; } + /** + * 获取对话体验配置。 + * + * @return 对话体验配置 + */ + public Map getInteractionConfigJson() { return interactionConfigJson; } + /** + * 设置对话体验配置。 + * + * @param interactionConfigJson 对话体验配置 + */ + public void setInteractionConfigJson(Map interactionConfigJson) { this.interactionConfigJson = interactionConfigJson == null ? new LinkedHashMap<>() : interactionConfigJson; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status = status; } public String getVisibilityScope() { return visibilityScope; } 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 index eb564326..62c6ad1c 100644 --- 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 @@ -2,6 +2,7 @@ package tech.easyflow.agent.publish; import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.update.UpdateChain; import org.springframework.stereotype.Component; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; @@ -122,32 +123,33 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @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); + // 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。 + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, publishStatus.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @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); + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); + updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot); + updateChain.set(Agent::getPublishedAt, new Date()); + updateChain.set(Agent::getPublishedBy, operatorId); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @Override protected void markResourceOffline(BigInteger resourceId) { - Agent agent = new Agent(); - agent.setId(resourceId); - agent.setPublishStatus(PublishStatus.OFFLINE.getCode()); - agent.setCurrentApprovalInstanceId(null); - agentService.updateById(agent); + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @Override 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 index c6faf467..cb06502f 100644 --- 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 @@ -5,6 +5,7 @@ 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.config.AgentInteractionConfigSupport; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; @@ -133,6 +134,7 @@ public class AgentServiceImpl extends ServiceImpl implements snapshot.put("promptConfigJson", detail.getPromptConfigJson()); snapshot.put("memoryConfigJson", detail.getMemoryConfigJson()); snapshot.put("executionConfigJson", detail.getExecutionConfigJson()); + snapshot.put("interactionConfigJson", detail.getInteractionConfigJson()); snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings())); snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings())); @@ -162,6 +164,7 @@ public class AgentServiceImpl extends ServiceImpl implements agent.setModelId(toBigInteger(snapshot.get("modelId"))); agent.setCategoryId(toBigInteger(snapshot.get("categoryId"))); agent.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); 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)); @@ -191,6 +194,7 @@ public class AgentServiceImpl extends ServiceImpl implements throw new BusinessException("Agent 模型不存在"); } agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name()); + agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); } private void applyDraftDefaults(Agent agent) { @@ -231,6 +235,7 @@ public class AgentServiceImpl extends ServiceImpl implements existing.setPromptConfigJson(incoming.getPromptConfigJson()); existing.setMemoryConfigJson(incoming.getMemoryConfigJson()); existing.setExecutionConfigJson(incoming.getExecutionConfigJson()); + existing.setInteractionConfigJson(incoming.getInteractionConfigJson()); existing.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus()); existing.setVisibilityScope(incoming.getVisibilityScope()); existing.setModified(new Date()); diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java new file mode 100644 index 00000000..bc0c3083 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java @@ -0,0 +1,93 @@ +package tech.easyflow.agent.config; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link AgentInteractionConfigSupport} 单元测试。 + */ +public class AgentInteractionConfigSupportTest { + + /** + * 验证空配置可以转换为稳定结构。 + */ + @Test + public void shouldNormalizeEmptyConfig() { + Map normalized = AgentInteractionConfigSupport.normalize(null); + + Assert.assertEquals("", normalized.get("welcomeMessage")); + Assert.assertEquals(List.of(), normalized.get("suggestedQuestions")); + Assert.assertEquals("", normalized.get("inputPlaceholder")); + } + + /** + * 验证空问题会被过滤,合法问题会去除首尾空白并保持顺序。 + */ + @Test + public void shouldNormalizeSuggestedQuestions() { + Map source = new LinkedHashMap<>(); + source.put("welcomeMessage", " 欢迎使用\n智能助手 "); + source.put("suggestedQuestions", List.of(" 第一个问题 ", "", " 第二个问题")); + source.put("inputPlaceholder", " 请输入问题 "); + + Map normalized = AgentInteractionConfigSupport.normalize(source); + + Assert.assertEquals("欢迎使用\n智能助手", normalized.get("welcomeMessage")); + Assert.assertEquals(List.of("第一个问题", "第二个问题"), normalized.get("suggestedQuestions")); + Assert.assertEquals("请输入问题", normalized.get("inputPlaceholder")); + } + + /** + * 验证重复问题会被拒绝。 + */ + @Test + public void shouldRejectDuplicateQuestionsAfterTrim() { + Map source = Map.of( + "suggestedQuestions", List.of("如何使用?", " 如何使用? ") + ); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 验证多行输入提示会被拒绝。 + */ + @Test + public void shouldRejectMultilinePlaceholder() { + Map source = Map.of("inputPlaceholder", "第一行\n第二行"); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 验证问题数量不能超过上限。 + */ + @Test + public void shouldRejectTooManyQuestions() { + Map source = Map.of( + "suggestedQuestions", + List.of("问题1", "问题2", "问题3", "问题4", "问题5", "问题6", "问题7") + ); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 断言操作会抛出业务异常。 + * + * @param action 待执行操作 + */ + private void assertBusinessException(Runnable action) { + try { + action.run(); + Assert.fail("应抛出 BusinessException"); + } catch (BusinessException expected) { + Assert.assertNotNull(expected.getMessage()); + } + } +} 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 index 27fc9b68..4b9f57d5 100644 --- 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 @@ -1,20 +1,86 @@ package tech.easyflow.agent.publish; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.update.UpdateChain; +import com.mybatisflex.core.util.LambdaGetter; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.agent.entity.Agent; 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 java.lang.reflect.Proxy; import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + /** * {@link AgentApprovalSubjectHandler} 单元测试。 */ public class AgentApprovalSubjectHandlerTest { + /** + * 审批状态变更必须使用字段级更新,避免覆盖 Agent 草稿配置。 + */ + @Test + public void persistResourceStateShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + + handler.persistResourceState( + BigInteger.valueOf(1001), + PublishStatus.PUBLISH_PENDING, + BigInteger.valueOf(2001) + ); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 2); + verify(agentService, never()).updateById(any(Agent.class)); + } + + /** + * 发布 Agent 必须使用字段级更新,确保发布后草稿配置仍可回显。 + */ + @Test + public void publishResourceShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("interactionConfigJson", Map.of("welcomeMessage", "你好")); + + handler.publishResource(BigInteger.valueOf(1001), snapshot, BigInteger.valueOf(3001)); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 5); + verify(agentService, never()).updateById(any(Agent.class)); + } + + /** + * Agent 下线必须使用字段级更新,避免覆盖未发布配置。 + */ + @Test + public void markResourceOfflineShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + + handler.markResourceOffline(BigInteger.valueOf(1001)); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 2); + verify(agentService, never()).updateById(any(Agent.class)); + } + /** * 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。 */ @@ -39,6 +105,60 @@ public class AgentApprovalSubjectHandlerTest { Assert.assertEquals(1, knowledgeRemoveCalls.get()); } + /** + * 创建用于生命周期测试的处理器。 + * + * @param agentService Agent 服务 + * @return Agent 审批资源处理器 + */ + private static AgentApprovalSubjectHandler handler(AgentService agentService) { + return new AgentApprovalSubjectHandler( + null, + new ObjectMapper(), + agentService, + null, + null, + null + ); + } + + /** + * 准备字段更新对象。 + * + * @param agentService Agent 服务 + * @return 字段更新对象 + */ + @SuppressWarnings("unchecked") + private static UpdateChain prepareUpdateChain(AgentService agentService) { + UpdateChain updateChain = mock(UpdateChain.class); + when(agentService.updateChain()).thenReturn(updateChain); + return updateChain; + } + + /** + * 校验生命周期操作仅通过指定主键执行字段更新。 + * + * @param updateChain 字段更新对象 + * @param resourceId Agent 主键 + * @param fieldCount 更新字段数量 + */ + @SuppressWarnings("unchecked") + private static void verifySelectiveUpdate(UpdateChain updateChain, + BigInteger resourceId, + int fieldCount) { + verify(updateChain, times(fieldCount)).set(any(LambdaGetter.class), any()); + verify(updateChain).eq(any(LambdaGetter.class), eq(resourceId)); + verify(updateChain).update(); + } + + /** + * 创建只统计删除调用的服务代理。 + * + * @param type 服务接口类型 + * @param removeCalls 删除调用计数器 + * @param 服务接口类型 + * @return 服务代理 + */ @SuppressWarnings("unchecked") private static T proxy(Class type, AtomicInteger removeCalls) { return (T) Proxy.newProxyInstance( diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql new file mode 100644 index 00000000..8cad9ccd --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql @@ -0,0 +1,2 @@ +ALTER TABLE `tb_agent` + ADD COLUMN `interaction_config_json` json NULL COMMENT 'Agent 对话体验配置' AFTER `execution_config_json`; diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue index 8f745d99..4cbdff3a 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue @@ -1,8 +1,8 @@ - - - - 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 a6a85cf7..361b4e6a 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 @@ -4,32 +4,27 @@ import type { ChatTimelineMessageItem, ChatTimelineToolApprovalPayload, } from '@easyflow/common-ui'; -import {ChatTimeline, ChatTimelineBuilder} from '@easyflow/common-ui'; -import type {AgentInfo} from '../agents/types'; -import type {AgentChatSessionView} from './api'; -import { - approveAgentRun, - deleteAgentSession, - getAgentConversation, - getAgentSession, - getAgentSessions, - getPublishedAgents, - getPublishedKnowledges, - rejectAgentRun, - renameAgentSession, - saveAgentSessionExtraKnowledges, -} from './api'; +import type { AgentInfo } from '../agents/types'; +import type { AgentChatSessionView } 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; -import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue'; +import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui'; + +import { + Delete, + EditPen, + MoreFilled, + Plus, + Promotion, +} from '@element-plus/icons-vue'; import { ElButton, ElDropdown, @@ -45,26 +40,28 @@ import { 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 { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger'; -import {recordsToTimelineItems} from './adapters/agentTimelineAdapter'; -import {agentChatRuntimeManager} from './agentChatRuntimeManager'; -import AgentChatWelcomeState from './components/AgentChatWelcomeState.vue'; +import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue'; +import { resolveInteractionDisplay } from '../agents/interaction-config'; +import { recordsToTimelineItems } from './adapters/agentTimelineAdapter'; +import { agentChatRuntimeManager } from './agentChatRuntimeManager'; +import { + approveAgentRun, + deleteAgentSession, + getAgentConversation, + getAgentSession, + getAgentSessions, + getPublishedAgents, + getPublishedKnowledges, + rejectAgentRun, + renameAgentSession, + saveAgentSessionExtraKnowledges, +} from './api'; const route = useRoute(); const router = useRouter(); -const WELCOME_TITLES = [ - '我们应该做些什么', - '让协作发生', - '今天想推进什么', - '把想法变成行动', - '让智能体开始工作', - '从一个问题开始', - '一起把事情理清楚', - '把下一步交给协作', -]; - const agents = ref([]); const sessions = ref([]); const timelineItems = ref([]); @@ -73,6 +70,7 @@ const currentSessionId = ref(''); const promptText = ref(''); const promptInputRef = ref(); const loadingAgents = ref(false); +const agentLoadError = ref(''); const loadingSessions = ref(false); const loadingConversation = ref(false); const loadingKnowledges = ref(false); @@ -90,6 +88,9 @@ let runtimeUnsubscribe: (() => void) | undefined; const selectedAgent = computed(() => agents.value.find((agent) => String(agent.id) === selectedAgentId.value), ); +const interactionDisplay = computed(() => + resolveInteractionDisplay(selectedAgent.value), +); const currentSession = computed(() => sessions.value.find( (session) => String(session.sessionId) === currentSessionId.value, @@ -104,7 +105,9 @@ const canSend = computed( !runtimeRunning.value, ); const composerPlaceholder = computed(() => - selectedAgent.value ? '输入消息' : '请选择智能体', + selectedAgent.value + ? interactionDisplay.value.inputPlaceholder + : '请选择智能体', ); const selectedExtraKnowledges = computed(() => { const knowledges: { id: string; title: string }[] = []; @@ -125,18 +128,12 @@ const capabilityDisabled = computed( ); const isWelcomeState = computed( () => + Boolean(selectedAgent.value) && + !loadingAgents.value && !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 WELCOME_TITLES[index % WELCOME_TITLES.length] || '我们应该做些什么'; -}); const agentSelectWidth = computed(() => { const name = selectedAgent.value?.name || '选择智能体'; const textWidth = [...name].reduce( @@ -222,6 +219,7 @@ async function syncSessionRoute(sessionId?: string) { async function loadAgents() { loadingAgents.value = true; + agentLoadError.value = ''; try { const res = await getPublishedAgents(); if (res.errorCode !== 0) { @@ -232,7 +230,9 @@ async function loadAgents() { selectedAgentId.value = String(agents.value[0].id); } } catch (error) { - ElMessage.error(error instanceof Error ? error.message : '智能体加载失败'); + agentLoadError.value = + error instanceof Error ? error.message : '智能体加载失败'; + ElMessage.error(agentLoadError.value); } finally { loadingAgents.value = false; } @@ -571,8 +571,8 @@ function buildCapabilities() { ]; } -async function handleSend() { - const content = promptText.value.trim(); +async function sendContent(rawContent: string) { + const content = rawContent.trim(); if (!content || !selectedAgentId.value || sending.value) { return; } @@ -602,6 +602,14 @@ async function handleSend() { } } +async function handleSend() { + await sendContent(promptText.value); +} + +function handleSuggestedQuestion(question: string) { + void sendContent(question); +} + function handlePromptInput() { chatInputTrigger.sync(); } @@ -894,12 +902,36 @@ onBeforeUnmount(() => { class="agent-chat__timeline-wrap" :class="{ 'is-welcome': isWelcomeState }" > -
+
加载中
- + {{ agentLoadError }} +
+
+ 暂无已发布智能体 +
+ { /> -
+
{ 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 index cda921f7..927f3e94 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue @@ -1,18 +1,28 @@ + + + + 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 index 0dc55e3c..3ac18c55 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts @@ -9,6 +9,13 @@ import type { import { computed, reactive } from 'vue'; +import { + buildInteractionConfigPayload, + createEmptyInteractionConfig, + normalizeInteractionConfig, + validateInteractionConfig, +} from '../interaction-config'; + const BASE_NODE_ID = 'agent-base'; const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/; @@ -85,6 +92,7 @@ export function createEmptyAgent(): AgentInfo { minCompressionTokenThreshold: 6000, }, }, + interactionConfigJson: createEmptyInteractionConfig(), status: 1, }; } @@ -119,6 +127,9 @@ function normalizeAgent(agent?: AgentInfo): AgentInfo { defaultCompressionParameter.minCompressionTokenThreshold, }, }, + interactionConfigJson: normalizeInteractionConfig( + source.interactionConfigJson, + ), }; } @@ -160,7 +171,7 @@ function normalizeToolBinding( String( binding.id || createLocalId(String(binding.toolType || 'tool').toLowerCase()), - ), + ), toolName: normalizeBindingToolName(binding), optionsJson, sortNo: binding.sortNo ?? index + 1, @@ -302,6 +313,9 @@ export function useAgentDesignerState() { message: '请选择模型', }); } + issues.push( + ...validateInteractionConfig(state.agent.interactionConfigJson), + ); state.knowledgeBindings.forEach((binding) => { if (!binding.knowledgeId) { issues.push({ @@ -339,6 +353,9 @@ export function useAgentDesignerState() { memoryConfigJson; return { ...state.agent, + interactionConfigJson: buildInteractionConfigPayload( + state.agent.interactionConfigJson, + ), memoryConfigJson: { ...restMemoryConfigJson, compressionParameter: { diff --git a/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts new file mode 100644 index 00000000..5ab40853 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildInteractionConfigPayload, + resolveInteractionDisplay, + validateInteractionConfig, +} from './interaction-config'; + +describe('agent 对话体验配置', () => { + it('为空配置补充展示默认值', () => { + expect(resolveInteractionDisplay({ name: '数据助手' })).toEqual({ + inputPlaceholder: '输入消息', + suggestedQuestions: [], + welcomeMessage: '你好,我是 数据助手,有什么可以帮你?', + }); + }); + + it('保存前清理首尾空白并过滤空问题', () => { + expect( + buildInteractionConfigPayload({ + inputPlaceholder: ' 输入需求 ', + suggestedQuestions: [' 问题一 ', '', ' 问题二'], + welcomeMessage: ' 欢迎使用 ', + }), + ).toEqual({ + inputPlaceholder: '输入需求', + suggestedQuestions: ['问题一', '问题二'], + welcomeMessage: '欢迎使用', + }); + }); + + it('识别去除空白后的重复问题', () => { + const issues = validateInteractionConfig({ + suggestedQuestions: ['如何开始?', ' 如何开始? '], + }); + + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'interaction.suggestedQuestions.1', + }), + ]), + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts new file mode 100644 index 00000000..33696282 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts @@ -0,0 +1,120 @@ +import type { + AgentInfo, + AgentInteractionConfig, + AgentValidationIssue, +} from './types'; + +export const MAX_WELCOME_MESSAGE_LENGTH = 300; +export const MAX_SUGGESTED_QUESTION_COUNT = 6; +export const MAX_SUGGESTED_QUESTION_LENGTH = 80; +export const MAX_INPUT_PLACEHOLDER_LENGTH = 40; +export const DEFAULT_INPUT_PLACEHOLDER = '输入消息'; + +export function createEmptyInteractionConfig(): AgentInteractionConfig { + return { + inputPlaceholder: '', + suggestedQuestions: [], + welcomeMessage: '', + }; +} + +export function normalizeInteractionConfig( + source?: Partial | Record, +): AgentInteractionConfig { + return { + inputPlaceholder: + typeof source?.inputPlaceholder === 'string' + ? source.inputPlaceholder + : '', + suggestedQuestions: Array.isArray(source?.suggestedQuestions) + ? source.suggestedQuestions.filter( + (question): question is string => typeof question === 'string', + ) + : [], + welcomeMessage: + typeof source?.welcomeMessage === 'string' ? source.welcomeMessage : '', + }; +} + +export function buildInteractionConfigPayload( + source?: Partial | Record, +): AgentInteractionConfig { + const normalized = normalizeInteractionConfig(source); + return { + inputPlaceholder: normalized.inputPlaceholder.trim(), + suggestedQuestions: normalized.suggestedQuestions + .map((question) => question.trim()) + .filter(Boolean), + welcomeMessage: normalized.welcomeMessage.trim(), + }; +} + +export function resolveInteractionDisplay(agent?: AgentInfo) { + const config = buildInteractionConfigPayload(agent?.interactionConfigJson); + const agentName = String(agent?.name || '').trim() || '智能体'; + return { + inputPlaceholder: config.inputPlaceholder || DEFAULT_INPUT_PLACEHOLDER, + suggestedQuestions: config.suggestedQuestions, + welcomeMessage: + config.welcomeMessage || `你好,我是 ${agentName},有什么可以帮你?`, + }; +} + +export function validateInteractionConfig( + source?: Partial | Record, +): AgentValidationIssue[] { + const config = normalizeInteractionConfig(source); + const issues: AgentValidationIssue[] = []; + if (config.welcomeMessage.trim().length > MAX_WELCOME_MESSAGE_LENGTH) { + issues.push({ + field: 'interaction.welcomeMessage', + message: '欢迎语不能超过 300 个字符', + nodeId: 'agent-base', + }); + } + if (/\r|\n/.test(config.inputPlaceholder)) { + issues.push({ + field: 'interaction.inputPlaceholder', + message: '输入提示仅支持单行文本', + nodeId: 'agent-base', + }); + } else if ( + config.inputPlaceholder.trim().length > MAX_INPUT_PLACEHOLDER_LENGTH + ) { + issues.push({ + field: 'interaction.inputPlaceholder', + message: '输入提示不能超过 40 个字符', + nodeId: 'agent-base', + }); + } + + const questions = config.suggestedQuestions + .map((question, index) => ({ index, value: question.trim() })) + .filter((item) => item.value); + if (questions.length > MAX_SUGGESTED_QUESTION_COUNT) { + issues.push({ + field: 'interaction.suggestedQuestions', + message: '猜你想问最多配置 6 条', + nodeId: 'agent-base', + }); + } + const seenQuestions = new Set(); + for (const question of questions) { + if (question.value.length > MAX_SUGGESTED_QUESTION_LENGTH) { + issues.push({ + field: `interaction.suggestedQuestions.${question.index}`, + message: `第 ${question.index + 1} 条猜你想问不能超过 80 个字符`, + nodeId: 'agent-base', + }); + } + if (seenQuestions.has(question.value)) { + issues.push({ + field: `interaction.suggestedQuestions.${question.index}`, + message: `第 ${question.index + 1} 条猜你想问与已有内容重复`, + nodeId: 'agent-base', + }); + } + seenQuestions.add(question.value); + } + return issues; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/types.ts index 9dc27b1c..79d7fe46 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/types.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/types.ts @@ -3,6 +3,12 @@ export type AgentPanelMode = 'base' | 'capability' | 'tryout'; export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp'; +export interface AgentInteractionConfig { + inputPlaceholder: string; + suggestedQuestions: string[]; + welcomeMessage: string; +} + export interface AgentInfo { id?: number | string; name?: string; @@ -15,6 +21,7 @@ export interface AgentInfo { promptConfigJson?: Record; memoryConfigJson?: Record; executionConfigJson?: Record; + interactionConfigJson?: AgentInteractionConfig; status?: number; visibilityScope?: string; publishStatus?: string;