发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
18 changed files with 1351 additions and 160 deletions
Showing only changes of commit 705e0faab6 - Show all commits

View File

@@ -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<String, Object> normalize(Map<String, Object> source) {
Map<String, Object> 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<String> suggestedQuestions = normalizeSuggestedQuestions(config.get("suggestedQuestions"));
Map<String, Object> 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<String> normalizeSuggestedQuestions(Object value) {
if (value == null) {
return List.of();
}
if (!(value instanceof Collection<?> values)) {
throw new BusinessException("猜你想问格式不正确");
}
List<String> normalized = new ArrayList<>();
Set<String> 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();
}
}

View File

@@ -43,6 +43,9 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl
private Map<String, Object> memoryConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> executionConfigJson = new LinkedHashMap<>();
/** 对话欢迎语、猜你想问和输入提示配置。 */
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> 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<String, Object> memoryConfigJson) { this.memoryConfigJson = memoryConfigJson == null ? new LinkedHashMap<>() : memoryConfigJson; }
public Map<String, Object> getExecutionConfigJson() { return executionConfigJson; }
public void setExecutionConfigJson(Map<String, Object> executionConfigJson) { this.executionConfigJson = executionConfigJson == null ? new LinkedHashMap<>() : executionConfigJson; }
/**
* 获取对话体验配置。
*
* @return 对话体验配置
*/
public Map<String, Object> getInteractionConfigJson() { return interactionConfigJson; }
/**
* 设置对话体验配置。
*
* @param interactionConfigJson 对话体验配置
*/
public void setInteractionConfigJson(Map<String, Object> 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; }

View File

@@ -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<Agent> 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<String, Object> resourceSnapshot, BigInteger operatorId) {
Agent agent = new Agent();
agent.setId(resourceId);
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
agent.setPublishedSnapshotJson(resourceSnapshot);
agent.setPublishedAt(new Date());
agent.setPublishedBy(operatorId);
agent.setCurrentApprovalInstanceId(null);
agentService.updateById(agent);
UpdateChain<Agent> 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<Agent> updateChain = agentService.updateChain();
updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode());
updateChain.set(Agent::getCurrentApprovalInstanceId, null);
updateChain.eq(Agent::getId, resourceId);
updateChain.update();
}
@Override

View File

@@ -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<AgentMapper, Agent> 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<AgentMapper, Agent> 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<AgentMapper, Agent> 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<AgentMapper, Agent> 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());

View File

@@ -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<String, Object> 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<String, Object> source = new LinkedHashMap<>();
source.put("welcomeMessage", " 欢迎使用\n智能助手 ");
source.put("suggestedQuestions", List.of(" 第一个问题 ", "", " 第二个问题"));
source.put("inputPlaceholder", " 请输入问题 ");
Map<String, Object> 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<String, Object> source = Map.of(
"suggestedQuestions", List.of("如何使用?", " 如何使用? ")
);
assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source));
}
/**
* 验证多行输入提示会被拒绝。
*/
@Test
public void shouldRejectMultilinePlaceholder() {
Map<String, Object> source = Map.of("inputPlaceholder", "第一行\n第二行");
assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source));
}
/**
* 验证问题数量不能超过上限。
*/
@Test
public void shouldRejectTooManyQuestions() {
Map<String, Object> 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());
}
}
}

View File

@@ -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<Agent> 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<Agent> updateChain = prepareUpdateChain(agentService);
AgentApprovalSubjectHandler handler = handler(agentService);
Map<String, Object> 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<Agent> 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<Agent> prepareUpdateChain(AgentService agentService) {
UpdateChain<Agent> 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<Agent> 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 <T> 服务接口类型
* @return 服务代理
*/
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, AtomicInteger removeCalls) {
return (T) Proxy.newProxyInstance(

View File

@@ -0,0 +1,2 @@
ALTER TABLE `tb_agent`
ADD COLUMN `interaction_config_json` json NULL COMMENT 'Agent 对话体验配置' AFTER `execution_config_json`;

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import type { AiChatMessage, AiToolApprovalPayload } from './types';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import { Close } from '@element-plus/icons-vue';
import { ElButton } from 'element-plus';
import AiConversation from './AiConversation.vue';
import AiPromptInput from './AiPromptInput.vue';
@@ -13,6 +13,7 @@ defineProps<{
emptyText?: string;
loading?: boolean;
messages: AiChatMessage[];
placeholder?: string;
subtitle?: string;
title: string;
}>();
@@ -63,6 +64,7 @@ defineSlots<{
</slot>
<AiPromptInput
:loading="loading"
:placeholder="placeholder"
@send="emit('send', $event)"
@stop="emit('stop')"
/>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
defineProps<{
title: string;
}>();
</script>
<template>
<section class="agent-chat-welcome-state" aria-live="polite">
<h2 class="agent-chat-welcome-state__title">{{ title }}</h2>
</section>
</template>
<style scoped>
.agent-chat-welcome-state {
display: flex;
justify-content: center;
pointer-events: none;
}
.agent-chat-welcome-state__title {
max-width: min(720px, 100%);
margin: 0;
overflow-wrap: anywhere;
font-size: clamp(24px, 2.6vw, 36px);
font-weight: 600;
line-height: 1.16;
color: var(--el-text-color-primary);
text-align: center;
}
</style>

View File

@@ -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<AgentInfo[]>([]);
const sessions = ref<AgentChatSessionView[]>([]);
const timelineItems = ref<ChatTimelineItem[]>([]);
@@ -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 }"
>
<div v-if="loadingConversation" class="agent-chat__state is-center">
<div
v-if="loadingConversation || loadingAgents"
class="agent-chat__state is-center"
>
加载中
</div>
<AgentChatWelcomeState
<div
v-else-if="
agentLoadError && !currentSessionId && timelineItems.length === 0
"
class="agent-chat__state is-center"
>
{{ agentLoadError }}
</div>
<div
v-else-if="
!selectedAgent && !currentSessionId && timelineItems.length === 0
"
class="agent-chat__state is-center"
>
暂无已发布智能体
</div>
<AgentWelcomeState
v-else-if="isWelcomeState"
:title="welcomeTitle"
:agent-name="selectedAgent?.name || '智能体'"
:avatar="selectedAgent?.avatar"
:disabled="sending || runtimeRunning"
:suggested-questions="interactionDisplay.suggestedQuestions"
:welcome-message="interactionDisplay.welcomeMessage"
@select-question="handleSuggestedQuestion"
/>
<ChatTimeline
v-else
@@ -915,10 +947,7 @@ onBeforeUnmount(() => {
/>
</div>
<div
class="agent-chat__composer"
:class="{ 'is-welcome': isWelcomeState }"
>
<div class="agent-chat__composer">
<ChatCapabilityMenu
:disabled="capabilityDisabled"
:extra-knowledge-ids="extraKnowledgeIds"
@@ -1011,13 +1040,13 @@ onBeforeUnmount(() => {
<style scoped>
.agent-chat {
box-sizing: border-box;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
height: var(--easyflow-content-height, 100%);
max-height: var(--easyflow-content-height, 100%);
min-height: 0;
max-height: var(--easyflow-content-height, 100%);
overflow: hidden;
box-sizing: border-box;
background: var(--el-bg-color-page);
}
@@ -1143,26 +1172,26 @@ onBeforeUnmount(() => {
}
.agent-chat__timeline-wrap {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding-bottom: 176px;
overflow: hidden;
box-sizing: border-box;
}
.agent-chat__timeline-wrap.is-welcome {
justify-content: center;
padding: 0 min(8vw, 96px) 252px;
padding: 0 min(8vw, 96px) 190px;
overflow: hidden auto;
}
.agent-chat__timeline-wrap :deep(.chat-timeline) {
box-sizing: border-box;
height: 100%;
min-height: 0;
padding: 24px min(8vw, 96px);
overflow: auto;
box-sizing: border-box;
}
.agent-chat__composer {
@@ -1180,12 +1209,6 @@ 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);
@@ -1212,9 +1235,9 @@ onBeforeUnmount(() => {
.agent-chat__composer-tools {
display: inline-flex;
gap: 4px;
align-items: center;
min-width: 0;
gap: 4px;
max-width: calc(100% - 64px);
}
@@ -1243,22 +1266,22 @@ onBeforeUnmount(() => {
min-width: 0;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-chat__agent-select :deep(.el-select__caret) {
color: var(--el-color-primary);
margin-left: 6px;
color: var(--el-color-primary);
}
.agent-chat__composer-actions {
display: inline-flex;
align-items: center;
flex: none;
align-items: center;
}
.agent-chat__send-button {
@@ -1283,7 +1306,7 @@ onBeforeUnmount(() => {
display: block;
width: 12px;
height: 12px;
background: currentColor;
background: currentcolor;
border-radius: 3px;
}
@@ -1300,8 +1323,8 @@ onBeforeUnmount(() => {
@media (max-width: 900px) {
.agent-chat {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
}
.agent-chat__history {
@@ -1324,7 +1347,13 @@ onBeforeUnmount(() => {
}
.agent-chat__timeline-wrap.is-welcome {
padding: 0 16px 244px;
padding: 0 16px;
}
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
flex: 0 0 auto;
min-height: 100%;
padding-bottom: 206px;
}
.agent-chat__timeline-wrap :deep(.chat-timeline) {
@@ -1337,11 +1366,6 @@ onBeforeUnmount(() => {
left: 16px;
}
.agent-chat__composer.is-welcome {
top: calc(50% + 52px);
bottom: auto;
}
.agent-chat__composer-footer {
align-items: flex-end;
}

View File

@@ -5,12 +5,13 @@ import type {
AgentValidationIssue,
} from '../types';
import { computed } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { Close } from '@element-plus/icons-vue';
import { ElButton } from 'element-plus';
import { ElButton, ElTabPane, ElTabs } from 'element-plus';
import AgentBaseForm from './AgentBaseForm.vue';
import AgentInteractionForm from './AgentInteractionForm.vue';
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
import AgentToolForm from './AgentToolForm.vue';
import AgentTryoutPanel from './AgentTryoutPanel.vue';
@@ -39,6 +40,36 @@ const selectedKnowledge = computed(() => {
return props.state.knowledgeBindings.find((item) => item.localId === localId);
});
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
function isInteractionIssue(issue?: AgentValidationIssue) {
return issue?.field?.startsWith('interaction.');
}
async function focusIssue(issue: AgentValidationIssue) {
if (issue.nodeId !== 'agent-base') return;
activeBaseTab.value = isInteractionIssue(issue) ? 'interaction' : 'basic';
if (isInteractionIssue(issue)) {
await nextTick();
await interactionForm.value?.focusField(issue.field);
}
}
function handleIssueClick(issue: AgentValidationIssue) {
void focusIssue(issue);
emit('selectIssue', issue.nodeId);
}
watch(
() => props.issues,
(issues) => {
const firstIssue = issues[0];
if (firstIssue?.nodeId === 'agent-base') void focusIssue(firstIssue);
},
{ deep: true },
);
const selectedTool = computed(() => {
if (!props.state.selectedNodeId.startsWith('tool:')) return;
const localId = props.state.selectedNodeId.slice('tool:'.length);
@@ -88,19 +119,31 @@ const selectedToolOptions = computed(() => {
:key="`${issue.nodeId}-${issue.field || issue.message}`"
class="agent-inspector__issue"
type="button"
@click="emit('selectIssue', issue.nodeId)"
@click="handleIssueClick(issue)"
>
{{ issue.message }}
</button>
</div>
<template v-if="state.panelMode === 'base'">
<ElTabs v-model="activeBaseTab" class="agent-inspector__tabs">
<ElTabPane label="基础设置" name="basic">
<AgentBaseForm
v-if="state.panelMode === 'base'"
:agent="state.agent"
:categories="categories"
:models="models"
@change="emit('change')"
/>
</ElTabPane>
<ElTabPane label="对话体验" name="interaction">
<AgentInteractionForm
ref="interactionForm"
:agent="state.agent"
@change="emit('change')"
/>
</ElTabPane>
</ElTabs>
</template>
<AgentKnowledgeForm
v-else-if="selectedKnowledge"
:binding="selectedKnowledge"
@@ -186,6 +229,20 @@ const selectedToolOptions = computed(() => {
border-radius: 8px;
}
.agent-inspector__tabs :deep(.el-tabs__header) {
position: sticky;
top: 0;
z-index: 2;
padding: 0 var(--space-4);
margin: 0;
background: hsl(var(--surface-panel));
border-bottom: 1px solid hsl(var(--line-subtle));
}
.agent-inspector__tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.agent-inspector__empty {
padding: 16px;
}

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import type { AgentInfo } from '../types';
import { computed, nextTick, ref } from 'vue';
import { Delete, Plus, Rank } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem, ElIcon, ElInput } from 'element-plus';
import {
MAX_INPUT_PLACEHOLDER_LENGTH,
MAX_SUGGESTED_QUESTION_COUNT,
MAX_SUGGESTED_QUESTION_LENGTH,
MAX_WELCOME_MESSAGE_LENGTH,
} from '../interaction-config';
const props = defineProps<{
agent: AgentInfo;
}>();
const emit = defineEmits<{ change: [] }>();
const welcomeInput = ref();
const placeholderInput = ref();
const questionInputs = ref<any[]>([]);
const draggingIndex = ref<number>();
const questions = computed(
() => props.agent.interactionConfigJson!.suggestedQuestions,
);
const duplicateQuestionIndexes = computed(() => {
const firstIndexes = new Map<string, number>();
const duplicates = new Set<number>();
questions.value.forEach((question, index) => {
const normalized = question.trim();
if (!normalized) return;
const firstIndex = firstIndexes.get(normalized);
if (firstIndex === undefined) {
firstIndexes.set(normalized, index);
return;
}
duplicates.add(firstIndex);
duplicates.add(index);
});
return duplicates;
});
function questionError(index: number) {
const question = questions.value[index] || '';
if (question.trim().length > MAX_SUGGESTED_QUESTION_LENGTH) {
return '最多 80 个字符';
}
if (duplicateQuestionIndexes.value.has(index)) {
return '内容重复';
}
return '';
}
async function addQuestion() {
if (questions.value.length >= MAX_SUGGESTED_QUESTION_COUNT) return;
questions.value.push('');
emit('change');
await nextTick();
questionInputs.value.at(-1)?.focus?.();
}
function removeQuestion(index: number) {
questions.value.splice(index, 1);
emit('change');
}
function moveQuestion(from: number, to: number) {
if (from === to || to < 0 || to >= questions.value.length) return;
const [question] = questions.value.splice(from, 1);
questions.value.splice(to, 0, question || '');
emit('change');
void nextTick(() => questionInputs.value[to]?.focus?.());
}
function handleDragStart(index: number, event: DragEvent) {
draggingIndex.value = index;
event.dataTransfer?.setData('text/plain', String(index));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
function handleDrop(index: number, event: DragEvent) {
event.preventDefault();
const from = draggingIndex.value;
draggingIndex.value = undefined;
if (from === undefined) return;
moveQuestion(from, index);
}
function handleMoveKeydown(index: number, event: KeyboardEvent) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
moveQuestion(index, index + (event.key === 'ArrowUp' ? -1 : 1));
}
async function focusField(field?: string) {
await nextTick();
if (field === 'interaction.welcomeMessage') {
welcomeInput.value?.focus?.();
return;
}
if (field === 'interaction.inputPlaceholder') {
placeholderInput.value?.focus?.();
return;
}
const match = field?.match(/^interaction\.suggestedQuestions\.(\d+)$/);
if (match) questionInputs.value[Number(match[1])]?.focus?.();
}
defineExpose({ focusField });
</script>
<template>
<ElForm label-position="top" class="agent-interaction-form">
<ElFormItem label="欢迎语">
<ElInput
ref="welcomeInput"
v-model="agent.interactionConfigJson!.welcomeMessage"
type="textarea"
:rows="5"
resize="vertical"
:maxlength="MAX_WELCOME_MESSAGE_LENGTH"
show-word-limit
placeholder="留空时使用默认欢迎语"
@input="emit('change')"
/>
</ElFormItem>
<div class="agent-interaction-form__section-head">
<div>
<div class="agent-interaction-form__section-title">猜你想问</div>
<div class="agent-interaction-form__section-hint">
新会话中展示点击后直接发送
</div>
</div>
<span class="agent-interaction-form__count">
{{ questions.length }}/{{ MAX_SUGGESTED_QUESTION_COUNT }}
</span>
</div>
<div v-if="questions.length > 0" class="agent-interaction-form__questions">
<div
v-for="(_, index) in questions"
:key="index"
class="agent-interaction-form__question"
:class="{ 'is-dragging': draggingIndex === index }"
@dragover.prevent
@drop="handleDrop(index, $event)"
>
<button
class="agent-interaction-form__drag"
type="button"
draggable="true"
:aria-label="`调整第 ${index + 1} 条问题顺序`"
title="拖动排序,或使用上下方向键"
@dragend="draggingIndex = undefined"
@dragstart="handleDragStart(index, $event)"
@keydown="handleMoveKeydown(index, $event)"
>
<ElIcon><Rank /></ElIcon>
</button>
<div class="agent-interaction-form__question-input">
<ElInput
:ref="(element) => (questionInputs[index] = element)"
v-model="questions[index]"
:maxlength="MAX_SUGGESTED_QUESTION_LENGTH"
:placeholder="`问题 ${index + 1}`"
@input="emit('change')"
/>
<span
v-if="questionError(index)"
class="agent-interaction-form__error"
>
{{ questionError(index) }}
</span>
</div>
<ElButton
:icon="Delete"
circle
text
type="danger"
:aria-label="`删除第 ${index + 1} 条问题`"
@click="removeQuestion(index)"
/>
</div>
</div>
<div v-else class="agent-interaction-form__empty">暂无建议问题</div>
<ElButton
class="agent-interaction-form__add"
:icon="Plus"
:disabled="questions.length >= MAX_SUGGESTED_QUESTION_COUNT"
@click="addQuestion"
>
添加问题
</ElButton>
<ElFormItem label="输入提示" class="agent-interaction-form__placeholder">
<ElInput
ref="placeholderInput"
v-model="agent.interactionConfigJson!.inputPlaceholder"
:maxlength="MAX_INPUT_PLACEHOLDER_LENGTH"
show-word-limit
placeholder="输入消息"
@input="emit('change')"
/>
</ElFormItem>
</ElForm>
</template>
<style scoped>
.agent-interaction-form {
padding: var(--space-4);
}
.agent-interaction-form__section-head {
display: flex;
gap: var(--space-3);
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.agent-interaction-form__section-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.agent-interaction-form__section-hint,
.agent-interaction-form__count {
margin-top: var(--space-1);
font-size: 12px;
line-height: 18px;
color: hsl(var(--text-muted));
}
.agent-interaction-form__questions {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.agent-interaction-form__question {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 32px;
gap: var(--space-2);
align-items: start;
padding: var(--space-2);
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
.agent-interaction-form__question:focus-within {
border-color: var(--el-color-primary-light-5);
}
.agent-interaction-form__question.is-dragging {
opacity: 0.56;
}
.agent-interaction-form__drag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 32px;
padding: 0;
color: hsl(var(--text-muted));
cursor: grab;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.agent-interaction-form__drag:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.agent-interaction-form__drag:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 1px;
}
.agent-interaction-form__question-input {
min-width: 0;
}
.agent-interaction-form__error {
display: block;
margin-top: var(--space-1);
font-size: 12px;
color: var(--el-color-danger);
}
.agent-interaction-form__empty {
padding: var(--space-4);
font-size: 13px;
color: hsl(var(--text-muted));
text-align: center;
background: hsl(var(--surface-subtle));
border-radius: var(--radius-toolbar);
}
.agent-interaction-form__add {
width: 100%;
margin-top: var(--space-2);
border-style: dashed;
}
.agent-interaction-form__placeholder {
margin-top: var(--space-6);
}
@media (prefers-reduced-motion: reduce) {
.agent-interaction-form__question {
transition: none;
}
}
</style>

View File

@@ -1,18 +1,28 @@
<script setup lang="ts">
import type {ChatTimelineMessageItem, ChatTimelineToolApprovalPayload,} from '@easyflow/common-ui';
import {ChatTimeline} from '@easyflow/common-ui';
import type {
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui';
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
import type {
AgentInfo,
AgentKnowledgeBinding,
AgentToolBinding,
} from '../types';
import {onMounted, ref, watch} from 'vue';
import {BrushCleaning} from '@easyflow/icons';
import { computed, onMounted, ref, watch } from 'vue';
import {ElButton, ElMessage} from 'element-plus';
import { ChatTimeline } from '@easyflow/common-ui';
import { BrushCleaning } from '@easyflow/icons';
import { ElButton, ElMessage } from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import {approveAgentRun, rejectAgentRun} from '../api';
import {useAgentTryoutStream} from '../composables/useAgentTryoutStream';
import { approveAgentRun, rejectAgentRun } from '../api';
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
import { resolveInteractionDisplay } from '../interaction-config';
import AgentWelcomeState from './AgentWelcomeState.vue';
const props = defineProps<{
agent: AgentInfo;
@@ -35,6 +45,9 @@ const {
stop,
} = useAgentTryoutStream();
const approvalLoading = ref(false);
const interactionDisplay = computed(() =>
resolveInteractionDisplay(props.agent),
);
function getDraftContext() {
return {
@@ -68,12 +81,17 @@ watch(
);
async function handleSend(prompt: string) {
if (loading.value || approvalLoading.value) return;
await sendDraft({
...getDraftContext(),
prompt,
});
}
function handleSuggestedQuestion(question: string) {
void handleSend(question);
}
function canCopyMessage(item: ChatTimelineMessageItem) {
if (item.role === 'user') {
return Boolean(copyMessageText(item).trim());
@@ -176,6 +194,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
closable
:messages="[]"
:loading="loading"
:placeholder="interactionDisplay.inputPlaceholder"
:approval-loading="approvalLoading"
@send="handleSend"
@stop="handleStop"
@@ -194,7 +213,18 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
@click="handleClearSession"
/>
</template>
<div class="agent-tryout__conversation">
<AgentWelcomeState
v-if="timelineItems.length === 0"
:agent-name="agent.name || '智能体'"
:avatar="agent.avatar"
:disabled="loading || approvalLoading"
:suggested-questions="interactionDisplay.suggestedQuestions"
:welcome-message="interactionDisplay.welcomeMessage"
@select-question="handleSuggestedQuestion"
/>
<ChatTimeline
v-else
:items="timelineItems"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"
@@ -208,5 +238,22 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
@select-next-variant="handleSelectNextVariant"
@select-previous-variant="handleSelectPreviousVariant"
/>
</div>
</AiChatPanel>
</template>
<style scoped>
.agent-tryout__conversation {
display: flex;
flex: 1;
min-height: 0;
padding: 0 var(--space-4);
overflow: auto;
overscroll-behavior: contain;
}
.agent-tryout__conversation :deep(.chat-timeline) {
width: 100%;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ArrowRight } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
const props = defineProps<{
agentName: string;
avatar?: string;
disabled?: boolean;
suggestedQuestions?: string[];
welcomeMessage: string;
}>();
const emit = defineEmits<{
selectQuestion: [question: string];
}>();
const avatarFailed = ref(false);
const headingId = `agent-welcome-${Math.random().toString(36).slice(2, 9)}`;
const initial = computed(() => [...props.agentName.trim()][0] || 'A');
watch(
() => props.avatar,
() => {
avatarFailed.value = false;
},
);
</script>
<template>
<section class="agent-welcome" :aria-labelledby="headingId">
<div class="agent-welcome__content">
<div class="agent-welcome__avatar" aria-hidden="true">
<img
v-if="avatar && !avatarFailed"
:src="avatar"
alt=""
@error="avatarFailed = true"
/>
<span v-else>{{ initial }}</span>
</div>
<h2 :id="headingId" class="agent-welcome__name">{{ agentName }}</h2>
<p class="agent-welcome__message">{{ welcomeMessage }}</p>
<div v-if="suggestedQuestions?.length" class="agent-welcome__suggestions">
<div class="agent-welcome__suggestions-title">猜你想问</div>
<div class="agent-welcome__suggestions-grid">
<button
v-for="question in suggestedQuestions"
:key="question"
class="agent-welcome__question"
type="button"
:title="question"
:disabled="disabled"
@click="emit('selectQuestion', question)"
@keydown.enter.stop.prevent="emit('selectQuestion', question)"
@keydown.space.stop.prevent="emit('selectQuestion', question)"
>
<span>{{ question }}</span>
<ElIcon aria-hidden="true"><ArrowRight /></ElIcon>
</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.agent-welcome {
box-sizing: border-box;
display: flex;
width: 100%;
min-height: 100%;
padding: var(--space-8) 0;
container-type: inline-size;
}
.agent-welcome__content {
width: min(680px, 100%);
margin: auto;
text-align: center;
}
.agent-welcome__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
overflow: hidden;
font-size: 20px;
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-8);
border-radius: var(--radius-panel);
}
.agent-welcome__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.agent-welcome__name {
margin: var(--space-3) 0 0;
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: hsl(var(--text-strong));
}
.agent-welcome__message {
max-width: 640px;
margin: var(--space-2) auto 0;
font-size: 17px;
line-height: 1.7;
color: var(--el-text-color-regular);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.agent-welcome__suggestions {
margin-top: var(--space-6);
text-align: left;
}
.agent-welcome__suggestions-title {
margin-bottom: var(--space-2);
font-size: 12px;
font-weight: 500;
color: hsl(var(--text-muted));
}
.agent-welcome__suggestions-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.agent-welcome__question {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
gap: var(--space-2);
align-items: center;
min-height: 48px;
padding: var(--space-3) var(--space-4);
font: inherit;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.agent-welcome__question span {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 14px;
line-height: 20px;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
}
.agent-welcome__question .el-icon {
color: hsl(var(--text-muted));
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.agent-welcome__question:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
transform: translateY(-1px);
}
.agent-welcome__question:active:not(:disabled) {
background: var(--el-color-primary-light-8);
transform: translateY(0);
}
.agent-welcome__question:hover:not(:disabled) .el-icon {
color: var(--el-color-primary);
transform: translateX(2px);
}
.agent-welcome__question:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.agent-welcome__question:disabled {
cursor: not-allowed;
opacity: 0.56;
}
@container (max-width: 560px) {
.agent-welcome__suggestions-grid {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.agent-welcome__message {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-welcome__question,
.agent-welcome__question .el-icon {
transition: none;
}
}
</style>

View File

@@ -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,
),
};
}
@@ -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: {

View File

@@ -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',
}),
]),
);
});
});

View File

@@ -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<AgentInteractionConfig> | Record<string, unknown>,
): 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<AgentInteractionConfig> | Record<string, unknown>,
): 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<AgentInteractionConfig> | Record<string, unknown>,
): 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<string>();
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;
}

View File

@@ -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<string, any>;
memoryConfigJson?: Record<string, any>;
executionConfigJson?: Record<string, any>;
interactionConfigJson?: AgentInteractionConfig;
status?: number;
visibilityScope?: string;
publishStatus?: string;