feat: 完成 Agent MCP 对接
- 增加 MCP 连接类型、环境检测接口和容器运行环境支持 - 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec - 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
This commit is contained in:
21
Dockerfile
21
Dockerfile
@@ -8,12 +8,31 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
|
|||||||
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
||||||
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||||
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
||||||
|
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||||
|
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN useradd --system --create-home easyflow && \
|
RUN useradd --system --create-home easyflow && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3 inotify-tools tini && \
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
inotify-tools \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
tini && \
|
||||||
|
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||||
|
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
|
||||||
|
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||||
|
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
|
||||||
|
npm install -g pnpm && \
|
||||||
|
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||||
|
mkdir -p /etc/pip && \
|
||||||
|
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir -p /app/logs /app/artifacts /app/data && \
|
mkdir -p /app/logs /app/artifacts /app/data && \
|
||||||
chown -R easyflow:easyflow /app
|
chown -R easyflow:easyflow /app
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -64,6 +65,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
|||||||
return Result.ok(service.getMcpTools(id));
|
return Result.ok(service.getMcpTools(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/check")
|
||||||
|
public Result<McpEnvironmentCheckResult> check(@JsonBody("configJson") String configJson) {
|
||||||
|
return Result.ok(service.checkMcp(configJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("pageTools")
|
@GetMapping("pageTools")
|
||||||
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||||
|
|||||||
@@ -51,9 +51,22 @@ public class ChatAssistantAccumulator {
|
|||||||
* @param arguments tool 参数
|
* @param arguments tool 参数
|
||||||
*/
|
*/
|
||||||
public void appendToolCall(String id, String name, Object arguments) {
|
public void appendToolCall(String id, String name, Object arguments) {
|
||||||
|
appendToolCall(id, name, null, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 tool call,同时保留面向前端展示的工具名称。
|
||||||
|
*
|
||||||
|
* @param id tool call id
|
||||||
|
* @param name tool 名称
|
||||||
|
* @param displayName tool 展示名称
|
||||||
|
* @param arguments tool 参数
|
||||||
|
*/
|
||||||
|
public void appendToolCall(String id, String name, String displayName, Object arguments) {
|
||||||
Map<String, Object> chain = findToolChain(id, name);
|
Map<String, Object> chain = findToolChain(id, name);
|
||||||
chain.put("status", "TOOL_CALL");
|
chain.put("status", "TOOL_CALL");
|
||||||
chain.put("arguments", arguments);
|
chain.put("arguments", arguments);
|
||||||
|
putIfNotBlank(chain, "toolDisplayName", displayName);
|
||||||
|
|
||||||
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
|
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -63,6 +76,7 @@ public class ChatAssistantAccumulator {
|
|||||||
toolCall.put("id", id);
|
toolCall.put("id", id);
|
||||||
toolCall.put("name", name);
|
toolCall.put("name", name);
|
||||||
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
|
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
|
||||||
|
putIfNotBlank(toolCall, "toolDisplayName", displayName);
|
||||||
toolCalls.add(toolCall);
|
toolCalls.add(toolCall);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +88,22 @@ public class ChatAssistantAccumulator {
|
|||||||
* @param result tool 结果
|
* @param result tool 结果
|
||||||
*/
|
*/
|
||||||
public void appendToolResult(String id, String name, Object result) {
|
public void appendToolResult(String id, String name, Object result) {
|
||||||
|
appendToolResult(id, name, null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 tool result,并保留面向前端展示的工具名称。
|
||||||
|
*
|
||||||
|
* @param id tool call id
|
||||||
|
* @param name tool 名称
|
||||||
|
* @param displayName tool 展示名称
|
||||||
|
* @param result tool 结果
|
||||||
|
*/
|
||||||
|
public void appendToolResult(String id, String name, String displayName, Object result) {
|
||||||
Map<String, Object> chain = findToolChain(id, name);
|
Map<String, Object> chain = findToolChain(id, name);
|
||||||
chain.put("status", "TOOL_RESULT");
|
chain.put("status", "TOOL_RESULT");
|
||||||
chain.put("result", result);
|
chain.put("result", result);
|
||||||
|
putIfNotBlank(chain, "toolDisplayName", displayName);
|
||||||
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
|
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
|
||||||
id,
|
id,
|
||||||
result == null ? null : String.valueOf(result)
|
result == null ? null : String.valueOf(result)
|
||||||
@@ -191,4 +218,10 @@ public class ChatAssistantAccumulator {
|
|||||||
private String stringValue(Object value) {
|
private String stringValue(Object value) {
|
||||||
return value == null ? null : String.valueOf(value);
|
return value == null ? null : String.valueOf(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void putIfNotBlank(Map<String, Object> target, String key, String value) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
target.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
|||||||
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
||||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||||
@@ -28,7 +30,6 @@ import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
|||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
|
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
|
||||||
import tech.easyflow.ai.easyagents.tool.McpTool;
|
|
||||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.*;
|
import tech.easyflow.ai.entity.*;
|
||||||
@@ -40,6 +41,8 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +53,7 @@ public class AgentDefinitionCompiler {
|
|||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(AgentDefinitionCompiler.class);
|
private static final Logger LOG = LoggerFactory.getLogger(AgentDefinitionCompiler.class);
|
||||||
private static final int LOG_TEXT_MAX_LENGTH = 500;
|
private static final int LOG_TEXT_MAX_LENGTH = 500;
|
||||||
|
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ModelService modelService;
|
private ModelService modelService;
|
||||||
@@ -210,16 +214,29 @@ public class AgentDefinitionCompiler {
|
|||||||
}
|
}
|
||||||
List<AgentToolSpec> specs = new ArrayList<>();
|
List<AgentToolSpec> specs = new ArrayList<>();
|
||||||
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
|
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
|
||||||
|
List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||||
|
Map<BigInteger, McpSpec> mcpSpecMap = new LinkedHashMap<>();
|
||||||
for (AgentToolBinding binding : agent.getToolBindings()) {
|
for (AgentToolBinding binding : agent.getToolBindings()) {
|
||||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
AgentToolType type = AgentToolType.from(binding.getToolType());
|
||||||
|
if (type == AgentToolType.MCP) {
|
||||||
|
McpSpec mcpSpec = mcpSpecMap.computeIfAbsent(binding.getTargetId(),
|
||||||
|
ignored -> buildMcpSpec(binding));
|
||||||
|
applyMcpToolBinding(mcpSpec, binding);
|
||||||
|
if (!mcpSpecs.contains(mcpSpec)) {
|
||||||
|
mcpSpecs.add(mcpSpec);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Tool tool = buildTool(binding);
|
Tool tool = buildTool(binding);
|
||||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||||
specs.add(spec);
|
specs.add(spec);
|
||||||
invokers.put(spec.getName(), (arguments, context) -> invokeTool(tool, arguments));
|
invokers.put(spec.getName(), (arguments, context) -> invokeTool(tool, arguments));
|
||||||
}
|
}
|
||||||
definition.setToolSpecs(specs);
|
definition.setToolSpecs(specs);
|
||||||
|
definition.setMcpSpecs(mcpSpecs);
|
||||||
bundle.setToolInvokers(invokers);
|
bundle.setToolInvokers(invokers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,16 +260,74 @@ public class AgentDefinitionCompiler {
|
|||||||
}
|
}
|
||||||
return pluginItem.toFunction();
|
return pluginItem.toFunction();
|
||||||
}
|
}
|
||||||
|
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpSpec buildMcpSpec(AgentToolBinding binding) {
|
||||||
Mcp mcp = snapshotOrCurrentMcp(binding);
|
Mcp mcp = snapshotOrCurrentMcp(binding);
|
||||||
if (mcp == null) {
|
if (mcp == null) {
|
||||||
throw new BusinessException("绑定 MCP 不存在");
|
throw new BusinessException("绑定 MCP 不存在");
|
||||||
}
|
}
|
||||||
McpTool tool = new McpTool();
|
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp);
|
||||||
tool.setMcpId(mcp.getId());
|
Map<String, Object> serverConfig = server.getValue();
|
||||||
tool.setName(binding.getToolName());
|
McpTransportType transportType = parseMcpTransportType(mcp, serverConfig);
|
||||||
tool.setDescription(mcp.getDescription());
|
|
||||||
tool.setParameters(new Parameter[0]);
|
McpSpec spec = new McpSpec();
|
||||||
return tool;
|
spec.setName(mcpRuntimeName(mcp));
|
||||||
|
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
|
||||||
|
spec.setTransportType(transportType);
|
||||||
|
spec.setCommand(resolveMcpInput(stringValue(serverConfig, "command", null)));
|
||||||
|
spec.setArgs(resolveMcpInputs(stringListValue(serverConfig, "args")));
|
||||||
|
spec.setEnv(resolveMcpInputMap(stringMapValue(serverConfig, "env")));
|
||||||
|
spec.setUrl(resolveMcpInput(stringValue(serverConfig, "url", null)));
|
||||||
|
spec.setHeaders(resolveMcpInputMap(stringMapValue(serverConfig, "headers")));
|
||||||
|
spec.setQueryParams(resolveMcpInputMap(stringMapValue(serverConfig, "queryParams")));
|
||||||
|
Duration timeout = durationValue(serverConfig, "timeout");
|
||||||
|
if (timeout != null) {
|
||||||
|
spec.setTimeout(timeout);
|
||||||
|
}
|
||||||
|
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
|
||||||
|
if (initializationTimeout != null) {
|
||||||
|
spec.setInitializationTimeout(initializationTimeout);
|
||||||
|
}
|
||||||
|
spec.setGroupName(mcpRuntimeName(mcp));
|
||||||
|
spec.setApprovalRequired(Boolean.TRUE.equals(mcp.getApprovalRequired()));
|
||||||
|
spec.setApprovalRequest(buildMcpApprovalRequest(mcp));
|
||||||
|
spec.setToolNamePrefix(mcpRuntimeToolPrefix(mcp.getId()));
|
||||||
|
spec.getMetadata().put("toolType", AgentToolType.MCP.name());
|
||||||
|
spec.getMetadata().put("mcpId", String.valueOf(mcp.getId()));
|
||||||
|
spec.getMetadata().put("mcpTitle", mcp.getTitle());
|
||||||
|
spec.getMetadata().put("serverName", server.getKey());
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
|
||||||
|
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
||||||
|
spec.setApprovalRequired(true);
|
||||||
|
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) {
|
||||||
|
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
||||||
|
request.setApprovalPrompt("是否批准执行 MCP 工具:" + firstNonBlank(mcp.getTitle(), mcpRuntimeName(mcp)));
|
||||||
|
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
|
metadata.put("toolType", AgentToolType.MCP.name());
|
||||||
|
metadata.put("mcpId", String.valueOf(mcp.getId()));
|
||||||
|
metadata.put("mcpTitle", mcp.getTitle());
|
||||||
|
request.setMetadata(metadata);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) {
|
||||||
|
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
||||||
|
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行 MCP 工具"));
|
||||||
|
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
|
||||||
|
metadata.put("toolType", binding.getToolType());
|
||||||
|
metadata.put("bindingId", binding.getId());
|
||||||
|
metadata.put("targetId", binding.getTargetId());
|
||||||
|
request.setMetadata(metadata);
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
|
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
|
||||||
@@ -477,6 +552,138 @@ public class AgentDefinitionCompiler {
|
|||||||
return mcpService.getById(binding.getTargetId());
|
return mcpService.getById(binding.getTargetId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map.Entry<String, Map<String, Object>> firstMcpServer(Mcp mcp) {
|
||||||
|
Map<String, Object> config = parseMcpConfig(mcp);
|
||||||
|
Map<String, Object> servers = mapValue(config, "mcpServers");
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务名称");
|
||||||
|
}
|
||||||
|
Map.Entry<String, Object> first = servers.entrySet().iterator().next();
|
||||||
|
if (!(first.getValue() instanceof Map<?, ?> rawServer)) {
|
||||||
|
throw new BusinessException("MCP 服务配置必须是对象:" + first.getKey());
|
||||||
|
}
|
||||||
|
Map<String, Object> serverConfig = new LinkedHashMap<>();
|
||||||
|
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
|
||||||
|
return Map.entry(first.getKey(), serverConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> parseMcpConfig(Mcp mcp) {
|
||||||
|
String configJson = mcp == null ? null : mcp.getConfigJson();
|
||||||
|
if (configJson == null || configJson.isBlank()) {
|
||||||
|
throw new BusinessException("MCP 配置 JSON 不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("MCP 配置 JSON 格式错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpTransportType parseMcpTransportType(Mcp mcp, Map<String, Object> serverConfig) {
|
||||||
|
String transport = firstNonBlank(
|
||||||
|
mcp == null ? null : mcp.getTransportType(),
|
||||||
|
stringValue(serverConfig, "transport", null)
|
||||||
|
);
|
||||||
|
return McpTransportType.from(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mcpRuntimeName(Mcp mcp) {
|
||||||
|
BigInteger id = mcp == null ? null : mcp.getId();
|
||||||
|
return "mcp_" + safeToolNameSegment(id == null ? "unknown" : String.valueOf(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mcpRuntimeToolPrefix(BigInteger mcpId) {
|
||||||
|
return "mcp_" + safeToolNameSegment(String.valueOf(mcpId)) + "_";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeToolNameSegment(String value) {
|
||||||
|
String normalized = String.valueOf(value == null ? "" : value).trim()
|
||||||
|
.replaceAll("[^A-Za-z0-9_-]", "_")
|
||||||
|
.replaceAll("_+", "_");
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
return "tool";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> stringListValue(Map<String, Object> map, String key) {
|
||||||
|
Object value = map == null ? null : map.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
if (value instanceof Collection<?> collection) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Object item : collection) {
|
||||||
|
if (item != null) {
|
||||||
|
result.add(String.valueOf(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw new BusinessException("Agent 配置字段必须是数组:" + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Duration durationValue(Map<String, Object> map, String key) {
|
||||||
|
Object value = map == null ? null : map.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return Duration.ofSeconds(number.longValue());
|
||||||
|
}
|
||||||
|
String text = String.valueOf(value).trim();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Duration.parse(text);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
try {
|
||||||
|
return Duration.ofSeconds(Long.parseLong(text));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new BusinessException("Agent 配置字段必须是秒数或 Duration:" + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveMcpInputs(List<String> values) {
|
||||||
|
if (values == null || values.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>(values.size());
|
||||||
|
for (String value : values) {
|
||||||
|
result.add(resolveMcpInput(value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> resolveMcpInputMap(Map<String, String> values) {
|
||||||
|
if (values == null || values.isEmpty()) {
|
||||||
|
return new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
|
values.forEach((key, value) -> result.put(key, resolveMcpInput(value)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveMcpInput(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
Matcher matcher = MCP_INPUT_PATTERN.matcher(value);
|
||||||
|
StringBuffer resolved = new StringBuffer();
|
||||||
|
while (matcher.find()) {
|
||||||
|
String inputKey = matcher.group(1);
|
||||||
|
String resolvedValue = System.getProperty("mcp.input." + inputKey);
|
||||||
|
if (resolvedValue == null || resolvedValue.isBlank()) {
|
||||||
|
throw new BusinessException("MCP 输入变量未解析:" + inputKey);
|
||||||
|
}
|
||||||
|
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedValue));
|
||||||
|
}
|
||||||
|
matcher.appendTail(resolved);
|
||||||
|
return resolved.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
|
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
||||||
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
|
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.easyagents.agent.runtime.AgentResumeRequest;
|
|||||||
import com.easyagents.agent.runtime.AgentRuntime;
|
import com.easyagents.agent.runtime.AgentRuntime;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
|
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
@@ -25,6 +27,8 @@ import java.util.function.Consumer;
|
|||||||
@Component
|
@Component
|
||||||
public class AgentRunRegistry {
|
public class AgentRunRegistry {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentRunRegistry.class);
|
||||||
|
|
||||||
private final Map<String, AgentRunContext> runs = new ConcurrentHashMap<>();
|
private final Map<String, AgentRunContext> runs = new ConcurrentHashMap<>();
|
||||||
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
|
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
|
||||||
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
|
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
|
||||||
@@ -138,6 +142,7 @@ public class AgentRunRegistry {
|
|||||||
if (context != null) {
|
if (context != null) {
|
||||||
sessionRuns.remove(context.sessionId(), requestId);
|
sessionRuns.remove(context.sessionId(), requestId);
|
||||||
context.releaseLock();
|
context.releaseLock();
|
||||||
|
context.closeRuntime();
|
||||||
}
|
}
|
||||||
owners.remove(requestId);
|
owners.remove(requestId);
|
||||||
Set<String> tokens = requestTokens.remove(requestId);
|
Set<String> tokens = requestTokens.remove(requestId);
|
||||||
@@ -210,6 +215,23 @@ public class AgentRunRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前恢复目标是否为草稿试运行。
|
||||||
|
*
|
||||||
|
* @param requestId 请求 ID,可为空
|
||||||
|
* @param resumeToken 恢复令牌
|
||||||
|
* @return true 表示目标为草稿试运行
|
||||||
|
*/
|
||||||
|
public boolean isDraftResumeTarget(String requestId, String resumeToken) {
|
||||||
|
try {
|
||||||
|
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
|
||||||
|
AgentRunContext context = runs.get(resolvedRequestId);
|
||||||
|
return context != null && !context.persistChatlog();
|
||||||
|
} catch (BusinessException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void submit(String requestId, String resumeToken, String userId, boolean approved, String reason) {
|
private void submit(String requestId, String resumeToken, String userId, boolean approved, String reason) {
|
||||||
submit(requestId, resumeToken, userId, approved, reason, null);
|
submit(requestId, resumeToken, userId, approved, reason, null);
|
||||||
}
|
}
|
||||||
@@ -430,6 +452,15 @@ public class AgentRunRegistry {
|
|||||||
return suspended.get();
|
return suspended.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前运行是否持久化聊天日志与运行态。
|
||||||
|
*
|
||||||
|
* @return true 表示正式聊天持久化运行
|
||||||
|
*/
|
||||||
|
public boolean persistChatlog() {
|
||||||
|
return persistChatlog;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定运行订阅。
|
* 绑定运行订阅。
|
||||||
*
|
*
|
||||||
@@ -477,6 +508,18 @@ public class AgentRunRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭底层运行时并释放资源。
|
||||||
|
*/
|
||||||
|
public void closeRuntime() {
|
||||||
|
try {
|
||||||
|
runtime.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("Close Agent runtime failed, requestId={}, sessionId={}, message={}",
|
||||||
|
requestId, sessionId, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过同一个 runtime 恢复挂起运行,事件继续写入原 SSE。
|
* 通过同一个 runtime 恢复挂起运行,事件继续写入原 SSE。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ public class AgentRunService {
|
|||||||
@Resource
|
@Resource
|
||||||
private AgentChatCapabilityService agentChatCapabilityService;
|
private AgentChatCapabilityService agentChatCapabilityService;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentSessionStore agentSessionStore;
|
|
||||||
@Resource
|
|
||||||
private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
|
private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
|
||||||
@Resource
|
@Resource
|
||||||
|
private AgentSessionStore draftAgentSessionStore;
|
||||||
|
@Resource
|
||||||
private AgentRunRegistry agentRunRegistry;
|
private AgentRunRegistry agentRunRegistry;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentRunLock agentRunLock;
|
private AgentRunLock agentRunLock;
|
||||||
@@ -136,7 +136,7 @@ public class AgentRunService {
|
|||||||
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
||||||
// 执行对话
|
// 执行对话
|
||||||
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
|
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
|
||||||
ASSISTANT_CODE, chatContext, true);
|
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,7 +160,7 @@ public class AgentRunService {
|
|||||||
String traceId = UUID.randomUUID().toString();
|
String traceId = UUID.randomUUID().toString();
|
||||||
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
|
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
|
||||||
return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId,
|
return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId,
|
||||||
DRAFT_ASSISTANT_CODE, chatContext, false);
|
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SseEmitter run(Agent agent,
|
private SseEmitter run(Agent agent,
|
||||||
@@ -170,7 +170,8 @@ public class AgentRunService {
|
|||||||
String runtimeSessionId,
|
String runtimeSessionId,
|
||||||
String assistantCode,
|
String assistantCode,
|
||||||
ChatRuntimeContext chatContext,
|
ChatRuntimeContext chatContext,
|
||||||
boolean persistChatlog) {
|
boolean persistChatlog,
|
||||||
|
AgentSessionStore runtimeSessionStore) {
|
||||||
ChatSseEmitter chatSseEmitter = new ChatSseEmitter();
|
ChatSseEmitter chatSseEmitter = new ChatSseEmitter();
|
||||||
// 获取会话锁
|
// 获取会话锁
|
||||||
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
|
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
|
||||||
@@ -186,7 +187,7 @@ public class AgentRunService {
|
|||||||
chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt));
|
chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt));
|
||||||
}
|
}
|
||||||
threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId,
|
threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId,
|
||||||
assistantCode, chatContext, chatSseEmitter, persistChatlog, lockHandle));
|
assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle));
|
||||||
submitted = true;
|
submitted = true;
|
||||||
return chatSseEmitter.getEmitter();
|
return chatSseEmitter.getEmitter();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -210,11 +211,12 @@ public class AgentRunService {
|
|||||||
throw new BusinessException("仅允许清理 Agent 草稿试运行会话");
|
throw new BusinessException("仅允许清理 Agent 草稿试运行会话");
|
||||||
}
|
}
|
||||||
LoginAccount account = requireCurrentLoginAccount();
|
LoginAccount account = requireCurrentLoginAccount();
|
||||||
agentRunRegistry.cancelSession(sessionId, account.getId() == null ? null : account.getId().toString());
|
clearDraftSessionInternal(sessionId, account.getId() == null ? null : account.getId().toString());
|
||||||
agentSessionStore.delete(sessionId);
|
}
|
||||||
if (agentHitlPendingService != null) {
|
|
||||||
agentHitlPendingService.deleteByRuntimeSessionId(sessionId);
|
private void clearDraftSessionInternal(String sessionId, String userId) {
|
||||||
}
|
agentRunRegistry.cancelSession(sessionId, userId);
|
||||||
|
draftAgentSessionStore.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,9 +227,16 @@ public class AgentRunService {
|
|||||||
*/
|
*/
|
||||||
public void approve(String requestId, String resumeToken) {
|
public void approve(String requestId, String resumeToken) {
|
||||||
LoginAccount account = requireCurrentLoginAccount();
|
LoginAccount account = requireCurrentLoginAccount();
|
||||||
String userId = account.getId() == null ? null : account.getId().toString();
|
approveRuntime(requestId, resumeToken, account.getId(), account.getId() == null ? null : account.getId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void approveRuntime(String requestId, String resumeToken, BigInteger operatorId, String userId) {
|
||||||
|
if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
|
||||||
|
agentRunRegistry.approve(requestId, resumeToken, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
agentRunRegistry.approve(requestId, resumeToken, userId,
|
agentRunRegistry.approve(requestId, resumeToken, userId,
|
||||||
() -> agentHitlPendingService.approve(resumeToken, account.getId()));
|
() -> agentHitlPendingService.approve(resumeToken, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,9 +248,16 @@ public class AgentRunService {
|
|||||||
*/
|
*/
|
||||||
public void reject(String requestId, String resumeToken, String reason) {
|
public void reject(String requestId, String resumeToken, String reason) {
|
||||||
LoginAccount account = requireCurrentLoginAccount();
|
LoginAccount account = requireCurrentLoginAccount();
|
||||||
String userId = account.getId() == null ? null : account.getId().toString();
|
rejectRuntime(requestId, resumeToken, reason, account.getId(), account.getId() == null ? null : account.getId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rejectRuntime(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) {
|
||||||
|
if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
|
||||||
|
agentRunRegistry.reject(requestId, resumeToken, userId, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
agentRunRegistry.reject(requestId, resumeToken, userId, reason,
|
agentRunRegistry.reject(requestId, resumeToken, userId, reason,
|
||||||
() -> agentHitlPendingService.reject(resumeToken, account.getId(), reason));
|
() -> agentHitlPendingService.reject(resumeToken, operatorId, reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startRuntime(Agent agent,
|
private void startRuntime(Agent agent,
|
||||||
@@ -253,6 +269,7 @@ public class AgentRunService {
|
|||||||
ChatRuntimeContext chatContext,
|
ChatRuntimeContext chatContext,
|
||||||
ChatSseEmitter chatSseEmitter,
|
ChatSseEmitter chatSseEmitter,
|
||||||
boolean persistChatlog,
|
boolean persistChatlog,
|
||||||
|
AgentSessionStore runtimeSessionStore,
|
||||||
AgentRunLock.Handle initialLockHandle) {
|
AgentRunLock.Handle initialLockHandle) {
|
||||||
AtomicBoolean finished = new AtomicBoolean(false);
|
AtomicBoolean finished = new AtomicBoolean(false);
|
||||||
StringBuilder answer = new StringBuilder();
|
StringBuilder answer = new StringBuilder();
|
||||||
@@ -262,7 +279,9 @@ public class AgentRunService {
|
|||||||
assistantAccumulator, finished, persistChatlog);
|
assistantAccumulator, finished, persistChatlog);
|
||||||
AgentRunLock.Handle lockHandle = initialLockHandle;
|
AgentRunLock.Handle lockHandle = initialLockHandle;
|
||||||
try {
|
try {
|
||||||
bindAgentSession(agent, runtimeSessionId, chatContext);
|
if (persistChatlog) {
|
||||||
|
bindAgentSession(agent, runtimeSessionId, chatContext);
|
||||||
|
}
|
||||||
AgentRuntimeBundle bundle = agentDefinitionCompiler.compile(agent);
|
AgentRuntimeBundle bundle = agentDefinitionCompiler.compile(agent);
|
||||||
AgentRuntime runtime = agentRuntimeFactory.create();
|
AgentRuntime runtime = agentRuntimeFactory.create();
|
||||||
// 会话初始化请求
|
// 会话初始化请求
|
||||||
@@ -272,7 +291,7 @@ public class AgentRunService {
|
|||||||
request.setRuntimeContext(buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId));
|
request.setRuntimeContext(buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId));
|
||||||
request.setToolInvokers(bundle.getToolInvokers());
|
request.setToolInvokers(bundle.getToolInvokers());
|
||||||
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
||||||
request.setSessionStore(agentSessionStore);
|
request.setSessionStore(runtimeSessionStore);
|
||||||
request.getMetadata().put("assistantCode", assistantCode);
|
request.getMetadata().put("assistantCode", assistantCode);
|
||||||
runtime.init(request);
|
runtime.init(request);
|
||||||
// 注册会话运行时管理
|
// 注册会话运行时管理
|
||||||
@@ -346,20 +365,20 @@ public class AgentRunService {
|
|||||||
return agentRunLock.acquire(agent == null ? null : agent.getId(), runtimeSessionId);
|
return agentRunLock.acquire(agent == null ? null : agent.getId(), runtimeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
|
||||||
if (agentRunEventRecorder != null) {
|
if (persistChatlog && agentRunEventRecorder != null) {
|
||||||
agentRunEventRecorder.record(requestId, chatContext, event);
|
agentRunEventRecorder.record(requestId, chatContext, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
|
||||||
if (agentHitlPendingService != null) {
|
if (persistChatlog && agentHitlPendingService != null) {
|
||||||
agentHitlPendingService.recordApprovalRequired(requestId, chatContext, event);
|
agentHitlPendingService.recordApprovalRequired(requestId, chatContext, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cancelPending(String requestId, String reason) {
|
private void cancelPending(String requestId, String reason, boolean persistChatlog) {
|
||||||
if (agentHitlPendingService != null) {
|
if (persistChatlog && agentHitlPendingService != null) {
|
||||||
agentHitlPendingService.cancelByRequestId(requestId, reason);
|
agentHitlPendingService.cancelByRequestId(requestId, reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +416,7 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
agentRunRegistry.remove(requestId);
|
agentRunRegistry.remove(requestId);
|
||||||
cancelPending(requestId, "客户端连接已断开,Agent 运行已取消");
|
cancelPending(requestId, "客户端连接已断开,Agent 运行已取消", persistChatlog);
|
||||||
if (!persistChatlog) {
|
if (!persistChatlog) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -420,7 +439,7 @@ public class AgentRunService {
|
|||||||
if (event == null || event.getEventType() == null) {
|
if (event == null || event.getEventType() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recordRuntimeEvent(requestId, chatContext, event);
|
recordRuntimeEvent(requestId, chatContext, event, persistChatlog);
|
||||||
if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) {
|
if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) {
|
||||||
String text = stringPayload(event, "text");
|
String text = stringPayload(event, "text");
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
@@ -448,7 +467,7 @@ public class AgentRunService {
|
|||||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
||||||
String resumeToken = stringPayload(event, "resumeToken");
|
String resumeToken = stringPayload(event, "resumeToken");
|
||||||
agentRunRegistry.registerResumeToken(requestId, resumeToken);
|
agentRunRegistry.registerResumeToken(requestId, resumeToken);
|
||||||
recordApprovalRequired(requestId, chatContext, event);
|
recordApprovalRequired(requestId, chatContext, event, persistChatlog);
|
||||||
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) {
|
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) {
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
|
||||||
}
|
}
|
||||||
@@ -460,6 +479,7 @@ public class AgentRunService {
|
|||||||
assistantAccumulator.appendToolCall(
|
assistantAccumulator.appendToolCall(
|
||||||
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
|
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
|
||||||
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
|
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
|
||||||
|
stringPayload(event, "toolDisplayName"),
|
||||||
firstNonNull(event.getPayload().get("input"), event.getPayload().get("toolInput"))
|
firstNonNull(event.getPayload().get("input"), event.getPayload().get("toolInput"))
|
||||||
);
|
);
|
||||||
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, buildToolEventPayload(event))) {
|
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, buildToolEventPayload(event))) {
|
||||||
@@ -473,6 +493,7 @@ public class AgentRunService {
|
|||||||
assistantAccumulator.appendToolResult(
|
assistantAccumulator.appendToolResult(
|
||||||
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
|
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
|
||||||
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
|
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
|
||||||
|
stringPayload(event, "toolDisplayName"),
|
||||||
firstNonNull(firstNonNull(event.getPayload().get("output"), event.getPayload().get("result")),
|
firstNonNull(firstNonNull(event.getPayload().get("output"), event.getPayload().get("result")),
|
||||||
event.getPayload().get("text"))
|
event.getPayload().get("text"))
|
||||||
);
|
);
|
||||||
@@ -587,7 +608,7 @@ public class AgentRunService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
agentRunRegistry.remove(requestId);
|
agentRunRegistry.remove(requestId);
|
||||||
cancelPending(requestId, safeErrorMessage(error));
|
cancelPending(requestId, safeErrorMessage(error), persistChatlog);
|
||||||
Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error;
|
Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error;
|
||||||
LOG.error("Agent run failed, requestId={}, message={}, exception={}", requestId,
|
LOG.error("Agent run failed, requestId={}, message={}, exception={}", requestId,
|
||||||
safeError.getMessage(), safeError.toString(), safeError);
|
safeError.getMessage(), safeError.toString(), safeError);
|
||||||
@@ -621,7 +642,7 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
agentRunRegistry.remove(requestId);
|
agentRunRegistry.remove(requestId);
|
||||||
String reason = errorMessage(event);
|
String reason = errorMessage(event);
|
||||||
cancelPending(requestId, reason);
|
cancelPending(requestId, reason, persistChatlog);
|
||||||
LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason);
|
LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason);
|
||||||
if (persistChatlog) {
|
if (persistChatlog) {
|
||||||
recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, reason);
|
recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, reason);
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package tech.easyflow.agent.runtime.session;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
|
import io.agentscope.core.state.State;
|
||||||
|
import io.agentscope.core.util.JsonUtils;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 草稿试运行 Redis-only session store。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DraftAgentSessionStore implements AgentSessionStore {
|
||||||
|
|
||||||
|
private static final String REDIS_PREFIX = "easyflow:agent:draft-session:";
|
||||||
|
private static final String ENVELOPE_VERSION = "1";
|
||||||
|
private static final String SINGLE_STATES = "singleStates";
|
||||||
|
private static final String LIST_STATES = "listStates";
|
||||||
|
|
||||||
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
private final AgentRuntimeProperties properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建草稿试运行 session store。
|
||||||
|
*
|
||||||
|
* @param stringRedisTemplate Redis 模板
|
||||||
|
* @param properties Agent 运行态配置
|
||||||
|
*/
|
||||||
|
public DraftAgentSessionStore(StringRedisTemplate stringRedisTemplate,
|
||||||
|
AgentRuntimeProperties properties) {
|
||||||
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存单个状态项。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
* @param name 状态名称
|
||||||
|
* @param state 状态值
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void save(String sessionKey, String name, State state) {
|
||||||
|
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||||
|
singleStates(envelope).put(name, JsonUtils.getJsonCodec().toJson(state));
|
||||||
|
writeCache(sessionKey, envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存状态列表。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
* @param name 状态名称
|
||||||
|
* @param states 状态列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void saveList(String sessionKey, String name, List<? extends State> states) {
|
||||||
|
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
if (states != null) {
|
||||||
|
for (State state : states) {
|
||||||
|
values.add(JsonUtils.getJsonCodec().toJson(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||||
|
listStates(envelope).put(name, values);
|
||||||
|
writeCache(sessionKey, envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个状态项。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
* @param name 状态名称
|
||||||
|
* @param type 状态类型
|
||||||
|
* @param <T> 状态类型
|
||||||
|
* @return 可选状态
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||||
|
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || type == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Object json = singleStates(loadEnvelope(sessionKey)).get(name);
|
||||||
|
if (!(json instanceof String text) || text.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(JsonUtils.getJsonCodec().fromJson(text, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取状态列表。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
* @param name 状态名称
|
||||||
|
* @param itemType 状态元素类型
|
||||||
|
* @param <T> 状态元素类型
|
||||||
|
* @return 状态列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||||
|
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || itemType == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Object raw = listStates(loadEnvelope(sessionKey)).get(name);
|
||||||
|
if (!(raw instanceof List<?> values) || values.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<T> result = new ArrayList<>();
|
||||||
|
for (Object value : values) {
|
||||||
|
if (value instanceof String text && !text.isBlank()) {
|
||||||
|
result.add(JsonUtils.getJsonCodec().fromJson(text, itemType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断会话键是否存在。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
* @return 存在时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean exists(String sessionKey) {
|
||||||
|
return StringUtils.hasText(sessionKey) && readCache(sessionKey) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定会话键下的全部状态。
|
||||||
|
*
|
||||||
|
* @param sessionKey 会话键
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String sessionKey) {
|
||||||
|
if (!StringUtils.hasText(sessionKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteCache(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出当前存储中的会话键。
|
||||||
|
*
|
||||||
|
* <p>草稿 session 使用哈希 Redis key,不维护反向索引,避免为试运行引入额外持久化状态。</p>
|
||||||
|
*
|
||||||
|
* @return 空集合
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Set<String> listSessionKeys() {
|
||||||
|
return new LinkedHashSet<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadEnvelope(String sessionKey) {
|
||||||
|
Map<String, Object> cached = readCache(sessionKey);
|
||||||
|
return cached == null ? emptyEnvelope() : deepCopy(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> emptyEnvelope() {
|
||||||
|
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||||
|
envelope.put("version", ENVELOPE_VERSION);
|
||||||
|
envelope.put(SINGLE_STATES, new LinkedHashMap<String, Object>());
|
||||||
|
envelope.put(LIST_STATES, new LinkedHashMap<String, Object>());
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> singleStates(Map<String, Object> envelope) {
|
||||||
|
return (Map<String, Object>) envelope.computeIfAbsent(SINGLE_STATES, key -> new LinkedHashMap<String, Object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> listStates(Map<String, Object> envelope) {
|
||||||
|
return (Map<String, Object>) envelope.computeIfAbsent(LIST_STATES, key -> new LinkedHashMap<String, Object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> readCache(String sessionKey) {
|
||||||
|
try {
|
||||||
|
String value = stringRedisTemplate.opsForValue().get(cacheKey(sessionKey));
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JsonUtils.getJsonCodec().fromJson(value, Map.class);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeCache(String sessionKey, Map<String, Object> envelope) {
|
||||||
|
long seconds = Math.max(1L, properties.getSessionCacheTtl().toSeconds());
|
||||||
|
stringRedisTemplate.opsForValue().set(cacheKey(sessionKey), JsonUtils.getJsonCodec().toJson(envelope),
|
||||||
|
seconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteCache(String sessionKey) {
|
||||||
|
stringRedisTemplate.delete(cacheKey(sessionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> deepCopy(Map<String, Object> source) {
|
||||||
|
if (source == null || source.isEmpty()) {
|
||||||
|
return emptyEnvelope();
|
||||||
|
}
|
||||||
|
return JsonUtils.getJsonCodec().fromJson(JsonUtils.getJsonCodec().toJson(source), Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cacheKey(String sessionKey) {
|
||||||
|
return REDIS_PREFIX + hash(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hash(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
return value.replace(':', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -276,20 +276,22 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
summary.put("bindingId", binding.getId());
|
summary.put("bindingId", binding.getId());
|
||||||
summary.put("toolType", binding.getToolType());
|
summary.put("toolType", binding.getToolType());
|
||||||
summary.put("targetId", binding.getTargetId());
|
summary.put("targetId", binding.getTargetId());
|
||||||
summary.put("toolName", binding.getToolName());
|
|
||||||
summary.put("enabled", Boolean.TRUE.equals(binding.getEnabled()));
|
summary.put("enabled", Boolean.TRUE.equals(binding.getEnabled()));
|
||||||
summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
|
summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
|
||||||
summary.put("hitlConfigJson", binding.getHitlConfigJson());
|
summary.put("hitlConfigJson", binding.getHitlConfigJson());
|
||||||
summary.put("sortNo", binding.getSortNo());
|
summary.put("sortNo", binding.getSortNo());
|
||||||
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
|
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
|
||||||
|
summary.put("toolName", binding.getToolName());
|
||||||
Workflow workflow = workflowService.getById(binding.getTargetId());
|
Workflow workflow = workflowService.getById(binding.getTargetId());
|
||||||
summary.put("title", workflow == null ? null : workflow.getTitle());
|
summary.put("title", workflow == null ? null : workflow.getTitle());
|
||||||
} else if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
|
} else if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
|
||||||
|
summary.put("toolName", binding.getToolName());
|
||||||
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
|
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
|
||||||
summary.put("title", pluginItem == null ? null : pluginItem.getName());
|
summary.put("title", pluginItem == null ? null : pluginItem.getName());
|
||||||
} else {
|
} else {
|
||||||
Mcp mcp = mcpService.getById(binding.getTargetId());
|
Mcp mcp = mcpService.getById(binding.getTargetId());
|
||||||
summary.put("title", mcp == null ? null : mcp.getTitle());
|
summary.put("title", mcp == null ? null : mcp.getTitle());
|
||||||
|
summary.put("tools", mcp == null || mcp.getTools() == null ? List.of() : mcp.getTools());
|
||||||
}
|
}
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.AgentDefinition;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
|
import tech.easyflow.ai.entity.Mcp;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.entity.ModelProvider;
|
||||||
|
import tech.easyflow.ai.service.McpService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent MCP 运行时定义编译测试。
|
||||||
|
*/
|
||||||
|
public class AgentDefinitionCompilerMcpTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Agent 绑定 MCP 后会编译为 runtime 原生 MCP 声明,并按整个 MCP 暴露工具。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldBuildWholeMcpSpecWithDynamicPrefixAndApproval() throws Exception {
|
||||||
|
BigInteger modelId = BigInteger.valueOf(10L);
|
||||||
|
BigInteger mcpId = BigInteger.valueOf(20L);
|
||||||
|
Model model = model(modelId);
|
||||||
|
Mcp mcp = mcp(mcpId);
|
||||||
|
AgentDefinitionCompiler compiler = new AgentDefinitionCompiler();
|
||||||
|
setField(compiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
|
setField(compiler, "modelService", modelService(model));
|
||||||
|
setField(compiler, "mcpService", mcpService(mcp));
|
||||||
|
|
||||||
|
Agent agent = agent(modelId, mcpId);
|
||||||
|
|
||||||
|
AgentRuntimeBundle bundle = compiler.compile(agent);
|
||||||
|
AgentDefinition definition = bundle.getDefinition();
|
||||||
|
|
||||||
|
Assert.assertTrue(definition.getToolSpecs().isEmpty());
|
||||||
|
Assert.assertTrue(bundle.getToolInvokers().isEmpty());
|
||||||
|
Assert.assertEquals(1, definition.getMcpSpecs().size());
|
||||||
|
McpSpec spec = definition.getMcpSpecs().get(0);
|
||||||
|
Assert.assertEquals("mcp_20", spec.getName());
|
||||||
|
Assert.assertEquals(McpTransportType.STDIO, spec.getTransportType());
|
||||||
|
Assert.assertEquals("npx", spec.getCommand());
|
||||||
|
Assert.assertEquals(List.of("-y", "@modelcontextprotocol/server-everything"), spec.getArgs());
|
||||||
|
Assert.assertTrue(spec.isApprovalRequired());
|
||||||
|
Assert.assertEquals("mcp_20_", spec.getToolNamePrefix());
|
||||||
|
Assert.assertTrue(spec.getToolAliases().isEmpty());
|
||||||
|
Assert.assertTrue(spec.getEnableTools().isEmpty());
|
||||||
|
Assert.assertEquals(AgentToolType.MCP.name(), spec.getMetadata().get("toolType"));
|
||||||
|
Assert.assertEquals(String.valueOf(mcpId), spec.getMetadata().get("mcpId"));
|
||||||
|
Assert.assertEquals("everything", spec.getMetadata().get("serverName"));
|
||||||
|
Assert.assertTrue(spec.getToolApprovalRequests().isEmpty());
|
||||||
|
Assert.assertEquals("确认调用 MCP 工具?", spec.getApprovalRequest().getApprovalPrompt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Agent agent(BigInteger modelId, BigInteger mcpId) {
|
||||||
|
AgentToolBinding binding = new AgentToolBinding();
|
||||||
|
binding.setToolType(AgentToolType.MCP.name());
|
||||||
|
binding.setTargetId(mcpId);
|
||||||
|
binding.setEnabled(true);
|
||||||
|
binding.setHitlEnabled(true);
|
||||||
|
binding.setHitlConfigJson(Map.of("prompt", "确认调用 MCP 工具?"));
|
||||||
|
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.valueOf(1L));
|
||||||
|
agent.setName("MCP Agent");
|
||||||
|
agent.setModelId(modelId);
|
||||||
|
agent.setToolBindings(List.of(binding));
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Model model(BigInteger modelId) {
|
||||||
|
ModelProvider provider = new ModelProvider();
|
||||||
|
provider.setProviderType("openai");
|
||||||
|
provider.setProviderName("OpenAI");
|
||||||
|
Model model = new Model();
|
||||||
|
model.setId(modelId);
|
||||||
|
model.setModelProvider(provider);
|
||||||
|
model.setModelName("gpt-test");
|
||||||
|
model.setEndpoint("https://example.com");
|
||||||
|
model.setRequestPath("/v1/chat/completions");
|
||||||
|
model.setApiKey("test-key");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mcp mcp(BigInteger mcpId) {
|
||||||
|
Mcp mcp = new Mcp();
|
||||||
|
mcp.setId(mcpId);
|
||||||
|
mcp.setTitle("Everything");
|
||||||
|
mcp.setDescription("MCP Everything");
|
||||||
|
mcp.setApprovalRequired(true);
|
||||||
|
mcp.setStatus(true);
|
||||||
|
mcp.setConfigJson("""
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"everything": {
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-everything"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
return mcp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModelService modelService(Model model) {
|
||||||
|
return (ModelService) Proxy.newProxyInstance(
|
||||||
|
ModelService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{ModelService.class},
|
||||||
|
(proxy, method, args) -> "getModelInstance".equals(method.getName()) ? model : defaultValue(method.getReturnType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpService mcpService(Mcp mcp) {
|
||||||
|
return (McpService) Proxy.newProxyInstance(
|
||||||
|
McpService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{McpService.class},
|
||||||
|
(proxy, method, args) -> "getById".equals(method.getName()) ? mcp : defaultValue(method.getReturnType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (type == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (type == double.class || type == float.class) {
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.AgentInitRequest;
|
||||||
|
import com.easyagents.agent.runtime.AgentRuntime;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||||
|
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
|
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
|
||||||
|
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
@@ -402,14 +409,150 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
|
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
|
||||||
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
|
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
|
||||||
String.class, ChatRuntimeContext.class, boolean.class},
|
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
|
||||||
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true));
|
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
|
||||||
|
new InMemoryAgentSessionStore()));
|
||||||
|
|
||||||
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
|
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
|
||||||
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
|
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
|
||||||
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
|
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿运行会使用独立 session store,且不会绑定 MySQL session 元信息。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void startRuntimeShouldUseDraftSessionStoreWithoutBindingMysqlSession() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingAgentDefinitionCompiler compiler = new RecordingAgentDefinitionCompiler();
|
||||||
|
RecordingAgentRuntime runtime = new RecordingAgentRuntime();
|
||||||
|
RecordingAgentRuntimeFactory runtimeFactory = new RecordingAgentRuntimeFactory(runtime);
|
||||||
|
AgentSessionStore draftStore = new InMemoryAgentSessionStore();
|
||||||
|
setField(service, "agentDefinitionCompiler", compiler);
|
||||||
|
setField(service, "agentRuntimeFactory", runtimeFactory);
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.valueOf(100));
|
||||||
|
invoke(service, "startRuntime",
|
||||||
|
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
|
||||||
|
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
|
||||||
|
AgentRunLock.Handle.class},
|
||||||
|
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
||||||
|
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
|
||||||
|
|
||||||
|
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿事件不会写运行事件表,正式事件仍会记录。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void handleRuntimeEventShouldOnlyPersistEventsForFormalChat() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder();
|
||||||
|
setField(service, "agentRunEventRecorder", recorder);
|
||||||
|
AgentRuntimeEvent draftEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
|
draftEvent.getPayload().put("toolName", "search");
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertEquals(0, recorder.recordCount);
|
||||||
|
|
||||||
|
AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
|
formalEvent.getPayload().put("toolName", "search");
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, recorder.recordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿工具审批只注册内存恢复令牌,不写 HITL pending 表。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void draftToolApprovalShouldNotPersistPending() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
|
||||||
|
setField(service, "agentRunRegistry", registry);
|
||||||
|
setField(service, "agentHitlPendingService", pendingService);
|
||||||
|
registry.register(runContext("request-draft", "agent-draft-tool", false));
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||||
|
event.getPayload().put("resumeToken", "token-draft");
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
|
||||||
|
Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿审批恢复不执行 pending 表消费,正式审批仍执行。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void approveShouldSkipPendingConsumeOnlyForDraftRun() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
|
||||||
|
setField(service, "agentRunRegistry", registry);
|
||||||
|
setField(service, "agentHitlPendingService", pendingService);
|
||||||
|
|
||||||
|
registry.register(runContext("request-draft-approve", "agent-draft-approve", false));
|
||||||
|
registry.registerResumeToken("request-draft-approve", "token-draft-approve");
|
||||||
|
invoke(service, "approveRuntime",
|
||||||
|
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
|
||||||
|
"request-draft-approve", "token-draft-approve", BigInteger.ONE, "1");
|
||||||
|
|
||||||
|
Assert.assertEquals(0, pendingService.approveCount);
|
||||||
|
|
||||||
|
registry.register(runContext("request-formal-approve", "session-formal-approve", true));
|
||||||
|
registry.registerResumeToken("request-formal-approve", "token-formal-approve");
|
||||||
|
invoke(service, "approveRuntime",
|
||||||
|
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
|
||||||
|
"request-formal-approve", "token-formal-approve", BigInteger.ONE, "1");
|
||||||
|
|
||||||
|
Assert.assertEquals(1, pendingService.approveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证清理草稿会话只清草稿 store,不触碰 MySQL pending 清理。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void clearDraftSessionShouldOnlyDeleteDraftStore() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
|
||||||
|
RecordingAgentSessionStore draftStore = new RecordingAgentSessionStore();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
setField(service, "agentHitlPendingService", pendingService);
|
||||||
|
setField(service, "draftAgentSessionStore", draftStore);
|
||||||
|
|
||||||
|
invoke(service, "clearDraftSessionInternal",
|
||||||
|
new Class<?>[]{String.class, String.class}, "agent-draft-clear", "1");
|
||||||
|
|
||||||
|
Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
|
||||||
|
Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
|
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
|
||||||
*
|
*
|
||||||
@@ -530,6 +673,28 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AgentRunRegistry.AgentRunContext runContext(String requestId, String sessionId, boolean persistChatlog) {
|
||||||
|
return new AgentRunRegistry.AgentRunContext(
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
new RecordingAgentRuntime(),
|
||||||
|
new RecordingChatSseEmitter(),
|
||||||
|
chatContext(),
|
||||||
|
new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(),
|
||||||
|
new AtomicBoolean(false),
|
||||||
|
persistChatlog,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", sessionId, "1"),
|
||||||
|
null,
|
||||||
|
event -> {
|
||||||
|
},
|
||||||
|
error -> {
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private ChatRuntimeContext chatContext() {
|
private ChatRuntimeContext chatContext() {
|
||||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||||
context.setAssistantId(BigInteger.valueOf(100));
|
context.setAssistantId(BigInteger.valueOf(100));
|
||||||
@@ -598,6 +763,148 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentRuntime implements AgentRuntime {
|
||||||
|
|
||||||
|
private AgentInitRequest initRequest;
|
||||||
|
private int resumeCount;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(AgentInitRequest request) {
|
||||||
|
initRequest = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
|
||||||
|
return reactor.core.publisher.Flux.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
|
||||||
|
resumeCount++;
|
||||||
|
return reactor.core.publisher.Flux.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentRuntimeFactory implements AgentRuntimeFactory {
|
||||||
|
|
||||||
|
private final AgentRuntime runtime;
|
||||||
|
|
||||||
|
private RecordingAgentRuntimeFactory(AgentRuntime runtime) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentRuntime create() {
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentDefinitionCompiler extends AgentDefinitionCompiler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentRuntimeBundle compile(Agent agent) {
|
||||||
|
AgentRuntimeBundle bundle = new AgentRuntimeBundle();
|
||||||
|
bundle.setDefinition(new com.easyagents.agent.runtime.AgentDefinition());
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {
|
||||||
|
|
||||||
|
private int recordCount;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
||||||
|
recordCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentHitlPendingService implements AgentHitlPendingService {
|
||||||
|
|
||||||
|
private int recordApprovalRequiredCount;
|
||||||
|
private int approveCount;
|
||||||
|
private int rejectCount;
|
||||||
|
private int cancelByRequestIdCount;
|
||||||
|
private int deleteByRuntimeSessionIdCount;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
||||||
|
recordApprovalRequiredCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
|
||||||
|
approveCount++;
|
||||||
|
return new AgentHitlPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
|
||||||
|
rejectCount++;
|
||||||
|
return new AgentHitlPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void cancelByRequestId(String requestId, String reason) {
|
||||||
|
cancelByRequestIdCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByChatSessionId(BigInteger chatSessionId) {
|
||||||
|
// 测试桩无需处理。
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByRuntimeSessionId(String runtimeSessionId) {
|
||||||
|
deleteByRuntimeSessionIdCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AgentHitlPending> expirePending(int limit) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RecordingAgentSessionStore implements AgentSessionStore {
|
||||||
|
|
||||||
|
private String deletedSessionKey;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(String sessionKey, String name, io.agentscope.core.state.State state) {
|
||||||
|
// 测试桩无需处理。
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveList(String sessionKey, String name, List<? extends io.agentscope.core.state.State> states) {
|
||||||
|
// 测试桩无需处理。
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T extends io.agentscope.core.state.State> java.util.Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T extends io.agentscope.core.state.State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean exists(String sessionKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String sessionKey) {
|
||||||
|
deletedSessionKey = sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Set<String> listSessionKeys() {
|
||||||
|
return java.util.Set.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录 chatlog 写入动作的测试桩。
|
* 记录 chatlog 写入动作的测试桩。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ public class McpBase extends DateEntity implements Serializable {
|
|||||||
@Column(comment = "完整MCP配置JSON")
|
@Column(comment = "完整MCP配置JSON")
|
||||||
private String configJson;
|
private String configJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP连接方式
|
||||||
|
*/
|
||||||
|
@Column(comment = "MCP连接方式")
|
||||||
|
private String transportType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用工具调用审批
|
||||||
|
*/
|
||||||
|
@Column(comment = "是否启用工具调用审批")
|
||||||
|
private Boolean approvalRequired;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 部门ID
|
* 部门ID
|
||||||
*/
|
*/
|
||||||
@@ -111,6 +123,22 @@ public class McpBase extends DateEntity implements Serializable {
|
|||||||
this.configJson = configJson;
|
this.configJson = configJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTransportType() {
|
||||||
|
return transportType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTransportType(String transportType) {
|
||||||
|
this.transportType = transportType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getApprovalRequired() {
|
||||||
|
return approvalRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApprovalRequired(Boolean approvalRequired) {
|
||||||
|
this.approvalRequired = approvalRequired;
|
||||||
|
}
|
||||||
|
|
||||||
public BigInteger getDeptId() {
|
public BigInteger getDeptId() {
|
||||||
return deptId;
|
return deptId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package tech.easyflow.ai.mcp;
|
||||||
|
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP 连接方式。
|
||||||
|
*/
|
||||||
|
public enum McpTransportType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准输入输出进程通信。
|
||||||
|
*/
|
||||||
|
STDIO("stdio"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP SSE 通信。
|
||||||
|
*/
|
||||||
|
SSE("http-sse"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streamable HTTP 通信。
|
||||||
|
*/
|
||||||
|
HTTP("http-stream");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 MCP 连接方式。
|
||||||
|
*
|
||||||
|
* @param value 配置值
|
||||||
|
*/
|
||||||
|
McpTransportType(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置值。
|
||||||
|
*
|
||||||
|
* @return 配置值
|
||||||
|
*/
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析连接方式。
|
||||||
|
*
|
||||||
|
* @param value 连接方式文本
|
||||||
|
* @return MCP 连接方式
|
||||||
|
*/
|
||||||
|
public static McpTransportType from(String value) {
|
||||||
|
if (StringUtil.noText(value)) {
|
||||||
|
return STDIO;
|
||||||
|
}
|
||||||
|
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return switch (normalized) {
|
||||||
|
case "stdio" -> STDIO;
|
||||||
|
case "sse", "http-sse" -> SSE;
|
||||||
|
case "http", "http-stream", "streamable-http" -> HTTP;
|
||||||
|
default -> throw new BusinessException("不支持的 MCP 连接方式: " + value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package tech.easyflow.ai.service;
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
|
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
import tech.easyflow.ai.entity.BotMcp;
|
import tech.easyflow.ai.entity.BotMcp;
|
||||||
@@ -30,4 +31,6 @@ public interface McpService extends IService<Mcp> {
|
|||||||
Mcp getMcpTools(String id);
|
Mcp getMcpTools(String id);
|
||||||
|
|
||||||
Page<Mcp> pageTools(Page<Mcp> mcpPage);
|
Page<Mcp> pageTools(Page<Mcp> mcpPage);
|
||||||
|
|
||||||
|
McpEnvironmentCheckResult checkMcp(String configJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package tech.easyflow.ai.service.impl;
|
|||||||
import com.easyagents.core.model.chat.tool.Parameter;
|
import com.easyagents.core.model.chat.tool.Parameter;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
import com.easyagents.mcp.client.McpClientManager;
|
import com.easyagents.mcp.client.McpClientManager;
|
||||||
|
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||||
|
import com.easyagents.mcp.client.McpEnvironmentChecker;
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
@@ -16,6 +18,7 @@ import tech.easyflow.ai.easyagents.tool.McpTool;
|
|||||||
import tech.easyflow.ai.entity.BotMcp;
|
import tech.easyflow.ai.entity.BotMcp;
|
||||||
import tech.easyflow.ai.entity.Mcp;
|
import tech.easyflow.ai.entity.Mcp;
|
||||||
import tech.easyflow.ai.mapper.McpMapper;
|
import tech.easyflow.ai.mapper.McpMapper;
|
||||||
|
import tech.easyflow.ai.mcp.McpTransportType;
|
||||||
import tech.easyflow.ai.service.McpService;
|
import tech.easyflow.ai.service.McpService;
|
||||||
import tech.easyflow.ai.utils.CommonFiledUtil;
|
import tech.easyflow.ai.utils.CommonFiledUtil;
|
||||||
import tech.easyflow.common.constant.enums.EnumRes;
|
import tech.easyflow.common.constant.enums.EnumRes;
|
||||||
@@ -37,7 +40,8 @@ import java.util.*;
|
|||||||
@Service
|
@Service
|
||||||
public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpService {
|
public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpService {
|
||||||
private final McpClientManager mcpClientManager = McpClientManager.getInstance();
|
private final McpClientManager mcpClientManager = McpClientManager.getInstance();
|
||||||
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
|
private final McpEnvironmentChecker mcpEnvironmentChecker = new McpEnvironmentChecker();
|
||||||
|
protected Logger Log = LoggerFactory.getLogger(McpServiceImpl.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<?> saveMcp(Mcp entity) {
|
public Result<?> saveMcp(Mcp entity) {
|
||||||
@@ -49,6 +53,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
if (!StringUtil.hasText(serverName)) {
|
if (!StringUtil.hasText(serverName)) {
|
||||||
return Result.fail("未找到mcp服务名称", serverName);
|
return Result.fail("未找到mcp服务名称", serverName);
|
||||||
}
|
}
|
||||||
|
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
|
||||||
|
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
|
||||||
try {
|
try {
|
||||||
mcpClientManager.registerFromJson(entity.getConfigJson());
|
mcpClientManager.registerFromJson(entity.getConfigJson());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -79,6 +85,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
if (!StringUtil.hasText(serverName)) {
|
if (!StringUtil.hasText(serverName)) {
|
||||||
return Result.fail("未找到mcp服务名称", serverName);
|
return Result.fail("未找到mcp服务名称", serverName);
|
||||||
}
|
}
|
||||||
|
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
|
||||||
|
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
|
||||||
if (entity.getStatus()) {
|
if (entity.getStatus()) {
|
||||||
try {
|
try {
|
||||||
mcpClientManager.registerFromJson(entity.getConfigJson());
|
mcpClientManager.registerFromJson(entity.getConfigJson());
|
||||||
@@ -121,6 +129,7 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
records.forEach(mcp -> {
|
records.forEach(mcp -> {
|
||||||
boolean clientOnline = mcpClientManager.isClientOnline(getFirstMcpServerName(mcp.getConfigJson()));
|
boolean clientOnline = mcpClientManager.isClientOnline(getFirstMcpServerName(mcp.getConfigJson()));
|
||||||
mcp.setClientOnline(clientOnline);
|
mcp.setClientOnline(clientOnline);
|
||||||
|
mcp.setTransportType(resolveMcpTransportType(mcp));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
page.getData().setRecords(records);
|
page.getData().setRecords(records);
|
||||||
@@ -130,6 +139,9 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
@Override
|
@Override
|
||||||
public Mcp getMcpTools(String id) {
|
public Mcp getMcpTools(String id) {
|
||||||
Mcp mcp = this.getById(id);
|
Mcp mcp = this.getById(id);
|
||||||
|
if (mcp != null) {
|
||||||
|
mcp.setTransportType(resolveMcpTransportType(mcp));
|
||||||
|
}
|
||||||
if (mcp != null && mcp.getStatus()) {
|
if (mcp != null && mcp.getStatus()) {
|
||||||
McpSyncClient mcpClient = getMcpClient(mcp, mcpClientManager);
|
McpSyncClient mcpClient = getMcpClient(mcp, mcpClientManager);
|
||||||
List<McpSchema.Tool> tools = null;
|
List<McpSchema.Tool> tools = null;
|
||||||
@@ -209,9 +221,27 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
return firstServerName.orElse(null);
|
return firstServerName.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String getFirstMcpTransportType(String mcpJson) {
|
||||||
|
JSONObject rootJson = JSON.parseObject(mcpJson);
|
||||||
|
JSONObject mcpServersJson = rootJson.getJSONObject("mcpServers");
|
||||||
|
if (mcpServersJson == null || mcpServersJson.isEmpty()) {
|
||||||
|
return McpTransportType.STDIO.getValue();
|
||||||
|
}
|
||||||
|
Optional<String> firstServerName = mcpServersJson.keySet().stream().findFirst();
|
||||||
|
if (firstServerName.isEmpty()) {
|
||||||
|
return McpTransportType.STDIO.getValue();
|
||||||
|
}
|
||||||
|
JSONObject serverJson = mcpServersJson.getJSONObject(firstServerName.get());
|
||||||
|
if (serverJson == null) {
|
||||||
|
return McpTransportType.STDIO.getValue();
|
||||||
|
}
|
||||||
|
return McpTransportType.from(serverJson.getString("transport")).getValue();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<Mcp> pageTools(Page<Mcp> page) {
|
public Page<Mcp> pageTools(Page<Mcp> page) {
|
||||||
page.getRecords().forEach(mcp -> {
|
page.getRecords().forEach(mcp -> {
|
||||||
|
mcp.setTransportType(resolveMcpTransportType(mcp));
|
||||||
// mcp 未启用,不查询工具
|
// mcp 未启用,不查询工具
|
||||||
if (!mcp.getStatus()) {
|
if (!mcp.getStatus()) {
|
||||||
return;
|
return;
|
||||||
@@ -235,6 +265,11 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public McpEnvironmentCheckResult checkMcp(String configJson) {
|
||||||
|
return mcpEnvironmentChecker.check(configJson);
|
||||||
|
}
|
||||||
|
|
||||||
private Result<?> validateMcpConfig(Mcp entity) {
|
private Result<?> validateMcpConfig(Mcp entity) {
|
||||||
if (entity == null || !StringUtil.hasText(entity.getConfigJson())) {
|
if (entity == null || !StringUtil.hasText(entity.getConfigJson())) {
|
||||||
Log.error("MCP 配置不能为空");
|
Log.error("MCP 配置不能为空");
|
||||||
@@ -242,4 +277,14 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
|||||||
}
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveMcpTransportType(Mcp mcp) {
|
||||||
|
if (mcp == null) {
|
||||||
|
return McpTransportType.STDIO.getValue();
|
||||||
|
}
|
||||||
|
if (StringUtil.hasText(mcp.getTransportType())) {
|
||||||
|
return McpTransportType.from(mcp.getTransportType()).getValue();
|
||||||
|
}
|
||||||
|
return getFirstMcpTransportType(mcp.getConfigJson());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package tech.easyflow.ai.mcp;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.service.impl.McpServiceImpl;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link McpTransportType} 单元测试。
|
||||||
|
*/
|
||||||
|
public class McpTransportTypeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应兼容解析 MCP 配置中常见的连接方式文本。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void fromShouldParseSupportedTransportTypes() {
|
||||||
|
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
|
||||||
|
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("sse"));
|
||||||
|
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
|
||||||
|
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http"));
|
||||||
|
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
|
||||||
|
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
|
||||||
|
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
|
||||||
|
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应从 MCP 配置 JSON 中推断首个 server 的连接方式。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void getFirstMcpTransportTypeShouldInferFromConfigJson() {
|
||||||
|
Assert.assertEquals("stdio", McpServiceImpl.getFirstMcpTransportType("""
|
||||||
|
{"mcpServers":{"everything":{"command":"npx","args":["-y","@modelcontextprotocol/server-everything"]}}}
|
||||||
|
"""));
|
||||||
|
Assert.assertEquals("http-sse", McpServiceImpl.getFirstMcpTransportType("""
|
||||||
|
{"mcpServers":{"remote":{"transport":"http-sse","url":"http://127.0.0.1:3000/sse"}}}
|
||||||
|
"""));
|
||||||
|
Assert.assertEquals("http-stream", McpServiceImpl.getFirstMcpTransportType("""
|
||||||
|
{"mcpServers":{"remote":{"transport":"http-stream","url":"http://127.0.0.1:3000/mcp"}}}
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不支持的连接方式应直接失败,避免保存无法启动的 MCP 配置。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void getFirstMcpTransportTypeShouldRejectUnsupportedTransportType() {
|
||||||
|
try {
|
||||||
|
McpServiceImpl.getFirstMcpTransportType("""
|
||||||
|
{"mcpServers":{"remote":{"transport":"websocket","url":"ws://127.0.0.1:3000/mcp"}}}
|
||||||
|
""");
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("不支持的 MCP 连接方式"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,4 +70,25 @@ public class ChatAssistantAccumulatorTest {
|
|||||||
Assert.assertEquals(1, secondToolCalls.size());
|
Assert.assertEquals(1, secondToolCalls.size());
|
||||||
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
|
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具展示名应进入展示链和 assistant toolCalls,但不覆盖真实工具名。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldKeepToolDisplayNameWithoutOverridingToolName() {
|
||||||
|
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||||
|
accumulator.appendToolCall("call-1", "mcp_123_search", "知识库 MCP - search", "{\"q\":\"java\"}");
|
||||||
|
accumulator.appendToolResult("call-1", "mcp_123_search", "知识库 MCP - search", "{\"ok\":true}");
|
||||||
|
|
||||||
|
Map<String, Object> payload = accumulator.buildPayload(null);
|
||||||
|
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
|
||||||
|
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
|
||||||
|
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
|
||||||
|
|
||||||
|
Assert.assertEquals("mcp_123_search", chains.get(0).get("name"));
|
||||||
|
Assert.assertEquals("知识库 MCP - search", chains.get(0).get("toolDisplayName"));
|
||||||
|
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
|
||||||
|
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `tb_mcp`
|
||||||
|
ADD COLUMN `transport_type` varchar(32) NULL DEFAULT NULL COMMENT 'MCP连接方式' AFTER `config_json`,
|
||||||
|
ADD COLUMN `approval_required` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用工具调用审批' AFTER `transport_type`;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"configJson": "ConfigJson",
|
"configJson": "ConfigJson",
|
||||||
|
"approvalRequired": "Approval Required",
|
||||||
"deptId": "DeptId",
|
"deptId": "DeptId",
|
||||||
"tenantId": "TenantId",
|
"tenantId": "TenantId",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
@@ -27,5 +28,23 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"clientOnline": "ClientOnline",
|
"clientOnline": "ClientOnline",
|
||||||
"clientOffline": "ClientOffline"
|
"clientOffline": "ClientOffline"
|
||||||
|
},
|
||||||
|
"jsonEditor": {
|
||||||
|
"format": "Format",
|
||||||
|
"invalid": "Invalid JSON"
|
||||||
|
},
|
||||||
|
"check": {
|
||||||
|
"action": "Check",
|
||||||
|
"overall": "Overall",
|
||||||
|
"resultTitle": "MCP Environment Check",
|
||||||
|
"toolCount": "Tools",
|
||||||
|
"message": {
|
||||||
|
"configRequired": "Please enter MCP config JSON first"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"success": "Passed",
|
||||||
|
"warning": "Warning",
|
||||||
|
"failed": "Failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"title": "名称",
|
"title": "名称",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"configJson": "MCP配置JSON",
|
"configJson": "MCP配置JSON",
|
||||||
|
"approvalRequired": "执行前审批",
|
||||||
"deptId": "部门ID",
|
"deptId": "部门ID",
|
||||||
"tenantId": "租户ID",
|
"tenantId": "租户ID",
|
||||||
"created": "创建时间",
|
"created": "创建时间",
|
||||||
@@ -27,5 +28,23 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"clientOnline": "客户端在线",
|
"clientOnline": "客户端在线",
|
||||||
"clientOffline": "客户端离线"
|
"clientOffline": "客户端离线"
|
||||||
|
},
|
||||||
|
"jsonEditor": {
|
||||||
|
"format": "格式化",
|
||||||
|
"invalid": "JSON 格式错误"
|
||||||
|
},
|
||||||
|
"check": {
|
||||||
|
"action": "检测",
|
||||||
|
"overall": "整体状态",
|
||||||
|
"resultTitle": "MCP 环境检测",
|
||||||
|
"toolCount": "工具数",
|
||||||
|
"message": {
|
||||||
|
"configRequired": "请先填写 MCP 配置 JSON"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"success": "通过",
|
||||||
|
"warning": "警告",
|
||||||
|
"failed": "失败"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,49 @@ describe('agentTimelineAdapter', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses tool display name when restoring aliased MCP tools from history', () => {
|
||||||
|
const items = recordsToTimelineItems([
|
||||||
|
{
|
||||||
|
id: 'mcp-history',
|
||||||
|
senderRole: 'assistant',
|
||||||
|
contentText: '已完成',
|
||||||
|
roundId: 'round-mcp',
|
||||||
|
contentPayload: {
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
id: 'tool-mcp-1',
|
||||||
|
name: 'mcp_123_search',
|
||||||
|
toolDisplayName: 'Context MCP - search',
|
||||||
|
status: 'TOOL_RESULT',
|
||||||
|
result: 'ok',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messageChain: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: 'tool-mcp-1',
|
||||||
|
name: 'mcp_123_search',
|
||||||
|
toolDisplayName: 'Context MCP - search',
|
||||||
|
arguments: '{}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: 'tool-mcp-1',
|
||||||
|
content: 'ok',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tool = items.find((item) => item.type === 'tool');
|
||||||
|
expect(tool?.toolName).toBe('Context MCP - search');
|
||||||
|
});
|
||||||
|
|
||||||
it('hides knowledge retrieval cards when restoring agent chat history', () => {
|
it('hides knowledge retrieval cards when restoring agent chat history', () => {
|
||||||
const items = recordsToTimelineItems([
|
const items = recordsToTimelineItems([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ function shouldSkipToolProjection(value: unknown) {
|
|||||||
|
|
||||||
function normalizeToolCallName(payload: Record<string, any>) {
|
function normalizeToolCallName(payload: Record<string, any>) {
|
||||||
const fn = asRecord(payload.function);
|
const fn = asRecord(payload.function);
|
||||||
return normalizeToolName(payload.name ?? payload.toolName ?? fn.name);
|
return normalizeToolName(
|
||||||
|
payload.toolDisplayName ?? payload.name ?? payload.toolName ?? fn.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToolCallInput(payload: Record<string, any>) {
|
function normalizeToolCallInput(payload: Record<string, any>) {
|
||||||
@@ -211,7 +213,9 @@ function projectHistoryChain(
|
|||||||
hasAssistantThinking = true;
|
hasAssistantThinking = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const toolName = normalizeToolName(item.name ?? item.toolName);
|
const toolName = normalizeToolName(
|
||||||
|
item.toolDisplayName ?? item.name ?? item.toolName,
|
||||||
|
);
|
||||||
const toolCallId = normalizeToolCallId(item);
|
const toolCallId = normalizeToolCallId(item);
|
||||||
if (toolCallId && toolName) {
|
if (toolCallId && toolName) {
|
||||||
toolNameByCallId.set(toolCallId, toolName);
|
toolNameByCallId.set(toolCallId, toolName);
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* cspell:ignore tryit */
|
/* cspell:ignore tryit */
|
||||||
import type {AgentCapabilityKind, AgentOption, AgentValidationIssue,} from './types';
|
import type {
|
||||||
|
AgentCapabilityKind,
|
||||||
|
AgentOption,
|
||||||
|
AgentValidationIssue,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
import {computed, onMounted, ref} from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {ElMessage, ElMessageBox} from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {tryit} from 'radash';
|
import { tryit } from 'radash';
|
||||||
|
|
||||||
import {api} from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import {
|
import {
|
||||||
canAiResourceOffline,
|
canAiResourceOffline,
|
||||||
canAiResourcePublish,
|
canAiResourcePublish,
|
||||||
@@ -30,7 +34,7 @@ import {
|
|||||||
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
||||||
import AgentCommandBar from './components/AgentCommandBar.vue';
|
import AgentCommandBar from './components/AgentCommandBar.vue';
|
||||||
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
||||||
import {useAgentDesignerState} from './composables/useAgentDesignerState';
|
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -62,6 +66,7 @@ const models = ref<AgentOption[]>([]);
|
|||||||
const knowledges = ref<AgentOption[]>([]);
|
const knowledges = ref<AgentOption[]>([]);
|
||||||
const workflows = ref<AgentOption[]>([]);
|
const workflows = ref<AgentOption[]>([]);
|
||||||
const pluginTools = ref<AgentOption[]>([]);
|
const pluginTools = ref<AgentOption[]>([]);
|
||||||
|
const mcps = ref<AgentOption[]>([]);
|
||||||
|
|
||||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||||
const publishText = computed(() => {
|
const publishText = computed(() => {
|
||||||
@@ -132,6 +137,21 @@ async function loadAgent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAgentLifecycleState() {
|
||||||
|
if (!state.agent.id) return;
|
||||||
|
const [, res] = await tryit(getAgentDetail)(String(state.agent.id));
|
||||||
|
if (res?.errorCode !== 0 || !res.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agentState = { ...res.data };
|
||||||
|
delete agentState.knowledgeBindings;
|
||||||
|
delete agentState.toolBindings;
|
||||||
|
state.agent = {
|
||||||
|
...state.agent,
|
||||||
|
...agentState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasNavTitle() {
|
function hasNavTitle() {
|
||||||
const navTitle = Array.isArray(route.query.navTitle)
|
const navTitle = Array.isArray(route.query.navTitle)
|
||||||
? route.query.navTitle[0]
|
? route.query.navTitle[0]
|
||||||
@@ -172,7 +192,7 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
|
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes, mcpRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
api.get('/api/v1/agentCategory/visibleList', {
|
api.get('/api/v1/agentCategory/visibleList', {
|
||||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||||
@@ -185,6 +205,9 @@ async function loadOptions() {
|
|||||||
api.get('/api/v1/plugin/pageByCategory', {
|
api.get('/api/v1/plugin/pageByCategory', {
|
||||||
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
||||||
}),
|
}),
|
||||||
|
api.get('/api/v1/mcp/pageTools', {
|
||||||
|
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
categories.value = (categoryRes.data || []).map((item: any) => ({
|
categories.value = (categoryRes.data || []).map((item: any) => ({
|
||||||
@@ -212,6 +235,7 @@ async function loadOptions() {
|
|||||||
pluginTools.value = flattenPluginTools(
|
pluginTools.value = flattenPluginTools(
|
||||||
pluginRes.data?.records || pluginRes.data || [],
|
pluginRes.data?.records || pluginRes.data || [],
|
||||||
);
|
);
|
||||||
|
mcps.value = mapMcpOptions(mcpRes.data?.records || mcpRes.data || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenPluginTools(list: any[]): AgentOption[] {
|
function flattenPluginTools(list: any[]): AgentOption[] {
|
||||||
@@ -229,6 +253,22 @@ function flattenPluginTools(list: any[]): AgentOption[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapMcpOptions(list: any[]): AgentOption[] {
|
||||||
|
return list.map((mcp) => ({
|
||||||
|
label: mcp.title || mcp.name || 'MCP',
|
||||||
|
value: String(mcp.id),
|
||||||
|
raw: {
|
||||||
|
...mcp,
|
||||||
|
id: mcp.id,
|
||||||
|
mcpId: mcp.id,
|
||||||
|
mcpTitle: mcp.title || mcp.name,
|
||||||
|
title: mcp.title || mcp.name,
|
||||||
|
tools: Array.isArray(mcp.tools) ? mcp.tools : [],
|
||||||
|
approvalRequired: Boolean(mcp.approvalRequired),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdd(kind: AgentCapabilityKind) {
|
async function handleAdd(kind: AgentCapabilityKind) {
|
||||||
if (kind === 'knowledge') {
|
if (kind === 'knowledge') {
|
||||||
addKnowledgeNode();
|
addKnowledgeNode();
|
||||||
@@ -331,7 +371,7 @@ async function handlePublish() {
|
|||||||
const res = await submitAgentPublishApproval(String(state.agent.id));
|
const res = await submitAgentPublishApproval(String(state.agent.id));
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message || '已提交');
|
ElMessage.success(res.message || '已提交');
|
||||||
await loadAgent();
|
await refreshAgentLifecycleState();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishLoading.value = false;
|
publishLoading.value = false;
|
||||||
@@ -358,7 +398,7 @@ async function handleOffline() {
|
|||||||
const res = await submitAgentOfflineApproval(String(state.agent.id));
|
const res = await submitAgentOfflineApproval(String(state.agent.id));
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message || '已提交');
|
ElMessage.success(res.message || '已提交');
|
||||||
await loadAgent();
|
await refreshAgentLifecycleState();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
offlineLoading.value = false;
|
offlineLoading.value = false;
|
||||||
@@ -380,7 +420,10 @@ function handleCloseTryout() {
|
|||||||
<AgentStudioCanvas
|
<AgentStudioCanvas
|
||||||
:state="state"
|
:state="state"
|
||||||
:knowledge-options="knowledges"
|
:knowledge-options="knowledges"
|
||||||
|
:mcp-options="mcps"
|
||||||
|
:plugin-options="pluginTools"
|
||||||
:selected-node-id="state.selectedNodeId"
|
:selected-node-id="state.selectedNodeId"
|
||||||
|
:workflow-options="workflows"
|
||||||
@select="handleSelectNode"
|
@select="handleSelectNode"
|
||||||
/>
|
/>
|
||||||
<AgentInspectorPanel
|
<AgentInspectorPanel
|
||||||
@@ -390,6 +433,7 @@ function handleCloseTryout() {
|
|||||||
:knowledges="knowledges"
|
:knowledges="knowledges"
|
||||||
:workflows="workflows"
|
:workflows="workflows"
|
||||||
:plugin-tools="pluginTools"
|
:plugin-tools="pluginTools"
|
||||||
|
:mcps="mcps"
|
||||||
:issues="issues"
|
:issues="issues"
|
||||||
@change="markDirty"
|
@change="markDirty"
|
||||||
@remove-capability="removeSelectedCapability"
|
@remove-capability="removeSelectedCapability"
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentCapabilityKind} from '../types';
|
import type { AgentCapabilityKind } from '../types';
|
||||||
|
|
||||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import {Connection, Files, Loading, Plus, Share, VideoPlay,} from '@element-plus/icons-vue';
|
import {
|
||||||
|
Connection,
|
||||||
|
Files,
|
||||||
|
Link,
|
||||||
|
Loading,
|
||||||
|
Plus,
|
||||||
|
Share,
|
||||||
|
VideoPlay,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
offlineDisabled?: boolean;
|
offlineDisabled?: boolean;
|
||||||
@@ -47,6 +55,12 @@ const capabilityItems = [
|
|||||||
desc: '执行工具能力',
|
desc: '执行工具能力',
|
||||||
icon: Connection,
|
icon: Connection,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'mcp' as const,
|
||||||
|
title: 'MCP',
|
||||||
|
desc: '连接外部工具',
|
||||||
|
icon: Link,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleAdd(kind: AgentCapabilityKind) {
|
function handleAdd(kind: AgentCapabilityKind) {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentDraftState, AgentOption, AgentValidationIssue,} from '../types';
|
import type {
|
||||||
|
AgentDraftState,
|
||||||
|
AgentOption,
|
||||||
|
AgentValidationIssue,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
import {computed} from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import {Close} from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import {ElButton} from 'element-plus';
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
import AgentBaseForm from './AgentBaseForm.vue';
|
import AgentBaseForm from './AgentBaseForm.vue';
|
||||||
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
|
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
|
||||||
@@ -15,6 +19,7 @@ const props = defineProps<{
|
|||||||
categories: AgentOption[];
|
categories: AgentOption[];
|
||||||
issues: AgentValidationIssue[];
|
issues: AgentValidationIssue[];
|
||||||
knowledges: AgentOption[];
|
knowledges: AgentOption[];
|
||||||
|
mcps: AgentOption[];
|
||||||
models: AgentOption[];
|
models: AgentOption[];
|
||||||
pluginTools: AgentOption[];
|
pluginTools: AgentOption[];
|
||||||
state: AgentDraftState;
|
state: AgentDraftState;
|
||||||
@@ -40,11 +45,18 @@ const selectedTool = computed(() => {
|
|||||||
return props.state.toolBindings.find((item) => item.localId === localId);
|
return props.state.toolBindings.find((item) => item.localId === localId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedToolKind = computed(() =>
|
const selectedToolKind = computed(() => {
|
||||||
String(selectedTool.value?.toolType || '').toUpperCase() === 'WORKFLOW'
|
const toolType = String(selectedTool.value?.toolType || '').toUpperCase();
|
||||||
? 'workflow'
|
if (toolType === 'WORKFLOW') return 'workflow';
|
||||||
: 'plugin',
|
if (toolType === 'MCP') return 'mcp';
|
||||||
);
|
return 'plugin';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedToolOptions = computed(() => {
|
||||||
|
if (selectedToolKind.value === 'workflow') return props.workflows;
|
||||||
|
if (selectedToolKind.value === 'mcp') return props.mcps;
|
||||||
|
return props.pluginTools;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -100,7 +112,7 @@ const selectedToolKind = computed(() =>
|
|||||||
v-else-if="selectedTool"
|
v-else-if="selectedTool"
|
||||||
:binding="selectedTool"
|
:binding="selectedTool"
|
||||||
:kind="selectedToolKind"
|
:kind="selectedToolKind"
|
||||||
:options="selectedToolKind === 'workflow' ? workflows : pluginTools"
|
:options="selectedToolOptions"
|
||||||
@change="emit('change')"
|
@change="emit('change')"
|
||||||
@remove="emit('removeCapability')"
|
@remove="emit('removeCapability')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* eslint-disable vue/no-mutating-props */
|
/* eslint-disable vue/no-mutating-props */
|
||||||
import type {AgentOption, AgentToolBinding} from '../types';
|
import type { AgentOption, AgentToolBinding } from '../types';
|
||||||
|
|
||||||
import {ElButton, ElForm, ElFormItem, ElInput, ElOption, ElSelect, ElSwitch,} from 'element-plus';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElEmpty,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
binding: AgentToolBinding;
|
binding: AgentToolBinding;
|
||||||
kind: 'plugin' | 'workflow';
|
kind: 'mcp' | 'plugin' | 'workflow';
|
||||||
options: AgentOption[];
|
options: AgentOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -15,7 +26,7 @@ const emit = defineEmits<{
|
|||||||
remove: [];
|
remove: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
|
||||||
|
|
||||||
function isSafeToolName(name?: string) {
|
function isSafeToolName(name?: string) {
|
||||||
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
|
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
|
||||||
@@ -40,11 +51,47 @@ function shouldSyncToolName() {
|
|||||||
return name.startsWith(prefix);
|
return name.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetValue = computed({
|
||||||
|
get() {
|
||||||
|
return props.binding.targetId ? String(props.binding.targetId) : '';
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
props.binding.targetId = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceLabel = computed(() => {
|
||||||
|
if (props.kind === 'workflow') return '工作流';
|
||||||
|
if (props.kind === 'mcp') return 'MCP';
|
||||||
|
return '插件工具';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMcpTools = computed(() => {
|
||||||
|
if (props.kind !== 'mcp') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const option = props.options.find(
|
||||||
|
(item) => String(item.value) === String(props.binding.targetId),
|
||||||
|
);
|
||||||
|
const tools = option?.raw?.tools || props.binding.resourceSummary?.tools;
|
||||||
|
return Array.isArray(tools) ? tools : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMcpToolCount = computed(() => selectedMcpTools.value.length);
|
||||||
|
|
||||||
function handleTargetChange(value: string) {
|
function handleTargetChange(value: string) {
|
||||||
const option = props.options.find(
|
const option = props.options.find(
|
||||||
(item) => String(item.value) === String(value),
|
(item) => String(item.value) === String(value),
|
||||||
);
|
);
|
||||||
props.binding.resourceSummary = option?.raw || {};
|
props.binding.resourceSummary = option?.raw || {};
|
||||||
|
if (props.kind === 'mcp') {
|
||||||
|
props.binding.targetId = option?.raw?.mcpId
|
||||||
|
? String(option.raw.mcpId)
|
||||||
|
: value;
|
||||||
|
props.binding.toolName = '';
|
||||||
|
emit('change');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shouldSyncToolName()) {
|
if (shouldSyncToolName()) {
|
||||||
props.binding.toolName = resolveToolName(option);
|
props.binding.toolName = resolveToolName(option);
|
||||||
}
|
}
|
||||||
@@ -54,9 +101,9 @@ function handleTargetChange(value: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElForm label-position="top" class="agent-form">
|
<ElForm label-position="top" class="agent-form">
|
||||||
<ElFormItem :label="kind === 'workflow' ? '工作流' : '插件工具'" required>
|
<ElFormItem :label="resourceLabel" required>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="binding.targetId"
|
v-model="targetValue"
|
||||||
filterable
|
filterable
|
||||||
placeholder="选择资源"
|
placeholder="选择资源"
|
||||||
@change="handleTargetChange"
|
@change="handleTargetChange"
|
||||||
@@ -69,7 +116,28 @@ function handleTargetChange(value: string) {
|
|||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="工具名称" required>
|
<div v-if="kind === 'mcp'" class="agent-form__mcp-tools">
|
||||||
|
<div class="agent-form__mcp-tools-header">
|
||||||
|
<span>工具列表</span>
|
||||||
|
<span>{{ selectedMcpToolCount }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMcpTools.length > 0" class="agent-form__mcp-tool-list">
|
||||||
|
<div
|
||||||
|
v-for="tool in selectedMcpTools"
|
||||||
|
:key="tool.name || tool.title"
|
||||||
|
class="agent-form__mcp-tool"
|
||||||
|
>
|
||||||
|
<div class="agent-form__mcp-tool-name">
|
||||||
|
{{ tool.name || tool.title || '未命名工具' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="tool.description" class="agent-form__mcp-tool-desc">
|
||||||
|
{{ tool.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElEmpty v-else description="暂无工具" :image-size="64" />
|
||||||
|
</div>
|
||||||
|
<ElFormItem v-if="kind !== 'mcp'" label="工具名称" required>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="binding.toolName"
|
v-model="binding.toolName"
|
||||||
placeholder="仅支持英文、数字、下划线或中划线"
|
placeholder="仅支持英文、数字、下划线或中划线"
|
||||||
@@ -99,6 +167,59 @@ function handleTargetChange(value: string) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tools {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tools-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tools-header span:last-child {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tool-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tool {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tool-name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 20px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form__mcp-tool-desc {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-form__danger {
|
.agent-form__danger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ import '@tinyflow-ai/vue/dist/index.css';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
knowledgeOptions?: AgentOption[];
|
knowledgeOptions?: AgentOption[];
|
||||||
|
mcpOptions?: AgentOption[];
|
||||||
|
pluginOptions?: AgentOption[];
|
||||||
selectedNodeId: string;
|
selectedNodeId: string;
|
||||||
state: AgentDraftState;
|
state: AgentDraftState;
|
||||||
|
workflowOptions?: AgentOption[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -52,6 +55,11 @@ const canvasModel = useAgentStudioModel(
|
|||||||
layout,
|
layout,
|
||||||
() => canvasSize.value,
|
() => canvasSize.value,
|
||||||
() => props.knowledgeOptions || [],
|
() => props.knowledgeOptions || [],
|
||||||
|
() => ({
|
||||||
|
mcp: props.mcpOptions || [],
|
||||||
|
plugin: props.pluginOptions || [],
|
||||||
|
workflow: props.workflowOptions || [],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const liveNodes = ref<AgentStudioNodeView[]>([]);
|
const liveNodes = ref<AgentStudioNodeView[]>([]);
|
||||||
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
|
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentStudioNodeData} from './types';
|
import type { AgentStudioNodeData } from './types';
|
||||||
|
|
||||||
import {computed} from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import {Connection, Cpu, Files, Share} from '@element-plus/icons-vue';
|
import { Connection, Cpu, Files, Link, Share } from '@element-plus/icons-vue';
|
||||||
import {ElIcon} from 'element-plus';
|
import { ElIcon } from 'element-plus';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: AgentStudioNodeData;
|
data: AgentStudioNodeData;
|
||||||
@@ -14,6 +14,7 @@ const iconComponent = computed(() => {
|
|||||||
const icons = {
|
const icons = {
|
||||||
base: Cpu,
|
base: Cpu,
|
||||||
knowledge: Files,
|
knowledge: Files,
|
||||||
|
mcp: Link,
|
||||||
plugin: Connection,
|
plugin: Connection,
|
||||||
workflow: Share,
|
workflow: Share,
|
||||||
};
|
};
|
||||||
@@ -108,6 +109,7 @@ const iconComponent = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-studio-node--knowledge,
|
.agent-studio-node--knowledge,
|
||||||
|
.agent-studio-node--mcp,
|
||||||
.agent-studio-node--workflow,
|
.agent-studio-node--workflow,
|
||||||
.agent-studio-node--plugin {
|
.agent-studio-node--plugin {
|
||||||
min-height: 78px;
|
min-height: 78px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
import {resolveCapabilityNodePosition} from './useAgentStudioModel';
|
import {useAgentStudioModel, resolveCapabilityNodePosition} from './useAgentStudioModel';
|
||||||
|
|
||||||
describe('resolveCapabilityNodePosition', () => {
|
describe('resolveCapabilityNodePosition', () => {
|
||||||
it('无视口信息时沿用默认左侧列位置', () => {
|
it('无视口信息时沿用默认左侧列位置', () => {
|
||||||
@@ -57,3 +57,47 @@ describe('resolveCapabilityNodePosition', () => {
|
|||||||
).toEqual({ x: 128, y: 256 });
|
).toEqual({ x: 128, y: 256 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useAgentStudioModel', () => {
|
||||||
|
it('MCP 绑定缺少资源快照时从选项中回显节点信息', () => {
|
||||||
|
const model = useAgentStudioModel(
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
name: '测试智能体',
|
||||||
|
},
|
||||||
|
dirty: false,
|
||||||
|
knowledgeBindings: [],
|
||||||
|
panelMode: 'capability',
|
||||||
|
selectedNodeId: 'tool:mcp-1',
|
||||||
|
toolBindings: [
|
||||||
|
{
|
||||||
|
localId: 'mcp-1',
|
||||||
|
targetId: '1001',
|
||||||
|
toolType: 'MCP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => 'tool:mcp-1',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => ({
|
||||||
|
mcp: [
|
||||||
|
{
|
||||||
|
label: 'context7',
|
||||||
|
value: '1001',
|
||||||
|
raw: {
|
||||||
|
id: '1001',
|
||||||
|
title: 'context7',
|
||||||
|
tools: [{ name: 'resolve-library-id' }, { name: 'query-docs' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mcpNode = model.value.nodes.find((node) => node.id === 'tool:mcp-1');
|
||||||
|
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
|
||||||
|
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
AgentToolBinding,
|
AgentToolBinding,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import {computed} from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const BASE_NODE_ID = 'agent-base';
|
const BASE_NODE_ID = 'agent-base';
|
||||||
const BASE_POSITION = { x: 430, y: 260 };
|
const BASE_POSITION = { x: 430, y: 260 };
|
||||||
@@ -57,19 +57,66 @@ function buildKnowledgeTitle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolTitle(binding: AgentToolBinding) {
|
function findMatchedToolOption(
|
||||||
|
binding: AgentToolBinding,
|
||||||
|
options: AgentOption[],
|
||||||
|
) {
|
||||||
|
const targetId = String(binding.targetId || '');
|
||||||
|
if (!targetId) return undefined;
|
||||||
|
return options.find((item) => {
|
||||||
|
const raw = item.raw || {};
|
||||||
|
return (
|
||||||
|
String(item.value) === targetId ||
|
||||||
|
String(raw.id || '') === targetId ||
|
||||||
|
String(raw.mcpId || '') === targetId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = []) {
|
||||||
|
const matchedOption = findMatchedToolOption(binding, options);
|
||||||
return firstText(
|
return firstText(
|
||||||
binding.resourceSummary?.title,
|
binding.resourceSummary?.title,
|
||||||
binding.resourceSummary?.name,
|
binding.resourceSummary?.name,
|
||||||
|
binding.resourceSummary?.label,
|
||||||
|
binding.resourceSummary?.displayName,
|
||||||
|
binding.resourceSummary?.mcpTitle,
|
||||||
binding.resourceSnapshot?.title,
|
binding.resourceSnapshot?.title,
|
||||||
binding.resourceSnapshot?.name,
|
binding.resourceSnapshot?.name,
|
||||||
|
binding.resourceSnapshot?.label,
|
||||||
|
binding.resourceSnapshot?.displayName,
|
||||||
|
binding.resourceSnapshot?.mcpTitle,
|
||||||
|
matchedOption?.label,
|
||||||
|
matchedOption?.raw?.title,
|
||||||
|
matchedOption?.raw?.name,
|
||||||
|
matchedOption?.raw?.label,
|
||||||
|
matchedOption?.raw?.displayName,
|
||||||
|
matchedOption?.raw?.mcpTitle,
|
||||||
binding.toolName,
|
binding.toolName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolDetail(binding: AgentToolBinding, fallback: string) {
|
function buildToolDetail(
|
||||||
|
binding: AgentToolBinding,
|
||||||
|
fallback: string,
|
||||||
|
options: AgentOption[] = [],
|
||||||
|
) {
|
||||||
|
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||||
|
const matchedOption = findMatchedToolOption(binding, options);
|
||||||
|
const resourceName = buildToolTitle(binding, options);
|
||||||
|
const tools =
|
||||||
|
binding.resourceSummary?.tools ||
|
||||||
|
binding.resourceSnapshot?.tools ||
|
||||||
|
matchedOption?.raw?.tools ||
|
||||||
|
[];
|
||||||
|
const toolCount = Array.isArray(tools) ? tools.length : 0;
|
||||||
|
if (resourceName && toolCount > 0) {
|
||||||
|
return `${resourceName} · ${toolCount} 个工具`;
|
||||||
|
}
|
||||||
|
return resourceName || fallback;
|
||||||
|
}
|
||||||
const toolName = firstText(binding.toolName);
|
const toolName = firstText(binding.toolName);
|
||||||
const resourceName = buildToolTitle(binding);
|
const resourceName = buildToolTitle(binding, options);
|
||||||
if (toolName && resourceName && toolName !== resourceName) {
|
if (toolName && resourceName && toolName !== resourceName) {
|
||||||
return `${resourceName} / ${toolName}`;
|
return `${resourceName} / ${toolName}`;
|
||||||
}
|
}
|
||||||
@@ -157,6 +204,11 @@ export function useAgentStudioModel(
|
|||||||
layout?: AgentStudioLayoutSnapshot,
|
layout?: AgentStudioLayoutSnapshot,
|
||||||
canvasSize?: () => AgentStudioCanvasSize | undefined,
|
canvasSize?: () => AgentStudioCanvasSize | undefined,
|
||||||
knowledgeOptions?: () => AgentOption[],
|
knowledgeOptions?: () => AgentOption[],
|
||||||
|
toolOptions?: () => {
|
||||||
|
mcp?: AgentOption[];
|
||||||
|
plugin?: AgentOption[];
|
||||||
|
workflow?: AgentOption[];
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
||||||
@@ -214,10 +266,20 @@ export function useAgentStudioModel(
|
|||||||
});
|
});
|
||||||
const toolNodes = state.toolBindings.map((binding, index) => {
|
const toolNodes = state.toolBindings.map((binding, index) => {
|
||||||
const nodeId = `tool:${binding.localId}`;
|
const nodeId = `tool:${binding.localId}`;
|
||||||
const isWorkflow =
|
const toolType = String(binding.toolType || '').toUpperCase();
|
||||||
String(binding.toolType || '').toUpperCase() === 'WORKFLOW';
|
const isWorkflow = toolType === 'WORKFLOW';
|
||||||
const fallback = isWorkflow ? '待选择工作流' : '待选择插件工具';
|
const isMcp = toolType === 'MCP';
|
||||||
const detail = buildToolDetail(binding, fallback);
|
const matchedOptions = isWorkflow
|
||||||
|
? toolOptions?.().workflow || []
|
||||||
|
: isMcp
|
||||||
|
? toolOptions?.().mcp || []
|
||||||
|
: toolOptions?.().plugin || [];
|
||||||
|
const fallback = isWorkflow
|
||||||
|
? '待选择工作流'
|
||||||
|
: isMcp
|
||||||
|
? '待选择 MCP'
|
||||||
|
: '待选择插件工具';
|
||||||
|
const detail = buildToolDetail(binding, fallback, matchedOptions);
|
||||||
const position = resolveCapabilityNodePosition({
|
const position = resolveCapabilityNodePosition({
|
||||||
canvasSize: size,
|
canvasSize: size,
|
||||||
fallbackIndex: state.knowledgeBindings.length + index,
|
fallbackIndex: state.knowledgeBindings.length + index,
|
||||||
@@ -233,14 +295,20 @@ export function useAgentStudioModel(
|
|||||||
width: CAPABILITY_NODE_WIDTH,
|
width: CAPABILITY_NODE_WIDTH,
|
||||||
height: CAPABILITY_NODE_HEIGHT,
|
height: CAPABILITY_NODE_HEIGHT,
|
||||||
data: {
|
data: {
|
||||||
badge: isWorkflow ? '工作流' : '插件',
|
badge: isWorkflow ? '工作流' : isMcp ? 'MCP' : '插件',
|
||||||
detail,
|
detail,
|
||||||
iconKey: isWorkflow ? 'workflow' : 'plugin',
|
iconKey: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
kind: isWorkflow ? 'workflow' : 'plugin',
|
kind: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||||
selected: selectedNodeId() === nodeId,
|
selected: selectedNodeId() === nodeId,
|
||||||
title:
|
title:
|
||||||
detail === fallback ? (isWorkflow ? '工作流' : '插件') : detail,
|
detail === fallback
|
||||||
|
? isWorkflow
|
||||||
|
? '工作流'
|
||||||
|
: isMcp
|
||||||
|
? 'MCP'
|
||||||
|
: '插件'
|
||||||
|
: detail,
|
||||||
} satisfies AgentStudioNodeData,
|
} satisfies AgentStudioNodeData,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import type {
|
|||||||
AgentValidationIssue,
|
AgentValidationIssue,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import {computed, reactive} from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
|
|
||||||
const BASE_NODE_ID = 'agent-base';
|
const BASE_NODE_ID = 'agent-base';
|
||||||
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
|
||||||
|
|
||||||
function createLocalId(prefix: string) {
|
function createLocalId(prefix: string) {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
@@ -25,6 +25,15 @@ function buildFallbackToolName(prefix: string, resource?: Record<string, any>) {
|
|||||||
return `${prefix}_${id}`;
|
return `${prefix}_${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolKindFromType(
|
||||||
|
toolType?: string,
|
||||||
|
): Exclude<AgentCapabilityKind, 'knowledge'> {
|
||||||
|
const normalized = String(toolType || '').toUpperCase();
|
||||||
|
if (normalized === 'WORKFLOW') return 'workflow';
|
||||||
|
if (normalized === 'MCP') return 'mcp';
|
||||||
|
return 'plugin';
|
||||||
|
}
|
||||||
|
|
||||||
function resolveToolName(
|
function resolveToolName(
|
||||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||||
resource?: Record<string, any>,
|
resource?: Record<string, any>,
|
||||||
@@ -36,19 +45,19 @@ function resolveToolName(
|
|||||||
return String(resource?.name);
|
return String(resource?.name);
|
||||||
}
|
}
|
||||||
return buildFallbackToolName(
|
return buildFallbackToolName(
|
||||||
kind === 'workflow' ? 'workflow' : 'plugin',
|
kind === 'workflow' ? 'workflow' : kind === 'mcp' ? 'mcp' : 'plugin',
|
||||||
resource,
|
resource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBindingToolName(binding: AgentToolBinding) {
|
function normalizeBindingToolName(binding: AgentToolBinding) {
|
||||||
|
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
if (isSafeToolName(binding.toolName)) {
|
if (isSafeToolName(binding.toolName)) {
|
||||||
return String(binding.toolName);
|
return String(binding.toolName);
|
||||||
}
|
}
|
||||||
const kind =
|
const kind = toolKindFromType(binding.toolType);
|
||||||
String(binding.toolType || '').toUpperCase() === 'WORKFLOW'
|
|
||||||
? 'workflow'
|
|
||||||
: 'plugin';
|
|
||||||
const resource = {
|
const resource = {
|
||||||
...(binding.resourceSnapshot || {}),
|
...(binding.resourceSnapshot || {}),
|
||||||
...(binding.resourceSummary || {}),
|
...(binding.resourceSummary || {}),
|
||||||
@@ -178,10 +187,7 @@ export function useAgentDesignerState() {
|
|||||||
(item) => item.localId === localId,
|
(item) => item.localId === localId,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
kind:
|
kind: toolKindFromType(binding?.toolType) as AgentCapabilityKind,
|
||||||
String(binding?.toolType || '').toUpperCase() === 'WORKFLOW'
|
|
||||||
? ('workflow' as AgentCapabilityKind)
|
|
||||||
: ('plugin' as AgentCapabilityKind),
|
|
||||||
binding,
|
binding,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,12 +243,17 @@ export function useAgentDesignerState() {
|
|||||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||||
resource?: Record<string, any>,
|
resource?: Record<string, any>,
|
||||||
) {
|
) {
|
||||||
const toolType = kind === 'workflow' ? 'WORKFLOW' : 'PLUGIN';
|
const toolType =
|
||||||
|
kind === 'workflow' ? 'WORKFLOW' : kind === 'mcp' ? 'MCP' : 'PLUGIN';
|
||||||
const binding = normalizeToolBinding(
|
const binding = normalizeToolBinding(
|
||||||
{
|
{
|
||||||
toolType,
|
toolType,
|
||||||
targetId: resource?.id ? String(resource.id) : '',
|
targetId: resource?.mcpId
|
||||||
toolName: resolveToolName(kind, resource),
|
? String(resource.mcpId)
|
||||||
|
: resource?.id
|
||||||
|
? String(resource.id)
|
||||||
|
: '',
|
||||||
|
toolName: kind === 'mcp' ? '' : resolveToolName(kind, resource),
|
||||||
resourceSummary: resource || {},
|
resourceSummary: resource || {},
|
||||||
},
|
},
|
||||||
state.toolBindings.length,
|
state.toolBindings.length,
|
||||||
@@ -299,6 +310,9 @@ export function useAgentDesignerState() {
|
|||||||
if (!binding.targetId) {
|
if (!binding.targetId) {
|
||||||
issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' });
|
issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' });
|
||||||
}
|
}
|
||||||
|
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!String(binding.toolName || '').trim()) {
|
if (!String(binding.toolName || '').trim()) {
|
||||||
issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' });
|
issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' });
|
||||||
} else if (!isSafeToolName(binding.toolName)) {
|
} else if (!isSafeToolName(binding.toolName)) {
|
||||||
@@ -340,13 +354,17 @@ export function useAgentDesignerState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildToolPayload(agentId?: number | string) {
|
function buildToolPayload(agentId?: number | string) {
|
||||||
return state.toolBindings.map((binding, index) => ({
|
return state.toolBindings.map((binding, index) => {
|
||||||
...binding,
|
const isMcp = String(binding.toolType || '').toUpperCase() === 'MCP';
|
||||||
agentId,
|
return {
|
||||||
enabled: binding.enabled !== false,
|
...binding,
|
||||||
hitlEnabled: Boolean(binding.hitlEnabled),
|
agentId,
|
||||||
sortNo: index + 1,
|
enabled: binding.enabled !== false,
|
||||||
}));
|
hitlEnabled: Boolean(binding.hitlEnabled),
|
||||||
|
toolName: isMcp ? '' : binding.toolName,
|
||||||
|
sortNo: index + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset();
|
reset();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* cspell:ignore hitl */
|
/* cspell:ignore hitl */
|
||||||
|
|
||||||
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
|
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
|
||||||
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow';
|
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp';
|
||||||
|
|
||||||
export interface AgentInfo {
|
export interface AgentInfo {
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
@@ -33,7 +33,7 @@ export interface AgentInfo {
|
|||||||
export interface AgentToolBinding {
|
export interface AgentToolBinding {
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
agentId?: number | string;
|
agentId?: number | string;
|
||||||
toolType: 'PLUGIN' | 'WORKFLOW' | string;
|
toolType: 'MCP' | 'PLUGIN' | 'WORKFLOW' | string;
|
||||||
targetId?: number | string;
|
targetId?: number | string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { markRaw, ref } from 'vue';
|
|||||||
|
|
||||||
import { Delete, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
import { Delete, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
|
ElAlert,
|
||||||
ElButton,
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
ElDropdown,
|
ElDropdown,
|
||||||
ElDropdownItem,
|
ElDropdownItem,
|
||||||
ElDropdownMenu,
|
ElDropdownMenu,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
ElSwitch,
|
ElSwitch,
|
||||||
ElTable,
|
ElTable,
|
||||||
ElTableColumn,
|
ElTableColumn,
|
||||||
|
ElTag,
|
||||||
ElTooltip,
|
ElTooltip,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
@@ -28,6 +31,26 @@ import McpModal from './McpModal.vue';
|
|||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const saveDialog = ref();
|
const saveDialog = ref();
|
||||||
|
interface McpCheckItem {
|
||||||
|
name: string;
|
||||||
|
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpServerCheckResult {
|
||||||
|
serverName: string;
|
||||||
|
transport: string;
|
||||||
|
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
toolCount: number;
|
||||||
|
checks: McpCheckItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpEnvironmentCheckResult {
|
||||||
|
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
servers: McpServerCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
function reset(formEl: FormInstance | undefined) {
|
function reset(formEl: FormInstance | undefined) {
|
||||||
formEl?.resetFields();
|
formEl?.resetFields();
|
||||||
pageDataRef.value.setQuery({});
|
pageDataRef.value.setQuery({});
|
||||||
@@ -103,11 +126,110 @@ const handleHeaderButtonClick = (button: any) => {
|
|||||||
};
|
};
|
||||||
const loadingMap = ref<Record<number | string, boolean>>({});
|
const loadingMap = ref<Record<number | string, boolean>>({});
|
||||||
const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
||||||
|
const checkLoadingMap = ref<Record<number | string, boolean>>({});
|
||||||
|
const checkDialogVisible = ref(false);
|
||||||
|
const checkResult = ref<McpEnvironmentCheckResult>();
|
||||||
|
|
||||||
|
const checkTagType = (status?: string) => {
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (status === 'WARNING') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatusLabel = (status?: string) => {
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return $t('mcp.check.status.success');
|
||||||
|
}
|
||||||
|
if (status === 'WARNING') {
|
||||||
|
return $t('mcp.check.status.warning');
|
||||||
|
}
|
||||||
|
return $t('mcp.check.status.failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheck = (row: any) => {
|
||||||
|
checkLoadingMap.value[row.id] = true;
|
||||||
|
api
|
||||||
|
.post('/api/v1/mcp/check', { configJson: row.configJson })
|
||||||
|
.then((res) => {
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
checkResult.value = res.data;
|
||||||
|
checkDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
checkLoadingMap.value[row.id] = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6 p-6">
|
<div class="flex h-full flex-col gap-6 p-6">
|
||||||
<McpModal ref="saveDialog" @reload="reset" />
|
<McpModal ref="saveDialog" @reload="reset" />
|
||||||
|
<ElDialog
|
||||||
|
v-model="checkDialogVisible"
|
||||||
|
:title="$t('mcp.check.resultTitle')"
|
||||||
|
width="720px"
|
||||||
|
>
|
||||||
|
<div v-if="checkResult" class="mcp-check-result">
|
||||||
|
<ElAlert
|
||||||
|
:closable="false"
|
||||||
|
:type="
|
||||||
|
checkResult.overallStatus === 'SUCCESS'
|
||||||
|
? 'success'
|
||||||
|
: checkResult.overallStatus === 'WARNING'
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
"
|
||||||
|
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
|
||||||
|
checkResult.overallStatus,
|
||||||
|
)}`"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="server in checkResult.servers"
|
||||||
|
:key="server.serverName"
|
||||||
|
class="mcp-check-server"
|
||||||
|
>
|
||||||
|
<div class="mcp-check-server__head">
|
||||||
|
<div>
|
||||||
|
<div class="mcp-check-server__title">
|
||||||
|
{{ server.serverName }}
|
||||||
|
</div>
|
||||||
|
<div class="mcp-check-server__meta">
|
||||||
|
{{ server.transport || '-' }} · {{ $t('mcp.check.toolCount') }}
|
||||||
|
{{ server.toolCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElTag :type="checkTagType(server.status)" size="small">
|
||||||
|
{{ checkStatusLabel(server.status) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-check-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in server.checks"
|
||||||
|
:key="`${item.name}-${index}`"
|
||||||
|
class="mcp-check-item"
|
||||||
|
>
|
||||||
|
<ElTag :type="checkTagType(item.status)" size="small">
|
||||||
|
{{ checkStatusLabel(item.status) }}
|
||||||
|
</ElTag>
|
||||||
|
<div class="mcp-check-item__content">
|
||||||
|
<div class="mcp-check-item__message">
|
||||||
|
{{ item.message }}
|
||||||
|
</div>
|
||||||
|
<div v-if="item.detail" class="mcp-check-item__detail">
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElDialog>
|
||||||
<ListPageShell>
|
<ListPageShell>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
@@ -185,6 +307,17 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
|||||||
<ElButton link :icon="MoreFilled" />
|
<ElButton link :icon="MoreFilled" />
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
|
<div v-access:code="'/api/v1/mcp/check'">
|
||||||
|
<ElDropdownItem @click="handleCheck(row)">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:loading="checkLoadingMap[row.id]"
|
||||||
|
>
|
||||||
|
{{ $t('mcp.check.action') }}
|
||||||
|
</ElButton>
|
||||||
|
</ElDropdownItem>
|
||||||
|
</div>
|
||||||
<div v-access:code="'/api/v1/mcp/remove'">
|
<div v-access:code="'/api/v1/mcp/remove'">
|
||||||
<ElDropdownItem @click="remove(row)">
|
<ElDropdownItem @click="remove(row)">
|
||||||
<ElButton type="danger" :icon="Delete" link>
|
<ElButton type="danger" :icon="Delete" link>
|
||||||
@@ -205,4 +338,59 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.mcp-check-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__meta,
|
||||||
|
.mcp-check-item__detail {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item__message {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||||
|
import { MagicStick } from '@element-plus/icons-vue';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import jsonLanguage from 'highlight.js/lib/languages/json';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ElAlert,
|
||||||
|
ElButton,
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
ElFormItem,
|
||||||
ElInput,
|
ElInput,
|
||||||
@@ -15,11 +20,14 @@ import {
|
|||||||
ElTableColumn,
|
ElTableColumn,
|
||||||
ElTabPane,
|
ElTabPane,
|
||||||
ElTabs,
|
ElTabs,
|
||||||
|
ElTag,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
hljs.registerLanguage('json', jsonLanguage);
|
||||||
|
|
||||||
interface PropValue {
|
interface PropValue {
|
||||||
type?: string;
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -42,9 +50,30 @@ interface McpEntity {
|
|||||||
configJson: string;
|
configJson: string;
|
||||||
deptId: string;
|
deptId: string;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
|
approvalRequired: boolean;
|
||||||
tools: McpTool[];
|
tools: McpTool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface McpCheckItem {
|
||||||
|
name: string;
|
||||||
|
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpServerCheckResult {
|
||||||
|
serverName: string;
|
||||||
|
transport: string;
|
||||||
|
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
toolCount: number;
|
||||||
|
checks: McpCheckItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpEnvironmentCheckResult {
|
||||||
|
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||||
|
servers: McpServerCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
|
|
||||||
onMounted(() => {});
|
onMounted(() => {});
|
||||||
@@ -56,6 +85,11 @@ const saveForm = ref<FormInstance>();
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const isAdd = ref(true);
|
const isAdd = ref(true);
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
|
const checkLoading = ref(false);
|
||||||
|
const checkResult = ref<McpEnvironmentCheckResult>();
|
||||||
|
const jsonEditorTextarea = ref<HTMLTextAreaElement>();
|
||||||
|
const jsonEditorHighlight = ref<HTMLElement>();
|
||||||
|
const jsonError = ref('');
|
||||||
|
|
||||||
const defaultEntity: McpEntity = {
|
const defaultEntity: McpEntity = {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -63,6 +97,7 @@ const defaultEntity: McpEntity = {
|
|||||||
configJson: '',
|
configJson: '',
|
||||||
deptId: '',
|
deptId: '',
|
||||||
status: false,
|
status: false,
|
||||||
|
approvalRequired: false,
|
||||||
tools: [],
|
tools: [],
|
||||||
};
|
};
|
||||||
const entity = ref<McpEntity>({ ...defaultEntity });
|
const entity = ref<McpEntity>({ ...defaultEntity });
|
||||||
@@ -84,13 +119,30 @@ const rules = ref({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const highlightedConfigJson = computed(() => {
|
||||||
|
const value = entity.value.configJson || '';
|
||||||
|
return hljs.highlight(value || ' ', { language: 'json' }).value;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => entity.value.configJson,
|
||||||
|
() => {
|
||||||
|
validateConfigJson(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function openDialog(row: Partial<McpEntity> = {}) {
|
function openDialog(row: Partial<McpEntity> = {}) {
|
||||||
isAdd.value = !row.id;
|
isAdd.value = !row.id;
|
||||||
entity.value = { ...defaultEntity, ...row };
|
entity.value = { ...defaultEntity, ...row };
|
||||||
|
checkResult.value = undefined;
|
||||||
if (!isAdd.value) {
|
if (!isAdd.value) {
|
||||||
getMcpTools(row);
|
getMcpTools(row);
|
||||||
}
|
}
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
syncJsonEditorScroll();
|
||||||
|
validateConfigJson(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMcpTools(row: Partial<McpEntity>) {
|
function getMcpTools(row: Partial<McpEntity>) {
|
||||||
@@ -101,6 +153,9 @@ function getMcpTools(row: Partial<McpEntity>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function save() {
|
function save() {
|
||||||
|
if (!validateConfigJson(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveForm.value?.validate((valid) => {
|
saveForm.value?.validate((valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
@@ -128,12 +183,96 @@ function save() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkMcpConfig() {
|
||||||
|
if (!entity.value.configJson) {
|
||||||
|
ElMessage.warning($t('mcp.check.message.configRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateConfigJson(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkLoading.value = true;
|
||||||
|
api
|
||||||
|
.post('api/v1/mcp/check', { configJson: entity.value.configJson })
|
||||||
|
.then((res) => {
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
checkResult.value = res.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
checkLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
saveForm.value?.resetFields();
|
saveForm.value?.resetFields();
|
||||||
isAdd.value = true;
|
isAdd.value = true;
|
||||||
entity.value = { ...defaultEntity };
|
entity.value = { ...defaultEntity };
|
||||||
|
checkResult.value = undefined;
|
||||||
|
jsonError.value = '';
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatConfigJson() {
|
||||||
|
if (!validateConfigJson(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entity.value.configJson = JSON.stringify(
|
||||||
|
JSON.parse(entity.value.configJson),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
nextTick(() => {
|
||||||
|
syncJsonEditorScroll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfigJson(showMessage: boolean) {
|
||||||
|
if (!entity.value.configJson) {
|
||||||
|
jsonError.value = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(entity.value.configJson);
|
||||||
|
jsonError.value = '';
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
jsonError.value =
|
||||||
|
error instanceof Error ? error.message : $t('mcp.jsonEditor.invalid');
|
||||||
|
if (showMessage) {
|
||||||
|
ElMessage.warning($t('mcp.jsonEditor.invalid'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncJsonEditorScroll() {
|
||||||
|
if (!jsonEditorTextarea.value || !jsonEditorHighlight.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jsonEditorHighlight.value.scrollTop = jsonEditorTextarea.value.scrollTop;
|
||||||
|
jsonEditorHighlight.value.scrollLeft = jsonEditorTextarea.value.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkTagType = (status?: string) => {
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (status === 'WARNING') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatusLabel = (status?: string) => {
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return $t('mcp.check.status.success');
|
||||||
|
}
|
||||||
|
if (status === 'WARNING') {
|
||||||
|
return $t('mcp.check.status.warning');
|
||||||
|
}
|
||||||
|
return $t('mcp.check.status.failed');
|
||||||
|
};
|
||||||
const jsonPlaceholder = ref(`{
|
const jsonPlaceholder = ref(`{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"12306-mcp": {
|
"12306-mcp": {
|
||||||
@@ -176,13 +315,113 @@ const activeName = ref('config');
|
|||||||
<ElInput v-model.trim="entity.description" />
|
<ElInput v-model.trim="entity.description" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="configJson" :label="$t('mcp.configJson')">
|
<ElFormItem prop="configJson" :label="$t('mcp.configJson')">
|
||||||
<ElInput
|
<div
|
||||||
type="textarea"
|
class="mcp-json-editor"
|
||||||
:rows="15"
|
:class="{ 'mcp-json-editor--error': jsonError }"
|
||||||
v-model.trim="entity.configJson"
|
>
|
||||||
:placeholder="$t('mcp.example') + jsonPlaceholder"
|
<div class="mcp-json-editor__toolbar">
|
||||||
/>
|
<ElButton
|
||||||
|
:aria-label="$t('mcp.jsonEditor.format')"
|
||||||
|
:icon="MagicStick"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:title="$t('mcp.jsonEditor.format')"
|
||||||
|
@click="formatConfigJson"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
ref="jsonEditorHighlight"
|
||||||
|
class="mcp-json-editor__highlight"
|
||||||
|
aria-hidden="true"
|
||||||
|
><code v-html="highlightedConfigJson"></code></pre>
|
||||||
|
<textarea
|
||||||
|
ref="jsonEditorTextarea"
|
||||||
|
v-model="entity.configJson"
|
||||||
|
class="mcp-json-editor__textarea"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="$t('mcp.example') + jsonPlaceholder"
|
||||||
|
:aria-invalid="Boolean(jsonError)"
|
||||||
|
:aria-label="$t('mcp.configJson')"
|
||||||
|
@scroll="syncJsonEditorScroll"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="jsonError" class="mcp-json-editor__error">
|
||||||
|
{{ jsonError }}
|
||||||
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<div class="mcp-check-actions">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:loading="checkLoading"
|
||||||
|
:disabled="checkLoading"
|
||||||
|
@click="checkMcpConfig"
|
||||||
|
>
|
||||||
|
{{ $t('mcp.check.action') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElFormItem
|
||||||
|
prop="approvalRequired"
|
||||||
|
:label="$t('mcp.approvalRequired')"
|
||||||
|
>
|
||||||
|
<ElSwitch v-model="entity.approvalRequired" />
|
||||||
|
</ElFormItem>
|
||||||
|
<div v-if="checkResult" class="mcp-check-result">
|
||||||
|
<ElAlert
|
||||||
|
:closable="false"
|
||||||
|
:type="
|
||||||
|
checkResult.overallStatus === 'SUCCESS'
|
||||||
|
? 'success'
|
||||||
|
: checkResult.overallStatus === 'WARNING'
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
"
|
||||||
|
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
|
||||||
|
checkResult.overallStatus,
|
||||||
|
)}`"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="server in checkResult.servers"
|
||||||
|
:key="server.serverName"
|
||||||
|
class="mcp-check-server"
|
||||||
|
>
|
||||||
|
<div class="mcp-check-server__head">
|
||||||
|
<div>
|
||||||
|
<div class="mcp-check-server__title">
|
||||||
|
{{ server.serverName }}
|
||||||
|
</div>
|
||||||
|
<div class="mcp-check-server__meta">
|
||||||
|
{{ server.transport || '-' }} ·
|
||||||
|
{{ $t('mcp.check.toolCount') }} {{ server.toolCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElTag :type="checkTagType(server.status)" size="small">
|
||||||
|
{{ checkStatusLabel(server.status) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-check-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in server.checks"
|
||||||
|
:key="`${item.name}-${index}`"
|
||||||
|
class="mcp-check-item"
|
||||||
|
>
|
||||||
|
<ElTag :type="checkTagType(item.status)" size="small">
|
||||||
|
{{ checkStatusLabel(item.status) }}
|
||||||
|
</ElTag>
|
||||||
|
<div class="mcp-check-item__content">
|
||||||
|
<div class="mcp-check-item__message">
|
||||||
|
{{ item.message }}
|
||||||
|
</div>
|
||||||
|
<div v-if="item.detail" class="mcp-check-item__detail">
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ElFormItem prop="status" :label="$t('mcp.status')">
|
<ElFormItem prop="status" :label="$t('mcp.status')">
|
||||||
<ElSwitch v-model="entity.status" />
|
<ElSwitch v-model="entity.status" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
@@ -319,14 +558,195 @@ const activeName = ref('config');
|
|||||||
|
|
||||||
.params-left-title-container {
|
.params-left-title-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px;
|
min-width: 120px;
|
||||||
background-color: #fafafa;
|
}
|
||||||
border: 1px solid #e6e9ee;
|
|
||||||
|
.mcp-json-editor {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
transition:
|
||||||
|
border-color var(--el-transition-duration),
|
||||||
|
box-shadow var(--el-transition-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor:focus-within {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor--error {
|
||||||
|
border-color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor--error:focus-within {
|
||||||
|
border-color: var(--el-color-danger);
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-danger-light-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__toolbar :deep(.el-button) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__toolbar :deep(.el-button:hover),
|
||||||
|
.mcp-json-editor__toolbar :deep(.el-button:focus-visible) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__highlight,
|
||||||
|
.mcp-json-editor__textarea {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px 48px 12px 12px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
tab-size: 2;
|
||||||
|
white-space: pre;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__highlight {
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__textarea {
|
||||||
|
z-index: 2;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: var(--el-text-color-primary);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__textarea::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__textarea::selection {
|
||||||
|
color: transparent;
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor__error {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor :deep(.hljs-attr) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor :deep(.hljs-string) {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor :deep(.hljs-number),
|
||||||
|
.mcp-json-editor :deep(.hljs-literal) {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-json-editor :deep(.hljs-punctuation) {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-server__meta,
|
||||||
|
.mcp-check-item__detail {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-check-item__message {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.required-mark {
|
.required-mark {
|
||||||
|
|||||||
Reference in New Issue
Block a user