#
Junjie
3 天以前 1b8a4677f362d234d834120deac4880d7ae89a50
#
2个文件已删除
8个文件已修改
11个文件已添加
2488 ■■■■ 已修改文件
src/main/java/com/zy/ai/config/AiMcpMountInitializer.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/AiMcpMountController.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/entity/AiMcpMount.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/enums/AiMcpTransportType.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mcp/service/SpringAiMcpToolManager.java 574 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AiMcpMountService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiPromptUtils.java 52 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/model/enums/HtmlNavIconType.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/messages.properties 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/zh-CN/messages.properties 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260303_add_ai_config_menu.sql 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260312_add_ai_prompt_menu.sql 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260312_init_ai_management_menu.sql 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/llm_config.html 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/mcp_mount.html 717 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/prompt_config.html 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/config/AiMcpMountInitializer.java
New file
@@ -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;
    }
}
src/main/java/com/zy/ai/controller/AiMcpMountController.java
New file
@@ -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);
    }
}
src/main/java/com/zy/ai/entity/AiMcpMount.java
New file
@@ -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;
}
src/main/java/com/zy/ai/enums/AiMcpTransportType.java
New file
@@ -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;
    }
}
src/main/java/com/zy/ai/mapper/AiMcpMountMapper.java
New file
@@ -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> {
}
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;
        }
    }
}
src/main/java/com/zy/ai/service/AiMcpMountService.java
New file
@@ -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();
}
src/main/java/com/zy/ai/service/impl/AiMcpMountServiceImpl.java
New file
@@ -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自身的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();
    }
}
src/main/java/com/zy/ai/utils/AiPromptUtils.java
@@ -42,11 +42,11 @@
                    "你是一名资深 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" +
@@ -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: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" +
@@ -147,11 +147,11 @@
    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" +
@@ -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;
    }
}
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"),
    ;
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
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=指定功能
src/main/resources/sql/20260303_add_ai_config_menu.sql
File was deleted
src/main/resources/sql/20260312_add_ai_prompt_menu.sql
File was deleted
src/main/resources/sql/20260312_init_ai_management_menu.sql
New file
@@ -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;
src/main/resources/sql/20260312_init_ai_mcp_mount_full.sql
New file
@@ -0,0 +1,69 @@
-- 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';
src/main/resources/sql/20260312_migrate_ai_prompt_local_mcp_tool_names.sql
New file
@@ -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');
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);
      }
    });
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) {
src/main/webapp/views/ai/mcp_mount.html
New file
@@ -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 服务,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>
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;