From 1b8a4677f362d234d834120deac4880d7ae89a50 Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期四, 12 三月 2026 17:03:59 +0800
Subject: [PATCH] #
---
src/main/java/com/zy/ai/entity/AiMcpMount.java | 61 +
src/main/webapp/views/ai/diagnosis.html | 55 +
src/main/java/com/zy/ai/enums/AiMcpTransportType.java | 42 +
src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java | 574 ++++++++++---
src/main/java/com/zy/ai/utils/AiPromptUtils.java | 52
src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql | 55 +
src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java | 11
src/main/java/com/zy/ai/service/AiMcpMountService.java | 26
src/main/webapp/views/ai/llm_config.html | 4
src/main/java/com/zy/ai/config/AiMcpMountInitializer.java | 50 +
src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java | 374 +++++++++
src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql | 69 +
src/main/java/com/zy/common/model/enums/HtmlNavIconType.java | 1
src/main/resources/i18n/zh-CN/messages.properties | 3
src/main/java/com/zy/ai/controller/AiMcpMountController.java | 97 ++
/dev/null | 42 -
src/main/webapp/views/ai/prompt_config.html | 4
src/main/webapp/views/ai/mcp_mount.html | 717 +++++++++++++++++
src/main/resources/i18n/en-US/messages.properties | 3
src/main/resources/sql/20260312_init_ai_management_menu.sql | 202 ++++
20 files changed, 2,218 insertions(+), 224 deletions(-)
diff --git a/src/main/java/com/zy/ai/config/AiMcpMountInitializer.java b/src/main/java/com/zy/ai/config/AiMcpMountInitializer.java
new file mode 100644
index 0000000..f76013a
--- /dev/null
+++ b/src/main/java/com/zy/ai/config/AiMcpMountInitializer.java
@@ -0,0 +1,50 @@
+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;
+ }
+}
diff --git a/src/main/java/com/zy/ai/controller/AiMcpMountController.java b/src/main/java/com/zy/ai/controller/AiMcpMountController.java
new file mode 100644
index 0000000..f51ac70
--- /dev/null
+++ b/src/main/java/com/zy/ai/controller/AiMcpMountController.java
@@ -0,0 +1,97 @@
+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);
+ }
+}
diff --git a/src/main/java/com/zy/ai/entity/AiMcpMount.java b/src/main/java/com/zy/ai/entity/AiMcpMount.java
new file mode 100644
index 0000000..a35b2d7
--- /dev/null
+++ b/src/main/java/com/zy/ai/entity/AiMcpMount.java
@@ -0,0 +1,61 @@
+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;
+}
diff --git a/src/main/java/com/zy/ai/enums/AiMcpTransportType.java b/src/main/java/com/zy/ai/enums/AiMcpTransportType.java
new file mode 100644
index 0000000..a15e318
--- /dev/null
+++ b/src/main/java/com/zy/ai/enums/AiMcpTransportType.java
@@ -0,0 +1,42 @@
+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;
+ }
+}
diff --git a/src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java b/src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java
new file mode 100644
index 0000000..934d1c3
--- /dev/null
+++ b/src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java
@@ -0,0 +1,11 @@
+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> {
+}
diff --git a/src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java b/src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java
index a40a717..cb91533 100644
--- a/src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java
+++ b/src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java
@@ -2,73 +2,52 @@
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() {
@@ -97,40 +76,301 @@
}
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);
}
}
@@ -163,89 +403,8 @@
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();
@@ -255,28 +414,81 @@
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
@@ -284,13 +496,77 @@
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;
+ }
+ }
}
diff --git a/src/main/java/com/zy/ai/service/AiMcpMountService.java b/src/main/java/com/zy/ai/service/AiMcpMountService.java
new file mode 100644
index 0000000..7ba435c
--- /dev/null
+++ b/src/main/java/com/zy/ai/service/AiMcpMountService.java
@@ -0,0 +1,26 @@
+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();
+}
diff --git a/src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java b/src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java
new file mode 100644
index 0000000..ccc49d6
--- /dev/null
+++ b/src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java
@@ -0,0 +1,374 @@
+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鑷韩鐨凪CP鏈嶅姟锛孉I鍔╂墜涔熼�氳繃鎸傝浇閰嶇疆璁块棶鏈郴缁熷伐鍏�");
+ 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蹇呴』鍖呭惈瀹屾暣鐨凪CP璺緞");
+ }
+ 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();
+ }
+
+}
diff --git a/src/main/java/com/zy/ai/utils/AiPromptUtils.java b/src/main/java/com/zy/ai/utils/AiPromptUtils.java
index e0a0e88..fc442f0 100644
--- a/src/main/java/com/zy/ai/utils/AiPromptUtils.java
+++ b/src/main/java/com/zy/ai/utils/AiPromptUtils.java
@@ -42,11 +42,11 @@
"浣犳槸涓�鍚嶈祫娣� WCS锛堜粨鍌ㄦ帶鍒剁郴缁燂級涓庤嚜鍔ㄥ寲绔嬪簱涓撳锛岀啛鎮夛細鍫嗗灈鏈恒�佽緭閫佺嚎銆佹彁鍗囨満銆佺┛姊溅绛夎澶囩殑浠诲姟鍒嗛厤鍜岃繍琛岄�昏緫锛屼篃鐔熸倝甯歌鐨勭郴缁熷崱姝汇�佷换鍔′笉鎵ц銆佽澶囩┖闂蹭絾鏃犱换鍔$瓑闂妯″紡銆�");
blocks.put(AiPromptBlockType.TOOL_POLICY,
"浣犲彲浠ユ寜闇�璋冪敤绯荤粺鎻愪緵鐨勫伐鍏蜂互鑾峰彇瀹炴椂鏁版嵁涓庝笂涓嬫枃锛堝伐鍏疯繑鍥� JSON锛夛細\n" +
- "- 浠诲姟锛歵ask_query\n" +
- "- 璁惧瀹炴椂鐘舵�侊細device_get_crn_status / device_get_station_status / device_get_rgv_status\n" +
- "- 鏃ュ織锛歭og_query\n" +
- "- 璁惧閰嶇疆锛歝onfig_get_device_config\n" +
- "- 绯荤粺閰嶇疆锛歝onfig_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" +
@@ -82,16 +82,16 @@
"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锛歊GV / 绌挎杞﹀疄鏃剁姸鎬乗n" +
+ "- " + localTool("device_get_crn_status") + "锛氬爢鍨涙満瀹炴椂鐘舵�乗n" +
+ "- " + localTool("device_get_station_status") + "锛氬伐浣嶅疄鏃剁姸鎬乗n" +
+ "- " + localTool("device_get_rgv_status") + "锛歊GV / 绌挎杞﹀疄鏃剁姸鎬乗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" +
@@ -147,11 +147,11 @@
public String getAiDiagnosePromptMcp() {
String prompt = "浣犳槸涓�鍚嶈祫娣� WCS锛堜粨鍌ㄦ帶鍒剁郴缁燂級涓庤嚜鍔ㄥ寲绔嬪簱涓撳锛岀啛鎮夛細鍫嗗灈鏈恒�佽緭閫佺嚎銆佹彁鍗囨満銆佺┛姊溅绛夎澶囩殑浠诲姟鍒嗛厤鍜岃繍琛岄�昏緫锛屼篃鐔熸倝甯歌鐨勭郴缁熷崱姝汇�佷换鍔′笉鎵ц銆佽澶囩┖闂蹭絾鏃犱换鍔$瓑闂妯″紡銆俓n\n" +
"浣犲彲浠ユ寜闇�璋冪敤绯荤粺鎻愪緵鐨勫伐鍏蜂互鑾峰彇瀹炴椂鏁版嵁涓庝笂涓嬫枃锛堝伐鍏疯繑鍥� JSON锛夛細\n" +
- "- 浠诲姟锛歵ask_query\n" +
- "- 璁惧瀹炴椂鐘舵�侊細device_get_crn_status / device_get_station_status / device_get_rgv_status\n" +
- "- 鏃ュ織锛歭og_query\n" +
- "- 璁惧閰嶇疆锛歝onfig_get_device_config\n" +
- "- 绯荤粺閰嶇疆锛歝onfig_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" +
@@ -193,19 +193,19 @@
"==================== 鍙敤宸ュ叿锛堣繑鍥� 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" +
@@ -235,4 +235,8 @@
"- 鑻ラ渶瑕佽繘涓�姝ユ暟鎹紝璇�**鍏堣皟鐢ㄥ伐鍏凤紝鍐嶇户缁垎鏋�**\n";
return prompt;
}
+
+ private String localTool(String name) {
+ return "wcs_local_" + name;
+ }
}
diff --git a/src/main/java/com/zy/common/model/enums/HtmlNavIconType.java b/src/main/java/com/zy/common/model/enums/HtmlNavIconType.java
index 77c4569..d1ca262 100644
--- a/src/main/java/com/zy/common/model/enums/HtmlNavIconType.java
+++ b/src/main/java/com/zy/common/model/enums/HtmlNavIconType.java
@@ -14,6 +14,7 @@
BASE("base", "layui-icon-file"),
ORDER("erp", "layui-icon-senior"),
SENSOR("sensor", "layui-icon-engine"),
+ AI_MANAGE("aiManage", "layui-icon-engine"),
;
diff --git a/src/main/resources/i18n/en-US/messages.properties b/src/main/resources/i18n/en-US/messages.properties
index d0b1d8e..e961a33 100644
--- a/src/main/resources/i18n/en-US/messages.properties
+++ b/src/main/resources/i18n/en-US/messages.properties
@@ -133,7 +133,10 @@
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
diff --git a/src/main/resources/i18n/zh-CN/messages.properties b/src/main/resources/i18n/zh-CN/messages.properties
index d31f592..bfabe23 100644
--- a/src/main/resources/i18n/zh-CN/messages.properties
+++ b/src/main/resources/i18n/zh-CN/messages.properties
@@ -133,7 +133,10 @@
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=鎸囧畾鍔熻兘
diff --git a/src/main/resources/sql/20260303_add_ai_config_menu.sql b/src/main/resources/sql/20260303_add_ai_config_menu.sql
deleted file mode 100644
index 58c9a97..0000000
--- a/src/main/resources/sql/20260303_add_ai_config_menu.sql
+++ /dev/null
@@ -1,46 +0,0 @@
--- 灏� AI閰嶇疆 鑿滃崟鎸傝浇鍒帮細寮�鍙戜笓鐢� -> AI閰嶇疆
--- 璇存槑锛氭湰绯荤粺鑿滃崟鏉ユ簮浜� sys_resource锛屾墽琛屾湰鑴氭湰鍚庤鍦ㄢ�滆鑹叉巿鏉冣�濋噷缁欏搴旇鑹插嬀閫夋柊鑿滃崟銆�
-
--- 1) 瀹氫綅鈥滃紑鍙戜笓鐢ㄢ�濅竴绾ц彍鍗�
-SET @dev_parent_id := (
- SELECT id
- FROM sys_resource
- WHERE name = '寮�鍙戜笓鐢�' AND level = 1
- ORDER BY id
- LIMIT 1
-);
-
--- 2) 鏂板浜岀骇鑿滃崟锛欰I閰嶇疆锛堥〉闈級
-INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
-SELECT 'ai/llm_config.html', 'AI閰嶇疆', @dev_parent_id, 2, 999, 1
-FROM dual
-WHERE @dev_parent_id IS NOT NULL
- AND NOT EXISTS (
- SELECT 1
- FROM sys_resource
- WHERE code = 'ai/llm_config.html' AND level = 2
- );
-
--- 3) 鏂板涓夌骇鎸夐挳鏉冮檺锛氭煡鐪嬶紙鐢ㄤ簬瑙掕壊缁嗙矑搴︽巿鏉冿級
-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
- );
-
--- 鍙�夋鏌�
-SELECT id, code, name, resource_id, level, sort, status
-FROM sys_resource
-WHERE code IN ('ai/llm_config.html', 'ai/llm_config.html#view');
diff --git a/src/main/resources/sql/20260312_add_ai_prompt_menu.sql b/src/main/resources/sql/20260312_add_ai_prompt_menu.sql
deleted file mode 100644
index 7d6aa10..0000000
--- a/src/main/resources/sql/20260312_add_ai_prompt_menu.sql
+++ /dev/null
@@ -1,42 +0,0 @@
--- 灏� Prompt閰嶇疆 鑿滃崟鎸傝浇鍒帮細寮�鍙戜笓鐢� -> Prompt閰嶇疆
--- 鎵ц鍚庤鍦ㄢ�滆鑹叉巿鏉冣�濋噷缁欏搴旇鑹插嬀閫夋柊鑿滃崟銆�
-
-SET @dev_parent_id := (
- SELECT id
- FROM sys_resource
- WHERE name = '寮�鍙戜笓鐢�' AND level = 1
- ORDER BY id
- LIMIT 1
-);
-
-INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
-SELECT 'ai/prompt_config.html', 'Prompt閰嶇疆', @dev_parent_id, 2, 1000, 1
-FROM dual
-WHERE @dev_parent_id IS NOT NULL
- AND NOT EXISTS (
- SELECT 1
- FROM sys_resource
- 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
- );
-
-SELECT id, code, name, resource_id, level, sort, status
-FROM sys_resource
-WHERE code IN ('ai/prompt_config.html', 'ai/prompt_config.html#view');
diff --git a/src/main/resources/sql/20260312_init_ai_management_menu.sql b/src/main/resources/sql/20260312_init_ai_management_menu.sql
new file mode 100644
index 0000000..14cb532
--- /dev/null
+++ b/src/main/resources/sql/20260312_init_ai_management_menu.sql
@@ -0,0 +1,202 @@
+-- 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;
diff --git a/src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql b/src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql
new file mode 100644
index 0000000..ab37717
--- /dev/null
+++ b/src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql
@@ -0,0 +1,69 @@
+-- AI MCP鎸傝浇瀹屾暣鍒濆鍖栬剼鏈�
+-- 鍖呭惈锛�
+-- 1. 鍒涘缓 sys_ai_mcp_mount 琛�
+-- 2. 鍒濆鍖栭粯璁ゆ湰鍦版寕杞� wcs_local
+--
+-- 璇存槑锛�
+-- 1. 鏈剼鏈寜褰撳墠浠g爜鐗堟湰鐢熸垚锛屾寕杞介厤缃娇鐢ㄥ崟瀛楁 url锛屼笉鍐嶆媶鍒� base_url / endpoint銆�
+-- 2. 榛樿鎸傝浇鍦板潃浣跨敤鏈湴寮�鍙戠幆澧冿細http://127.0.0.1:9090/wcs
+-- 濡傛灉浣犵殑閮ㄧ讲鍦板潃涓嶅悓锛屾墽琛屽悗鍙湪鈥淢CP鎸傝浇鈥濋〉闈慨鏀广��
+-- 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 '浼犺緭绫诲瀷锛歋SE/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鑷韩鐨凪CP鏈嶅姟锛孉I鍔╂墜涔熼�氳繃鎸傝浇閰嶇疆璁块棶鏈郴缁熷伐鍏�'
+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鑷韩鐨凪CP鏈嶅姟锛孉I鍔╂墜涔熼�氳繃鎸傝浇閰嶇疆璁块棶鏈郴缁熷伐鍏�' 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';
diff --git a/src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql b/src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql
new file mode 100644
index 0000000..3e2f99c
--- /dev/null
+++ b/src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql
@@ -0,0 +1,55 @@
+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');
diff --git a/src/main/webapp/views/ai/diagnosis.html b/src/main/webapp/views/ai/diagnosis.html
index c613791..d8ed943 100644
--- a/src/main/webapp/views/ai/diagnosis.html
+++ b/src/main/webapp/views/ai/diagnosis.html
@@ -747,6 +747,38 @@
}
},
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 } : {};
@@ -850,6 +882,8 @@
if (!chatId || this.streaming) return;
this.currentChatId = chatId;
this.runTokenUsage = null;
+ this.resetting = false;
+ this.persistAssistantState();
this.switchChat();
},
switchChat: function() {
@@ -883,6 +917,7 @@
self.messages = msgs;
self.pendingText = '';
self.resetting = false;
+ self.persistAssistantState();
self.$nextTick(function() { self.scrollToBottom(true); });
})
.catch(function() {
@@ -896,6 +931,7 @@
this.resetting = true;
this.runTokenUsage = null;
this.clear();
+ this.persistAssistantState();
},
deleteChat: function() {
var self = this;
@@ -907,6 +943,7 @@
.then(function(r) { return r.json(); })
.then(function(ok) {
if (ok === true) {
+ self.clearAssistantState();
self.currentChatId = '';
self.clear();
self.loadChats(false);
@@ -966,6 +1003,7 @@
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;
@@ -993,6 +1031,7 @@
};
this.userInput = '';
this.resetting = false;
+ this.persistAssistantState();
},
start: function() {
if (this.streaming) return;
@@ -1087,9 +1126,23 @@
}
},
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);
}
});
diff --git a/src/main/webapp/views/ai/llm_config.html b/src/main/webapp/views/ai/llm_config.html
index ba79721..edf9f45 100644
--- a/src/main/webapp/views/ai/llm_config.html
+++ b/src/main/webapp/views/ai/llm_config.html
@@ -268,7 +268,6 @@
</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>
@@ -540,9 +539,6 @@
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) {
diff --git a/src/main/webapp/views/ai/mcp_mount.html b/src/main/webapp/views/ai/mcp_mount.html
new file mode 100644
index 0000000..cc5985e
--- /dev/null
+++ b/src/main/webapp/views/ai/mcp_mount.html
@@ -0,0 +1,717 @@
+<!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 鏈嶅姟锛孉I 鍔╂墜閫氳繃杩欓噷鑱氬悎宸ュ叿</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>
diff --git a/src/main/webapp/views/ai/prompt_config.html b/src/main/webapp/views/ai/prompt_config.html
index 6125dd8..4ade518 100644
--- a/src/main/webapp/views/ai/prompt_config.html
+++ b/src/main/webapp/views/ai/prompt_config.html
@@ -365,7 +365,6 @@
</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>
@@ -849,9 +848,6 @@
if (list[i].published !== 1) count++;
}
return count;
- },
- goLlmConfig: function() {
- window.location.href = 'llm_config.html';
},
reloadAll: function() {
var self = this;
--
Gitblit v1.9.1