feat: 先进智能体功能上线

- 基于 agent-runtime 打造,默认 ReAct agent
- 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力
- 全新 agent 编排界面,支持可视化便捷配置 agent
- 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
This commit is contained in:
2026-05-28 11:29:18 +08:00
parent 11e595b088
commit 1c205c3720
39 changed files with 3546 additions and 217 deletions

View File

@@ -78,7 +78,7 @@ public class AgentCategoryController extends BaseCurdController<AgentCategorySer
for (Serializable id : ids) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
if (!agents.isEmpty()) {
if (agents != null && !agents.isEmpty()) {
throw new BusinessException("请先删除该分类下的所有 Agent");
}
}

View File

@@ -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<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
}
/**
* 删除 Agent 会话。
*

View File

@@ -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<BigInteger> 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<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
}
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
if (normalizedIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
List<BigInteger> validKnowledgeIds = new ArrayList<>();
List<String> 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<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return List.of();
}
List<BigInteger> 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<BigInteger> 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<ChatWorkspaceKnowledgeView> validKnowledges,
List<BigInteger> validKnowledgeIds,
List<String> removedNames,
boolean shouldSync) {
}
}

View File

@@ -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<BigInteger> resourceIds = new ArrayList<>();
/**
* 获取能力类型。
*
* @return 能力类型
*/
public String getType() {
return type;
}
/**
* 设置能力类型。
*
* @param type 能力类型
*/
public void setType(String type) {
this.type = type;
}
/**
* 获取资源 ID 列表。
*
* @return 资源 ID 列表
*/
public List<BigInteger> getResourceIds() {
return resourceIds;
}
/**
* 设置资源 ID 列表。
*
* @param resourceIds 资源 ID 列表
*/
public void setResourceIds(List<BigInteger> resourceIds) {
this.resourceIds = resourceIds == null ? new ArrayList<>() : new ArrayList<>(resourceIds);
}
}

View File

@@ -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<AgentChatCapability> capabilities,
LoginAccount account) {
List<BigInteger> 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<AgentKnowledgeBinding> mergedBindings = new ArrayList<>();
Set<BigInteger> 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<BigInteger> resolveKnowledgeIds(List<AgentChatCapability> capabilities) {
if (capabilities == null || capabilities.isEmpty()) {
return List.of();
}
LinkedHashSet<BigInteger> 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<AgentChatCapability> 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<BigInteger> extraKnowledgeIds,
boolean knowledgeCapabilityProvided) {
}
}

View File

@@ -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<AgentChatCapability> 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<AgentChatCapability> getCapabilities() {
return capabilities;
}
/**
* 设置本次聊天启用的临时能力。
*
* @param capabilities 临时能力列表
*/
public void setCapabilities(List<AgentChatCapability> capabilities) {
this.capabilities = capabilities == null ? new ArrayList<>() : new ArrayList<>(capabilities);
}
}

View File

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

View File

@@ -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<AgentKnowledgeBinding> 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<AgentKnowledgeBinding> 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<BigInteger, DocumentCollection> 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);
}
}
}

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue';
import {ArrowRight, Check, Close, Collection, Plus,} from '@element-plus/icons-vue';
import {ElPopover} from 'element-plus';
interface KnowledgeOption {
label: string;
value: string;
}
interface KnowledgeView {
id: string;
title: string;
}
const props = withDefaults(
defineProps<{
disabled?: boolean;
extraKnowledgeIds?: string[];
knowledgeOptions?: KnowledgeOption[];
loading?: boolean;
selectedKnowledges?: KnowledgeView[];
showTrigger?: boolean;
}>(),
{
disabled: false,
extraKnowledgeIds: () => [],
knowledgeOptions: () => [],
loading: false,
selectedKnowledges: () => [],
showTrigger: true,
},
);
const emit = defineEmits<{
'update:extraKnowledgeIds': [value: string[]];
}>();
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
const rootOpen = ref(false);
const knowledgeOpen = ref(false);
const selectedIdSet = computed(
() => new Set(props.extraKnowledgeIds.map(String)),
);
function setKnowledgeOpen(open: boolean) {
if (props.disabled) {
return;
}
knowledgeOpen.value = open;
}
watch(rootOpen, (open) => {
if (!open) {
knowledgeOpen.value = false;
}
});
function updateKnowledgeIds(value: string[]) {
emit('update:extraKnowledgeIds', value);
}
function isSelected(id: string) {
return selectedIdSet.value.has(String(id));
}
function isKnowledgeDisabled(id: string) {
return (
props.disabled ||
props.loading ||
(props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
!isSelected(id))
);
}
function toggleKnowledge(id: string) {
const normalizedId = String(id);
const nextIds = props.extraKnowledgeIds.map(String);
const currentIndex = nextIds.indexOf(normalizedId);
if (currentIndex !== -1) {
nextIds.splice(currentIndex, 1);
updateKnowledgeIds(nextIds);
return;
}
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
return;
}
nextIds.push(normalizedId);
updateKnowledgeIds(nextIds);
}
function removeKnowledge(id: string) {
updateKnowledgeIds(
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
);
}
</script>
<template>
<div
v-if="showTrigger || selectedKnowledges.length > 0"
class="chat-capability"
>
<div v-if="selectedKnowledges.length > 0" class="chat-capability__chips">
<span
v-for="knowledge in selectedKnowledges"
:key="knowledge.id"
class="chat-capability__chip"
>
<Collection class="chat-capability__chip-icon" />
<span class="chat-capability__chip-label">{{ knowledge.title }}</span>
<button
type="button"
class="chat-capability__chip-remove"
aria-label="移除知识库"
:disabled="disabled"
@click="removeKnowledge(knowledge.id)"
>
<Close />
</button>
</span>
</div>
<ElPopover
v-if="showTrigger"
v-model:visible="rootOpen"
placement="top-start"
:show-arrow="false"
:width="120"
:offset="8"
popper-class="chat-capability-popper"
trigger="click"
>
<template #reference>
<button
type="button"
class="chat-capability__trigger"
:class="{ 'is-open': rootOpen }"
:disabled="disabled"
aria-label="添加能力"
title="添加能力"
>
<Plus />
</button>
</template>
<div class="chat-capability__menu" @mouseleave="setKnowledgeOpen(false)">
<button
type="button"
class="chat-capability__menu-item"
:class="{ 'is-open': knowledgeOpen }"
:disabled="disabled"
@click="setKnowledgeOpen(true)"
@focus="setKnowledgeOpen(true)"
@mouseenter="setKnowledgeOpen(true)"
>
<Collection class="chat-capability__menu-icon" />
<span>知识库</span>
<ArrowRight class="chat-capability__menu-arrow" />
</button>
<div
v-if="knowledgeOpen"
class="chat-capability__knowledge-panel"
@mouseenter="setKnowledgeOpen(true)"
@mouseleave="setKnowledgeOpen(false)"
>
<div class="chat-capability__knowledge-head">
<span>选择知识库</span>
<span>最多 3 </span>
</div>
<div
v-if="knowledgeOptions.length > 0"
class="chat-capability__knowledge-list"
>
<button
v-for="item in knowledgeOptions"
:key="item.value"
type="button"
class="chat-capability__knowledge-item"
:class="{
'is-active': isSelected(item.value),
'is-disabled': isKnowledgeDisabled(item.value),
}"
:disabled="isKnowledgeDisabled(item.value)"
@click.stop="toggleKnowledge(item.value)"
>
<span>{{ item.label }}</span>
<Check
v-if="isSelected(item.value)"
class="chat-capability__knowledge-check"
/>
</button>
</div>
<div v-else class="chat-capability__empty">
{{ loading ? '加载中' : '暂无可选知识库' }}
</div>
</div>
</div>
</ElPopover>
</div>
</template>
<style scoped>
.chat-capability {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.chat-capability__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.chat-capability__chip {
display: inline-flex;
align-items: center;
max-width: 220px;
min-height: 28px;
gap: 6px;
padding: 0 8px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 999px;
}
.chat-capability__chip-icon {
width: 14px;
height: 14px;
flex: none;
}
.chat-capability__chip-label {
min-width: 0;
overflow: hidden;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-capability__chip-remove,
.chat-capability__trigger,
.chat-capability__menu-item,
.chat-capability__knowledge-item {
font: inherit;
}
.chat-capability__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
color: inherit;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 999px;
}
.chat-capability__chip-remove:hover:not(:disabled),
.chat-capability__chip-remove:focus-visible {
background: var(--el-color-primary-light-8);
outline: none;
}
.chat-capability__chip-remove :deep(.el-icon),
.chat-capability__chip-remove svg {
width: 12px;
height: 12px;
}
.chat-capability__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 999px;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.chat-capability__trigger:hover:not(:disabled),
.chat-capability__trigger:focus-visible,
.chat-capability__trigger.is-open {
color: var(--el-color-primary);
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__trigger:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-capability__trigger svg {
width: 18px;
height: 18px;
}
.chat-capability__menu,
.chat-capability__knowledge-panel {
padding: 4px;
}
.chat-capability__menu {
position: relative;
}
.chat-capability__menu-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
min-height: 36px;
padding: 0 8px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 12px;
}
.chat-capability__menu-item:hover:not(:disabled),
.chat-capability__menu-item:focus-visible,
.chat-capability__menu-item.is-open {
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__menu-item:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-capability__menu-icon,
.chat-capability__menu-arrow {
width: 16px;
height: 16px;
color: var(--el-text-color-secondary);
}
.chat-capability__knowledge-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-capability__knowledge-panel {
position: absolute;
bottom: -2px;
left: calc(100% + 4px);
width: 296px;
background: var(--el-bg-color-overlay);
border-radius: 14px;
box-shadow: var(--el-box-shadow-light);
box-sizing: border-box;
z-index: 1;
}
.chat-capability__knowledge-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow: auto;
}
.chat-capability__knowledge-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 36px;
gap: 12px;
padding: 0 10px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 8px;
}
.chat-capability__knowledge-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-capability__knowledge-item:hover:not(:disabled),
.chat-capability__knowledge-item:focus-visible,
.chat-capability__knowledge-item.is-active {
background: var(--el-fill-color-light);
outline: none;
}
.chat-capability__knowledge-item.is-active {
color: var(--el-color-primary);
}
.chat-capability__knowledge-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-capability__knowledge-check {
width: 15px;
height: 15px;
flex: none;
}
.chat-capability__empty {
padding: 16px 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:global(.chat-capability-popper) {
width: 120px !important;
min-width: 120px !important;
overflow: visible !important;
padding: 4px !important;
border-radius: 12px !important;
box-sizing: border-box;
}
:global(.chat-capability-popper .chat-capability__menu) {
padding: 0;
}
:global(.chat-capability-popper.el-popper) {
transition:
opacity 0.08s ease,
transform 0.08s ease !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import type {ChatInputTriggerItem} from './input-triggers/types';
import {Collection} from '@element-plus/icons-vue';
defineProps<{
activeIndex?: number;
groupLabel?: string;
items?: ChatInputTriggerItem[];
keyword?: string;
}>();
const emit = defineEmits<{
select: [item: ChatInputTriggerItem, index: number];
setActive: [index: number];
}>();
</script>
<template>
<div class="chat-input-trigger-panel">
<div v-if="groupLabel" class="chat-input-trigger-panel__head">
{{ groupLabel }}
</div>
<div
v-if="items && items.length > 0"
class="chat-input-trigger-panel__list"
>
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
class="chat-input-trigger-panel__item"
:class="{ 'is-active': index === activeIndex }"
:disabled="item.disabled"
@mouseenter="emit('setActive', index)"
@mousedown.prevent="emit('select', item, index)"
>
<component
:is="item.icon || Collection"
class="chat-input-trigger-panel__icon"
/>
<span class="chat-input-trigger-panel__content">
<span class="chat-input-trigger-panel__label">{{ item.label }}</span>
</span>
</button>
</div>
<div v-else class="chat-input-trigger-panel__empty">
{{ keyword ? '没有匹配项' : '暂无可选项' }}
</div>
</div>
</template>
<style scoped>
.chat-input-trigger-panel {
width: 100%;
padding: 8px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 18px;
box-shadow: var(--el-box-shadow-light);
box-sizing: border-box;
}
.chat-input-trigger-panel__head {
min-height: 32px;
padding: 0 10px;
font-size: 13px;
font-weight: 500;
line-height: 32px;
color: var(--el-text-color-secondary);
}
.chat-input-trigger-panel__list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.chat-input-trigger-panel__item {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
gap: 10px;
padding: 0 10px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 12px;
}
.chat-input-trigger-panel__item:hover:not(:disabled),
.chat-input-trigger-panel__item.is-active {
background: var(--el-fill-color-light);
}
.chat-input-trigger-panel__item:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-input-trigger-panel__item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-input-trigger-panel__icon {
width: 18px;
height: 18px;
flex: none;
color: var(--el-color-primary);
}
.chat-input-trigger-panel__content {
display: inline-flex;
min-width: 0;
align-items: baseline;
}
.chat-input-trigger-panel__label {
min-width: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-input-trigger-panel__empty {
padding: 18px 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -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;
}

View File

@@ -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<ChatInputTriggerSymbol>(['#', '$', '/', '@']);
const TRIGGER_BOUNDARY = /[\s,.!?;:()[\]{}]/;
export interface ChatInputTriggerOptions {
disabled: Ref<boolean>;
groups: Ref<ChatInputTriggerGroup[]>;
inputRef: Ref<undefined | unknown>;
text: Ref<string>;
}
/**
* 管理聊天输入框快捷触发状态。
*
* @param options 触发器依赖
* @return 输入触发状态与操作方法
*/
export function useChatInputTrigger(options: ChatInputTriggerOptions) {
const activeMatch = ref<ChatInputTriggerMatch>();
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,
};
}

View File

@@ -22,7 +22,7 @@ const routes: RouteRecordRaw[] = [
title: '智能体聊天',
fullPathKey: false,
hideInMenu: true,
activePath: '/ai/agents',
activePath: '/ai/agent-chat',
},
},
];

View File

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

View File

@@ -54,6 +54,38 @@ function normalizeToolCallId(payload: Record<string, any>) {
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<string, any>) {
const fn = asRecord(payload.function);
return normalizeToolName(payload.name ?? payload.toolName ?? fn.name);
}
function normalizeToolCallInput(payload: Record<string, any>) {
const fn = asRecord(payload.function);
return payload.arguments ?? payload.input ?? fn.arguments;
}
function statusKeyForProjection(
payload: Record<string, any>,
metadata?: Partial<ChatTimelineMessageItem>,
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<string, string>();
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;

View File

@@ -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<T>(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,
},

View File

@@ -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<string, AgentChatMessageRecord[]>;
}
export interface AgentChatCapabilityPayload {
resourceIds: Array<number | string>;
type: 'KNOWLEDGE';
}
export function getPublishedAgents() {
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
params: { publishedOnly: true },
@@ -69,11 +88,20 @@ export function generateAgentSessionId() {
}
export function getAgentSession(sessionId: number | string) {
return api.get<RequestResult<AgentChatSessionView>>(
return api.get<RequestResult<AgentChatSessionDetailView>>(
`/api/v1/agent/session/${sessionId}`,
);
}
export function getPublishedKnowledges() {
return api.get<RequestResult<AgentChatKnowledgeView[]>>(
'/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<number | string>,
) {
return api.post<RequestResult<AgentChatSessionDetailView>>(
`/api/v1/agent/session/${sessionId}/extraKnowledges`,
{
knowledgeIds,
},
);
}
export function deleteAgentSession(sessionId: number | string) {
return api.post<RequestResult>(`/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;
},

View File

@@ -0,0 +1,30 @@
<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

@@ -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<AgentInfo[]>([]);
const sessions = ref<AgentChatSessionView[]>([]);
const timelineItems = ref<ChatTimelineItem[]>([]);
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<string, { id: string; title: string }>());
const extraKnowledgeIds = ref<string[]>([]);
const runtimeSendingState = new Map<string, boolean>();
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<ChatInputTriggerGroup[]>(() => [
{
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(() => {
</div>
</header>
<div class="agent-chat__timeline-wrap">
<div
class="agent-chat__timeline-wrap"
:class="{ 'is-welcome': isWelcomeState }"
>
<div v-if="loadingConversation" class="agent-chat__state is-center">
加载中
</div>
<AgentChatWelcomeState
v-else-if="isWelcomeState"
:title="welcomeTitle"
/>
<ChatTimeline
v-else
:items="timelineItems"
@@ -663,8 +915,31 @@ onBeforeUnmount(() => {
/>
</div>
<div class="agent-chat__composer">
<div
class="agent-chat__composer"
:class="{ 'is-welcome': isWelcomeState }"
>
<ChatCapabilityMenu
:disabled="capabilityDisabled"
:extra-knowledge-ids="extraKnowledgeIds"
:knowledge-options="knowledgeOptions"
:loading="loadingKnowledges"
:selected-knowledges="selectedExtraKnowledges"
:show-trigger="false"
@update:extra-knowledge-ids="handleExtraKnowledgeIdsChange"
/>
<ChatInputTriggerPanel
v-if="chatInputTrigger.activePanel.value"
class="agent-chat__trigger-panel"
:active-index="chatInputTrigger.activeIndex.value"
:group-label="chatInputTrigger.activePanel.value.groupLabel"
:items="chatInputTrigger.visibleItems.value"
:keyword="chatInputTrigger.activePanel.value.keyword"
@select="handleTriggerSelect"
@set-active="chatInputTrigger.setActiveIndex"
/>
<ElInput
ref="promptInputRef"
v-model="promptText"
class="agent-chat__composer-input"
type="textarea"
@@ -672,27 +947,41 @@ onBeforeUnmount(() => {
resize="none"
:placeholder="composerPlaceholder"
:disabled="sending || runtimeRunning || !selectedAgentId"
@keydown.enter.exact.prevent="handleSend"
@click="handlePromptClick"
@input="handlePromptInput"
@keydown="handlePromptKeydown"
@keyup="handlePromptKeyup"
/>
<div class="agent-chat__composer-footer">
<ElSelect
v-model="selectedAgentId"
:loading="loadingAgents"
placeholder="选择智能体"
class="agent-chat__agent-select"
:style="{ width: agentSelectWidth }"
@change="handleAgentChange"
>
<ElOption
v-for="agent in agents"
:key="String(agent.id)"
:label="agent.name || String(agent.id)"
:value="String(agent.id)"
<div class="agent-chat__composer-tools">
<ChatCapabilityMenu
class="agent-chat__capability-entry"
:disabled="capabilityDisabled"
:extra-knowledge-ids="extraKnowledgeIds"
:knowledge-options="knowledgeOptions"
:loading="loadingKnowledges"
:selected-knowledges="[]"
@update:extra-knowledge-ids="handleExtraKnowledgeIdsChange"
/>
</ElSelect>
<ElSelect
v-model="selectedAgentId"
:loading="loadingAgents"
placeholder="选择智能体"
class="agent-chat__agent-select"
:style="{ width: agentSelectWidth }"
@change="handleAgentChange"
>
<ElOption
v-for="agent in agents"
:key="String(agent.id)"
:label="agent.name || String(agent.id)"
:value="String(agent.id)"
/>
</ElSelect>
</div>
<div class="agent-chat__composer-actions">
<ElButton
v-if="canStopRuntime"
v-if="canStopRuntime"
type="primary"
circle
aria-label="中止"
@@ -700,7 +989,7 @@ onBeforeUnmount(() => {
class="agent-chat__send-button is-stop"
@click="handleStop"
>
<span class="agent-chat__stop-glyph" />
<span class="agent-chat__stop-glyph"></span>
</ElButton>
<ElButton
v-else
@@ -863,6 +1152,11 @@ onBeforeUnmount(() => {
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 {

View File

@@ -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<AgentValidationIssue[]>([]);
const categories = ref<AgentOption[]>([]);
@@ -62,14 +65,6 @@ const pluginTools = ref<AgentOption[]>([]);
const isNew = computed(() => String(route.params.id || '') === 'new');
const publishText = computed(() => {
if (
canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return '下线';
}
if (
canAiResourceRepublish(
state.agent.displayPublishStatus,
@@ -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<string, any>;
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"
/>

View File

@@ -36,6 +36,8 @@ import {
const router = useRouter();
const pageDataRef = ref();
const sideList = ref<any[]>([]);
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 });
}

View File

@@ -1,19 +1,14 @@
<script setup lang="ts">
import type {AgentCapabilityKind} from '../types';
import {onBeforeUnmount, onMounted, ref} from 'vue';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import {
Connection,
Files,
Loading,
Plus,
Promotion,
Share,
VideoPlay,
} from '@element-plus/icons-vue';
import {Connection, Files, Loading, Plus, Share, VideoPlay,} from '@element-plus/icons-vue';
defineProps<{
const props = defineProps<{
offlineDisabled?: boolean;
offlineLoading?: boolean;
offlineVisible?: boolean;
publishDisabled?: boolean;
publishLoading?: boolean;
publishText: string;
@@ -21,8 +16,11 @@ defineProps<{
tryoutDisabled?: boolean;
}>();
const isRepublish = computed(() => props.publishText === '重新发布');
const emit = defineEmits<{
add: [kind: AgentCapabilityKind];
offline: [];
publish: [];
save: [];
tryout: [];
@@ -120,13 +118,23 @@ onBeforeUnmount(() => {
</button>
<div class="agent-command-bar__divider"></div>
<button
v-if="offlineVisible"
class="agent-command-bar__button agent-command-bar__button--ghost"
type="button"
:disabled="offlineDisabled || offlineLoading || publishLoading"
@click="emit('offline')"
>
<Loading v-if="offlineLoading" class="is-loading" />
<span>下线</span>
</button>
<button
class="agent-command-bar__button agent-command-bar__button--ghost"
:class="{ 'agent-command-bar__button--republish': isRepublish }"
type="button"
:disabled="publishDisabled || publishLoading"
@click="emit('publish')"
>
<Loading v-if="publishLoading" class="is-loading" />
<Promotion v-else />
<span>{{ publishText }}</span>
</button>
</div>
@@ -280,11 +288,21 @@ onBeforeUnmount(() => {
color: var(--el-text-color-secondary);
}
.agent-command-bar__button--republish {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
.agent-command-bar__button:hover:not(:disabled) {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.agent-command-bar__button--republish:hover:not(:disabled) {
color: var(--el-color-warning);
background: var(--el-color-warning-light-8);
}
.agent-command-bar__button--primary:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-8);

View File

@@ -98,6 +98,37 @@ describe('useAgentTryoutRawRounds', () => {
]);
});
it('AgentScope context_reload 工具事件不进入页面时间线', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-context-reload',
});
const roundId = store.createRound('展开第一层');
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
input: { working_context_offload_uuid: 'context-id' },
toolCallId: 'context-reload-1',
toolName: 'context_reload',
},
type: 'TOOL_CALL',
});
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
output: 'context',
toolCallId: 'context-reload-1',
toolName: 'context_reload',
},
type: 'TOOL_RESULT',
});
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
'message',
]);
});
it('刷新后能从 raw rounds 恢复 timeline', () => {
const first = useAgentTryoutRawRounds({
mode: 'draft',

View File

@@ -75,7 +75,11 @@ function normalizeToolName(value: unknown) {
function isHiddenToolName(value: unknown) {
const normalizedName = normalizeToolName(value);
return normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__';
return (
normalizedName === 'retrieve_knowledge' ||
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
);
}
function clone<T>(value: T): T {

View File

@@ -1,30 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {useRoute} from 'vue-router';
import { usePreferences } from '@easyflow/preferences';
import { getOptions, sortNodes } from '@easyflow/utils';
import { Tinyflow } from '@tinyflow-ai/vue';
import {usePreferences} from '@easyflow/preferences';
import {getOptions, sortNodes} from '@easyflow/utils';
import {Tinyflow} from '@tinyflow-ai/vue';
import {
ArrowLeft,
CircleCheck,
Close,
Promotion,
} from '@element-plus/icons-vue';
import {
ElButton,
ElDrawer,
ElMessage,
ElMessageBox,
ElSkeleton,
} from 'element-plus';
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
import {ElButton, ElDrawer, ElMessage, ElMessageBox, ElSkeleton,} from 'element-plus';
import { api } from '#/api/request';
import {api} from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
import {$t} from '#/locales';
import {router} from '#/router';
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
import {
canAiResourceRepublish,
isAiResourceApprovalPending,
@@ -35,7 +24,7 @@ import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import { getCustomNode } from './customNode/index';
import {getCustomNode} from './customNode/index';
import nodeNames from './customNode/nodeNames';
import {
createInitialWorkflowData,
@@ -368,15 +357,17 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
saveLoading.value = false;
}
}
async function getWorkflowInfo(workflowId: any) {
async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
workflowInfo.value = res.data;
const parsedContent = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content)
: {};
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
: normalizeWorkflowStartNodes(parsedContent);
if (syncFlowData) {
const parsedContent = workflowInfo.value.content
? JSON.parse(workflowInfo.value.content)
: {};
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
: normalizeWorkflowStartNodes(parsedContent);
}
syncNavTitle(workflowInfo.value?.title || '');
});
}
@@ -561,7 +552,7 @@ async function handlePublishAction() {
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
await getWorkflowInfo(workflowId.value);
await getWorkflowInfo(workflowId.value, false);
}
} finally {
publishLoading.value = false;

View File

@@ -23,6 +23,7 @@ export {
Copy,
CornerDownLeft,
Ellipsis,
EllipsisVertical,
Expand,
ExternalLink,
Eye,

View File

@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type {
DropdownMenuProps,
EasyFlowDropdownMenuItem as IDropdownMenuItem,
} from './interface';
import type {DropdownMenuProps, EasyFlowDropdownMenuItem as IDropdownMenuItem,} from './interface';
import {computed, ref} from 'vue';
import {Search} from '@easyflow-core/icons';
import {
DropdownMenu,
@@ -11,12 +12,29 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
} from '../../ui';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownMenu' });
const props = withDefaults(defineProps<Props>(), {});
const props = withDefaults(defineProps<Props>(), {
align: 'start',
searchEmptyText: '无匹配标签',
searchPlaceholder: '搜索标签',
});
const searchKeyword = ref('');
const filteredMenus = computed(() => {
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
if (!props.searchable || !keyword) {
return props.menus;
}
return props.menus.filter((menu) =>
menu.label.toLocaleLowerCase().includes(keyword),
);
});
function handleItemClick(menu: IDropdownMenuItem) {
if (menu.disabled) {
@@ -27,22 +45,50 @@ function handleItemClick(menu: IDropdownMenuItem) {
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger class="flex h-full items-center gap-1">
<DropdownMenuTrigger
:aria-label="triggerLabel"
class="flex h-full items-center gap-1"
>
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent :align="align" :class="contentClass">
<div v-if="searchable" class="sticky top-0 z-10 bg-popover p-1">
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-[hsl(var(--text-muted))]"
/>
<Input
v-model="searchKeyword"
:placeholder="searchPlaceholder"
class="h-8 rounded-md border-[hsl(var(--line-subtle))] bg-[hsl(var(--surface-subtle))] py-1 pl-8 pr-2 text-xs"
@keydown.stop
/>
</div>
</div>
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.value">
<template v-for="menu in filteredMenus" :key="menu.value">
<DropdownMenuItem
:disabled="menu.disabled"
:class="{
'bg-[hsl(var(--nav-item-active))] text-[hsl(var(--nav-item-active-foreground))] font-medium':
menu.active,
}"
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
@click="handleItemClick(menu)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
{{ menu.label }}
<span class="min-w-0 flex-1 truncate">
{{ menu.label }}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
</template>
<div
v-if="filteredMenus.length === 0"
class="px-3 py-5 text-center text-xs text-[hsl(var(--text-muted))]"
>
{{ searchEmptyText }}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,6 +1,10 @@
import type { Component } from 'vue';
import type {Component} from 'vue';
interface EasyFlowDropdownMenuItem {
/**
* @zh_CN 是否为当前选中项
*/
active?: boolean;
disabled?: boolean;
/**
* @zh_CN 点击事件处理
@@ -26,7 +30,31 @@ interface EasyFlowDropdownMenuItem {
}
interface DropdownMenuProps {
/**
* @zh_CN 菜单对齐方向
*/
align?: 'center' | 'end' | 'start';
/**
* @zh_CN 菜单浮层样式
*/
contentClass?: any;
menus: EasyFlowDropdownMenuItem[];
/**
* @zh_CN 搜索无结果文案
*/
searchEmptyText?: string;
/**
* @zh_CN 搜索占位文案
*/
searchPlaceholder?: string;
/**
* @zh_CN 是否显示菜单搜索框
*/
searchable?: boolean;
/**
* @zh_CN 触发按钮的无障碍标签
*/
triggerLabel?: string;
}
export type { DropdownMenuProps, EasyFlowDropdownMenuItem };

View File

@@ -1,18 +1,27 @@
<script lang="ts" setup>
import type { DropdownMenuProps } from '@easyflow-core/shadcn-ui';
import type {DropdownMenuProps} from '@easyflow-core/shadcn-ui';
import {EasyFlowDropdownMenu} from '@easyflow-core/shadcn-ui';
import { ChevronDown } from '@easyflow-core/icons';
import { EasyFlowDropdownMenu } from '@easyflow-core/shadcn-ui';
import {EllipsisVertical} from '@easyflow-core/icons';
defineProps<DropdownMenuProps>();
</script>
<template>
<EasyFlowDropdownMenu :menus="menus" :modal="false">
<EasyFlowDropdownMenu
:menus="menus"
align="end"
content-class="max-h-[min(70vh,420px)] min-w-56 overflow-y-auto"
:modal="false"
search-empty-text="无匹配标签"
search-placeholder="搜索标签"
searchable
trigger-label="查看标签页"
>
<div
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92]"
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
>
<ChevronDown class="size-4" />
<EllipsisVertical class="size-4" />
</div>
</EasyFlowDropdownMenu>
</template>

View File

@@ -36,6 +36,7 @@
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"mermaid": "^11.15.0",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",

View File

@@ -24,6 +24,11 @@ const incremarkOptions = {
htmlTree: false,
math: true,
};
const codeBlockConfigs = {
mermaid: {
takeOver: true,
},
};
const previousContent = ref('');
watch(
@@ -33,7 +38,9 @@ watch(
if (import.meta.env.DEV && streaming) {
const startsWithPrevious = content.startsWith(previous);
console.debug('[ChatTimeMarkdown] streaming update', {
deltaLength: startsWithPrevious ? content.length - previous.length : null,
deltaLength: startsWithPrevious
? content.length - previous.length
: null,
length: content.length,
previousLength: previous.length,
preview: content.slice(-160).replaceAll('\n', '\\n'),
@@ -50,6 +57,7 @@ watch(
<div class="chat-time-markdown">
<ThemeProvider :theme="isDark ? 'dark' : 'default'">
<IncremarkContent
:code-block-configs="codeBlockConfigs"
:content="markdownContent"
:incremark-options="incremarkOptions"
:is-finished="isFinished"
@@ -290,6 +298,7 @@ watch(
}
.chat-time-markdown :deep(.incremark-code) {
position: relative;
max-width: 100%;
margin: 1em 0;
overflow: hidden;
@@ -300,10 +309,52 @@ watch(
}
.chat-time-markdown :deep(.incremark-code .code-header) {
padding: 8px 12px;
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
min-height: 0;
padding: 0;
background: transparent;
border: 0;
}
.chat-time-markdown :deep(.incremark-code .code-header .language) {
display: none;
}
.chat-time-markdown :deep(.incremark-code .code-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle) / 0.72);
border-bottom: 1px solid hsl(var(--divider-faint) / 0.72);
background: hsl(var(--surface-subtle) / 0.68);
border: 0;
border-radius: 4px;
opacity: 0.64;
transition:
background-color 0.16s ease,
color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.incremark-code .code-btn:hover:not(:disabled)) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
opacity: 1;
}
.chat-time-markdown :deep(.incremark-code .code-btn svg),
.chat-time-markdown :deep(.incremark-mermaid .code-btn svg) {
width: 14px;
height: 14px;
}
.chat-time-markdown :deep(.incremark-code .code-btn:focus-visible) {
outline: 2px solid hsl(var(--primary) / 0.42);
outline-offset: 2px;
}
.chat-time-markdown :deep(.incremark-code .code-content) {
@@ -317,6 +368,10 @@ watch(
border-radius: 0;
}
.chat-time-markdown :deep(.incremark-code .code-content pre) {
padding-right: 36px;
}
.chat-time-markdown :deep(.shiki),
.chat-time-markdown :deep(.shiki code) {
background: transparent !important;
@@ -342,13 +397,121 @@ watch(
font-size: 1em;
}
.chat-time-markdown :deep(.mermaid),
.chat-time-markdown :deep([class*='mermaid']) {
.chat-time-markdown :deep(svg.mermaid),
.chat-time-markdown
:deep(
.mermaid:not(
.incremark-mermaid,
.mermaid-header,
.mermaid-actions,
.mermaid-content,
.mermaid-loading,
.mermaid-source-code,
.mermaid-svg
)
) {
max-width: 100%;
margin: 1em 0;
overflow: auto;
}
.chat-time-markdown :deep(.incremark-mermaid) {
position: relative;
max-width: 100%;
margin: 1em 0;
overflow: hidden;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 12px;
}
.chat-time-markdown :deep(.mermaid-header) {
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
min-height: 0;
padding: 0;
background: transparent;
border: 0;
}
.chat-time-markdown :deep(.mermaid-header .language) {
display: none;
}
.chat-time-markdown :deep(.mermaid-actions) {
gap: 2px;
padding: 1px;
background: hsl(var(--surface-subtle) / 0.68);
border: 0;
border-radius: 5px;
opacity: 0.68;
transition:
background-color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.mermaid-actions:hover) {
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
opacity: 1;
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn) {
width: 18px;
height: 18px;
color: hsl(var(--text-muted));
border-radius: 4px;
transition:
background-color 0.16s ease,
color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn:hover:not(:disabled)) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.86);
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn:focus-visible) {
outline: 2px solid hsl(var(--primary) / 0.42);
outline-offset: 2px;
}
.chat-time-markdown :deep(.mermaid-content) {
min-height: 96px;
margin: 0;
padding: 0 16px 16px;
overflow: auto;
}
.chat-time-markdown :deep(.mermaid-loading) {
color: hsl(var(--text-muted));
}
.chat-time-markdown :deep(.mermaid-source-code) {
margin: 0;
padding: 0 52px 0 0;
overflow: auto;
color: hsl(var(--text-strong));
background: transparent;
border: 0;
border-radius: 0;
}
.chat-time-markdown :deep(.mermaid-svg) {
display: flex;
justify-content: center;
min-width: 0;
overflow: auto;
}
.chat-time-markdown :deep(.mermaid-svg svg) {
max-width: 100%;
height: auto;
}
.chat-time-markdown :deep(svg) {
max-width: 100%;
height: auto;

View File

@@ -0,0 +1,37 @@
import {mount} from '@vue/test-utils';
import {nextTick} from 'vue';
import {describe, expect, it, vi} from 'vitest';
import ChatTimeMarkdown from '../ChatTimeMarkdown.vue';
vi.mock('@easyflow-core/preferences', () => ({
usePreferences: () => ({
isDark: false,
}),
}));
describe('ChatTimeMarkdown', () => {
it('renders mermaid code fences with the shared mermaid block', async () => {
const wrapper = mount(ChatTimeMarkdown, {
props: {
content: [
'```mermaid',
'flowchart TD',
'A[开始] --> B[结束]',
'```',
].join('\n'),
},
});
await nextTick();
expect(wrapper.find('.incremark-mermaid').exists()).toBe(true);
expect(wrapper.find('.mermaid-header').text().toLowerCase()).toContain(
'mermaid',
);
expect(wrapper.find('.mermaid-source-code').text()).toContain(
'flowchart TD',
);
});
});

View File

@@ -156,25 +156,24 @@ describe('chat timeline builder', () => {
}
});
it('shows no compression needed when memory compression produced no compressed event', () => {
it('removes memory compression status when compression produced no compressed event', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
label: '正在整理上下文',
phase: 'started',
status: 'running',
statusKey: 'memory-compression',
});
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
compressed: false,
label: '已整理上下文',
label: '无需压缩上下文',
phase: 'completed',
status: 'done',
statusKey: 'memory-compression',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('无需压缩上下文');
expect(items[0].presentation).toBe('separator');
expect(items[0].status).toBe('done');
expect(items[0].statusKey).toBe('memory-compression');
}
expect(items).toHaveLength(0);
});
it('ends current thinking before showing knowledge retrieval status', () => {
@@ -227,6 +226,24 @@ describe('chat timeline builder', () => {
expect(items).toHaveLength(0);
});
it('keeps AgentScope context reload hidden without showing a tool card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-context-reload',
toolName: 'context_reload',
input: { working_context_offload_uuid: 'context-id' },
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-context-reload',
toolName: 'context_reload',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(0);
});
it('ignores anonymous tool call events instead of rendering a fallback card', () => {
const items: ChatTimelineItem[] = [];

View File

@@ -38,7 +38,9 @@ function normalizeToolName(value?: string) {
function isHiddenToolName(toolName?: string) {
const normalizedName = normalizeToolName(toolName);
return (
normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__'
normalizedName === 'retrieve_knowledge' ||
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
);
}
@@ -178,6 +180,15 @@ function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
);
}
function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
const index = items.findIndex(
(item) => item.type === 'status' && item.statusKey === statusKey,
);
if (index >= 0) {
items.splice(index, 1);
}
}
function doneStatusLabel(item: ChatTimelineStatusItem) {
if (item.statusKey === 'knowledge-retrieval') {
return '已检索知识库';
@@ -453,17 +464,20 @@ export const ChatTimelineBuilder = {
payload?.status === 'done' || payload?.phase === 'completed'
? 'done'
: 'running';
const statusKey = payload?.statusKey || 'memory-compression';
finishAssistantMessage(items, false);
if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey);
return;
}
const label =
status === 'running'
? payload?.label || '正在整理上下文'
: payload?.compressed === false
? '无需压缩上下文'
: payload?.label || '已整理上下文';
: payload?.label || '已整理上下文';
upsertStatus(items, {
label,
status,
statusKey: payload?.statusKey || 'memory-compression',
statusKey,
presentation: 'separator',
tone: 'muted',
});

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import {computed} from 'vue';
import {useRoute} from 'vue-router';
import { useContentMaximize, useTabs } from '@easyflow/hooks';
import { preferences } from '@easyflow/preferences';
import { useTabbarStore } from '@easyflow/stores';
import {useContentMaximize, useTabs} from '@easyflow/hooks';
import {preferences} from '@easyflow/preferences';
import {useTabbarStore} from '@easyflow/stores';
import { TabsToolMore, TabsToolScreen, TabsView } from '@easyflow-core/tabs-ui';
import {TabsToolMore, TabsToolScreen, TabsView} from '@easyflow-core/tabs-ui';
import { useTabbar } from './use-tabbar';
import {useTabbar} from './use-tabbar';
defineOptions({
name: 'LayoutTabbar',
@@ -30,13 +30,17 @@ const {
} = useTabbar();
const menus = computed(() => {
const tab = tabbarStore.getTabByKey(currentActive.value);
const menus = createContextMenus(tab);
return menus.map((item) => {
return (currentTabs.value || []).map((tab) => {
const key = tab.key as string;
const title =
(tab.meta?.newTabTitle || tab.meta?.title || tab.name || tab.path) as
| string
| undefined;
return {
...item,
label: item.text,
value: item.key,
active: key === currentActive.value,
handler: () => handleClick(key),
label: title || key,
value: key,
};
});
});

View File

@@ -1,13 +1,11 @@
import type { RouteLocationNormalizedGeneric } from 'vue-router';
import type {TabDefinition} from '@easyflow/types';
import type { TabDefinition } from '@easyflow/types';
import type {IContextMenuItem} from '@easyflow-core/tabs-ui';
import type { IContextMenuItem } from '@easyflow-core/tabs-ui';
import {computed, ref, watch} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useContentMaximize, useTabs } from '@easyflow/hooks';
import {useContentMaximize, useTabs} from '@easyflow/hooks';
import {
ArrowLeftToLine,
ArrowRightLeft,
@@ -21,9 +19,9 @@ import {
RotateCw,
X,
} from '@easyflow/icons';
import { $t, useI18n } from '@easyflow/locales';
import { getTabKey, useAccessStore, useTabbarStore } from '@easyflow/stores';
import { filterTree } from '@easyflow/utils';
import {$t, useI18n} from '@easyflow/locales';
import {getTabKey, useAccessStore, useTabbarStore} from '@easyflow/stores';
import {filterTree} from '@easyflow/utils';
export function useTabbar() {
const router = useRouter();
@@ -52,7 +50,7 @@ export function useTabbar() {
});
const { locale } = useI18n();
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
const currentTabs = ref<TabDefinition[]>();
watch(
[
() => tabbarStore.getTabs,
@@ -99,7 +97,7 @@ export function useTabbar() {
}
}
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
function wrapperTabLocale(tab: TabDefinition) {
const navTitle = tab?.meta?.navTitle as string | undefined;
return {
...tab,

View File

@@ -120,6 +120,25 @@ describe('chat-time timeline builder', () => {
});
});
it('does not render built-in context reload tools as normal tool cards', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'context_reload',
toolCallId: 'context-reload-1',
value: '{"working_context_offload_uuid":"context-id"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
name: 'context_reload',
result: '{"messages":[]}',
toolCallId: 'context-reload-1',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
});
it('does not render anonymous internal tool calls as normal tool cards', () => {
const items: any[] = [];
@@ -254,6 +273,114 @@ describe('chat-time history mapper', () => {
});
});
it('skips internal tools when restoring OpenAI-style structured history', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"query":"暑假安排"}',
name: 'retrieve_knowledge',
},
id: 'knowledge-1',
},
{
function: {
arguments: '{"working_context_offload_uuid":"context-id"}',
name: 'context_reload',
},
id: 'context-1',
},
{
function: {
arguments: '{"text":"partial"}',
name: '__fragment__',
},
id: 'fragment-1',
},
{
function: {
arguments: '{"query":"java"}',
name: 'search_docs',
},
id: 'tool-1',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
tool_call_id: 'knowledge-1',
},
{
content: '{"messages":[]}',
role: 'tool',
tool_call_id: 'context-1',
},
{
content: '{"ok":true}',
role: 'tool',
tool_call_id: 'fragment-1',
},
{
content: '{"hits":2}',
role: 'tool',
tool_call_id: 'tool-1',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
arguments: '{"query":"java"}',
name: 'search_docs',
result: '{"hits":2}',
role: 'tool',
toolCallId: 'tool-1',
});
});
it('skips nameless tool result records when restoring history', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
content: '{"hits":1}',
contentPayload: {
result: '{"hits":1}',
toolCallId: 'knowledge-1',
},
created: 100,
id: 'tool-record',
senderRole: 'tool',
},
{
content: '最终回答',
created: 101,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
content: '最终回答',
role: 'assistant',
});
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{

View File

@@ -19,13 +19,31 @@ type ChatTimeToolMeta = {
function isHiddenToolName(value?: string) {
const normalized = normalizePlainText(value).trim().toLowerCase();
return normalized === 'retrieve_knowledge' || normalized === '__fragment__';
return (
normalized === 'retrieve_knowledge' ||
normalized === 'context_reload' ||
normalized === '__fragment__'
);
}
function isBlankToolName(value?: string) {
return !normalizePlainText(value).trim();
}
function normalizeToolCallId(value: Record<string, any>) {
return normalizePlainText(value.id ?? value.toolCallId ?? value.tool_call_id);
}
function normalizeToolCallName(value: Record<string, any>) {
const fn = toObjectRecord(value.function);
return normalizePlainText(value.name ?? value.toolName ?? fn.name);
}
function normalizeToolCallArguments(value: Record<string, any>) {
const fn = toObjectRecord(value.function);
return normalizePayloadValue(value.arguments ?? fn.arguments);
}
/**
* 聊天时间线实时构建器。
*/
@@ -623,6 +641,9 @@ function createToolItemFromChain(
if (!toolCallId && !name && !argumentsValue) {
return null;
}
if (isBlankToolName(name)) {
return null;
}
return createToolItem({
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
created,
@@ -655,6 +676,9 @@ function createToolItemFromStructuredMessage(
if (isHiddenToolName(toolMeta?.name || toolName)) {
return null;
}
if (isBlankToolName(toolMeta?.name || toolName)) {
return null;
}
const result = normalizePayloadValue(rawMessage.content);
return createToolItem({
arguments: toolMeta?.arguments,
@@ -680,6 +704,9 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
if (isHiddenToolName(name)) {
return null;
}
if (isBlankToolName(name)) {
return null;
}
const toolCallId = normalizePlainText(
payload.toolCallId ?? payload.tool_call_id ?? record.id,
);
@@ -768,13 +795,13 @@ function collectToolMeta(
) {
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
for (const toolCall of toolCalls) {
const toolCallId = normalizePlainText(toolCall.id);
const toolCallId = normalizeToolCallId(toolCall);
if (!toolCallId) {
continue;
}
toolMetaMap.set(toolCallId, {
arguments: normalizePayloadValue(toolCall.arguments),
name: normalizePlainText(toolCall.name ?? toolCall.toolName),
arguments: normalizeToolCallArguments(toolCall),
name: normalizeToolCallName(toolCall),
});
}
}

File diff suppressed because it is too large Load Diff