| New file |
| | |
| | | package com.zy.ai.config; |
| | | |
| | | import com.zy.ai.service.AiMcpMountService; |
| | | import jakarta.annotation.PostConstruct; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.sql.DataSource; |
| | | import java.sql.Connection; |
| | | import java.sql.DatabaseMetaData; |
| | | import java.sql.ResultSet; |
| | | |
| | | @Slf4j |
| | | @Component |
| | | public class AiMcpMountInitializer { |
| | | |
| | | private final DataSource dataSource; |
| | | private final AiMcpMountService aiMcpMountService; |
| | | |
| | | public AiMcpMountInitializer(DataSource dataSource, AiMcpMountService aiMcpMountService) { |
| | | this.dataSource = dataSource; |
| | | this.aiMcpMountService = aiMcpMountService; |
| | | } |
| | | |
| | | @PostConstruct |
| | | public void init() { |
| | | try (Connection connection = dataSource.getConnection()) { |
| | | if (!hasTable(connection, "sys_ai_mcp_mount")) { |
| | | log.warn("Skip AI MCP mount initialization because mount table does not exist"); |
| | | return; |
| | | } |
| | | int changed = aiMcpMountService.initDefaultsIfMissing(); |
| | | log.info("AI MCP mounts initialized, insertedOrRecovered={}", changed); |
| | | } catch (Exception e) { |
| | | log.warn("Failed to initialize AI MCP mounts", e); |
| | | } |
| | | } |
| | | |
| | | private boolean hasTable(Connection connection, String tableName) throws Exception { |
| | | DatabaseMetaData metaData = connection.getMetaData(); |
| | | try (ResultSet resultSet = metaData.getTables(connection.getCatalog(), null, tableName, new String[]{"TABLE"})) { |
| | | while (resultSet.next()) { |
| | | if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.zy.ai.controller; |
| | | |
| | | import com.core.annotations.ManagerAuth; |
| | | import com.core.common.R; |
| | | import com.zy.ai.entity.AiMcpMount; |
| | | import com.zy.ai.mcp.service.SpringAiMcpToolManager; |
| | | import com.zy.ai.service.AiMcpMountService; |
| | | import com.zy.common.web.BaseController; |
| | | import lombok.RequiredArgsConstructor; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | @RestController |
| | | @RequestMapping("/ai/mcp/mount") |
| | | @RequiredArgsConstructor |
| | | public class AiMcpMountController extends BaseController { |
| | | |
| | | private final AiMcpMountService aiMcpMountService; |
| | | private final SpringAiMcpToolManager springAiMcpToolManager; |
| | | |
| | | @GetMapping("/types/auth") |
| | | @ManagerAuth |
| | | public R types() { |
| | | return R.ok(aiMcpMountService.listSupportedTransportTypes()); |
| | | } |
| | | |
| | | @GetMapping("/list/auth") |
| | | @ManagerAuth |
| | | public R list() { |
| | | return R.ok(aiMcpMountService.listOrdered()); |
| | | } |
| | | |
| | | @GetMapping("/toolList/auth") |
| | | @ManagerAuth |
| | | public R toolList() { |
| | | return R.ok(springAiMcpToolManager.listTools()); |
| | | } |
| | | |
| | | @PostMapping("/save/auth") |
| | | @ManagerAuth |
| | | public R save(@RequestBody AiMcpMount mount) { |
| | | try { |
| | | AiMcpMount saved = aiMcpMountService.saveMount(mount); |
| | | springAiMcpToolManager.evictCache(); |
| | | return R.ok(saved); |
| | | } catch (IllegalArgumentException e) { |
| | | return R.error(e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | @PostMapping("/delete/auth") |
| | | @ManagerAuth |
| | | public R delete(@RequestParam("id") Long id) { |
| | | try { |
| | | boolean deleted = aiMcpMountService.deleteMount(id); |
| | | springAiMcpToolManager.evictCache(); |
| | | return R.ok(deleted); |
| | | } catch (IllegalArgumentException e) { |
| | | return R.error(e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | @PostMapping("/refresh/auth") |
| | | @ManagerAuth |
| | | public R refresh() { |
| | | springAiMcpToolManager.evictCache(); |
| | | return R.ok(springAiMcpToolManager.listTools()); |
| | | } |
| | | |
| | | @PostMapping("/test/auth") |
| | | @ManagerAuth |
| | | public R test(@RequestBody AiMcpMount mount) { |
| | | try { |
| | | java.util.Map<String, Object> result = springAiMcpToolManager.testMount(aiMcpMountService.prepareMountDraft(mount)); |
| | | if (mount != null && mount.getId() != null) { |
| | | aiMcpMountService.recordTestResult(mount.getId(), |
| | | Boolean.TRUE.equals(result.get("ok")), |
| | | String.valueOf(result.get("message"))); |
| | | } |
| | | return R.ok(result); |
| | | } catch (IllegalArgumentException e) { |
| | | return R.error(e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | @PostMapping("/initDefaults/auth") |
| | | @ManagerAuth |
| | | public R initDefaults() { |
| | | int changed = aiMcpMountService.initDefaultsIfMissing(); |
| | | springAiMcpToolManager.evictCache(); |
| | | return R.ok(changed); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.zy.ai.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableField; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @TableName("sys_ai_mcp_mount") |
| | | public class AiMcpMount implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | private String name; |
| | | |
| | | @TableField("mount_code") |
| | | private String mountCode; |
| | | |
| | | @TableField("transport_type") |
| | | private String transportType; |
| | | |
| | | @TableField("url") |
| | | private String url; |
| | | |
| | | @TableField("request_timeout_ms") |
| | | private Integer requestTimeoutMs; |
| | | |
| | | private Integer priority; |
| | | |
| | | /** |
| | | * 1 启用 0 禁用 |
| | | */ |
| | | private Short status; |
| | | |
| | | @TableField("last_test_ok") |
| | | private Short lastTestOk; |
| | | |
| | | @TableField("last_test_time") |
| | | private Date lastTestTime; |
| | | |
| | | @TableField("last_test_summary") |
| | | private String lastTestSummary; |
| | | |
| | | @TableField("create_time") |
| | | private Date createTime; |
| | | |
| | | @TableField("update_time") |
| | | private Date updateTime; |
| | | |
| | | private String memo; |
| | | |
| | | @TableField(exist = false) |
| | | private Integer toolCount; |
| | | } |
| New file |
| | |
| | | package com.zy.ai.enums; |
| | | |
| | | public enum AiMcpTransportType { |
| | | |
| | | SSE("SSE", "SSE", "/ai/mcp/sse"), |
| | | STREAMABLE_HTTP("STREAMABLE_HTTP", "Streamable HTTP", "/ai/mcp"); |
| | | |
| | | private final String code; |
| | | private final String label; |
| | | private final String defaultEndpoint; |
| | | |
| | | AiMcpTransportType(String code, String label, String defaultEndpoint) { |
| | | this.code = code; |
| | | this.label = label; |
| | | this.defaultEndpoint = defaultEndpoint; |
| | | } |
| | | |
| | | public String getCode() { |
| | | return code; |
| | | } |
| | | |
| | | public String getLabel() { |
| | | return label; |
| | | } |
| | | |
| | | public String getDefaultEndpoint() { |
| | | return defaultEndpoint; |
| | | } |
| | | |
| | | public static AiMcpTransportType ofCode(String code) { |
| | | if (code == null) { |
| | | return null; |
| | | } |
| | | String value = code.trim(); |
| | | for (AiMcpTransportType item : values()) { |
| | | if (item.code.equalsIgnoreCase(value)) { |
| | | return item; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.zy.ai.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.zy.ai.entity.AiMcpMount; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiMcpMountMapper extends BaseMapper<AiMcpMount> { |
| | | } |
| | |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.zy.ai.entity.AiMcpMount; |
| | | import com.zy.ai.enums.AiMcpTransportType; |
| | | import com.zy.ai.service.AiMcpMountService; |
| | | import io.modelcontextprotocol.client.McpClient; |
| | | import io.modelcontextprotocol.client.McpSyncClient; |
| | | import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; |
| | | import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; |
| | | import io.modelcontextprotocol.spec.McpSchema; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; |
| | | import org.springframework.ai.tool.ToolCallback; |
| | | import org.springframework.ai.tool.ToolCallbackProvider; |
| | | import org.springframework.ai.tool.definition.DefaultToolDefinition; |
| | | import org.springframework.ai.tool.definition.ToolDefinition; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import jakarta.annotation.PreDestroy; |
| | | import java.net.URI; |
| | | import java.time.Duration; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.Comparator; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Slf4j |
| | | @Service |
| | | @RequiredArgsConstructor |
| | | public class SpringAiMcpToolManager { |
| | | |
| | | private static final McpSchema.Implementation CLIENT_INFO = |
| | | new McpSchema.Implementation("wcs-ai-assistant", "1.0.0"); |
| | | |
| | | private final Object clientMonitor = new Object(); |
| | | |
| | | @Value("${server.port:9090}") |
| | | private Integer serverPort; |
| | | |
| | | @Value("${server.servlet.context-path:}") |
| | | private String contextPath; |
| | | |
| | | @Value("${spring.ai.mcp.server.sse-endpoint:/ai/mcp/sse}") |
| | | private String sseEndpoint; |
| | | private final AiMcpMountService aiMcpMountService; |
| | | |
| | | @Value("${spring.ai.mcp.server.request-timeout:20s}") |
| | | private Duration requestTimeout; |
| | | private Duration defaultRequestTimeout; |
| | | |
| | | @Value("${app.ai.mcp.client.base-url:}") |
| | | private String configuredBaseUrl; |
| | | |
| | | private volatile ClientSession clientSession; |
| | | private volatile ClientRegistry clientRegistry; |
| | | |
| | | public List<Map<String, Object>> listTools() { |
| | | List<Map<String, Object>> tools = new ArrayList<Map<String, Object>>(); |
| | | for (ToolCallback callback : getToolCallbacks()) { |
| | | if (callback == null || callback.getToolDefinition() == null) { |
| | | continue; |
| | | } |
| | | ToolDefinition definition = callback.getToolDefinition(); |
| | | Map<String, Object> item = new LinkedHashMap<String, Object>(); |
| | | item.put("name", definition.name()); |
| | | item.put("description", definition.description()); |
| | | item.put("inputSchema", parseSchema(definition.inputSchema())); |
| | | tools.add(item); |
| | | } |
| | | tools.sort(new Comparator<Map<String, Object>>() { |
| | | @Override |
| | | public int compare(Map<String, Object> left, Map<String, Object> right) { |
| | | return String.valueOf(left.get("name")).compareTo(String.valueOf(right.get("name"))); |
| | | } |
| | | }); |
| | | return tools; |
| | | return ensureClientRegistry().toolList; |
| | | } |
| | | |
| | | public List<Object> buildOpenAiTools() { |
| | |
| | | } |
| | | |
| | | public Object callTool(String toolName, JSONObject arguments) { |
| | | if (toolName == null || toolName.trim().isEmpty()) { |
| | | if (isBlank(toolName)) { |
| | | throw new IllegalArgumentException("missing tool name"); |
| | | } |
| | | |
| | | ToolCallback callback = findCallback(toolName); |
| | | if (callback == null) { |
| | | MountedTool mountedTool = ensureClientRegistry().toolMap.get(toolName); |
| | | if (mountedTool == null) { |
| | | throw new IllegalArgumentException("tool not found: " + toolName); |
| | | } |
| | | |
| | | String rawResult = callback.call(arguments == null ? "{}" : arguments.toJSONString()); |
| | | return parseToolResult(rawResult); |
| | | try { |
| | | String rawResult = mountedTool.callback.call(arguments == null ? "{}" : arguments.toJSONString()); |
| | | return parseToolResult(rawResult); |
| | | } catch (Exception e) { |
| | | evictCache(); |
| | | throw e; |
| | | } |
| | | } |
| | | |
| | | private ToolCallback findCallback(String toolName) { |
| | | for (ToolCallback callback : getToolCallbacks()) { |
| | | if (callback == null || callback.getToolDefinition() == null) { |
| | | continue; |
| | | } |
| | | if (toolName.equals(callback.getToolDefinition().name())) { |
| | | return callback; |
| | | public Map<String, Object> testMount(AiMcpMount mount) { |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("参数不能为空"); |
| | | } |
| | | MountSession session = null; |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | session = openSession(mount); |
| | | LinkedHashMap<String, Object> result = new LinkedHashMap<String, Object>(); |
| | | result.put("ok", true); |
| | | result.put("message", "连接成功,已发现 " + session.callbacks.length + " 个工具"); |
| | | result.put("latencyMs", System.currentTimeMillis() - start); |
| | | result.put("url", session.url); |
| | | result.put("transportType", mount.getTransportType()); |
| | | result.put("toolCount", session.callbacks.length); |
| | | result.put("toolNames", collectToolNames(session.callbacks, mount, reservedToolNames(mount.getMountCode()))); |
| | | return result; |
| | | } catch (Exception e) { |
| | | TransportTarget target = resolveTransportTarget(mount); |
| | | LinkedHashMap<String, Object> result = new LinkedHashMap<String, Object>(); |
| | | result.put("ok", false); |
| | | result.put("message", safeMessage(e)); |
| | | result.put("latencyMs", System.currentTimeMillis() - start); |
| | | result.put("url", target.url); |
| | | result.put("transportType", mount.getTransportType()); |
| | | result.put("toolCount", 0); |
| | | result.put("toolNames", new ArrayList<String>()); |
| | | return result; |
| | | } finally { |
| | | if (session != null) { |
| | | session.close(); |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private ToolCallback[] getToolCallbacks() { |
| | | public void evictCache() { |
| | | resetClientRegistry(); |
| | | } |
| | | |
| | | private ClientRegistry ensureClientRegistry() { |
| | | ClientRegistry current = clientRegistry; |
| | | if (current != null) { |
| | | return current; |
| | | } |
| | | |
| | | synchronized (clientMonitor) { |
| | | current = clientRegistry; |
| | | if (current != null) { |
| | | return current; |
| | | } |
| | | |
| | | current = buildClientRegistry(); |
| | | clientRegistry = current; |
| | | return current; |
| | | } |
| | | } |
| | | |
| | | private ClientRegistry buildClientRegistry() { |
| | | List<AiMcpMount> mounts = loadEnabledMounts(); |
| | | List<MountSession> sessions = new ArrayList<MountSession>(); |
| | | LinkedHashMap<String, MountedTool> toolMap = new LinkedHashMap<String, MountedTool>(); |
| | | List<Map<String, Object>> toolList = new ArrayList<Map<String, Object>>(); |
| | | |
| | | for (AiMcpMount mount : mounts) { |
| | | if (mount == null) { |
| | | continue; |
| | | } |
| | | MountSession session = null; |
| | | try { |
| | | session = openSession(mount); |
| | | sessions.add(session); |
| | | for (ToolCallback callback : session.callbacks) { |
| | | MountedTool mountedTool = buildMountedTool(mount, callback, toolMap.keySet()); |
| | | toolMap.put(mountedTool.toolName, mountedTool); |
| | | toolList.add(toToolDescriptor(mountedTool)); |
| | | } |
| | | log.info("MCP mount loaded, mountCode={}, transport={}, url={}, tools={}", |
| | | mount.getMountCode(), mount.getTransportType(), session.url, session.callbacks.length); |
| | | } catch (Exception e) { |
| | | log.warn("Failed to load MCP mount, mountCode={}, transport={}, url={}", |
| | | mount.getMountCode(), mount.getTransportType(), resolveUrl(mount), e); |
| | | if (session != null) { |
| | | session.close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | toolList.sort(new Comparator<Map<String, Object>>() { |
| | | @Override |
| | | public int compare(Map<String, Object> left, Map<String, Object> right) { |
| | | return String.valueOf(left.get("name")).compareTo(String.valueOf(right.get("name"))); |
| | | } |
| | | }); |
| | | return new ClientRegistry(sessions, toolMap, toolList); |
| | | } |
| | | |
| | | private List<AiMcpMount> loadEnabledMounts() { |
| | | try { |
| | | ToolCallback[] callbacks = ensureToolCallbackProvider().getToolCallbacks(); |
| | | return callbacks == null ? new ToolCallback[0] : callbacks; |
| | | List<AiMcpMount> mounts = aiMcpMountService.listEnabledOrdered(); |
| | | if (mounts == null || mounts.isEmpty()) { |
| | | aiMcpMountService.initDefaultsIfMissing(); |
| | | mounts = aiMcpMountService.listEnabledOrdered(); |
| | | } |
| | | return mounts == null ? Collections.<AiMcpMount>emptyList() : mounts; |
| | | } catch (Exception e) { |
| | | log.warn("Failed to load MCP tools through SSE client, baseUrl={}, sseEndpoint={}", |
| | | resolveBaseUrl(), resolveClientSseEndpoint(), e); |
| | | resetClientSession(); |
| | | return new ToolCallback[0]; |
| | | log.warn("Failed to query MCP mount configuration", e); |
| | | return Collections.emptyList(); |
| | | } |
| | | } |
| | | |
| | | private MountedTool buildMountedTool(AiMcpMount mount, ToolCallback callback, java.util.Set<String> usedNames) { |
| | | ToolDefinition definition = callback == null ? null : callback.getToolDefinition(); |
| | | if (definition == null || isBlank(definition.name())) { |
| | | throw new IllegalArgumentException("invalid tool definition"); |
| | | } |
| | | |
| | | String originalName = definition.name(); |
| | | String preferredName = mount.getMountCode() + "_" + originalName; |
| | | String finalName = ensureUniqueToolName(preferredName, mount, originalName, usedNames); |
| | | ToolCallback effectiveCallback = originalName.equals(finalName) |
| | | ? callback |
| | | : new MountedToolCallback(finalName, callback); |
| | | |
| | | return new MountedTool(mount, originalName, finalName, effectiveCallback); |
| | | } |
| | | |
| | | private String ensureUniqueToolName(String preferredName, |
| | | AiMcpMount mount, |
| | | String originalName, |
| | | java.util.Set<String> usedNames) { |
| | | if (!usedNames.contains(preferredName)) { |
| | | return preferredName; |
| | | } |
| | | |
| | | String fallbackBase = mount.getMountCode() + "_" + originalName; |
| | | if (!usedNames.contains(fallbackBase)) { |
| | | log.warn("Duplicate MCP tool name detected, fallback rename applied, mountCode={}, originalName={}, finalName={}", |
| | | mount.getMountCode(), originalName, fallbackBase); |
| | | return fallbackBase; |
| | | } |
| | | |
| | | int index = 2; |
| | | String candidate = fallbackBase + "_" + index; |
| | | while (usedNames.contains(candidate)) { |
| | | index++; |
| | | candidate = fallbackBase + "_" + index; |
| | | } |
| | | log.warn("Duplicate MCP tool name detected, numbered rename applied, mountCode={}, originalName={}, finalName={}", |
| | | mount.getMountCode(), originalName, candidate); |
| | | return candidate; |
| | | } |
| | | |
| | | private Map<String, Object> toToolDescriptor(MountedTool mountedTool) { |
| | | ToolDefinition definition = mountedTool.callback.getToolDefinition(); |
| | | Map<String, Object> item = new LinkedHashMap<String, Object>(); |
| | | item.put("name", definition.name()); |
| | | item.put("originalName", mountedTool.originalName); |
| | | item.put("mountCode", mountedTool.mount.getMountCode()); |
| | | item.put("mountName", mountedTool.mount.getName()); |
| | | item.put("transportType", mountedTool.mount.getTransportType()); |
| | | item.put("description", definition.description()); |
| | | item.put("inputSchema", parseSchema(definition.inputSchema())); |
| | | return item; |
| | | } |
| | | |
| | | private List<String> collectToolNames(ToolCallback[] callbacks, AiMcpMount mount, java.util.Set<String> reservedNames) { |
| | | List<String> names = new ArrayList<String>(); |
| | | java.util.Set<String> used = new LinkedHashSet<String>(); |
| | | if (reservedNames != null && !reservedNames.isEmpty()) { |
| | | used.addAll(reservedNames); |
| | | } |
| | | if (callbacks == null) { |
| | | return names; |
| | | } |
| | | for (ToolCallback callback : callbacks) { |
| | | ToolDefinition definition = callback == null ? null : callback.getToolDefinition(); |
| | | if (definition == null || isBlank(definition.name())) { |
| | | continue; |
| | | } |
| | | String preferred = mount.getMountCode() + "_" + definition.name(); |
| | | String finalName = ensureUniqueToolName(preferred, mount, definition.name(), used); |
| | | used.add(finalName); |
| | | names.add(finalName); |
| | | } |
| | | return names; |
| | | } |
| | | |
| | | private java.util.Set<String> reservedToolNames(String mountCode) { |
| | | LinkedHashSet<String> reserved = new LinkedHashSet<String>(); |
| | | ClientRegistry current = clientRegistry; |
| | | if (current == null || current.toolMap == null || current.toolMap.isEmpty()) { |
| | | return reserved; |
| | | } |
| | | for (MountedTool mountedTool : current.toolMap.values()) { |
| | | if (mountedTool == null || mountedTool.mount == null) { |
| | | continue; |
| | | } |
| | | if (mountCode != null && mountCode.equals(mountedTool.mount.getMountCode())) { |
| | | continue; |
| | | } |
| | | reserved.add(mountedTool.toolName); |
| | | } |
| | | return reserved; |
| | | } |
| | | |
| | | private MountSession openSession(AiMcpMount mount) { |
| | | Duration timeout = resolveTimeout(mount); |
| | | TransportTarget target = resolveTransportTarget(mount); |
| | | String baseUrl = target.baseUrl; |
| | | String endpoint = target.endpoint; |
| | | AiMcpTransportType transportType = AiMcpTransportType.ofCode(mount.getTransportType()); |
| | | McpSyncClient syncClient; |
| | | |
| | | if (transportType == AiMcpTransportType.STREAMABLE_HTTP) { |
| | | HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl) |
| | | .endpoint(endpoint) |
| | | .connectTimeout(timeout) |
| | | .build(); |
| | | syncClient = McpClient.sync(transport) |
| | | .clientInfo(CLIENT_INFO) |
| | | .requestTimeout(timeout) |
| | | .initializationTimeout(timeout) |
| | | .build(); |
| | | } else { |
| | | HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUrl) |
| | | .sseEndpoint(endpoint) |
| | | .connectTimeout(timeout) |
| | | .build(); |
| | | syncClient = McpClient.sync(transport) |
| | | .clientInfo(CLIENT_INFO) |
| | | .requestTimeout(timeout) |
| | | .initializationTimeout(timeout) |
| | | .build(); |
| | | } |
| | | |
| | | syncClient.initialize(); |
| | | SyncMcpToolCallbackProvider callbackProvider = new SyncMcpToolCallbackProvider(syncClient); |
| | | ToolCallback[] callbacks = callbackProvider.getToolCallbacks(); |
| | | return new MountSession(mount, syncClient, target.url, callbacks == null ? new ToolCallback[0] : callbacks); |
| | | } |
| | | |
| | | private Duration resolveTimeout(AiMcpMount mount) { |
| | | if (mount != null && mount.getRequestTimeoutMs() != null && mount.getRequestTimeoutMs() > 0) { |
| | | return Duration.ofMillis(mount.getRequestTimeoutMs()); |
| | | } |
| | | return defaultRequestTimeout == null ? Duration.ofSeconds(20) : defaultRequestTimeout; |
| | | } |
| | | |
| | | private String resolveUrl(AiMcpMount mount) { |
| | | return trim(mount == null ? null : mount.getUrl()); |
| | | } |
| | | |
| | | private TransportTarget resolveTransportTarget(AiMcpMount mount) { |
| | | String rawUrl = resolveUrl(mount); |
| | | if (isBlank(rawUrl)) { |
| | | throw new IllegalArgumentException("missing url"); |
| | | } |
| | | try { |
| | | URI finalUri = URI.create(rawUrl); |
| | | String authority = finalUri.getRawAuthority(); |
| | | String scheme = finalUri.getScheme(); |
| | | if (isBlank(scheme) || isBlank(authority)) { |
| | | throw new IllegalArgumentException("invalid MCP url"); |
| | | } |
| | | String baseUrl = scheme + "://" + authority; |
| | | String endpoint = finalUri.getRawPath(); |
| | | if (isBlank(endpoint)) { |
| | | throw new IllegalArgumentException("missing MCP path"); |
| | | } |
| | | if (finalUri.getRawQuery() != null && !finalUri.getRawQuery().isEmpty()) { |
| | | endpoint = endpoint + "?" + finalUri.getRawQuery(); |
| | | } |
| | | return new TransportTarget(rawUrl, baseUrl, endpoint); |
| | | } catch (IllegalArgumentException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new IllegalArgumentException("invalid url: " + rawUrl, e); |
| | | } |
| | | } |
| | | |
| | |
| | | return fallback; |
| | | } |
| | | |
| | | private ToolCallbackProvider ensureToolCallbackProvider() { |
| | | return ensureClientSession().toolCallbackProvider; |
| | | } |
| | | |
| | | private ClientSession ensureClientSession() { |
| | | ClientSession current = clientSession; |
| | | if (current != null) { |
| | | return current; |
| | | } |
| | | |
| | | synchronized (clientMonitor) { |
| | | current = clientSession; |
| | | if (current != null) { |
| | | return current; |
| | | } |
| | | |
| | | String baseUrl = resolveBaseUrl(); |
| | | String clientSseEndpoint = resolveClientSseEndpoint(); |
| | | HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUrl) |
| | | .sseEndpoint(clientSseEndpoint) |
| | | .connectTimeout(requestTimeout) |
| | | .build(); |
| | | |
| | | McpSyncClient syncClient = McpClient.sync(transport) |
| | | .clientInfo(CLIENT_INFO) |
| | | .requestTimeout(requestTimeout) |
| | | .initializationTimeout(requestTimeout) |
| | | .build(); |
| | | syncClient.initialize(); |
| | | |
| | | SyncMcpToolCallbackProvider callbackProvider = new SyncMcpToolCallbackProvider(syncClient); |
| | | current = new ClientSession(syncClient, callbackProvider, baseUrl); |
| | | clientSession = current; |
| | | log.info("Spring AI MCP SSE client initialized, baseUrl={}, sseEndpoint={}, tools={}", |
| | | baseUrl, clientSseEndpoint, current.toolCallbackProvider.getToolCallbacks().length); |
| | | return current; |
| | | } |
| | | } |
| | | |
| | | private void resetClientSession() { |
| | | synchronized (clientMonitor) { |
| | | ClientSession current = clientSession; |
| | | clientSession = null; |
| | | if (current != null) { |
| | | current.close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private String resolveBaseUrl() { |
| | | if (configuredBaseUrl != null && !configuredBaseUrl.trim().isEmpty()) { |
| | | return trimTrailingSlash(configuredBaseUrl.trim()); |
| | | } |
| | | StringBuilder url = new StringBuilder("http://127.0.0.1:"); |
| | | url.append(serverPort == null ? 9090 : serverPort); |
| | | return trimTrailingSlash(url.toString()); |
| | | } |
| | | |
| | | private String resolveClientSseEndpoint() { |
| | | String endpoint = normalizePath(sseEndpoint); |
| | | if (configuredBaseUrl != null && !configuredBaseUrl.trim().isEmpty()) { |
| | | return endpoint; |
| | | } |
| | | String context = normalizeContextPath(contextPath); |
| | | if (context.isEmpty()) { |
| | | return endpoint; |
| | | } |
| | | return context + endpoint; |
| | | } |
| | | |
| | | private String normalizeContextPath(String path) { |
| | | if (path == null || path.trim().isEmpty() || "/".equals(path.trim())) { |
| | | return ""; |
| | | } |
| | | String value = path.trim(); |
| | | if (!value.startsWith("/")) { |
| | | value = "/" + value; |
| | | } |
| | | return trimTrailingSlash(value); |
| | | } |
| | | |
| | | private String normalizePath(String path) { |
| | | if (path == null || path.trim().isEmpty()) { |
| | | if (isBlank(path)) { |
| | | return "/"; |
| | | } |
| | | String value = path.trim(); |
| | |
| | | return value; |
| | | } |
| | | |
| | | private String trimTrailingSlash(String value) { |
| | | if (value == null || value.isEmpty()) { |
| | | return ""; |
| | | private String safeMessage(Throwable throwable) { |
| | | if (throwable == null) { |
| | | return "unknown error"; |
| | | } |
| | | return value.endsWith("/") && value.length() > 1 ? value.substring(0, value.length() - 1) : value; |
| | | if (!isBlank(throwable.getMessage())) { |
| | | return throwable.getMessage(); |
| | | } |
| | | return throwable.getClass().getSimpleName(); |
| | | } |
| | | |
| | | private String trim(String text) { |
| | | return text == null ? null : text.trim(); |
| | | } |
| | | |
| | | private boolean isBlank(String text) { |
| | | return text == null || text.trim().isEmpty(); |
| | | } |
| | | |
| | | private void resetClientRegistry() { |
| | | synchronized (clientMonitor) { |
| | | ClientRegistry current = clientRegistry; |
| | | clientRegistry = null; |
| | | if (current != null) { |
| | | current.close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @PreDestroy |
| | | public void destroy() { |
| | | resetClientSession(); |
| | | resetClientRegistry(); |
| | | } |
| | | |
| | | private static final class ClientSession implements AutoCloseable { |
| | | private static final class ClientRegistry implements AutoCloseable { |
| | | |
| | | private final List<MountSession> sessions; |
| | | private final Map<String, MountedTool> toolMap; |
| | | private final List<Map<String, Object>> toolList; |
| | | |
| | | private ClientRegistry(List<MountSession> sessions, |
| | | Map<String, MountedTool> toolMap, |
| | | List<Map<String, Object>> toolList) { |
| | | this.sessions = sessions; |
| | | this.toolMap = toolMap; |
| | | this.toolList = toolList; |
| | | } |
| | | |
| | | @Override |
| | | public void close() { |
| | | if (sessions == null) { |
| | | return; |
| | | } |
| | | for (MountSession session : sessions) { |
| | | if (session != null) { |
| | | session.close(); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static final class MountSession implements AutoCloseable { |
| | | |
| | | private final AiMcpMount mount; |
| | | private final McpSyncClient syncClient; |
| | | private final ToolCallbackProvider toolCallbackProvider; |
| | | private final String baseUrl; |
| | | private final String url; |
| | | private final ToolCallback[] callbacks; |
| | | |
| | | private ClientSession(McpSyncClient syncClient, ToolCallbackProvider toolCallbackProvider, String baseUrl) { |
| | | private MountSession(AiMcpMount mount, |
| | | McpSyncClient syncClient, |
| | | String url, |
| | | ToolCallback[] callbacks) { |
| | | this.mount = mount; |
| | | this.syncClient = syncClient; |
| | | this.toolCallbackProvider = toolCallbackProvider; |
| | | this.baseUrl = baseUrl; |
| | | this.url = url; |
| | | this.callbacks = callbacks; |
| | | } |
| | | |
| | | @Override |
| | |
| | | try { |
| | | syncClient.closeGracefully(); |
| | | } catch (Exception e) { |
| | | log.debug("Close MCP SSE client gracefully failed, baseUrl={}", baseUrl, e); |
| | | log.debug("Close MCP client gracefully failed, mountCode={}", mount == null ? null : mount.getMountCode(), e); |
| | | } |
| | | try { |
| | | syncClient.close(); |
| | | } catch (Exception e) { |
| | | log.debug("Close MCP SSE client failed, baseUrl={}", baseUrl, e); |
| | | log.debug("Close MCP client failed, mountCode={}", mount == null ? null : mount.getMountCode(), e); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static final class MountedTool { |
| | | |
| | | private final AiMcpMount mount; |
| | | private final String originalName; |
| | | private final String toolName; |
| | | private final ToolCallback callback; |
| | | |
| | | private MountedTool(AiMcpMount mount, String originalName, String toolName, ToolCallback callback) { |
| | | this.mount = mount; |
| | | this.originalName = originalName; |
| | | this.toolName = toolName; |
| | | this.callback = callback; |
| | | } |
| | | } |
| | | |
| | | private static final class MountedToolCallback implements ToolCallback { |
| | | |
| | | private final ToolCallback delegate; |
| | | private final ToolDefinition definition; |
| | | |
| | | private MountedToolCallback(String name, ToolCallback delegate) { |
| | | this.delegate = delegate; |
| | | ToolDefinition source = delegate.getToolDefinition(); |
| | | this.definition = DefaultToolDefinition.builder() |
| | | .name(name) |
| | | .description(source == null ? "" : source.description()) |
| | | .inputSchema(source == null ? "{}" : source.inputSchema()) |
| | | .build(); |
| | | } |
| | | |
| | | @Override |
| | | public ToolDefinition getToolDefinition() { |
| | | return definition; |
| | | } |
| | | |
| | | @Override |
| | | public org.springframework.ai.tool.metadata.ToolMetadata getToolMetadata() { |
| | | return delegate.getToolMetadata(); |
| | | } |
| | | |
| | | @Override |
| | | public String call(String toolInput) { |
| | | return delegate.call(toolInput); |
| | | } |
| | | |
| | | @Override |
| | | public String call(String toolInput, org.springframework.ai.chat.model.ToolContext toolContext) { |
| | | return delegate.call(toolInput, toolContext); |
| | | } |
| | | } |
| | | |
| | | private static final class TransportTarget { |
| | | |
| | | private final String url; |
| | | private final String baseUrl; |
| | | private final String endpoint; |
| | | |
| | | private TransportTarget(String url, String baseUrl, String endpoint) { |
| | | this.url = url; |
| | | this.baseUrl = baseUrl; |
| | | this.endpoint = endpoint; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.zy.ai.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.zy.ai.entity.AiMcpMount; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | public interface AiMcpMountService extends IService<AiMcpMount> { |
| | | |
| | | List<AiMcpMount> listOrdered(); |
| | | |
| | | List<AiMcpMount> listEnabledOrdered(); |
| | | |
| | | AiMcpMount saveMount(AiMcpMount mount); |
| | | |
| | | boolean deleteMount(Long id); |
| | | |
| | | int initDefaultsIfMissing(); |
| | | |
| | | void recordTestResult(Long id, boolean ok, String summary); |
| | | |
| | | AiMcpMount prepareMountDraft(AiMcpMount mount); |
| | | |
| | | List<Map<String, Object>> listSupportedTransportTypes(); |
| | | } |
| New file |
| | |
| | | package com.zy.ai.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.zy.ai.entity.AiMcpMount; |
| | | import com.zy.ai.enums.AiMcpTransportType; |
| | | import com.zy.ai.mapper.AiMcpMountMapper; |
| | | import com.zy.ai.service.AiMcpMountService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.net.URI; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | |
| | | @Slf4j |
| | | @Service("aiMcpMountService") |
| | | public class AiMcpMountServiceImpl extends ServiceImpl<AiMcpMountMapper, AiMcpMount> implements AiMcpMountService { |
| | | |
| | | private static final int DEFAULT_TIMEOUT_MS = 20000; |
| | | private static final int DEFAULT_PRIORITY = 100; |
| | | private static final String DEFAULT_LOCAL_MOUNT_CODE = "wcs_local"; |
| | | |
| | | @Value("${spring.ai.mcp.server.sse-endpoint:/ai/mcp/sse}") |
| | | private String defaultSseEndpoint; |
| | | |
| | | @Value("${spring.ai.mcp.server.streamable-http.mcp-endpoint:/ai/mcp}") |
| | | private String defaultStreamableEndpoint; |
| | | |
| | | @Value("${app.ai.mcp.server.public-base-url:http://127.0.0.1:${server.port:9090}${server.servlet.context-path:}}") |
| | | private String defaultLocalBaseUrl; |
| | | |
| | | @Override |
| | | public List<AiMcpMount> listOrdered() { |
| | | return this.list(new QueryWrapper<AiMcpMount>() |
| | | .orderByAsc("priority") |
| | | .orderByAsc("id")); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiMcpMount> listEnabledOrdered() { |
| | | return this.list(new QueryWrapper<AiMcpMount>() |
| | | .eq("status", 1) |
| | | .orderByAsc("priority") |
| | | .orderByAsc("id")); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public AiMcpMount saveMount(AiMcpMount mount) { |
| | | AiMcpMount candidate = prepareMountDraft(mount); |
| | | Date now = new Date(); |
| | | if (candidate.getId() == null) { |
| | | candidate.setCreateTime(now); |
| | | candidate.setUpdateTime(now); |
| | | this.save(candidate); |
| | | return candidate; |
| | | } |
| | | |
| | | AiMcpMount db = this.getById(candidate.getId()); |
| | | if (db == null) { |
| | | throw new IllegalArgumentException("MCP挂载不存在"); |
| | | } |
| | | candidate.setCreateTime(db.getCreateTime()); |
| | | candidate.setLastTestOk(db.getLastTestOk()); |
| | | candidate.setLastTestTime(db.getLastTestTime()); |
| | | candidate.setLastTestSummary(db.getLastTestSummary()); |
| | | candidate.setUpdateTime(now); |
| | | this.updateById(candidate); |
| | | return candidate; |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public boolean deleteMount(Long id) { |
| | | if (id == null) { |
| | | return false; |
| | | } |
| | | AiMcpMount db = this.getById(id); |
| | | if (db == null) { |
| | | return false; |
| | | } |
| | | return this.removeById(id); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public int initDefaultsIfMissing() { |
| | | AiMcpMount existing = this.getOne(new QueryWrapper<AiMcpMount>() |
| | | .eq("mount_code", DEFAULT_LOCAL_MOUNT_CODE) |
| | | .last("limit 1")); |
| | | if (existing != null) { |
| | | boolean changed = false; |
| | | AiMcpTransportType transportType = AiMcpTransportType.ofCode(existing.getTransportType()); |
| | | if (transportType == null) { |
| | | existing.setTransportType(AiMcpTransportType.SSE.getCode()); |
| | | changed = true; |
| | | } |
| | | String expectedEndpoint = defaultEndpoint(AiMcpTransportType.ofCode(existing.getTransportType())); |
| | | String expectedUrl = defaultUrl(AiMcpTransportType.ofCode(existing.getTransportType())); |
| | | if (isBlank(existing.getUrl()) || isLegacyLocalUrl(existing.getUrl(), expectedEndpoint)) { |
| | | existing.setUrl(expectedUrl); |
| | | changed = true; |
| | | } |
| | | if (changed) { |
| | | existing.setUpdateTime(new Date()); |
| | | this.updateById(existing); |
| | | return 1; |
| | | } |
| | | return 0; |
| | | } |
| | | |
| | | AiMcpMount mount = new AiMcpMount(); |
| | | mount.setName("WCS默认MCP"); |
| | | mount.setMountCode(DEFAULT_LOCAL_MOUNT_CODE); |
| | | mount.setTransportType(AiMcpTransportType.SSE.getCode()); |
| | | mount.setUrl(defaultUrl(AiMcpTransportType.SSE)); |
| | | mount.setRequestTimeoutMs(DEFAULT_TIMEOUT_MS); |
| | | mount.setPriority(0); |
| | | mount.setStatus((short) 1); |
| | | mount.setMemo("默认挂载当前WCS自身的MCP服务,AI助手也通过挂载配置访问本系统工具"); |
| | | Date now = new Date(); |
| | | mount.setCreateTime(now); |
| | | mount.setUpdateTime(now); |
| | | this.save(mount); |
| | | return 1; |
| | | } |
| | | |
| | | @Override |
| | | public void recordTestResult(Long id, boolean ok, String summary) { |
| | | if (id == null) { |
| | | return; |
| | | } |
| | | AiMcpMount db = this.getById(id); |
| | | if (db == null) { |
| | | return; |
| | | } |
| | | db.setLastTestOk(ok ? (short) 1 : (short) 0); |
| | | db.setLastTestTime(new Date()); |
| | | db.setLastTestSummary(cut(trim(summary), 1000)); |
| | | db.setUpdateTime(new Date()); |
| | | this.updateById(db); |
| | | } |
| | | |
| | | @Override |
| | | public AiMcpMount prepareMountDraft(AiMcpMount mount) { |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("参数不能为空"); |
| | | } |
| | | AiMcpMount candidate = new AiMcpMount(); |
| | | candidate.setId(mount.getId()); |
| | | candidate.setName(trim(mount.getName())); |
| | | candidate.setMountCode(normalizeIdentifier(mount.getMountCode())); |
| | | candidate.setTransportType(normalizeTransportType(mount.getTransportType()).getCode()); |
| | | candidate.setUrl(normalizeUrl(mount.getUrl(), AiMcpTransportType.ofCode(candidate.getTransportType()))); |
| | | candidate.setRequestTimeoutMs(normalizeTimeout(mount.getRequestTimeoutMs())); |
| | | candidate.setPriority(mount.getPriority() == null ? DEFAULT_PRIORITY : Math.max(0, mount.getPriority())); |
| | | candidate.setStatus(normalizeShort(mount.getStatus(), (short) 1)); |
| | | candidate.setMemo(cut(trim(mount.getMemo()), 1000)); |
| | | |
| | | if (isBlank(candidate.getMountCode())) { |
| | | throw new IllegalArgumentException("必须填写挂载编码"); |
| | | } |
| | | if (isBlank(candidate.getName())) { |
| | | candidate.setName(candidate.getMountCode()); |
| | | } |
| | | if (isBlank(candidate.getUrl())) { |
| | | throw new IllegalArgumentException("必须填写URL"); |
| | | } |
| | | |
| | | AiMcpMount duplicate = this.getOne(new QueryWrapper<AiMcpMount>() |
| | | .eq("mount_code", candidate.getMountCode()) |
| | | .ne(candidate.getId() != null, "id", candidate.getId()) |
| | | .last("limit 1")); |
| | | if (duplicate != null) { |
| | | throw new IllegalArgumentException("挂载编码已存在:" + candidate.getMountCode()); |
| | | } |
| | | return candidate; |
| | | } |
| | | |
| | | @Override |
| | | public List<java.util.Map<String, Object>> listSupportedTransportTypes() { |
| | | List<java.util.Map<String, Object>> result = new ArrayList<java.util.Map<String, Object>>(); |
| | | for (AiMcpTransportType item : AiMcpTransportType.values()) { |
| | | LinkedHashMap<String, Object> row = new LinkedHashMap<String, Object>(); |
| | | row.put("code", item.getCode()); |
| | | row.put("label", item.getLabel()); |
| | | row.put("defaultUrl", defaultUrl(item)); |
| | | result.add(row); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | private AiMcpTransportType normalizeTransportType(String raw) { |
| | | AiMcpTransportType transportType = AiMcpTransportType.ofCode(raw); |
| | | if (transportType == null) { |
| | | throw new IllegalArgumentException("不支持的MCP传输类型:" + raw); |
| | | } |
| | | return transportType; |
| | | } |
| | | |
| | | private Short normalizeShort(Short value, Short defaultValue) { |
| | | return value == null ? defaultValue : value; |
| | | } |
| | | |
| | | private int normalizeTimeout(Integer requestTimeoutMs) { |
| | | int timeout = requestTimeoutMs == null ? DEFAULT_TIMEOUT_MS : requestTimeoutMs; |
| | | if (timeout < 1000) { |
| | | timeout = 1000; |
| | | } |
| | | if (timeout > 300000) { |
| | | timeout = 300000; |
| | | } |
| | | return timeout; |
| | | } |
| | | |
| | | private String normalizeUrl(String url, AiMcpTransportType transportType) { |
| | | String value = trim(url); |
| | | if (isBlank(value)) { |
| | | return defaultUrl(transportType); |
| | | } |
| | | while (value.endsWith("/") && value.length() > "http://x".length()) { |
| | | value = value.substring(0, value.length() - 1); |
| | | } |
| | | if (!value.startsWith("http://") && !value.startsWith("https://")) { |
| | | throw new IllegalArgumentException("URL必须以 http:// 或 https:// 开头"); |
| | | } |
| | | try { |
| | | URI uri = URI.create(value); |
| | | if (isBlank(uri.getScheme()) || isBlank(uri.getHost())) { |
| | | throw new IllegalArgumentException("URL格式不正确"); |
| | | } |
| | | if (isBlank(uri.getPath()) || "/".equals(uri.getPath())) { |
| | | throw new IllegalArgumentException("URL必须包含完整的MCP路径"); |
| | | } |
| | | return value; |
| | | } catch (IllegalArgumentException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new IllegalArgumentException("URL格式不正确"); |
| | | } |
| | | } |
| | | |
| | | private String defaultEndpoint(AiMcpTransportType transportType) { |
| | | if (transportType == AiMcpTransportType.STREAMABLE_HTTP) { |
| | | String endpoint = trim(defaultStreamableEndpoint); |
| | | if (isBlank(endpoint)) { |
| | | endpoint = AiMcpTransportType.STREAMABLE_HTTP.getDefaultEndpoint(); |
| | | } |
| | | if (!endpoint.startsWith("/")) { |
| | | endpoint = "/" + endpoint; |
| | | } |
| | | return endpoint; |
| | | } |
| | | String endpoint = trim(defaultSseEndpoint); |
| | | if (isBlank(endpoint)) { |
| | | endpoint = AiMcpTransportType.SSE.getDefaultEndpoint(); |
| | | } |
| | | if (!endpoint.startsWith("/")) { |
| | | endpoint = "/" + endpoint; |
| | | } |
| | | return endpoint; |
| | | } |
| | | |
| | | private String resolveDefaultLocalBaseUrl() { |
| | | String value = trim(defaultLocalBaseUrl); |
| | | if (isBlank(value)) { |
| | | return null; |
| | | } |
| | | while (value.endsWith("/")) { |
| | | value = value.substring(0, value.length() - 1); |
| | | } |
| | | return value; |
| | | } |
| | | |
| | | private String defaultUrl(AiMcpTransportType transportType) { |
| | | String baseUrl = resolveDefaultLocalBaseUrl(); |
| | | String endpoint = defaultEndpoint(transportType); |
| | | if (isBlank(baseUrl)) { |
| | | return endpoint; |
| | | } |
| | | return baseUrl + endpoint; |
| | | } |
| | | |
| | | private boolean isLegacyLocalUrl(String url, String expectedEndpoint) { |
| | | String current = trim(url); |
| | | String targetBase = resolveDefaultLocalBaseUrl(); |
| | | if (isBlank(current) || isBlank(targetBase) || isBlank(expectedEndpoint)) { |
| | | return false; |
| | | } |
| | | try { |
| | | URI currentUri = URI.create(current); |
| | | URI targetBaseUri = URI.create(targetBase); |
| | | String currentPath = trim(currentUri.getPath()); |
| | | if (isBlank(currentPath) || "/".equals(currentPath)) { |
| | | return true; |
| | | } |
| | | String expectedPath = expectedEndpoint.startsWith("/") ? expectedEndpoint : ("/" + expectedEndpoint); |
| | | if (currentPath.equals(expectedPath)) { |
| | | return sameOrigin(currentUri, targetBaseUri); |
| | | } |
| | | String targetPath = trim(targetBaseUri.getPath()); |
| | | if (isBlank(targetPath)) { |
| | | return false; |
| | | } |
| | | String expectedFullPath = targetPath + expectedPath; |
| | | return sameOrigin(currentUri, targetBaseUri) && currentPath.equals(expectedFullPath); |
| | | } catch (Exception e) { |
| | | log.warn("Failed to inspect MCP mount url for legacy migration, url={}", current, e); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private boolean sameOrigin(URI left, URI right) { |
| | | return equalsIgnoreCase(left.getScheme(), right.getScheme()) |
| | | && equalsIgnoreCase(left.getHost(), right.getHost()) |
| | | && effectivePort(left) == effectivePort(right); |
| | | } |
| | | |
| | | private boolean equalsIgnoreCase(String left, String right) { |
| | | if (left == null) { |
| | | return right == null; |
| | | } |
| | | return left.equalsIgnoreCase(right); |
| | | } |
| | | |
| | | private int effectivePort(URI uri) { |
| | | if (uri == null) { |
| | | return -1; |
| | | } |
| | | if (uri.getPort() > 0) { |
| | | return uri.getPort(); |
| | | } |
| | | String scheme = uri.getScheme(); |
| | | if ("https".equalsIgnoreCase(scheme)) { |
| | | return 443; |
| | | } |
| | | if ("http".equalsIgnoreCase(scheme)) { |
| | | return 80; |
| | | } |
| | | return -1; |
| | | } |
| | | |
| | | private String normalizeIdentifier(String text) { |
| | | String value = trim(text); |
| | | if (isBlank(value)) { |
| | | return null; |
| | | } |
| | | value = value.replaceAll("[^0-9A-Za-z_]+", "_"); |
| | | value = value.replaceAll("_+", "_"); |
| | | value = value.replaceAll("^_+", "").replaceAll("_+$", ""); |
| | | return value.toLowerCase(); |
| | | } |
| | | |
| | | private String trim(String text) { |
| | | return text == null ? null : text.trim(); |
| | | } |
| | | |
| | | private String cut(String text, int maxLen) { |
| | | if (text == null) { |
| | | return null; |
| | | } |
| | | return text.length() > maxLen ? text.substring(0, maxLen) : text; |
| | | } |
| | | |
| | | private boolean isBlank(String text) { |
| | | return text == null || text.trim().isEmpty(); |
| | | } |
| | | |
| | | } |
| | |
| | | "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。"); |
| | | blocks.put(AiPromptBlockType.TOOL_POLICY, |
| | | "你可以按需调用系统提供的工具以获取实时数据与上下文(工具返回 JSON):\n" + |
| | | "- 任务:task_query\n" + |
| | | "- 设备实时状态:device_get_crn_status / device_get_station_status / device_get_rgv_status\n" + |
| | | "- 日志:log_query\n" + |
| | | "- 设备配置:config_get_device_config\n" + |
| | | "- 系统配置:config_get_system_config\n\n" + |
| | | "- 任务:" + localTool("task_query") + "\n" + |
| | | "- 设备实时状态:" + localTool("device_get_crn_status") + " / " + localTool("device_get_station_status") + " / " + localTool("device_get_rgv_status") + "\n" + |
| | | "- 日志:" + localTool("log_query") + "\n" + |
| | | "- 设备配置:" + localTool("config_get_device_config") + "\n" + |
| | | "- 系统配置:" + localTool("config_get_system_config") + "\n\n" + |
| | | "使用策略:\n" + |
| | | "1)避免臆测。如信息不足,先调用相应工具收集必要数据;可多轮调用。\n" + |
| | | "2)对工具返回的 JSON 先进行结构化归纳,提炼关键字段,再做推理。\n" + |
| | |
| | | "4. **工具返回的数据是事实依据,必须引用其关键信息进行推理。**\n\n" + |
| | | "==================== 可用工具(返回 JSON) ====================\n\n" + |
| | | "【任务相关】\n" + |
| | | "- task_query:按任务号、状态、设备、条码、库位等条件查询任务\n" + |
| | | "- " + localTool("task_query") + ":按任务号、状态、设备、条码、库位等条件查询任务\n" + |
| | | "\n【设备实时状态】\n" + |
| | | "- device_get_crn_status:堆垛机实时状态\n" + |
| | | "- device_get_station_status:工位实时状态\n" + |
| | | "- device_get_rgv_status:RGV / 穿梭车实时状态\n" + |
| | | "- " + localTool("device_get_crn_status") + ":堆垛机实时状态\n" + |
| | | "- " + localTool("device_get_station_status") + ":工位实时状态\n" + |
| | | "- " + localTool("device_get_rgv_status") + ":RGV / 穿梭车实时状态\n" + |
| | | "\n【日志】\n" + |
| | | "- log_query:查询系统/设备日志\n" + |
| | | "- " + localTool("log_query") + ":查询系统/设备日志\n" + |
| | | "\n【配置】\n" + |
| | | "- config_get_device_config:设备配置\n" + |
| | | "- config_get_system_config:系统级配置"); |
| | | "- " + localTool("config_get_device_config") + ":设备配置\n" + |
| | | "- " + localTool("config_get_system_config") + ":系统级配置"); |
| | | blocks.put(AiPromptBlockType.OUTPUT_CONTRACT, |
| | | "==================== 输出要求 ====================\n\n" + |
| | | "- 使用**简洁、明确的中文**\n" + |
| | |
| | | public String getAiDiagnosePromptMcp() { |
| | | String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" + |
| | | "你可以按需调用系统提供的工具以获取实时数据与上下文(工具返回 JSON):\n" + |
| | | "- 任务:task_query\n" + |
| | | "- 设备实时状态:device_get_crn_status / device_get_station_status / device_get_rgv_status\n" + |
| | | "- 日志:log_query\n" + |
| | | "- 设备配置:config_get_device_config\n" + |
| | | "- 系统配置:config_get_system_config\n\n" + |
| | | "- 任务:" + localTool("task_query") + "\n" + |
| | | "- 设备实时状态:" + localTool("device_get_crn_status") + " / " + localTool("device_get_station_status") + " / " + localTool("device_get_rgv_status") + "\n" + |
| | | "- 日志:" + localTool("log_query") + "\n" + |
| | | "- 设备配置:" + localTool("config_get_device_config") + "\n" + |
| | | "- 系统配置:" + localTool("config_get_system_config") + "\n\n" + |
| | | "使用策略:\n" + |
| | | "1)避免臆测。如信息不足,先调用相应工具收集必要数据;可多轮调用。\n" + |
| | | "2)对工具返回的 JSON 先进行结构化归纳,提炼关键字段,再做推理。\n" + |
| | |
| | | "==================== 可用工具(返回 JSON) ====================\n" + |
| | | "\n" + |
| | | "【任务相关】\n" + |
| | | "- task_query —— 按任务号、状态、设备、条码、库位等条件查询任务\n" + |
| | | "- " + localTool("task_query") + " —— 按任务号、状态、设备、条码、库位等条件查询任务\n" + |
| | | "\n" + |
| | | "【设备实时状态】\n" + |
| | | "- device_get_crn_status —— 堆垛机实时状态\n" + |
| | | "- device_get_station_status —— 工位实时状态\n" + |
| | | "- device_get_rgv_status —— RGV / 穿梭车实时状态\n" + |
| | | "- " + localTool("device_get_crn_status") + " —— 堆垛机实时状态\n" + |
| | | "- " + localTool("device_get_station_status") + " —— 工位实时状态\n" + |
| | | "- " + localTool("device_get_rgv_status") + " —— RGV / 穿梭车实时状态\n" + |
| | | "\n" + |
| | | "【日志】\n" + |
| | | "- log_query —— 查询系统/设备日志(按时间/关键字)\n" + |
| | | "- " + localTool("log_query") + " —— 查询系统/设备日志(按时间/关键字)\n" + |
| | | "\n" + |
| | | "【配置】\n" + |
| | | "- config_get_device_config —— 设备配置(启用、模式、策略)\n" + |
| | | "- config_get_system_config —— 系统级调度/策略配置\n" + |
| | | "- " + localTool("config_get_device_config") + " —— 设备配置(启用、模式、策略)\n" + |
| | | "- " + localTool("config_get_system_config") + " —— 系统级调度/策略配置\n" + |
| | | "\n" + |
| | | "==================== 推荐诊断流程 ====================\n" + |
| | | "\n" + |
| | |
| | | "- 若需要进一步数据,请**先调用工具,再继续分析**\n"; |
| | | return prompt; |
| | | } |
| | | |
| | | private String localTool(String name) { |
| | | return "wcs_local_" + name; |
| | | } |
| | | } |
| | |
| | | BASE("base", "layui-icon-file"), |
| | | ORDER("erp", "layui-icon-senior"), |
| | | SENSOR("sensor", "layui-icon-engine"), |
| | | AI_MANAGE("aiManage", "layui-icon-engine"), |
| | | ; |
| | | |
| | | |
| | |
| | | resource.base=Basic Data |
| | | resource.erp=ERP Integration |
| | | resource.sensor=Sensor Devices |
| | | resource.aiManage=AI Management |
| | | resource.ai.llm_config=AI Configuration |
| | | resource.ai.prompt_config=Prompt Center |
| | | resource.ai.mcp_mount=MCP Mounts |
| | | resource.notifyReport.notifyReport=Notification Report |
| | | resource.view=View |
| | | permission.function=Specified Functions |
| | |
| | | resource.base=基础资料 |
| | | resource.erp=ERP对接 |
| | | resource.sensor=感知设备 |
| | | resource.aiManage=AI管理 |
| | | resource.ai.llm_config=AI配置 |
| | | resource.ai.prompt_config=Prompt配置 |
| | | resource.ai.mcp_mount=MCP挂载 |
| | | resource.notifyReport.notifyReport=通知上报 |
| | | resource.view=查看 |
| | | permission.function=指定功能 |
| New file |
| | |
| | | -- AI 管理菜单统一初始化脚本 |
| | | -- 包含: |
| | | -- 1. 创建一级菜单:AI管理 |
| | | -- 2. 将 AI配置 / Prompt配置 / MCP挂载 统一挂到 AI管理 下 |
| | | -- 3. 补齐三个菜单各自的“查看”权限 |
| | | -- |
| | | -- 说明: |
| | | -- 1. 如果这三个菜单此前已经挂在“开发专用”下,本脚本会直接迁移,不会重复插入。 |
| | | -- 2. 执行后请在“角色授权”里给对应角色勾选 AI管理 下的新菜单与查看权限。 |
| | | |
| | | SET @ai_manage_id := COALESCE( |
| | | ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE code = 'aiManage' AND level = 1 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ), |
| | | ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE name = 'AI管理' AND level = 1 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'aiManage', 'AI管理', NULL, 1, 100, 1 |
| | | FROM dual |
| | | WHERE @ai_manage_id IS NULL; |
| | | |
| | | SET @ai_manage_id := COALESCE( |
| | | ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE code = 'aiManage' AND level = 1 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ), |
| | | ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE name = 'AI管理' AND level = 1 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET code = 'aiManage', |
| | | name = 'AI管理', |
| | | resource_id = NULL, |
| | | level = 1, |
| | | sort = 100, |
| | | status = 1 |
| | | WHERE id = @ai_manage_id; |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/llm_config.html', 'AI配置', @ai_manage_id, 2, 1, 1 |
| | | FROM dual |
| | | WHERE @ai_manage_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/llm_config.html' AND level = 2 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = 'AI配置', |
| | | resource_id = @ai_manage_id, |
| | | level = 2, |
| | | sort = 1, |
| | | status = 1 |
| | | WHERE code = 'ai/llm_config.html' AND level = 2; |
| | | |
| | | SET @ai_cfg_id := ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE code = 'ai/llm_config.html' AND level = 2 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/llm_config.html#view', '查看', @ai_cfg_id, 3, 1, 1 |
| | | FROM dual |
| | | WHERE @ai_cfg_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/llm_config.html#view' AND level = 3 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = '查看', |
| | | resource_id = @ai_cfg_id, |
| | | level = 3, |
| | | sort = 1, |
| | | status = 1 |
| | | WHERE code = 'ai/llm_config.html#view' AND level = 3; |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/prompt_config.html', 'Prompt配置', @ai_manage_id, 2, 2, 1 |
| | | FROM dual |
| | | WHERE @ai_manage_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/prompt_config.html' AND level = 2 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = 'Prompt配置', |
| | | resource_id = @ai_manage_id, |
| | | level = 2, |
| | | sort = 2, |
| | | status = 1 |
| | | WHERE code = 'ai/prompt_config.html' AND level = 2; |
| | | |
| | | SET @ai_prompt_id := ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE code = 'ai/prompt_config.html' AND level = 2 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/prompt_config.html#view', '查看', @ai_prompt_id, 3, 1, 1 |
| | | FROM dual |
| | | WHERE @ai_prompt_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/prompt_config.html#view' AND level = 3 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = '查看', |
| | | resource_id = @ai_prompt_id, |
| | | level = 3, |
| | | sort = 1, |
| | | status = 1 |
| | | WHERE code = 'ai/prompt_config.html#view' AND level = 3; |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/mcp_mount.html', 'MCP挂载', @ai_manage_id, 2, 3, 1 |
| | | FROM dual |
| | | WHERE @ai_manage_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/mcp_mount.html' AND level = 2 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = 'MCP挂载', |
| | | resource_id = @ai_manage_id, |
| | | level = 2, |
| | | sort = 3, |
| | | status = 1 |
| | | WHERE code = 'ai/mcp_mount.html' AND level = 2; |
| | | |
| | | SET @ai_mcp_mount_id := ( |
| | | SELECT id |
| | | FROM sys_resource |
| | | WHERE code = 'ai/mcp_mount.html' AND level = 2 |
| | | ORDER BY id |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO sys_resource(code, name, resource_id, level, sort, status) |
| | | SELECT 'ai/mcp_mount.html#view', '查看', @ai_mcp_mount_id, 3, 1, 1 |
| | | FROM dual |
| | | WHERE @ai_mcp_mount_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM sys_resource |
| | | WHERE code = 'ai/mcp_mount.html#view' AND level = 3 |
| | | ); |
| | | |
| | | UPDATE sys_resource |
| | | SET name = '查看', |
| | | resource_id = @ai_mcp_mount_id, |
| | | level = 3, |
| | | sort = 1, |
| | | status = 1 |
| | | WHERE code = 'ai/mcp_mount.html#view' AND level = 3; |
| | | |
| | | SELECT id, code, name, resource_id, level, sort, status |
| | | FROM sys_resource |
| | | WHERE code IN ( |
| | | 'aiManage', |
| | | 'ai/llm_config.html', |
| | | 'ai/llm_config.html#view', |
| | | 'ai/prompt_config.html', |
| | | 'ai/prompt_config.html#view', |
| | | 'ai/mcp_mount.html', |
| | | 'ai/mcp_mount.html#view' |
| | | ) |
| | | ORDER BY level, sort, id; |
| New file |
| | |
| | | -- AI MCP挂载完整初始化脚本 |
| | | -- 包含: |
| | | -- 1. 创建 sys_ai_mcp_mount 表 |
| | | -- 2. 初始化默认本地挂载 wcs_local |
| | | -- |
| | | -- 说明: |
| | | -- 1. 本脚本按当前代码版本生成,挂载配置使用单字段 url,不再拆分 base_url / endpoint。 |
| | | -- 2. 默认挂载地址使用本地开发环境:http://127.0.0.1:9090/wcs |
| | | -- 如果你的部署地址不同,执行后可在“MCP挂载”页面修改。 |
| | | -- 3. 菜单初始化已拆分到独立脚本:20260312_init_ai_management_menu.sql |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_mcp_mount` ( |
| | | `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', |
| | | `name` varchar(128) NOT NULL COMMENT '挂载名称', |
| | | `mount_code` varchar(64) NOT NULL COMMENT '挂载编码', |
| | | `transport_type` varchar(32) NOT NULL COMMENT '传输类型:SSE/STREAMABLE_HTTP', |
| | | `url` varchar(500) NOT NULL COMMENT 'MCP完整URL', |
| | | `request_timeout_ms` int NOT NULL DEFAULT '20000' COMMENT '请求超时毫秒', |
| | | `priority` int NOT NULL DEFAULT '100' COMMENT '优先级,越小越优先', |
| | | `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', |
| | | `last_test_ok` tinyint DEFAULT NULL COMMENT '最近一次测试是否成功:1成功 0失败', |
| | | `last_test_time` datetime DEFAULT NULL COMMENT '最近测试时间', |
| | | `last_test_summary` varchar(1000) DEFAULT NULL COMMENT '最近测试摘要', |
| | | `memo` varchar(1000) DEFAULT NULL COMMENT '备注', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_sys_ai_mcp_mount_code` (`mount_code`), |
| | | KEY `idx_sys_ai_mcp_mount_status_priority` (`status`,`priority`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP挂载配置表'; |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`name`, `mount_code`, `transport_type`, `url`, `request_timeout_ms`, `priority`, `status`, `memo`) |
| | | SELECT |
| | | 'WCS默认MCP', |
| | | 'wcs_local', |
| | | 'SSE', |
| | | 'http://127.0.0.1:9090/wcs/ai/mcp/sse', |
| | | 20000, |
| | | 0, |
| | | 1, |
| | | '默认挂载当前WCS自身的MCP服务,AI助手也通过挂载配置访问本系统工具' |
| | | FROM dual |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_ai_mcp_mount` |
| | | WHERE `mount_code` = 'wcs_local' |
| | | ); |
| | | |
| | | UPDATE `sys_ai_mcp_mount` |
| | | SET |
| | | `name` = CASE WHEN `name` IS NULL OR TRIM(`name`) = '' THEN 'WCS默认MCP' ELSE `name` END, |
| | | `transport_type` = CASE WHEN `transport_type` IS NULL OR TRIM(`transport_type`) = '' THEN 'SSE' ELSE `transport_type` END, |
| | | `url` = CASE |
| | | WHEN `url` IS NULL OR TRIM(`url`) = '' THEN 'http://127.0.0.1:9090/wcs/ai/mcp/sse' |
| | | WHEN TRIM(`url`) = 'http://127.0.0.1:9090' THEN 'http://127.0.0.1:9090/wcs/ai/mcp/sse' |
| | | WHEN TRIM(`url`) = 'http://localhost:9090' THEN 'http://127.0.0.1:9090/wcs/ai/mcp/sse' |
| | | WHEN TRIM(`url`) = 'http://127.0.0.1:9090/wcs' THEN 'http://127.0.0.1:9090/wcs/ai/mcp/sse' |
| | | ELSE `url` |
| | | END, |
| | | `request_timeout_ms` = CASE WHEN `request_timeout_ms` IS NULL OR `request_timeout_ms` < 1000 THEN 20000 ELSE `request_timeout_ms` END, |
| | | `priority` = CASE WHEN `priority` IS NULL THEN 0 ELSE `priority` END, |
| | | `status` = CASE WHEN `status` IS NULL THEN 1 ELSE `status` END, |
| | | `memo` = CASE WHEN `memo` IS NULL OR TRIM(`memo`) = '' THEN '默认挂载当前WCS自身的MCP服务,AI助手也通过挂载配置访问本系统工具' ELSE `memo` END |
| | | WHERE `mount_code` = 'wcs_local'; |
| | | |
| | | SELECT id, name, mount_code, transport_type, url, priority, status |
| | | FROM sys_ai_mcp_mount |
| | | WHERE mount_code = 'wcs_local'; |
| New file |
| | |
| | | UPDATE `sys_ai_prompt_block` |
| | | SET `content` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( |
| | | REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(`content`, |
| | | 'wcs_local_task_query', '__TMP_WCS_LOCAL_TASK_QUERY__'), |
| | | 'wcs_local_device_get_crn_status', '__TMP_WCS_LOCAL_DEVICE_GET_CRN_STATUS__'), |
| | | 'wcs_local_device_get_station_status', '__TMP_WCS_LOCAL_DEVICE_GET_STATION_STATUS__'), |
| | | 'wcs_local_device_get_rgv_status', '__TMP_WCS_LOCAL_DEVICE_GET_RGV_STATUS__'), |
| | | 'wcs_local_log_query', '__TMP_WCS_LOCAL_LOG_QUERY__'), |
| | | 'wcs_local_config_get_device_config', '__TMP_WCS_LOCAL_CONFIG_GET_DEVICE_CONFIG__'), |
| | | 'wcs_local_config_get_system_config', '__TMP_WCS_LOCAL_CONFIG_GET_SYSTEM_CONFIG__'), |
| | | 'task_query', 'wcs_local_task_query'), |
| | | 'device_get_crn_status', 'wcs_local_device_get_crn_status'), |
| | | 'device_get_station_status', 'wcs_local_device_get_station_status'), |
| | | 'device_get_rgv_status', 'wcs_local_device_get_rgv_status'), |
| | | 'log_query', 'wcs_local_log_query'), |
| | | 'config_get_device_config', 'wcs_local_config_get_device_config'), |
| | | 'config_get_system_config', 'wcs_local_config_get_system_config'); |
| | | |
| | | UPDATE `sys_ai_prompt_block` |
| | | SET `content` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(`content`, |
| | | '__TMP_WCS_LOCAL_TASK_QUERY__', 'wcs_local_task_query'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_CRN_STATUS__', 'wcs_local_device_get_crn_status'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_STATION_STATUS__', 'wcs_local_device_get_station_status'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_RGV_STATUS__', 'wcs_local_device_get_rgv_status'), |
| | | '__TMP_WCS_LOCAL_LOG_QUERY__', 'wcs_local_log_query'), |
| | | '__TMP_WCS_LOCAL_CONFIG_GET_DEVICE_CONFIG__', 'wcs_local_config_get_device_config'), |
| | | '__TMP_WCS_LOCAL_CONFIG_GET_SYSTEM_CONFIG__', 'wcs_local_config_get_system_config'); |
| | | |
| | | UPDATE `sys_ai_prompt_template` |
| | | SET `content` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( |
| | | REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(`content`, |
| | | 'wcs_local_task_query', '__TMP_WCS_LOCAL_TASK_QUERY__'), |
| | | 'wcs_local_device_get_crn_status', '__TMP_WCS_LOCAL_DEVICE_GET_CRN_STATUS__'), |
| | | 'wcs_local_device_get_station_status', '__TMP_WCS_LOCAL_DEVICE_GET_STATION_STATUS__'), |
| | | 'wcs_local_device_get_rgv_status', '__TMP_WCS_LOCAL_DEVICE_GET_RGV_STATUS__'), |
| | | 'wcs_local_log_query', '__TMP_WCS_LOCAL_LOG_QUERY__'), |
| | | 'wcs_local_config_get_device_config', '__TMP_WCS_LOCAL_CONFIG_GET_DEVICE_CONFIG__'), |
| | | 'wcs_local_config_get_system_config', '__TMP_WCS_LOCAL_CONFIG_GET_SYSTEM_CONFIG__'), |
| | | 'task_query', 'wcs_local_task_query'), |
| | | 'device_get_crn_status', 'wcs_local_device_get_crn_status'), |
| | | 'device_get_station_status', 'wcs_local_device_get_station_status'), |
| | | 'device_get_rgv_status', 'wcs_local_device_get_rgv_status'), |
| | | 'log_query', 'wcs_local_log_query'), |
| | | 'config_get_device_config', 'wcs_local_config_get_device_config'), |
| | | 'config_get_system_config', 'wcs_local_config_get_system_config'); |
| | | |
| | | UPDATE `sys_ai_prompt_template` |
| | | SET `content` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(`content`, |
| | | '__TMP_WCS_LOCAL_TASK_QUERY__', 'wcs_local_task_query'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_CRN_STATUS__', 'wcs_local_device_get_crn_status'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_STATION_STATUS__', 'wcs_local_device_get_station_status'), |
| | | '__TMP_WCS_LOCAL_DEVICE_GET_RGV_STATUS__', 'wcs_local_device_get_rgv_status'), |
| | | '__TMP_WCS_LOCAL_LOG_QUERY__', 'wcs_local_log_query'), |
| | | '__TMP_WCS_LOCAL_CONFIG_GET_DEVICE_CONFIG__', 'wcs_local_config_get_device_config'), |
| | | '__TMP_WCS_LOCAL_CONFIG_GET_SYSTEM_CONFIG__', 'wcs_local_config_get_system_config'); |
| | |
| | | } |
| | | }, |
| | | methods: { |
| | | stateHost: function() { |
| | | try { |
| | | if (window.top && window.top !== window) { |
| | | return window.top; |
| | | } |
| | | } catch (e) {} |
| | | return window; |
| | | }, |
| | | getAssistantState: function() { |
| | | var host = this.stateHost(); |
| | | if (!host.__WCS_AI_ASSISTANT_STATE__) { |
| | | host.__WCS_AI_ASSISTANT_STATE__ = {}; |
| | | } |
| | | return host.__WCS_AI_ASSISTANT_STATE__; |
| | | }, |
| | | restoreAssistantState: function() { |
| | | var state = this.getAssistantState(); |
| | | return { |
| | | chatId: state.currentChatId || '', |
| | | resetting: state.resetting === true |
| | | }; |
| | | }, |
| | | persistAssistantState: function() { |
| | | var state = this.getAssistantState(); |
| | | state.currentChatId = this.currentChatId || ''; |
| | | state.resetting = this.resetting === true; |
| | | }, |
| | | clearAssistantState: function() { |
| | | var state = this.getAssistantState(); |
| | | state.currentChatId = ''; |
| | | state.resetting = false; |
| | | }, |
| | | authHeaders: function() { |
| | | var token = localStorage.getItem('token'); |
| | | return token ? { token: token } : {}; |
| | |
| | | if (!chatId || this.streaming) return; |
| | | this.currentChatId = chatId; |
| | | this.runTokenUsage = null; |
| | | this.resetting = false; |
| | | this.persistAssistantState(); |
| | | this.switchChat(); |
| | | }, |
| | | switchChat: function() { |
| | |
| | | self.messages = msgs; |
| | | self.pendingText = ''; |
| | | self.resetting = false; |
| | | self.persistAssistantState(); |
| | | self.$nextTick(function() { self.scrollToBottom(true); }); |
| | | }) |
| | | .catch(function() { |
| | |
| | | this.resetting = true; |
| | | this.runTokenUsage = null; |
| | | this.clear(); |
| | | this.persistAssistantState(); |
| | | }, |
| | | deleteChat: function() { |
| | | var self = this; |
| | |
| | | .then(function(r) { return r.json(); }) |
| | | .then(function(ok) { |
| | | if (ok === true) { |
| | | self.clearAssistantState(); |
| | | self.currentChatId = ''; |
| | | self.clear(); |
| | | self.loadChats(false); |
| | |
| | | var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(message); |
| | | if (this.currentChatId) url += '&chatId=' + encodeURIComponent(this.currentChatId); |
| | | if (this.resetting) url += '&reset=true'; |
| | | this.persistAssistantState(); |
| | | |
| | | this.source = new EventSource(url); |
| | | var self = this; |
| | |
| | | }; |
| | | this.userInput = ''; |
| | | this.resetting = false; |
| | | this.persistAssistantState(); |
| | | }, |
| | | start: function() { |
| | | if (this.streaming) return; |
| | |
| | | } |
| | | }, |
| | | mounted: function() { |
| | | this.loadChats(false); |
| | | var restored = this.restoreAssistantState(); |
| | | if (restored.chatId) { |
| | | this.currentChatId = restored.chatId; |
| | | this.resetting = restored.resetting; |
| | | if (this.resetting) { |
| | | this.clear(); |
| | | } else { |
| | | this.switchChat(); |
| | | } |
| | | this.loadChats(true); |
| | | return; |
| | | } |
| | | this.newChat(); |
| | | this.loadChats(true); |
| | | }, |
| | | beforeDestroy: function() { |
| | | this.persistAssistantState(); |
| | | this.stop(true); |
| | | } |
| | | }); |
| | |
| | | </div> |
| | | </div> |
| | | <div class="hero-actions"> |
| | | <el-button size="mini" @click="goPromptCenter">Prompt配置</el-button> |
| | | <el-button type="primary" size="mini" @click="addRoute">新增路由</el-button> |
| | | <el-button size="mini" @click="exportRoutes">导出JSON</el-button> |
| | | <el-button size="mini" @click="triggerImport">导入JSON</el-button> |
| | |
| | | displayRouteName: function(route) { |
| | | var value = route && route.name ? String(route.name) : ''; |
| | | return this.translateLegacyText(value); |
| | | }, |
| | | goPromptCenter: function() { |
| | | window.location.href = 'prompt_config.html'; |
| | | }, |
| | | handleRouteNameInput: function(route, value) { |
| | | if (!route) { |
| New file |
| | |
| | | <!DOCTYPE html> |
| | | <html lang="zh-CN"> |
| | | <head> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <title>MCP挂载</title> |
| | | <link rel="stylesheet" href="../../static/vue/element/element.css" /> |
| | | <style> |
| | | body { |
| | | margin: 0; |
| | | font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; |
| | | background: |
| | | radial-gradient(1200px 500px at 10% -10%, rgba(17, 94, 89, 0.12), transparent 48%), |
| | | radial-gradient(980px 460px at 100% 0%, rgba(12, 120, 168, 0.12), transparent 55%), |
| | | #f3f7fb; |
| | | } |
| | | .container { |
| | | max-width: 1640px; |
| | | margin: 16px auto; |
| | | padding: 0 14px 20px; |
| | | } |
| | | .hero { |
| | | background: linear-gradient(135deg, #0f5964 0%, #176a8b 48%, #289c88 100%); |
| | | color: #fff; |
| | | border-radius: 16px; |
| | | padding: 14px 16px; |
| | | margin-bottom: 12px; |
| | | box-shadow: 0 12px 28px rgba(19, 69, 97, 0.2); |
| | | } |
| | | .hero-top { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | flex-wrap: wrap; |
| | | } |
| | | .hero-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | } |
| | | .hero-title .main { |
| | | font-size: 16px; |
| | | font-weight: 700; |
| | | } |
| | | .hero-title .sub { |
| | | font-size: 12px; |
| | | opacity: 0.92; |
| | | margin-top: 2px; |
| | | } |
| | | .hero-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | .summary-grid { |
| | | margin-top: 10px; |
| | | display: grid; |
| | | grid-template-columns: repeat(5, minmax(0, 1fr)); |
| | | gap: 8px; |
| | | } |
| | | .summary-card { |
| | | border-radius: 10px; |
| | | background: rgba(255, 255, 255, 0.16); |
| | | border: 1px solid rgba(255, 255, 255, 0.24); |
| | | padding: 8px 10px; |
| | | min-height: 56px; |
| | | backdrop-filter: blur(3px); |
| | | } |
| | | .summary-card .k { |
| | | font-size: 11px; |
| | | opacity: 0.88; |
| | | } |
| | | .summary-card .v { |
| | | margin-top: 4px; |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | line-height: 1.1; |
| | | } |
| | | .mount-board { |
| | | border-radius: 14px; |
| | | border: 1px solid #dbe5f2; |
| | | background: |
| | | radial-gradient(760px 220px at -8% 0, rgba(17, 100, 116, 0.06), transparent 52%), |
| | | radial-gradient(720px 240px at 108% 16%, rgba(29, 143, 124, 0.07), transparent 58%), |
| | | #f9fbff; |
| | | box-shadow: 0 8px 30px rgba(26, 53, 84, 0.1); |
| | | padding: 12px; |
| | | min-height: 64vh; |
| | | } |
| | | .mount-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(410px, 1fr)); |
| | | gap: 12px; |
| | | } |
| | | .mount-card { |
| | | border-radius: 14px; |
| | | border: 1px solid #e4ebf5; |
| | | background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); |
| | | box-shadow: 0 10px 24px rgba(14, 38, 68, 0.08); |
| | | padding: 12px; |
| | | transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; |
| | | animation: card-in 0.24s ease both; |
| | | } |
| | | .mount-card:hover { |
| | | transform: translateY(-2px); |
| | | box-shadow: 0 14px 26px rgba(14, 38, 68, 0.12); |
| | | border-color: #d4e2f2; |
| | | } |
| | | .mount-card.disabled { |
| | | opacity: 0.82; |
| | | } |
| | | .mount-head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | .mount-title { |
| | | flex: 1; |
| | | min-width: 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 5px; |
| | | } |
| | | .mount-id-line { |
| | | color: #8294aa; |
| | | font-size: 11px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | .mount-state { |
| | | display: flex; |
| | | gap: 6px; |
| | | flex-wrap: wrap; |
| | | justify-content: flex-end; |
| | | max-width: 48%; |
| | | } |
| | | .mount-fields { |
| | | display: grid; |
| | | grid-template-columns: 1fr 1fr; |
| | | gap: 8px; |
| | | } |
| | | .field-full { |
| | | grid-column: 1 / -1; |
| | | } |
| | | .field-label { |
| | | font-size: 11px; |
| | | color: #6f8094; |
| | | margin-bottom: 4px; |
| | | } |
| | | .switch-line { |
| | | margin-top: 10px; |
| | | display: grid; |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | gap: 8px; |
| | | } |
| | | .switch-item { |
| | | border: 1px solid #e7edf7; |
| | | border-radius: 10px; |
| | | padding: 6px 8px; |
| | | background: #fff; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | font-size: 12px; |
| | | color: #2f3f53; |
| | | } |
| | | .stats-box { |
| | | margin-top: 10px; |
| | | border: 1px solid #e8edf6; |
| | | border-radius: 10px; |
| | | background: linear-gradient(180deg, #fcfdff 0%, #f7faff 100%); |
| | | padding: 8px 10px; |
| | | font-size: 12px; |
| | | color: #4c5f76; |
| | | line-height: 1.6; |
| | | } |
| | | .stats-box .light { |
| | | color: #7f91a8; |
| | | } |
| | | .mount-actions { |
| | | margin-top: 10px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | .action-left, .action-right { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | .empty-shell { |
| | | min-height: 48vh; |
| | | border-radius: 12px; |
| | | border: 1px dashed #cfd8e5; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: #7d8ea4; |
| | | gap: 8px; |
| | | background: rgba(255, 255, 255, 0.55); |
| | | } |
| | | .tool-table-hint { |
| | | color: #74879d; |
| | | font-size: 12px; |
| | | margin-bottom: 8px; |
| | | } |
| | | .mono { |
| | | font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace; |
| | | font-size: 12px; |
| | | } |
| | | @keyframes card-in { |
| | | from { opacity: 0; transform: translateY(8px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | @media (max-width: 1280px) { |
| | | .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| | | .mount-grid { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } |
| | | .mount-fields { grid-template-columns: 1fr; } |
| | | .switch-line { grid-template-columns: 1fr; } |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div id="app" class="container"> |
| | | <div class="hero"> |
| | | <div class="hero-top"> |
| | | <div class="hero-title"> |
| | | <div v-html="headerIcon" style="display:flex;"></div> |
| | | <div> |
| | | <div class="main">MCP挂载中心</div> |
| | | <div class="sub">统一管理内置 WCS MCP 与外部 MCP 服务,AI 助手通过这里聚合工具</div> |
| | | </div> |
| | | </div> |
| | | <div class="hero-actions"> |
| | | <el-button type="primary" size="mini" @click="addMount">新增挂载</el-button> |
| | | <el-button size="mini" @click="loadMounts">刷新挂载</el-button> |
| | | <el-button size="mini" @click="refreshTools">刷新工具缓存</el-button> |
| | | <el-button size="mini" @click="openToolDialog">已加载工具</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="summary-grid"> |
| | | <div class="summary-card"> |
| | | <div class="k">总挂载</div> |
| | | <div class="v">{{ summary.total }}</div> |
| | | </div> |
| | | <div class="summary-card"> |
| | | <div class="k">启用</div> |
| | | <div class="v">{{ summary.enabled }}</div> |
| | | </div> |
| | | <div class="summary-card"> |
| | | <div class="k">测试失败</div> |
| | | <div class="v">{{ summary.failed }}</div> |
| | | </div> |
| | | <div class="summary-card"> |
| | | <div class="k">SSE / HTTP</div> |
| | | <div class="v">{{ summary.sse }} / {{ summary.http }}</div> |
| | | </div> |
| | | <div class="summary-card"> |
| | | <div class="k">已加载工具</div> |
| | | <div class="v">{{ loadedTools.length }}</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="mount-board" v-loading="loading"> |
| | | <div v-if="!mounts || mounts.length === 0" class="empty-shell"> |
| | | <div style="font-size:14px;font-weight:600;">暂无MCP挂载</div> |
| | | <div style="font-size:12px;">初始化后会自动生成一条内置 WCS MCP 挂载,你也可以手动新增外部服务</div> |
| | | <el-button type="primary" size="mini" @click="initDefaults">初始化默认挂载</el-button> |
| | | </div> |
| | | <div v-else class="mount-grid"> |
| | | <div class="mount-card" :class="{disabled: mount.status !== 1}" v-for="(mount, idx) in mounts" :key="mount.id ? ('mount_' + mount.id) : ('new_' + idx)"> |
| | | <div class="mount-head"> |
| | | <div class="mount-title"> |
| | | <el-input v-model="mount.name" size="mini" placeholder="挂载名称"></el-input> |
| | | <div class="mount-id-line">#{{ mount.id || 'new' }} · {{ mount.mountCode || '未命名编码' }} · {{ transportLabel(mount.transportType) }}</div> |
| | | </div> |
| | | <div class="mount-state"> |
| | | <el-tag size="mini" :type="mount.status === 1 ? 'success' : 'info'">{{ mount.status === 1 ? '启用' : '禁用' }}</el-tag> |
| | | <el-tag size="mini" type="danger" v-if="mount.lastTestOk === 0">最近测试失败</el-tag> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="mount-fields"> |
| | | <div> |
| | | <div class="field-label">挂载编码</div> |
| | | <el-input v-model="mount.mountCode" class="mono" size="mini" placeholder="必填,例如 docs_center"></el-input> |
| | | </div> |
| | | <div> |
| | | <div class="field-label">传输类型</div> |
| | | <el-select v-model="mount.transportType" size="mini" style="width:100%;" @change="handleTransportChange(mount)"> |
| | | <el-option v-for="item in transportTypes" :key="item.code" :label="item.label" :value="item.code"></el-option> |
| | | </el-select> |
| | | </div> |
| | | <div class="field-full"> |
| | | <div class="field-label">固定URL</div> |
| | | <el-input v-model="mount.url" class="mono" size="mini" placeholder="必填,例如 http://127.0.0.1:9090/wcs/ai/mcp/sse"></el-input> |
| | | </div> |
| | | <div> |
| | | <div class="field-label">优先级</div> |
| | | <el-input-number v-model="mount.priority" size="mini" :min="0" :max="99999" :controls="false" style="width:100%;"></el-input-number> |
| | | </div> |
| | | <div> |
| | | <div class="field-label">超时毫秒</div> |
| | | <el-input-number v-model="mount.requestTimeoutMs" size="mini" :min="1000" :max="300000" :controls="false" style="width:100%;"></el-input-number> |
| | | </div> |
| | | <div class="field-full"> |
| | | <div class="field-label">备注</div> |
| | | <el-input v-model="mount.memo" type="textarea" :rows="2" placeholder="可填写该挂载的用途、来源或接入说明"></el-input> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="switch-line"> |
| | | <div class="switch-item"> |
| | | <span>启用</span> |
| | | <el-switch v-model="mount.status" :active-value="1" :inactive-value="0"></el-switch> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="stats-box"> |
| | | <div>实际连接地址: <span class="mono">{{ displayUrl(mount) }}</span></div> |
| | | <div class="light">工具名前缀: {{ (mount.mountCode || '-') + '_' }}</div> |
| | | <div class="light">最近测试: {{ formatDateTime(mount.lastTestTime) }} / {{ testStatusLabel(mount.lastTestOk) }}</div> |
| | | <div class="light">测试摘要: {{ mount.lastTestSummary || '-' }}</div> |
| | | </div> |
| | | |
| | | <div class="mount-actions"> |
| | | <div class="action-left"> |
| | | <el-button type="primary" size="mini" @click="saveMount(mount)">保存</el-button> |
| | | <el-button size="mini" :loading="mount.__testing === true" @click="testMount(mount)"> |
| | | {{ mount.__testing === true ? '测试中...' : '测试' }} |
| | | </el-button> |
| | | </div> |
| | | <div class="action-right"> |
| | | <el-button size="mini" plain @click="previewMountTools(mount)">预览工具名</el-button> |
| | | <el-button size="mini" type="danger" plain @click="deleteMount(mount, idx)">删除</el-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-dialog title="已加载工具" :visible.sync="toolDialogVisible" width="88%" :close-on-click-modal="false"> |
| | | <div class="tool-table-hint">这里展示当前 AI 助手实际可用的聚合工具名。所有 MCP 工具统一按挂载编码命名,例如 `wcs_local_task_query`。</div> |
| | | <el-table :data="loadedTools" border stripe height="60vh" v-loading="toolLoading" :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}"> |
| | | <el-table-column prop="name" label="工具名" min-width="200"></el-table-column> |
| | | <el-table-column prop="originalName" label="原始工具名" min-width="180"></el-table-column> |
| | | <el-table-column prop="mountName" label="挂载" min-width="140"></el-table-column> |
| | | <el-table-column prop="mountCode" label="挂载编码" min-width="140"></el-table-column> |
| | | <el-table-column prop="transportType" label="传输" width="150"></el-table-column> |
| | | <el-table-column label="描述" min-width="260"> |
| | | <template slot-scope="scope"> |
| | | <div>{{ scope.row.description || '-' }}</div> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-dialog> |
| | | </div> |
| | | |
| | | <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script> |
| | | <script type="text/javascript" src="../../static/vue/element/element.js"></script> |
| | | <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script> |
| | | <script> |
| | | new Vue({ |
| | | el: '#app', |
| | | data: function() { |
| | | return { |
| | | headerIcon: getAiIconHtml(34, 34), |
| | | loading: false, |
| | | toolLoading: false, |
| | | mounts: [], |
| | | transportTypes: [], |
| | | loadedTools: [], |
| | | toolDialogVisible: false |
| | | }; |
| | | }, |
| | | computed: { |
| | | summary: function() { |
| | | var total = this.mounts.length; |
| | | var enabled = 0; |
| | | var failed = 0; |
| | | var sse = 0; |
| | | var http = 0; |
| | | for (var i = 0; i < this.mounts.length; i++) { |
| | | var x = this.mounts[i] || {}; |
| | | if (x.status === 1) enabled++; |
| | | if (x.lastTestOk === 0) failed++; |
| | | if (x.transportType === 'STREAMABLE_HTTP') http++; |
| | | else sse++; |
| | | } |
| | | return { |
| | | total: total, |
| | | enabled: enabled, |
| | | failed: failed, |
| | | sse: sse, |
| | | http: http |
| | | }; |
| | | } |
| | | }, |
| | | methods: { |
| | | authHeaders: function() { |
| | | return { 'token': localStorage.getItem('token') }; |
| | | }, |
| | | transportLabel: function(code) { |
| | | for (var i = 0; i < this.transportTypes.length; i++) { |
| | | if (this.transportTypes[i].code === code) { |
| | | return this.transportTypes[i].label; |
| | | } |
| | | } |
| | | return code || '-'; |
| | | }, |
| | | transportDefaultUrl: function(code) { |
| | | for (var i = 0; i < this.transportTypes.length; i++) { |
| | | if (this.transportTypes[i].code === code) { |
| | | return this.transportTypes[i].defaultUrl; |
| | | } |
| | | } |
| | | return code === 'STREAMABLE_HTTP' |
| | | ? 'http://127.0.0.1:9090/wcs/ai/mcp' |
| | | : 'http://127.0.0.1:9090/wcs/ai/mcp/sse'; |
| | | }, |
| | | handleTransportChange: function(mount) { |
| | | if (!mount) { |
| | | return; |
| | | } |
| | | if (!mount.url) { |
| | | mount.url = this.transportDefaultUrl(mount.transportType); |
| | | } |
| | | }, |
| | | formatDateTime: function(input) { |
| | | if (!input) return '-'; |
| | | var d = input instanceof Date ? input : new Date(input); |
| | | if (isNaN(d.getTime())) return String(input); |
| | | var pad = function(n) { return n < 10 ? ('0' + n) : String(n); }; |
| | | return d.getFullYear() + '-' |
| | | + pad(d.getMonth() + 1) + '-' |
| | | + pad(d.getDate()) + ' ' |
| | | + pad(d.getHours()) + ':' |
| | | + pad(d.getMinutes()) + ':' |
| | | + pad(d.getSeconds()); |
| | | }, |
| | | testStatusLabel: function(value) { |
| | | if (value === 1) return '成功'; |
| | | if (value === 0) return '失败'; |
| | | return '未测试'; |
| | | }, |
| | | displayUrl: function(mount) { |
| | | if (!mount) return '-'; |
| | | return mount.url || '-'; |
| | | }, |
| | | newMountTemplate: function() { |
| | | return { |
| | | id: null, |
| | | name: '', |
| | | mountCode: '', |
| | | transportType: this.transportTypes.length > 0 ? this.transportTypes[0].code : 'SSE', |
| | | url: this.transportTypes.length > 0 ? this.transportTypes[0].defaultUrl : 'http://127.0.0.1:9090/wcs/ai/mcp/sse', |
| | | requestTimeoutMs: 20000, |
| | | priority: 100, |
| | | status: 1, |
| | | memo: '', |
| | | lastTestOk: null, |
| | | lastTestTime: null, |
| | | lastTestSummary: '' |
| | | }; |
| | | }, |
| | | addMount: function() { |
| | | this.mounts.unshift(this.newMountTemplate()); |
| | | }, |
| | | buildPayload: function(mount) { |
| | | return { |
| | | id: mount.id, |
| | | name: mount.name, |
| | | mountCode: mount.mountCode, |
| | | transportType: mount.transportType, |
| | | url: mount.url, |
| | | requestTimeoutMs: mount.requestTimeoutMs, |
| | | priority: mount.priority, |
| | | status: mount.status, |
| | | memo: mount.memo |
| | | }; |
| | | }, |
| | | loadTransportTypes: function() { |
| | | var self = this; |
| | | return fetch(baseUrl + '/ai/mcp/mount/types/auth', { headers: self.authHeaders() }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (res && res.code === 200 && Array.isArray(res.data)) { |
| | | self.transportTypes = res.data; |
| | | } |
| | | }); |
| | | }, |
| | | loadMounts: function() { |
| | | var self = this; |
| | | self.loading = true; |
| | | fetch(baseUrl + '/ai/mcp/mount/list/auth', { headers: self.authHeaders() }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | self.loading = false; |
| | | if (res && res.code === 200) { |
| | | self.mounts = Array.isArray(res.data) ? res.data : []; |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '加载挂载失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.loading = false; |
| | | self.$message.error('加载挂载失败'); |
| | | }); |
| | | }, |
| | | loadToolList: function(showMessage) { |
| | | var self = this; |
| | | self.toolLoading = true; |
| | | fetch(baseUrl + '/ai/mcp/mount/toolList/auth', { headers: self.authHeaders() }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | self.toolLoading = false; |
| | | if (res && res.code === 200) { |
| | | self.loadedTools = Array.isArray(res.data) ? res.data : []; |
| | | if (showMessage === true) { |
| | | self.$message.success('已刷新工具列表'); |
| | | } |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '加载工具失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.toolLoading = false; |
| | | self.$message.error('加载工具失败'); |
| | | }); |
| | | }, |
| | | openToolDialog: function() { |
| | | this.toolDialogVisible = true; |
| | | this.loadToolList(false); |
| | | }, |
| | | initDefaults: function() { |
| | | var self = this; |
| | | fetch(baseUrl + '/ai/mcp/mount/initDefaults/auth', { |
| | | method: 'POST', |
| | | headers: self.authHeaders() |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (res && res.code === 200) { |
| | | self.$message.success('默认挂载已初始化'); |
| | | self.loadMounts(); |
| | | self.loadToolList(false); |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '初始化失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('初始化失败'); |
| | | }); |
| | | }, |
| | | saveMount: function(mount) { |
| | | var self = this; |
| | | fetch(baseUrl + '/ai/mcp/mount/save/auth', { |
| | | method: 'POST', |
| | | headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()), |
| | | body: JSON.stringify(self.buildPayload(mount)) |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (res && res.code === 200) { |
| | | self.$message.success('保存成功'); |
| | | self.loadMounts(); |
| | | self.loadToolList(false); |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '保存失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('保存失败'); |
| | | }); |
| | | }, |
| | | deleteMount: function(mount, idx) { |
| | | var self = this; |
| | | if (!mount.id) { |
| | | self.mounts.splice(idx, 1); |
| | | return; |
| | | } |
| | | self.$confirm('确定删除该挂载吗?', '提示', { type: 'warning' }).then(function() { |
| | | fetch(baseUrl + '/ai/mcp/mount/delete/auth?id=' + encodeURIComponent(mount.id), { |
| | | method: 'POST', |
| | | headers: self.authHeaders() |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (res && res.code === 200) { |
| | | self.$message.success('删除成功'); |
| | | self.loadMounts(); |
| | | self.loadToolList(false); |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '删除失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('删除失败'); |
| | | }); |
| | | }).catch(function(){}); |
| | | }, |
| | | testMount: function(mount) { |
| | | var self = this; |
| | | if (mount.__testing === true) return; |
| | | self.$set(mount, '__testing', true); |
| | | fetch(baseUrl + '/ai/mcp/mount/test/auth', { |
| | | method: 'POST', |
| | | headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()), |
| | | body: JSON.stringify(self.buildPayload(mount)) |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (!res || res.code !== 200) { |
| | | self.$message.error((res && res.msg) ? res.msg : '测试失败'); |
| | | return; |
| | | } |
| | | var data = res.data || {}; |
| | | var ok = data.ok === true; |
| | | var msg = '' |
| | | + '挂载: ' + (mount.name || '-') + '\n' |
| | | + 'URL: ' + (data.url || '-') + '\n' |
| | | + '传输: ' + (data.transportType || '-') + '\n' |
| | | + '耗时: ' + (data.latencyMs != null ? data.latencyMs : '-') + ' ms\n' |
| | | + '工具数: ' + (data.toolCount != null ? data.toolCount : 0) + '\n' |
| | | + '结果: ' + (data.message || '-') + '\n' |
| | | + '工具名: ' + ((data.toolNames || []).join(', ') || '-'); |
| | | self.$alert(msg, ok ? '连接成功' : '连接失败', { |
| | | confirmButtonText: '确定', |
| | | type: ok ? 'success' : 'error' |
| | | }); |
| | | mount.lastTestOk = ok ? 1 : 0; |
| | | mount.lastTestTime = new Date().getTime(); |
| | | mount.lastTestSummary = data.message || ''; |
| | | if (mount.id) { |
| | | self.loadMounts(); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('测试失败'); |
| | | }) |
| | | .finally(function(){ |
| | | self.$set(mount, '__testing', false); |
| | | }); |
| | | }, |
| | | previewMountTools: function(mount) { |
| | | var self = this; |
| | | fetch(baseUrl + '/ai/mcp/mount/test/auth', { |
| | | method: 'POST', |
| | | headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()), |
| | | body: JSON.stringify(self.buildPayload(mount)) |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (!res || res.code !== 200) { |
| | | self.$message.error((res && res.msg) ? res.msg : '预览失败'); |
| | | return; |
| | | } |
| | | var data = res.data || {}; |
| | | var names = Array.isArray(data.toolNames) ? data.toolNames : []; |
| | | self.$alert(names.length > 0 ? names.join('\n') : '未发现工具', '工具名预览', { |
| | | confirmButtonText: '确定' |
| | | }); |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('预览失败'); |
| | | }); |
| | | }, |
| | | refreshTools: function() { |
| | | var self = this; |
| | | fetch(baseUrl + '/ai/mcp/mount/refresh/auth', { |
| | | method: 'POST', |
| | | headers: self.authHeaders() |
| | | }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(res){ |
| | | if (res && res.code === 200) { |
| | | self.loadedTools = Array.isArray(res.data) ? res.data : []; |
| | | self.$message.success('工具缓存已刷新'); |
| | | } else { |
| | | self.$message.error((res && res.msg) ? res.msg : '刷新失败'); |
| | | } |
| | | }) |
| | | .catch(function(){ |
| | | self.$message.error('刷新失败'); |
| | | }); |
| | | } |
| | | }, |
| | | mounted: function() { |
| | | var self = this; |
| | | Promise.all([ |
| | | self.loadTransportTypes(), |
| | | self.loadMounts(), |
| | | self.loadToolList(false) |
| | | ]).then(function(){ |
| | | if (!self.mounts || self.mounts.length === 0) { |
| | | return; |
| | | } |
| | | for (var i = 0; i < self.mounts.length; i++) { |
| | | if (!self.mounts[i].url) { |
| | | self.mounts[i].url = self.transportDefaultUrl(self.mounts[i].transportType); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | </script> |
| | | </body> |
| | | </html> |
| | |
| | | </div> |
| | | </div> |
| | | <div class="hero-actions"> |
| | | <el-button size="mini" @click="goLlmConfig">LLM配置</el-button> |
| | | <el-button size="mini" @click="restoreDefaults">补齐默认Prompt</el-button> |
| | | <el-button size="mini" @click="reloadAll">刷新</el-button> |
| | | </div> |
| | |
| | | if (list[i].published !== 1) count++; |
| | | } |
| | | return count; |
| | | }, |
| | | goLlmConfig: function() { |
| | | window.location.href = 'llm_config.html'; |
| | | }, |
| | | reloadAll: function() { |
| | | var self = this; |