zhou zhou
12 小时以前 80a6d9236ade191a5de0975abe4de5a6e7e63915
#AI.注释
13个文件已修改
248 ■■■■■ 已修改文件
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamValidationSupport.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptRenderSupport.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/BuiltinToolGovernanceSupport.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
@@ -23,6 +23,11 @@
    private final AiChatService aiChatService;
    /**
     * 返回当前用户在指定 Prompt 场景下的 AI 运行时快照。
     * 这里不会真正触发模型调用,只负责把当前生效的模型、Prompt、
     * 已挂载 MCP 以及会话记忆概况一次性返回给前端抽屉初始化使用。
     */
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/ai/chat/runtime")
    public R runtime(@RequestParam(required = false) String promptCode,
@@ -30,6 +35,10 @@
        return R.ok().add(aiChatService.getRuntime(promptCode, sessionId, getLoginUserId(), getTenantId()));
    }
    /**
     * 查询当前登录用户在指定 Prompt 下的历史会话列表。
     * 前端左侧会话栏依赖该接口做会话切换、搜索和刷新。
     */
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/ai/chat/sessions")
    public R sessions(@RequestParam(required = false) String promptCode,
@@ -37,6 +46,9 @@
        return R.ok().add(aiChatService.listSessions(promptCode, keyword, getLoginUserId(), getTenantId()));
    }
    /**
     * 软删除单个 AI 会话,同时级联删除会话下的消息记录。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/remove/{sessionId}")
    public R removeSession(@PathVariable Long sessionId) {
@@ -44,18 +56,29 @@
        return R.ok("Delete Success").add(sessionId);
    }
    /**
     * 更新会话标题,供前端重命名会话时调用。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/rename/{sessionId}")
    public R renameSession(@PathVariable Long sessionId, @RequestBody AiChatSessionRenameRequest request) {
        return R.ok("Update Success").add(aiChatService.renameSession(sessionId, request, getLoginUserId(), getTenantId()));
    }
    /**
     * 更新会话置顶状态。
     * 置顶只影响当前用户的会话排序,不改变会话内容和记忆。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/pin/{sessionId}")
    public R pinSession(@PathVariable Long sessionId, @RequestBody AiChatSessionPinRequest request) {
        return R.ok("Update Success").add(aiChatService.pinSession(sessionId, request, getLoginUserId(), getTenantId()));
    }
    /**
     * 清空指定会话的持久化消息、摘要记忆和事实记忆。
     * 会话本身保留,便于前端继续在同一个 sessionId 上发起新对话。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/memory/clear/{sessionId}")
    public R clearSessionMemory(@PathVariable Long sessionId) {
@@ -63,6 +86,9 @@
        return R.ok("Clear Success").add(sessionId);
    }
    /**
     * 只保留会话最近一轮问答,用于主动裁剪上下文窗口。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/memory/retain-latest/{sessionId}")
    public R retainLatestRound(@PathVariable Long sessionId) {
@@ -70,6 +96,11 @@
        return R.ok("Retain Success").add(sessionId);
    }
    /**
     * 以 SSE 方式启动 AI 对话。
     * 控制器只负责生成 requestId、记录入口日志和把鉴权上下文透传给服务层,
     * 真正的流式推理、工具调用和记忆落库都在服务层完成。
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestBody AiChatRequest request) {
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
@@ -30,6 +30,11 @@
    private final AiChatSessionMapper aiChatSessionMapper;
    private final AiChatMessageMapper aiChatMessageMapper;
    /**
     * 读取会话记忆快照。
     * 返回结果同时包含完整落库历史、短期记忆窗口以及摘要/事实记忆,
     * 便于调用方按不同用途选择数据粒度。
     */
    @Override
    public AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId) {
        ensureIdentity(userId, tenantId);
@@ -59,6 +64,10 @@
                .build();
    }
    /**
     * 查询当前用户在某个 Prompt 下的会话列表。
     * 列表只返回用于侧边栏展示的摘要信息,不返回完整对话内容。
     */
    @Override
    public List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode, String keyword) {
        ensureIdentity(userId, tenantId);
@@ -83,6 +92,10 @@
        return result;
    }
    /**
     * 解析本轮请求应该落到哪个会话。
     * 如果前端带了 sessionId 则做归属校验并复用;否则自动创建新会话。
     */
    @Override
    public AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed) {
        ensureIdentity(userId, tenantId);
@@ -108,6 +121,10 @@
        return session;
    }
    /**
     * 落库保存一整轮对话。
     * 这里会顺序写入本轮用户消息和模型回复,并在最后刷新会话标题、最后活跃时间和记忆画像。
     */
    @Override
    public void saveRound(AiChatSession session, Long userId, Long tenantId, List<AiChatMessageDto> memoryMessages, String assistantContent) {
        if (session == null || session.getId() == null) {
@@ -136,6 +153,7 @@
        refreshMemoryProfile(session.getId(), userId);
    }
    /** 删除整个会话及其消息。 */
    @Override
    public void removeSession(Long userId, Long tenantId, Long sessionId) {
        ensureIdentity(userId, tenantId);
@@ -169,6 +187,7 @@
        }
    }
    /** 更新会话标题并返回最新会话摘要。 */
    @Override
    public AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request) {
        ensureIdentity(userId, tenantId);
@@ -186,6 +205,7 @@
        return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId));
    }
    /** 更新会话置顶状态。 */
    @Override
    public AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request) {
        ensureIdentity(userId, tenantId);
@@ -203,6 +223,7 @@
        return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId));
    }
    /** 清空某个会话的全部消息和派生记忆字段。 */
    @Override
    public void clearSessionMemory(Long userId, Long tenantId, Long sessionId) {
        ensureIdentity(userId, tenantId);
@@ -224,6 +245,7 @@
                .setLastMessageTime(session.getCreateTime()));
    }
    /** 只保留最近一轮问答,用于手动裁剪长会话。 */
    @Override
    public void retainLatestRound(Long userId, Long tenantId, Long sessionId) {
        ensureIdentity(userId, tenantId);
@@ -307,6 +329,7 @@
    }
    private List<AiChatMessageDto> normalizeMessages(List<AiChatMessageDto> memoryMessages) {
        /** 清洗前端上传的内存消息,只允许 user/assistant 两类角色落库。 */
        List<AiChatMessageDto> normalized = new ArrayList<>();
        if (Cools.isEmpty(memoryMessages)) {
            return normalized;
@@ -372,6 +395,10 @@
    }
    private String buildSessionTitle(String titleSeed) {
        /**
         * 把首轮用户问题压缩成适合作为会话标题的短摘要。
         * 这里会去掉换行、连续空白,并优先在自然语义断点处截断。
         */
        if (!StringUtils.hasText(titleSeed)) {
            throw new CoolException("AI 会话标题不能为空");
        }
@@ -429,6 +456,10 @@
    }
    private void refreshMemoryProfile(Long sessionId, Long userId) {
        /**
         * 重新计算会话的摘要记忆和关键事实。
         * 这是“持久化消息”和“模型上下文治理”之间的桥梁方法。
         */
        List<AiChatMessageDto> messages = listMessages(sessionId);
        List<AiChatMessageDto> shortMemoryMessages = tailMessagesByRounds(messages, AiDefaults.MEMORY_RECENT_ROUNDS);
        List<AiChatMessageDto> historyMessages = messages.size() > shortMemoryMessages.size()
@@ -454,6 +485,7 @@
    }
    private List<AiChatMessageDto> tailMessagesByRounds(List<AiChatMessageDto> source, int rounds) {
        /** 按“用户发言轮次”裁剪最近消息,而不是简单按条数截断。 */
        if (Cools.isEmpty(source) || rounds <= 0) {
            return List.of();
        }
@@ -492,6 +524,7 @@
    }
    private String buildMemorySummary(List<AiChatMessageDto> historyMessages) {
        /** 为较早历史生成可直接插入系统消息的文本摘要。 */
        StringBuilder builder = new StringBuilder("较早对话摘要:\n");
        for (AiChatMessageDto item : historyMessages) {
            if (item == null || !StringUtils.hasText(item.getContent())) {
@@ -511,6 +544,7 @@
    }
    private String buildMemoryFacts(List<AiChatMessageDto> messages) {
        /** 从最近用户关注点中提炼关键事实,作为轻量持久记忆。 */
        if (Cools.isEmpty(messages)) {
            return null;
        }
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -88,6 +88,10 @@
    @Qualifier("aiChatTaskExecutor")
    private final Executor aiChatTaskExecutor;
    /**
     * 获取当前对话抽屉初始化所需的运行时数据。
     * 该方法不会触发模型调用,而是把配置解析结果和会话记忆聚合成前端一次渲染所需的快照。
     */
    @Override
    public AiChatRuntimeDto getRuntime(String promptCode, Long sessionId, Long userId, Long tenantId) {
        AiResolvedConfig config = aiConfigResolverService.resolve(promptCode, tenantId);
@@ -109,6 +113,9 @@
                .build();
    }
    /**
     * 查询指定 Prompt 场景下的历史会话摘要列表。
     */
    @Override
    public List<AiChatSessionDto> listSessions(String promptCode, String keyword, Long userId, Long tenantId) {
        AiResolvedConfig config = aiConfigResolverService.resolve(promptCode, tenantId);
@@ -140,6 +147,10 @@
        aiChatMemoryService.retainLatestRound(userId, tenantId, sessionId);
    }
    /**
     * 启动一次新的 SSE 对话流。
     * 控制线程立即返回 emitter,真正的模型调用与工具执行交给 AI 专用线程池异步处理。
     */
    @Override
    public SseEmitter stream(AiChatRequest request, Long userId, Long tenantId) {
        SseEmitter emitter = new SseEmitter(AiDefaults.SSE_TIMEOUT_MS);
@@ -148,6 +159,14 @@
    }
    private void doStream(AiChatRequest request, Long userId, Long tenantId, SseEmitter emitter) {
        /**
         * AI 对话的核心执行链路:
         * 1. 校验身份和解析租户配置
         * 2. 解析或创建会话,加载记忆
         * 3. 动态挂载 MCP 工具
         * 4. 发起模型流式/非流式调用
         * 5. 持久化本轮消息,输出 SSE 事件并记录审计日志
         */
        String requestId = request.getRequestId();
        long startedAt = System.currentTimeMillis();
        AtomicReference<Long> firstTokenAtRef = new AtomicReference<>();
@@ -302,6 +321,7 @@
    }
    private AiResolvedConfig resolveConfig(AiChatRequest request, Long tenantId) {
        /** 把请求里的 Prompt 场景解析成一份可直接执行的 AI 配置。 */
        try {
            return aiConfigResolverService.resolve(request.getPromptCode(), tenantId);
        } catch (Exception e) {
@@ -311,6 +331,7 @@
    }
    private AiChatSession resolveSession(AiChatRequest request, Long userId, Long tenantId, String promptCode) {
        /** 根据 sessionId 复用历史会话,或在首次提问时创建新会话。 */
        try {
            return aiChatMemoryService.resolveSession(userId, tenantId, promptCode, request.getSessionId(), resolveTitleSeed(request.getMessages()));
        } catch (Exception e) {
@@ -320,6 +341,7 @@
    }
    private AiChatMemoryDto loadMemory(Long userId, Long tenantId, String promptCode, Long sessionId) {
        /** 读取会话的短期记忆、摘要记忆和事实记忆,供模型组装上下文。 */
        try {
            return aiChatMemoryService.getMemory(userId, tenantId, promptCode, sessionId);
        } catch (Exception e) {
@@ -329,6 +351,7 @@
    }
    private McpMountRuntimeFactory.McpMountRuntime createRuntime(AiResolvedConfig config, Long userId) {
        /** 按配置中的 MCP 挂载记录构造本轮对话专属的工具运行时。 */
        try {
            return mcpMountRuntimeFactory.create(config.getMcpMounts(), userId);
        } catch (Exception e) {
@@ -471,6 +494,10 @@
    private OpenAiChatOptions buildChatOptions(AiParam aiParam, ToolCallback[] toolCallbacks, Long userId, Long tenantId,
                                               String requestId, Long sessionId, Map<String, Object> metadata) {
        /**
         * 组装一次聊天调用的全部模型选项和 Tool Context。
         * Tool Context 会透传给内置工具和外部 MCP,保证工具在租户和会话范围内执行。
         */
        if (userId == null) {
            throw buildAiException("AI_AUTH_USER_MISSING", AiErrorCategory.AUTH, "OPTIONS_BUILD", "当前登录用户不存在", null);
        }
@@ -508,6 +535,7 @@
                                             Long sessionId, AtomicLong toolCallSequence,
                                             AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                             Long callLogId, Long userId, Long tenantId) {
        /** 给所有工具回调套上一层可观测包装,用于实时 SSE 轨迹和审计日志落库。 */
        if (Cools.isEmpty(toolCallbacks)) {
            return toolCallbacks;
        }
@@ -523,6 +551,10 @@
    }
    private List<Message> buildPromptMessages(AiChatMemoryDto memory, List<AiChatMessageDto> sourceMessages, AiPrompt aiPrompt, Map<String, Object> metadata) {
        /**
         * 组装最终提交给模型的消息列表。
         * 顺序上始终是:系统 Prompt -> 历史摘要 -> 关键事实 -> 最近对话 -> 当前用户输入。
         */
        if (Cools.isEmpty(sourceMessages)) {
            throw new CoolException("对话消息不能为空");
        }
@@ -569,6 +601,7 @@
    }
    private List<AiChatMessageDto> mergeMessages(List<AiChatMessageDto> persistedMessages, List<AiChatMessageDto> memoryMessages) {
        /** 把落库历史与本轮前端内存增量合并成模型可消费的完整上下文。 */
        List<AiChatMessageDto> merged = new ArrayList<>();
        if (!Cools.isEmpty(persistedMessages)) {
            merged.addAll(persistedMessages);
@@ -634,6 +667,7 @@
    }
    private void emitDone(SseEmitter emitter, String requestId, ChatResponseMetadata metadata, String fallbackModel, Long sessionId, long startedAt, Long firstTokenAt) {
        /** 输出对话完成事件,统一封装耗时、首包延迟和 token 用量。 */
        Usage usage = metadata == null ? null : metadata.getUsage();
        emitStrict(emitter, "done", AiChatDoneDto.builder()
                .requestId(requestId)
@@ -662,6 +696,7 @@
    }
    private void emitStrict(SseEmitter emitter, String eventName, Object payload) {
        /** 严格发送 SSE 事件;一旦发送失败,直接上抛为流式输出异常。 */
        try {
            String data = objectMapper.writeValueAsString(payload);
            emitter.send(SseEmitter.event()
@@ -673,6 +708,7 @@
    }
    private void emitSafely(SseEmitter emitter, String eventName, Object payload) {
        /** 尝试发送非关键事件,发送失败只记录日志,不打断主对话流程。 */
        try {
            emitStrict(emitter, eventName, payload);
        } catch (Exception e) {
@@ -748,6 +784,11 @@
        @Override
        public String call(String toolInput, ToolContext toolContext) {
            /**
             * 工具执行观测包装器。
             * 在真实调用前后分别发送 tool_start / tool_result / tool_error,
             * 同时把调用摘要写入 MCP 调用日志表。
             */
            String toolName = delegate.getToolDefinition() == null ? "unknown" : delegate.getToolDefinition().name();
            String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null;
            String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet();
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java
@@ -19,6 +19,11 @@
    private final AiPromptService aiPromptService;
    private final AiMcpMountService aiMcpMountService;
    /**
     * 按租户解析一次完整的 AI 运行配置。
     * 该方法是对话入口、运行态摘要和配置中心共用的统一解析点,
     * 负责把当前生效的参数、Prompt 和 MCP 挂载聚合成一个不可再拆分的配置对象。
     */
    @Override
    public AiResolvedConfig resolve(String promptCode, Long tenantId) {
        if (tenantId == null) {
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
@@ -36,6 +36,7 @@
    private final McpMountRuntimeFactory mcpMountRuntimeFactory;
    private final ObjectMapper objectMapper;
    /** 查询某个租户下当前启用的 MCP 挂载列表。 */
    @Override
    public List<AiMcpMount> listActiveMounts(Long tenantId) {
        ensureTenantId(tenantId);
@@ -47,6 +48,7 @@
                .orderByAsc(AiMcpMount::getId));
    }
    /** 保存前校验 MCP 挂载草稿,并补全运行时默认值。 */
    @Override
    public void validateBeforeSave(AiMcpMount aiMcpMount, Long tenantId) {
        ensureTenantId(tenantId);
@@ -55,6 +57,7 @@
        ensureRequiredFields(aiMcpMount, tenantId);
    }
    /** 更新前校验并锁定记录所属租户,防止跨租户修改。 */
    @Override
    public void validateBeforeUpdate(AiMcpMount aiMcpMount, Long tenantId) {
        ensureTenantId(tenantId);
@@ -67,6 +70,10 @@
        ensureRequiredFields(aiMcpMount, tenantId);
    }
    /**
     * 预览当前挂载最终会暴露给模型的工具目录。
     * 对内置 MCP 会额外合并治理目录信息,对外部 MCP 则以实际解析结果为准。
     */
    @Override
    public List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId) {
        AiMcpMount mount = requireMount(mountId, tenantId);
@@ -93,6 +100,7 @@
        }
    }
    /** 对已保存的挂载做真实连通性测试,并把结果回写到运行态字段。 */
    @Override
    public AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId) {
        AiMcpMount mount = requireMount(mountId, tenantId);
@@ -120,6 +128,7 @@
        }
    }
    /** 对表单里的草稿配置做临时连通性测试,不落库。 */
    @Override
    public AiMcpConnectivityTestDto testDraftConnectivity(AiMcpMount mount, Long userId, Long tenantId) {
        ensureTenantId(tenantId);
@@ -162,6 +171,10 @@
        }
    }
    /**
     * 直接执行某一个工具的测试调用。
     * 该方法主要服务于管理端的“工具测试”面板,不参与正式对话链路。
     */
    @Override
    public AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request) {
        if (userId == null) {
@@ -215,6 +228,7 @@
    }
    private void fillDefaults(AiMcpMount aiMcpMount) {
        /** 为挂载草稿补齐统一默认值,保证后续运行时代码不需要重复判断空值。 */
        if (!StringUtils.hasText(aiMcpMount.getTransportType())) {
            aiMcpMount.setTransportType(AiDefaults.MCP_TRANSPORT_SSE_HTTP);
        }
@@ -233,6 +247,10 @@
    }
    private void ensureRequiredFields(AiMcpMount aiMcpMount, Long tenantId) {
        /**
         * 按 transportType 校验挂载必填项。
         * 这里把“字段合法性”和“跨记录冲突”一起收口,避免校验逻辑分散在 controller 层。
         */
        if (!StringUtils.hasText(aiMcpMount.getName())) {
            throw new CoolException("MCP 挂载名称不能为空");
        }
@@ -257,6 +275,7 @@
    }
    private AiMcpMount requireMount(Long mountId, Long tenantId) {
        /** 按租户加载挂载记录,不存在直接抛错。 */
        ensureTenantId(tenantId);
        if (mountId == null) {
            throw new CoolException("MCP 挂载 ID 不能为空");
@@ -273,6 +292,7 @@
    }
    private void ensureBuiltinConflictFree(AiMcpMount aiMcpMount, Long tenantId) {
        /** 校验同租户下是否存在与当前内置编码互斥的启用挂载。 */
        if (aiMcpMount.getStatus() == null || aiMcpMount.getStatus() != StatusType.ENABLE.val) {
            return;
        }
@@ -311,6 +331,7 @@
    }
    private List<AiMcpToolPreviewDto> buildToolPreviewDtos(ToolCallback[] callbacks, List<AiMcpToolPreviewDto> governedCatalog) {
        /** 把底层 ToolCallback 和治理目录信息拼成前端需要的结构化工具卡片数据。 */
        List<AiMcpToolPreviewDto> tools = new ArrayList<>();
        Map<String, AiMcpToolPreviewDto> catalogMap = new java.util.LinkedHashMap<>();
        for (AiMcpToolPreviewDto item : governedCatalog) {
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamValidationSupport.java
@@ -35,6 +35,11 @@
    private final GenericApplicationContext applicationContext;
    private final ObservationRegistry observationRegistry;
    /**
     * 对一份 AI 参数草稿做真实连通性校验。
     * 校验方式不是简单判断字段非空,而是直接构造聊天模型并发起一次最小探测调用,
     * 用返回结果和耗时生成前端可展示的校验报告。
     */
    public AiParamValidateResultDto validate(AiParam aiParam) {
        long startedAt = System.currentTimeMillis();
        try {
@@ -66,6 +71,11 @@
    }
    private OpenAiChatModel createChatModel(AiParam aiParam) {
        /**
         * 构造仅用于校验的轻量聊天模型。
         * 这里沿用正式链路的 Observation 和 ToolCalling 依赖,
         * 保证校验结论与真实运行环境尽量一致。
         */
        OpenAiApi openAiApi = buildOpenAiApi(aiParam);
        ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
                .observationRegistry(observationRegistry)
@@ -88,6 +98,10 @@
    }
    private OpenAiApi buildOpenAiApi(AiParam aiParam) {
        /**
         * 根据表单里的 Base URL、API Key 和超时参数构造 OpenAI 兼容客户端。
         * 该方法被显式拆出来,是为了让“网络连接参数”和“模型选项”职责分离。
         */
        int timeoutMs = aiParam.getTimeoutMs() == null ? AiDefaults.DEFAULT_TIMEOUT_MS : aiParam.getTimeoutMs();
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(timeoutMs);
@@ -101,6 +115,7 @@
    }
    private String formatDate(Date date) {
        /** 统一输出给前端的校验时间格式。 */
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptRenderSupport.java
@@ -17,6 +17,10 @@
    private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{?([a-zA-Z0-9_.-]+)}}?");
    /**
     * 同时渲染 System Prompt 和 User Prompt,并回传本次解析到的变量清单。
     * 该方法用于 Prompt 管理页的预览能力,帮助管理员在不真正调用模型的前提下验证模板结果。
     */
    public AiPromptPreviewDto render(String systemPrompt, String userPromptTemplate, String input, Map<String, Object> metadata) {
        String finalInput = input == null ? "" : input;
        return AiPromptPreviewDto.builder()
@@ -26,6 +30,11 @@
                .build();
    }
    /**
     * 只渲染用户消息模板。
     * 如果模板没有消费任何变量,则保留模板原文并把用户输入附加到末尾,
     * 这样可以显式暴露“模板未生效”的问题,而不是静默吞掉输入。
     */
    public String renderUserPrompt(String userPromptTemplate, String input, Map<String, Object> metadata) {
        if (!StringUtils.hasText(userPromptTemplate)) {
            return input;
@@ -38,6 +47,7 @@
    }
    private String renderTemplate(String template, String input, Map<String, Object> metadata) {
        /** 渲染任意模板片段;空模板保持原样返回。 */
        if (!StringUtils.hasText(template)) {
            return template;
        }
@@ -45,6 +55,10 @@
    }
    private String replaceTemplateVariables(String template, String input, Map<String, Object> metadata) {
        /**
         * 统一处理 `{{input}}`、`{input}` 以及 metadata 里的占位变量替换。
         * 这里使用朴素替换而不是脚本执行,目的是让模板行为稳定、可预期、易排查。
         */
        String rendered = template
                .replace("{{input}}", input)
                .replace("{input}", input);
@@ -60,6 +74,7 @@
    }
    private List<String> resolveVariables(String systemPrompt, String userPromptTemplate, Map<String, Object> metadata) {
        /** 收集当前 Prompt 中显式出现过的变量名,用于前端展示。 */
        LinkedHashSet<String> variables = new LinkedHashSet<>();
        collectVariables(variables, systemPrompt);
        collectVariables(variables, userPromptTemplate);
@@ -70,6 +85,7 @@
    }
    private void collectVariables(LinkedHashSet<String> variables, String template) {
        /** 扫描模板文本中的占位变量并按出现顺序去重。 */
        if (!StringUtils.hasText(template)) {
            return;
        }
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java
@@ -28,6 +28,11 @@
    private final RsfWmsTaskTools rsfWmsTaskTools;
    private final RsfWmsBaseTools rsfWmsBaseTools;
    /**
     * 校验内置 MCP 编码是否合法。
     * 当前版本只允许使用显式登记在注册表中的编码,未知编码直接拒绝,
     * 这样可以确保“页面可选项”和“运行时可挂载项”始终一致。
     */
    @Override
    public void validateBuiltinCode(String builtinCode) {
        if (!StringUtils.hasText(builtinCode)) {
@@ -38,6 +43,10 @@
        }
    }
    /**
     * 根据挂载记录创建内置工具回调。
     * 这里不会做任何动态发现,所有工具都必须经过显式注册和治理目录校验后才能暴露给模型。
     */
    @Override
    public List<ToolCallback> createToolCallbacks(AiMcpMount mount, Long userId) {
        String builtinCode = mount.getBuiltinCode();
@@ -52,6 +61,10 @@
        throw new CoolException("不支持的内置 MCP 编码: " + builtinCode);
    }
    /**
     * 返回某个内置编码下可预览的工具目录信息。
     * 该目录比运行时回调多了工具用途、查询边界和示例提问,供管理页展示。
     */
    @Override
    public List<AiMcpToolPreviewDto> listBuiltinToolCatalog(String builtinCode) {
        validateBuiltinCode(builtinCode);
@@ -62,6 +75,11 @@
    }
    private List<ToolCallback> createValidatedCallbacks(Object toolBean, String builtinCode) {
        /**
         * 把 `@Tool` Bean 转成 Spring AI ToolCallback,并强制校验:
         * 1. 工具名必须符合命名规范
         * 2. 每个工具都必须出现在治理目录里
         */
        List<ToolCallback> callbacks = Arrays.asList(ToolCallbacks.from(toolBean));
        Map<String, AiMcpToolPreviewDto> catalog = catalogByBuiltinCode(builtinCode);
        for (ToolCallback callback : callbacks) {
@@ -80,10 +98,15 @@
    }
    private List<String> supportedBuiltinCodes() {
        /** 当前版本允许挂载的全部内置 MCP 编码。 */
        return List.of(AiDefaults.MCP_BUILTIN_RSF_WMS);
    }
    private Map<String, AiMcpToolPreviewDto> catalogByBuiltinCode(String builtinCode) {
        /**
         * 构造内置工具治理目录。
         * 这里的目录是运行时校验和管理端预览的共同事实来源,不能与工具实现脱节。
         */
        if (AiDefaults.MCP_BUILTIN_RSF_WMS.equals(builtinCode)) {
            Map<String, AiMcpToolPreviewDto> catalog = new LinkedHashMap<>();
            catalog.put("rsf_query_available_inventory", buildCatalogItem(
@@ -142,6 +165,7 @@
    private AiMcpToolPreviewDto buildCatalogItem(String name, String toolGroup, String toolPurpose,
                                                 String queryBoundary, List<String> exampleQuestions) {
        /** 统一创建工具目录条目,避免不同工具组出现字段风格不一致。 */
        return AiMcpToolPreviewDto.builder()
                .name(name)
                .toolGroup(toolGroup)
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
@@ -39,6 +39,11 @@
    private final ObjectMapper objectMapper;
    private final BuiltinMcpToolRegistry builtinMcpToolRegistry;
    /**
     * 把一组 MCP 挂载记录解析成一次对话可直接使用的运行时对象。
     * 该方法统一处理内置 MCP、远程 SSE MCP 和本地 STDIO MCP,
     * 同时收集挂载成功项、失败项以及最终暴露给模型的工具回调列表。
     */
    @Override
    public McpMountRuntime create(List<AiMcpMount> mounts, Long userId) {
        List<McpSyncClient> clients = new ArrayList<>();
@@ -75,6 +80,7 @@
    }
    private List<ToolCallback> wrapMountedCallbacks(List<ToolCallback> source, String mountName) {
        /** 为每个工具回调补上挂载来源,便于后续审计、观测和前端工具轨迹展示。 */
        List<ToolCallback> mountedCallbacks = new ArrayList<>();
        for (ToolCallback callback : source) {
            if (callback == null) {
@@ -86,6 +92,7 @@
    }
    private void ensureUniqueToolNames(List<ToolCallback> callbacks) {
        /** 确保多挂载聚合后不会出现同名工具,否则模型侧无法正确分辨工具定义。 */
        LinkedHashSet<String> duplicateNames = new LinkedHashSet<>();
        LinkedHashSet<String> seenNames = new LinkedHashSet<>();
        for (ToolCallback callback : callbacks) {
@@ -106,6 +113,10 @@
    }
    private McpSyncClient createClient(AiMcpMount mount) {
        /**
         * 按挂载配置动态创建 MCP Client。
         * 该方法只负责 transport 层初始化,不负责工具去重和错误聚合。
         */
        Duration timeout = Duration.ofMillis(mount.getRequestTimeoutMs() == null
                ? AiDefaults.DEFAULT_TIMEOUT_MS
                : mount.getRequestTimeoutMs());
@@ -153,6 +164,7 @@
    }
    private List<String> readStringList(String json) {
        /** 解析挂载表里的 JSON 数组配置,例如 STDIO args。 */
        if (!StringUtils.hasText(json)) {
            return Collections.emptyList();
        }
@@ -165,6 +177,7 @@
    }
    private Map<String, String> readStringMap(String json) {
        /** 解析挂载表里的 JSON Map 配置,例如 headers 或环境变量。 */
        if (!StringUtils.hasText(json)) {
            return Collections.emptyMap();
        }
@@ -184,6 +197,7 @@
        private final List<String> mountedNames;
        private final List<String> errors;
        /** 运行时对象本身只做数据封装和资源释放,不引入额外业务逻辑。 */
        private DefaultMcpMountRuntime(List<McpSyncClient> clients, ToolCallback[] callbacks, List<String> mountedNames, List<String> errors) {
            this.clients = clients;
            this.callbacks = callbacks;
@@ -213,6 +227,7 @@
        @Override
        public void close() {
            /** 统一关闭本次运行时里创建的外部 MCP Client,避免连接泄漏。 */
            for (McpSyncClient client : clients) {
                try {
                    client.close();
@@ -228,6 +243,7 @@
        private final ToolCallback delegate;
        private final String mountName;
        /** 装饰器仅补充挂载来源,不改变底层工具定义和调用行为。 */
        private MountedToolCallbackImpl(ToolCallback delegate, String mountName) {
            this.delegate = delegate;
            this.mountName = mountName;
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/BuiltinToolGovernanceSupport.java
@@ -11,6 +11,10 @@
    private BuiltinToolGovernanceSupport() {
    }
    /**
     * 把工具入参里的 limit 统一收敛到安全范围内。
     * 所有内置只读工具都通过该方法限制返回规模,避免模型一次查询过多数据。
     */
    public static int normalizeLimit(Integer limit, int defaultValue, int maxValue) {
        if (limit == null) {
            return defaultValue;
@@ -21,6 +25,10 @@
        return limit;
    }
    /**
     * 要求多个过滤条件里至少有一个有效值。
     * 这是防止 AI 工具被模型当成“全表扫描接口”使用的第一道保护。
     */
    public static void requireAnyFilter(String message, String... values) {
        if (values == null || values.length == 0) {
            throw new CoolException(message);
@@ -33,6 +41,10 @@
        throw new CoolException(message);
    }
    /**
     * 清洗单个文本型查询参数,并限制最大长度。
     * 这里只做轻量治理,不做模糊兜底或自动纠错,非法输入直接拒绝。
     */
    public static String sanitizeQueryText(String value, String fieldLabel, int maxLength) {
        if (!StringUtils.hasText(value)) {
            return null;
@@ -44,6 +56,10 @@
        return normalized;
    }
    /**
     * 清洗字符串数组型参数,常用于站点类型、状态列表等批量过滤条件。
     * 返回结果会自动剔除空值,但如果最终为空仍然视为非法请求。
     */
    public static List<String> sanitizeStringList(List<String> values, String fieldLabel, int maxSize, int maxItemLength) {
        if (values == null || values.isEmpty()) {
            throw new CoolException(fieldLabel + "不能为空");
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java
@@ -27,6 +27,10 @@
    private final BasStationService basStationService;
    private final DictDataService dictDataService;
    /**
     * 查询仓库基础信息。
     * 该工具面向“按编码/名称定位仓库”的问答场景,不负责提供全量仓库主数据导出能力。
     */
    @Tool(name = "rsf_query_warehouses", description = "只读查询工具。按仓库编码或名称查询仓库基础信息。")
    public List<Map<String, Object>> queryWarehouses(
            @ToolParam(description = "仓库编码,可选") String code,
@@ -61,6 +65,10 @@
        return result;
    }
    /**
     * 查询基础站点信息。
     * 查询条件允许按站点名称、编号或使用状态组合过滤,返回值只保留 AI 对话需要的字段。
     */
    @Tool(name = "rsf_query_bas_stations", description = "只读查询工具。按站点编号、站点名称或使用状态查询基础站点。")
    public List<Map<String, Object>> queryBasStations(
            @ToolParam(description = "站点名称,可选") String stationName,
@@ -106,6 +114,10 @@
        return result;
    }
    /**
     * 查询字典数据。
     * 字典类型编码是强制条件,用来确保模型不会越过业务边界直接遍历整张字典表。
     */
    @Tool(name = "rsf_query_dict_data", description = "只读查询工具。根据字典类型编码查询字典数据,可按值或标签进一步过滤。")
    public List<Map<String, Object>> queryDictData(
            @ToolParam(required = true, description = "字典类型编码") String dictTypeCode,
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java
@@ -27,6 +27,10 @@
    private final LocItemService locItemService;
    private final DeviceSiteService deviceSiteService;
    /**
     * 查询当前可用于出库的库存明细。
     * 该工具只允许按物料编码或物料名称做定向查询,不允许无条件扫描库存表。
     */
    @Tool(name = "rsf_query_available_inventory", description = "只读查询工具。根据物料编码或物料名称查询当前在库且可用于出库的库存明细。")
    public List<Map<String, Object>> queryAvailableInventory(
            @ToolParam(description = "物料编码,优先使用") String matnr,
@@ -72,6 +76,10 @@
        return result;
    }
    /**
     * 查询指定作业类型可用的设备站点。
     * 返回的是模型更容易消费的扁平结构,而不是直接暴露完整实体对象。
     */
    @Tool(name = "rsf_query_station_list", description = "只读查询工具。根据作业类型列表查询可用站点,返回站点编号、名称、目标位置和状态等信息。")
    public List<Map<String, Object>> queryStationList(
            @ToolParam(required = true, description = "作业类型列表") List<String> types,
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java
@@ -21,6 +21,10 @@
    private final TaskService taskService;
    /**
     * 查询任务列表。
     * 方法要求至少带一个过滤条件,避免模型把任务表当作可直接遍历的数据源。
     */
    @Tool(name = "rsf_query_task_list", description = "只读查询工具。按任务号、状态、任务类型、源站点、目标站点等条件查询任务列表。")
    public List<Map<String, Object>> queryTaskList(
            @ToolParam(description = "任务号,可模糊查询") String taskCode,
@@ -62,6 +66,10 @@
        return result;
    }
    /**
     * 查询单个任务详情。
     * 与列表查询不同,这里允许返回更丰富的字段,但仍然要求调用方通过任务 ID 或任务号做精确定位。
     */
    @Tool(name = "rsf_query_task_detail", description = "只读查询工具。根据任务 ID 或任务号查询任务详情。")
    public Map<String, Object> queryTaskDetail(
            @ToolParam(description = "任务 ID") Long taskId,
@@ -101,6 +109,7 @@
    }
    private Map<String, Object> buildTaskSummary(Task task) {
        /** 把任务实体收敛为适合模型阅读和前端展示的摘要结构。 */
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("id", task.getId());
        item.put("taskCode", task.getTaskCode());