#AI
zhou zhou
11 小时以前 8a3fa0452075df8290d4542e64ced002ff4b476d
#AI
65个文件已添加
8个文件已修改
5129 ■■■■■ 已修改文件
rsf-admin/src/api/ai/chat.js 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/mcpMount.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/config/authProvider.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/en.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/zh.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AiChatDrawer.jsx 475 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AppBarToolbar.jsx 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/ResourceContent.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/index.jsx 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamForm.jsx 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamList.jsx 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/index.jsx 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/index.jsx 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/pom.xml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java 288 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 349 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/chat.js
New file
@@ -0,0 +1,103 @@
import request from "@/utils/request";
import { PREFIX_BASE_URL, TOKEN_HEADER_NAME } from "@/config/setting";
import { getToken } from "@/utils/token-util";
export const getAiRuntime = async (promptCode = "home.default", sessionId = null) => {
    const res = await request.get("ai/chat/runtime", {
        params: { promptCode, sessionId },
    });
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "获取 AI 运行时信息失败");
};
export const getAiSessions = async (promptCode = "home.default") => {
    const res = await request.get("ai/chat/sessions", {
        params: { promptCode },
    });
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data || [];
    }
    throw new Error(msg || "获取 AI 会话列表失败");
};
export const removeAiSession = async (sessionId) => {
    const res = await request.post(`ai/chat/session/remove/${sessionId}`);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "删除 AI 会话失败");
};
export const streamAiChat = async (payload, { signal, onEvent } = {}) => {
    const token = getToken();
    const response = await fetch(`${PREFIX_BASE_URL}ai/chat/stream`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "Accept": "text/event-stream",
            ...(token ? { [TOKEN_HEADER_NAME]: token } : {}),
        },
        body: JSON.stringify(payload),
        signal,
    });
    if (!response.ok) {
        throw new Error(`AI 请求失败 (${response.status})`);
    }
    if (!response.body) {
        throw new Error("AI 响应流不可用");
    }
    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";
    while (true) {
        const { done, value } = await reader.read();
        if (done) {
            break;
        }
        buffer += decoder.decode(value, { stream: true });
        const events = buffer.split(/\r?\n\r?\n/);
        buffer = events.pop() || "";
        events.forEach((item) => dispatchSseEvent(item, onEvent));
    }
    if (buffer.trim()) {
        dispatchSseEvent(buffer, onEvent);
    }
};
const dispatchSseEvent = (rawEvent, onEvent) => {
    const lines = rawEvent.split(/\r?\n/);
    let eventName = "message";
    const dataLines = [];
    lines.forEach((line) => {
        if (line.startsWith("event:")) {
            eventName = line.slice(6).trim();
        }
        if (line.startsWith("data:")) {
            dataLines.push(line.slice(5).trim());
        }
    });
    if (!dataLines.length) {
        return;
    }
    const rawData = dataLines.join("\n");
    let payload = rawData;
    try {
        payload = JSON.parse(rawData);
    } catch (error) {
    }
    if (onEvent) {
        onEvent(eventName, payload);
    }
};
rsf-admin/src/api/ai/mcpMount.js
New file
@@ -0,0 +1,19 @@
import request from "@/utils/request";
export const previewMcpTools = async (mountId) => {
    const res = await request.get(`aiMcpMount/${mountId}/tools`);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data || [];
    }
    throw new Error(msg || "获取工具列表失败");
};
export const testMcpTool = async (mountId, payload) => {
    const res = await request.post(`aiMcpMount/${mountId}/tool/test`, payload);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "工具测试失败");
};
rsf-admin/src/config/authProvider.js
@@ -5,14 +5,11 @@
import avatar from '/avatar.jpg'
const AI_COMPONENTS = new Set([
  'aiParam',
  'aiPrompt',
  'aiDiagnosis',
  'aiDiagnosisPlan',
  'aiCallLog',
  'aiRoute',
  'aiToolConfig',
  'aiMcpMount',
]);
const filterAiMenus = (items = []) =>
rsf-admin/src/i18n/en.js
@@ -150,6 +150,9 @@
        token: 'Token',
        operation: 'Operation',
        config: 'Config',
        aiParam: 'AI Params',
        aiPrompt: 'Prompts',
        aiMcpMount: 'MCP Mounts',
        tenant: 'Tenant',
        userLogin: 'Token',
        customer: 'Customer',
rsf-admin/src/i18n/zh.js
@@ -151,6 +151,9 @@
        token: '登录日志',
        operation: '操作日志',
        config: '配置参数',
        aiParam: 'AI 参数',
        aiPrompt: 'Prompt 管理',
        aiMcpMount: 'MCP 挂载',
        tenant: '租户管理',
        userLogin: '登录日志',
        customer: '客户表',
rsf-admin/src/layout/AiChatDrawer.jsx
New file
@@ -0,0 +1,475 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useNotify } from "react-admin";
import {
    Alert,
    Box,
    Button,
    Chip,
    Divider,
    Drawer,
    IconButton,
    List,
    ListItemButton,
    ListItemText,
    Paper,
    Stack,
    TextField,
    Typography,
} from "@mui/material";
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
import SendRoundedIcon from "@mui/icons-material/SendRounded";
import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined";
import SettingsSuggestOutlinedIcon from "@mui/icons-material/SettingsSuggestOutlined";
import PsychologyAltOutlinedIcon from "@mui/icons-material/PsychologyAltOutlined";
import CableOutlinedIcon from "@mui/icons-material/CableOutlined";
import CloseIcon from "@mui/icons-material/Close";
import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import { getAiRuntime, getAiSessions, removeAiSession, streamAiChat } from "@/api/ai/chat";
const DEFAULT_PROMPT_CODE = "home.default";
const quickLinks = [
    { label: "AI 参数", path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> },
    { label: "Prompt", path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> },
    { label: "MCP", path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> },
];
const AiChatDrawer = ({ open, onClose }) => {
    const navigate = useNavigate();
    const location = useLocation();
    const notify = useNotify();
    const abortRef = useRef(null);
    const [runtime, setRuntime] = useState(null);
    const [sessionId, setSessionId] = useState(null);
    const [sessions, setSessions] = useState([]);
    const [persistedMessages, setPersistedMessages] = useState([]);
    const [messages, setMessages] = useState([]);
    const [input, setInput] = useState("");
    const [loadingRuntime, setLoadingRuntime] = useState(false);
    const [streaming, setStreaming] = useState(false);
    const [usage, setUsage] = useState(null);
    const [drawerError, setDrawerError] = useState("");
    const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE;
    const runtimeSummary = useMemo(() => {
        return {
            promptName: runtime?.promptName || "--",
            model: runtime?.model || "--",
            mountedMcpCount: runtime?.mountedMcpCount ?? 0,
        };
    }, [runtime]);
    useEffect(() => {
        if (open) {
            initializeDrawer();
        } else {
            stopStream(false);
        }
    }, [open]);
    useEffect(() => () => {
        stopStream(false);
    }, []);
    const initializeDrawer = async (targetSessionId = null) => {
        await Promise.all([
            loadRuntime(targetSessionId),
            loadSessions(),
        ]);
    };
    const loadRuntime = async (targetSessionId = null) => {
        setLoadingRuntime(true);
        setDrawerError("");
        try {
            const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId);
            const historyMessages = data?.persistedMessages || [];
            setRuntime(data);
            setSessionId(data?.sessionId || null);
            setPersistedMessages(historyMessages);
            setMessages(historyMessages);
        } catch (error) {
            const message = error.message || "获取 AI 运行时失败";
            setDrawerError(message);
        } finally {
            setLoadingRuntime(false);
        }
    };
    const loadSessions = async () => {
        try {
            const data = await getAiSessions(DEFAULT_PROMPT_CODE);
            setSessions(data);
        } catch (error) {
            const message = error.message || "获取 AI 会话列表失败";
            setDrawerError(message);
        }
    };
    const startNewSession = () => {
        if (streaming) {
            return;
        }
        setSessionId(null);
        setPersistedMessages([]);
        setMessages([]);
        setUsage(null);
        setDrawerError("");
    };
    const handleSwitchSession = async (targetSessionId) => {
        if (streaming || targetSessionId === sessionId) {
            return;
        }
        setUsage(null);
        await loadRuntime(targetSessionId);
    };
    const handleDeleteSession = async (targetSessionId) => {
        if (streaming || !targetSessionId) {
            return;
        }
        try {
            await removeAiSession(targetSessionId);
            notify("会话已删除");
            if (targetSessionId === sessionId) {
                startNewSession();
                await loadRuntime(null);
            }
            await loadSessions();
        } catch (error) {
            const message = error.message || "删除 AI 会话失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
    };
    const stopStream = (showTip = true) => {
        if (abortRef.current) {
            abortRef.current.abort();
            abortRef.current = null;
            setStreaming(false);
            if (showTip) {
                notify("已停止当前对话输出");
            }
        }
    };
    const appendAssistantDelta = (delta) => {
        setMessages((prev) => {
            const next = [...prev];
            const last = next[next.length - 1];
            if (last && last.role === "assistant") {
                next[next.length - 1] = {
                    ...last,
                    content: `${last.content || ""}${delta}`,
                };
                return next;
            }
            next.push({ role: "assistant", content: delta });
            return next;
        });
    };
    const ensureAssistantPlaceholder = (seedMessages) => {
        const next = [...seedMessages];
        const last = next[next.length - 1];
        if (!last || last.role !== "assistant") {
            next.push({ role: "assistant", content: "" });
        }
        return next;
    };
    const handleSend = async () => {
        const content = input.trim();
        if (!content || streaming) {
            return;
        }
        const memoryMessages = [{ role: "user", content }];
        const nextMessages = [...messages, ...memoryMessages];
        setInput("");
        setUsage(null);
        setDrawerError("");
        setMessages(ensureAssistantPlaceholder(nextMessages));
        setStreaming(true);
        const controller = new AbortController();
        abortRef.current = controller;
        let completed = false;
        let completedSessionId = sessionId;
        try {
            await streamAiChat(
                {
                    sessionId,
                    promptCode,
                    messages: memoryMessages,
                    metadata: {
                        path: location.pathname,
                    },
                },
                {
                    signal: controller.signal,
                    onEvent: (eventName, payload) => {
                        if (eventName === "start") {
                            setRuntime(payload);
                            if (payload?.sessionId) {
                                setSessionId(payload.sessionId);
                                completedSessionId = payload.sessionId;
                            }
                        }
                        if (eventName === "delta") {
                            appendAssistantDelta(payload?.content || "");
                        }
                        if (eventName === "done") {
                            setUsage(payload);
                            completed = true;
                            if (payload?.sessionId) {
                                completedSessionId = payload.sessionId;
                            }
                        }
                        if (eventName === "error") {
                            const message = payload?.message || "AI 对话失败";
                            setDrawerError(message);
                            notify(message, { type: "error" });
                        }
                    },
                }
            );
        } catch (error) {
            if (error?.name !== "AbortError") {
                const message = error.message || "AI 对话失败";
                setDrawerError(message);
                notify(message, { type: "error" });
            }
        } finally {
            abortRef.current = null;
            setStreaming(false);
            if (completed) {
                await Promise.all([
                    loadRuntime(completedSessionId),
                    loadSessions(),
                ]);
            }
        }
    };
    const handleKeyDown = (event) => {
        if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault();
            handleSend();
        }
    };
    return (
        <Drawer
            anchor="right"
            open={open}
            onClose={onClose}
            ModalProps={{ keepMounted: true }}
            sx={{
                zIndex: 1400,
                "& .MuiDrawer-paper": {
                    top: 0,
                    height: "100vh",
                    width: { xs: "100vw", md: "50vw" },
                },
            }}
        >
            <Box display="flex" flexDirection="column" height="100%">
                <Stack direction="row" alignItems="center" spacing={1} px={2} py={1.5}>
                    <SmartToyOutlinedIcon color="primary" />
                    <Typography variant="h6" flex={1}>
                        AI 对话
                    </Typography>
                    <IconButton size="small" onClick={startNewSession} title="新建会话" disabled={streaming}>
                        <AddCommentOutlinedIcon fontSize="small" />
                    </IconButton>
                    <IconButton size="small" onClick={onClose} title="关闭">
                        <CloseIcon fontSize="small" />
                    </IconButton>
                </Stack>
                <Divider />
                <Box flex={1} display="flex" flexDirection={{ xs: "column", md: "row" }} minHeight={0}>
                    <Box
                        width={{ xs: "100%", md: 240 }}
                        borderRight={{ xs: "none", md: "1px solid rgba(224, 224, 224, 1)" }}
                        borderBottom={{ xs: "1px solid rgba(224, 224, 224, 1)", md: "none" }}
                        display="flex"
                        flexDirection="column"
                        minHeight={0}
                    >
                        <Box px={2} py={1.5}>
                            <Stack direction="row" alignItems="center" justifyContent="space-between" mb={1}>
                                <Typography variant="subtitle2">会话列表</Typography>
                                <Button size="small" onClick={startNewSession} disabled={streaming}>
                                    新建会话
                                </Button>
                            </Stack>
                            <Paper variant="outlined" sx={{ overflow: "hidden" }}>
                                {!sessions.length ? (
                                    <Box px={1.5} py={1.25}>
                                        <Typography variant="body2" color="text.secondary">
                                            暂无历史会话
                                        </Typography>
                                    </Box>
                                ) : (
                                    <List disablePadding sx={{ maxHeight: { xs: 180, md: "calc(100vh - 260px)" }, overflow: "auto" }}>
                                        {sessions.map((item) => (
                                            <ListItemButton
                                                key={item.sessionId}
                                                selected={item.sessionId === sessionId}
                                                onClick={() => handleSwitchSession(item.sessionId)}
                                                disabled={streaming}
                                                alignItems="flex-start"
                                            >
                                                <ListItemText
                                                    primary={item.title || `会话 ${item.sessionId}`}
                                                    secondary={item.lastMessageTime || `Session ${item.sessionId}`}
                                                    primaryTypographyProps={{
                                                        noWrap: true,
                                                        fontSize: 14,
                                                    }}
                                                    secondaryTypographyProps={{
                                                        noWrap: true,
                                                        fontSize: 12,
                                                    }}
                                                />
                                                <IconButton
                                                    size="small"
                                                    edge="end"
                                                    disabled={streaming}
                                                    onClick={(event) => {
                                                        event.stopPropagation();
                                                        handleDeleteSession(item.sessionId);
                                                    }}
                                                    title="删除会话"
                                                >
                                                    <DeleteOutlineOutlinedIcon fontSize="small" />
                                                </IconButton>
                                            </ListItemButton>
                                        ))}
                                    </List>
                                )}
                            </Paper>
                        </Box>
                    </Box>
                    <Box flex={1} display="flex" flexDirection="column" minHeight={0}>
                        <Box px={2} py={1.5}>
                            <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
                                <Chip size="small" label={`Session: ${sessionId || "--"}`} />
                                <Chip size="small" label={`Prompt: ${runtimeSummary.promptName}`} />
                                <Chip size="small" label={`Model: ${runtimeSummary.model}`} />
                                <Chip size="small" label={`MCP: ${runtimeSummary.mountedMcpCount}`} />
                                <Chip size="small" label={`History: ${persistedMessages.length}`} />
                            </Stack>
                            <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap>
                                {quickLinks.map((item) => (
                                    <Button
                                        key={item.path}
                                        size="small"
                                        variant="outlined"
                                        startIcon={item.icon}
                                        onClick={() => navigate(item.path)}
                                    >
                                        {item.label}
                                    </Button>
                                ))}
                            </Stack>
                            {loadingRuntime && (
                                <Typography variant="body2" color="text.secondary" mt={1}>
                                    正在加载 AI 运行时信息...
                                </Typography>
                            )}
                            {!!drawerError && (
                                <Alert severity="warning" sx={{ mt: 1.5 }}>
                                    {drawerError}
                                </Alert>
                            )}
                        </Box>
                        <Divider />
                        <Box flex={1} overflow="auto" px={2} py={2} display="flex" flexDirection="column" gap={1.5}>
                            {!messages.length && (
                                <Paper variant="outlined" sx={{ p: 2, bgcolor: "grey.50" }}>
                                    <Typography variant="body2" color="text.secondary">
                                        这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。
                                    </Typography>
                                </Paper>
                            )}
                            {messages.map((message, index) => (
                                <Box
                                    key={`${message.role}-${index}`}
                                    display="flex"
                                    justifyContent={message.role === "user" ? "flex-end" : "flex-start"}
                                >
                                    <Paper
                                        elevation={0}
                                        sx={{
                                            px: 1.5,
                                            py: 1.25,
                                            maxWidth: "85%",
                                            borderRadius: 2,
                                            bgcolor: message.role === "user" ? "primary.main" : "grey.100",
                                            color: message.role === "user" ? "primary.contrastText" : "text.primary",
                                            whiteSpace: "pre-wrap",
                                            wordBreak: "break-word",
                                        }}
                                    >
                                        <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
                                            {message.role === "user" ? "你" : "AI"}
                                        </Typography>
                                        <Typography variant="body2">
                                            {message.content || (streaming && index === messages.length - 1 ? "思考中..." : "")}
                                        </Typography>
                                    </Paper>
                                </Box>
                            ))}
                        </Box>
                        <Divider />
                        <Box px={2} py={1.5}>
                            {usage?.totalTokens != null && (
                                <Typography variant="caption" color="text.secondary" display="block" mb={1}>
                                    Tokens: prompt {usage?.promptTokens ?? 0} / completion {usage?.completionTokens ?? 0} / total {usage?.totalTokens ?? 0}
                                </Typography>
                            )}
                            <TextField
                                value={input}
                                onChange={(event) => setInput(event.target.value)}
                                onKeyDown={handleKeyDown}
                                fullWidth
                                multiline
                                minRows={3}
                                maxRows={6}
                                placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行"
                            />
                            <Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}>
                                <Button onClick={() => setInput("")}>清空输入</Button>
                                {streaming ? (
                                    <Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}>
                                        停止
                                    </Button>
                                ) : (
                                    <Button variant="contained" startIcon={<SendRoundedIcon />} onClick={handleSend}>
                                        发送
                                    </Button>
                                )}
                            </Stack>
                        </Box>
                    </Box>
                </Box>
            </Box>
        </Drawer>
    );
};
export default AiChatDrawer;
rsf-admin/src/layout/AppBarToolbar.jsx
@@ -1,12 +1,26 @@
import { useState } from 'react';
import { LoadingIndicator, LocalesMenuButton } from 'react-admin';
import { IconButton, Tooltip } from '@mui/material';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import { ThemeSwapper } from '../themes/ThemeSwapper';
import { TenantTip } from './TenantTip';
import AiChatDrawer from './AiChatDrawer';
export const AppBarToolbar = () => (
    <>
        <LocalesMenuButton />
        <ThemeSwapper />
        <LoadingIndicator />
        <TenantTip />
    </>
);
export const AppBarToolbar = () => {
    const [drawerOpen, setDrawerOpen] = useState(false);
    return (
        <>
            <Tooltip title="AI 对话">
                <IconButton color="inherit" onClick={() => setDrawerOpen(true)}>
                    <SmartToyOutlinedIcon />
                </IconButton>
            </Tooltip>
            <LocalesMenuButton />
            <ThemeSwapper />
            <LoadingIndicator />
            <TenantTip />
            <AiChatDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
        </>
    );
};
rsf-admin/src/page/ResourceContent.js
@@ -70,6 +70,9 @@
import taskPathTemplate from './taskPathTemplate';
import taskPathTemplateMerge from './taskPathTemplateMerge';
import basStationArea from './basStationArea';
import aiParam from "./system/aiParam";
import aiPrompt from "./system/aiPrompt";
import aiMcpMount from "./system/aiMcpMount";
const ResourceContent = (node) => {
  switch (node.component) {
@@ -205,6 +208,12 @@
      return taskPathTemplateMerge;
    case 'basStationArea':
      return basStationArea;
    case "aiParam":
      return aiParam;
    case "aiPrompt":
      return aiPrompt;
    case "aiMcpMount":
      return aiMcpMount;
    // case "locItem":
    //   return locItem;
    default:
rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx
New file
@@ -0,0 +1,13 @@
import React from "react";
import { Create, SimpleForm } from "react-admin";
import AiMcpMountForm from "./AiMcpMountForm";
const AiMcpMountCreate = () => (
    <Create redirect="list">
        <SimpleForm defaultValues={{ transportType: "SSE_HTTP", endpoint: "/sse", requestTimeoutMs: 60000, sort: 0, status: 1 }}>
            <AiMcpMountForm />
        </SimpleForm>
    </Create>
);
export default AiMcpMountCreate;
rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx
New file
@@ -0,0 +1,26 @@
import React from "react";
import {
    DeleteButton,
    Edit,
    SaveButton,
    SimpleForm,
    Toolbar,
} from "react-admin";
import AiMcpMountForm from "./AiMcpMountForm";
const FormToolbar = () => (
    <Toolbar sx={{ justifyContent: "space-between" }}>
        <SaveButton />
        <DeleteButton mutationMode="pessimistic" />
    </Toolbar>
);
const AiMcpMountEdit = () => (
    <Edit redirect="list" mutationMode="pessimistic">
        <SimpleForm warnWhenUnsavedChanges toolbar={<FormToolbar />}>
            <AiMcpMountForm />
        </SimpleForm>
    </Edit>
);
export default AiMcpMountEdit;
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
New file
@@ -0,0 +1,93 @@
import React from "react";
import {
    FormDataConsumer,
    NumberInput,
    SelectInput,
    TextInput,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
const transportChoices = [
    { id: "SSE_HTTP", name: "SSE_HTTP" },
    { id: "STDIO", name: "STDIO" },
    { id: "BUILTIN", name: "BUILTIN" },
];
const AiMcpMountForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
        <Grid item xs={12}>
            <Typography variant="h6">MCP 挂载配置</Typography>
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="name" label="名称" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <SelectInput source="transportType" label="传输类型" choices={transportChoices} fullWidth disabled={readOnly} />
        </Grid>
        <FormDataConsumer>
            {({ formData }) => (
                <>
                    {formData.transportType === "BUILTIN" && (
                        <>
                            <Grid item xs={12}>
                                <SelectInput
                                    source="builtinCode"
                                    label="内置 MCP"
                                    choices={[
                                        { id: "RSF_WMS", name: "RSF_WMS" },
                                        { id: "RSF_WMS_STOCK", name: "RSF_WMS_STOCK" },
                                        { id: "RSF_WMS_TASK", name: "RSF_WMS_TASK" },
                                        { id: "RSF_WMS_BASE", name: "RSF_WMS_BASE" },
                                    ]}
                                    fullWidth
                                    disabled={readOnly}
                                />
                            </Grid>
                        </>
                    )}
                    {formData.transportType === "SSE_HTTP" && (
                        <>
                            <Grid item xs={12}>
                                <TextInput source="serverUrl" label="服务地址" fullWidth disabled={readOnly} />
                            </Grid>
                            <Grid item xs={12}>
                                <TextInput source="endpoint" label="SSE Endpoint" fullWidth disabled={readOnly} />
                            </Grid>
                            <Grid item xs={12}>
                                <TextInput source="headersJson" label="Headers JSON" fullWidth multiline minRows={4} disabled={readOnly} />
                            </Grid>
                        </>
                    )}
                    {formData.transportType === "STDIO" && (
                        <>
                            <Grid item xs={12}>
                                <TextInput source="command" label="命令" fullWidth disabled={readOnly} />
                            </Grid>
                            <Grid item xs={12}>
                                <TextInput source="argsJson" label="Args JSON" fullWidth multiline minRows={4} disabled={readOnly} />
                            </Grid>
                            <Grid item xs={12}>
                                <TextInput source="envJson" label="Env JSON" fullWidth multiline minRows={4} disabled={readOnly} />
                            </Grid>
                        </>
                    )}
                </>
            )}
        </FormDataConsumer>
        <Grid item xs={12} md={4}>
            <NumberInput source="requestTimeoutMs" label="Timeout(ms)" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={4}>
            <NumberInput source="sort" label="排序" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={4}>
            <StatusSelectInput disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
    </Grid>
);
export default AiMcpMountForm;
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
New file
@@ -0,0 +1,254 @@
import React, { useMemo, useState } from "react";
import {
    FilterButton,
    List,
    SearchInput,
    SelectInput,
    TopToolbar,
    useDelete,
    useListContext,
    useNotify,
    useRefresh,
} from "react-admin";
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    Chip,
    CircularProgress,
    Divider,
    Grid,
    Stack,
    Typography,
} from "@mui/material";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import MyExportButton from "@/page/components/MyExportButton";
import AiMcpMountForm from "./AiMcpMountForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
import AiMcpMountToolsPanel from "./AiMcpMountToolsPanel";
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <SelectInput
        source="transportType"
        label="传输类型"
        choices={[
            { id: "SSE_HTTP", name: "SSE_HTTP" },
            { id: "STDIO", name: "STDIO" },
            { id: "BUILTIN", name: "BUILTIN" },
        ]}
    />,
    <SelectInput
        source="status"
        label="状态"
        choices={[
            { id: "1", name: "common.enums.statusTrue" },
            { id: "0", name: "common.enums.statusFalse" },
        ]}
    />,
];
const defaultValues = {
    transportType: "SSE_HTTP",
    endpoint: "/sse",
    requestTimeoutMs: 60000,
    sort: 0,
    status: 1,
};
const truncateText = (value, max = 96) => {
    if (!value) {
        return "--";
    }
    return value.length > max ? `${value.slice(0, max)}...` : value;
};
const resolveTargetLabel = (record) => {
    if (record.transportType === "BUILTIN") {
        return record.builtinCode || "--";
    }
    if (record.transportType === "STDIO") {
        return record.command || "--";
    }
    return record.serverUrl || "--";
};
const AiMcpMountCards = ({ onView, onEdit, onDelete, deleting }) => {
    const { data, isLoading } = useListContext();
    const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
    if (isLoading) {
        return (
            <Box display="flex" justifyContent="center" py={8}>
                <CircularProgress size={28} />
            </Box>
        );
    }
    if (!records.length) {
        return (
            <Box px={2} py={6}>
                <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
                    <Typography variant="subtitle1">暂无 MCP 挂载</Typography>
                    <Typography variant="body2" color="text.secondary" mt={1}>
                        可以新建内置 MCP、远程 SSE 挂载或本地 STDIO 挂载。
                    </Typography>
                </Card>
            </Box>
        );
    }
    return (
        <Box px={2} py={2}>
            <Grid container spacing={2}>
                {records.map((record) => (
                    <Grid item xs={12} md={6} xl={4} key={record.id}>
                        <Card
                            variant="outlined"
                            sx={{
                                height: "100%",
                                borderRadius: 3,
                                boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
                            }}
                        >
                            <CardContent sx={{ pb: 1.5 }}>
                                <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
                                    <Box>
                                        <Typography variant="h6" sx={{ mb: 0.5 }}>
                                            {record.name}
                                        </Typography>
                                        <Typography variant="body2" color="text.secondary">
                                            {record.transportType$ || record.transportType || "--"}
                                        </Typography>
                                    </Box>
                                    <Chip
                                        size="small"
                                        color={record.statusBool ? "success" : "default"}
                                        label={record.statusBool ? "启用" : "停用"}
                                    />
                                </Stack>
                                <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
                                    <Chip size="small" variant="outlined" label={`排序 ${record.sort ?? 0}`} />
                                    <Chip size="small" variant="outlined" label={`${record.requestTimeoutMs ?? "--"} ms`} />
                                </Stack>
                                <Divider sx={{ my: 1.5 }} />
                                <Typography variant="caption" color="text.secondary">目标</Typography>
                                <Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}>
                                    {truncateText(resolveTargetLabel(record), 120)}
                                </Typography>
                                <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                    备注
                                </Typography>
                                <Typography variant="body2">{truncateText(record.memo)}</Typography>
                            </CardContent>
                            <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}>
                                <Stack direction="row" spacing={1}>
                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
                                        详情
                                    </Button>
                                    <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
                                        编辑
                                    </Button>
                                </Stack>
                                <Button
                                    size="small"
                                    color="error"
                                    startIcon={<DeleteOutlineOutlinedIcon />}
                                    onClick={() => onDelete(record)}
                                    disabled={deleting}
                                >
                                    删除
                                </Button>
                            </CardActions>
                        </Card>
                    </Grid>
                ))}
            </Grid>
        </Box>
    );
};
const AiMcpMountList = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    const [deleteOne, { isPending: deleting }] = useDelete();
    const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
    const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
    const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
    const handleDelete = (record) => {
        if (!record?.id || !window.confirm(`确认删除“${record.name}”吗?`)) {
            return;
        }
        deleteOne(
            "aiMcpMount",
            { id: record.id },
            {
                onSuccess: () => {
                    notify("删除成功");
                    refresh();
                },
                onError: (error) => {
                    notify(error?.message || "删除失败", { type: "error" });
                },
            }
        );
    };
    const dialogTitle = {
        create: "新建 MCP 挂载",
        edit: "编辑 MCP 挂载",
        show: "查看 MCP 挂载详情",
    }[dialogState.mode];
    return (
        <>
            <List
                title="menu.aiMcpMount"
                filters={filters}
                sort={{ field: "sort", order: "asc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                        <Button variant="contained" startIcon={<AddRoundedIcon />} onClick={() => openDialog("create")}>
                            新建
                        </Button>
                        <MyExportButton />
                    </TopToolbar>
                )}
            >
                <AiMcpMountCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
                    onDelete={handleDelete}
                    deleting={deleting}
                />
            </List>
            <AiConfigDialog
                open={dialogState.open}
                mode={dialogState.mode}
                title={dialogTitle}
                resource="aiMcpMount"
                recordId={dialogState.recordId}
                defaultValues={defaultValues}
                maxWidth="lg"
                onClose={closeDialog}
            >
                <>
                    <AiMcpMountForm readOnly={dialogState.mode === "show"} />
                    {dialogState.mode !== "create" && (
                        <AiMcpMountToolsPanel mountId={dialogState.recordId} />
                    )}
                </>
            </AiConfigDialog>
        </>
    );
};
export default AiMcpMountList;
rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
New file
@@ -0,0 +1,205 @@
import React, { useEffect, useState } from "react";
import {
    Accordion,
    AccordionDetails,
    AccordionSummary,
    Alert,
    Box,
    Button,
    Card,
    CardContent,
    CircularProgress,
    Divider,
    Grid,
    Stack,
    TextField,
    Typography,
} from "@mui/material";
import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined";
import PreviewOutlinedIcon from "@mui/icons-material/PreviewOutlined";
import ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined";
import { useNotify } from "react-admin";
import { previewMcpTools, testMcpTool } from "@/api/ai/mcpMount";
const AiMcpMountToolsPanel = ({ mountId }) => {
    const notify = useNotify();
    const [loading, setLoading] = useState(false);
    const [tools, setTools] = useState([]);
    const [error, setError] = useState("");
    const [inputs, setInputs] = useState({});
    const [outputs, setOutputs] = useState({});
    const [testingToolName, setTestingToolName] = useState("");
    useEffect(() => {
        if (!mountId) {
            setTools([]);
            setInputs({});
            setOutputs({});
            setError("");
            return;
        }
        loadTools();
    }, [mountId]);
    const loadTools = async () => {
        setLoading(true);
        setError("");
        try {
            const data = await previewMcpTools(mountId);
            setTools(data);
            setOutputs({});
        } catch (requestError) {
            setError(requestError.message || "获取工具列表失败");
        } finally {
            setLoading(false);
        }
    };
    const handleInputChange = (toolName, value) => {
        setInputs((prev) => ({
            ...prev,
            [toolName]: value,
        }));
    };
    const handleTest = async (toolName) => {
        const inputJson = inputs[toolName];
        if (!inputJson || !inputJson.trim()) {
            notify("请输入工具测试 JSON", { type: "warning" });
            return;
        }
        setTestingToolName(toolName);
        try {
            const result = await testMcpTool(mountId, {
                toolName,
                inputJson,
            });
            setOutputs((prev) => ({
                ...prev,
                [toolName]: result?.output || "",
            }));
            notify(`工具 ${toolName} 测试完成`);
        } catch (requestError) {
            const message = requestError.message || "工具测试失败";
            setOutputs((prev) => ({
                ...prev,
                [toolName]: message,
            }));
            notify(message, { type: "error" });
        } finally {
            setTestingToolName("");
        }
    };
    if (!mountId) {
        return (
            <Alert severity="info" sx={{ mt: 2 }}>
                保存挂载后即可预览工具并执行测试。
            </Alert>
        );
    }
    return (
        <Box mt={3}>
            <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}>
                <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}>
                    <Box flex={1}>
                        <Typography variant="h6">工具预览与测试</Typography>
                        <Typography variant="body2" color="text.secondary">
                            当前挂载解析出的全部工具都显示在这里,可直接输入 JSON 做测试。
                        </Typography>
                    </Box>
                </AccordionSummary>
                <AccordionDetails>
                    <Stack direction="row" justifyContent="flex-end" alignItems="center" mb={1.5}>
                        <Button size="small" startIcon={<PreviewOutlinedIcon />} onClick={loadTools} disabled={loading}>
                            刷新工具
                        </Button>
                    </Stack>
                    {loading && (
                        <Box display="flex" justifyContent="center" py={4}>
                            <CircularProgress size={28} />
                        </Box>
                    )}
                    {!!error && !loading && (
                        <Alert severity="warning" sx={{ mb: 2 }}>
                            {error}
                        </Alert>
                    )}
                    {!loading && !error && !tools.length && (
                        <Alert severity="info">当前挂载未解析出任何工具。</Alert>
                    )}
                    <Grid container spacing={2}>
                        {tools.map((tool) => (
                            <Grid item xs={12} key={tool.name}>
                                <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}>
                                    <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}>
                                        <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}>
                                            <Box>
                                                <Typography variant="subtitle1">{tool.name}</Typography>
                                                <Typography variant="body2" color="text.secondary">
                                                    {tool.description || "暂无描述"}
                                                </Typography>
                                            </Box>
                                            <Typography variant="caption" color="text.secondary">
                                                {tool.returnDirect ? "returnDirect" : "normal"}
                                            </Typography>
                                        </Stack>
                                    </AccordionSummary>
                                    <AccordionDetails>
                                        <Card variant="outlined" sx={{ borderRadius: 3 }}>
                                            <CardContent>
                                                <TextField
                                                    label="Input Schema"
                                                    value={tool.inputSchema || ""}
                                                    fullWidth
                                                    multiline
                                                    minRows={5}
                                                    maxRows={12}
                                                    InputProps={{ readOnly: true }}
                                                />
                                                <TextField
                                                    label="测试输入 JSON"
                                                    value={inputs[tool.name] || ""}
                                                    onChange={(event) => handleInputChange(tool.name, event.target.value)}
                                                    fullWidth
                                                    multiline
                                                    minRows={5}
                                                    maxRows={12}
                                                    sx={{ mt: 2 }}
                                                    placeholder='例如:{"code":"A01"}'
                                                />
                                                <Stack direction="row" justifyContent="flex-end" mt={1.5}>
                                                    <Button
                                                        variant="contained"
                                                        startIcon={<PlayCircleOutlineOutlinedIcon />}
                                                        onClick={() => handleTest(tool.name)}
                                                        disabled={testingToolName === tool.name}
                                                    >
                                                        {testingToolName === tool.name ? "测试中..." : "执行测试"}
                                                    </Button>
                                                </Stack>
                                                <TextField
                                                    label="测试结果"
                                                    value={outputs[tool.name] || ""}
                                                    fullWidth
                                                    multiline
                                                    minRows={5}
                                                    maxRows={16}
                                                    sx={{ mt: 2 }}
                                                    InputProps={{ readOnly: true }}
                                                />
                                            </CardContent>
                                        </Card>
                                    </AccordionDetails>
                                </Accordion>
                            </Grid>
                        ))}
                    </Grid>
                </AccordionDetails>
            </Accordion>
        </Box>
    );
};
export default AiMcpMountToolsPanel;
rsf-admin/src/page/system/aiMcpMount/index.jsx
New file
@@ -0,0 +1,12 @@
import { ShowGuesser } from "react-admin";
import AiMcpMountList from "./AiMcpMountList";
import AiMcpMountCreate from "./AiMcpMountCreate";
import AiMcpMountEdit from "./AiMcpMountEdit";
export default {
    list: AiMcpMountList,
    create: AiMcpMountCreate,
    edit: AiMcpMountEdit,
    show: ShowGuesser,
    recordRepresentation: (record) => `${record?.name || ''}`,
};
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
New file
@@ -0,0 +1,13 @@
import React from "react";
import { Create, SimpleForm } from "react-admin";
import AiParamForm from "./AiParamForm";
const AiParamCreate = () => (
    <Create redirect="list">
        <SimpleForm defaultValues={{ providerType: "OPENAI_COMPATIBLE", temperature: 0.7, topP: 1, timeoutMs: 60000, streamingEnabled: true, status: 1 }}>
            <AiParamForm />
        </SimpleForm>
    </Create>
);
export default AiParamCreate;
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
New file
@@ -0,0 +1,26 @@
import React from "react";
import {
    DeleteButton,
    Edit,
    SaveButton,
    SimpleForm,
    Toolbar,
} from "react-admin";
import AiParamForm from "./AiParamForm";
const FormToolbar = () => (
    <Toolbar sx={{ justifyContent: "space-between" }}>
        <SaveButton />
        <DeleteButton mutationMode="pessimistic" />
    </Toolbar>
);
const AiParamEdit = () => (
    <Edit redirect="list" mutationMode="pessimistic">
        <SimpleForm warnWhenUnsavedChanges toolbar={<FormToolbar />}>
            <AiParamForm />
        </SimpleForm>
    </Edit>
);
export default AiParamEdit;
rsf-admin/src/page/system/aiParam/AiParamForm.jsx
New file
@@ -0,0 +1,59 @@
import React from "react";
import {
    BooleanInput,
    NumberInput,
    SelectInput,
    TextInput,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
const providerChoices = [
    { id: "OPENAI_COMPATIBLE", name: "OPENAI_COMPATIBLE" },
];
const AiParamForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
        <Grid item xs={12}>
            <Typography variant="h6">主要配置</Typography>
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="name" label="名称" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <SelectInput source="providerType" label="提供方类型" choices={providerChoices} fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="baseUrl" label="Base URL" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="apiKey" label="API Key" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="model" label="模型" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={3}>
            <NumberInput source="temperature" label="Temperature" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={3}>
            <NumberInput source="topP" label="Top P" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={3}>
            <NumberInput source="maxTokens" label="Max Tokens" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={3}>
            <NumberInput source="timeoutMs" label="Timeout(ms)" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <BooleanInput source="streamingEnabled" label="启用流式响应" disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <StatusSelectInput disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
    </Grid>
);
export default AiParamForm;
rsf-admin/src/page/system/aiParam/AiParamList.jsx
New file
@@ -0,0 +1,258 @@
import React, { useMemo, useState } from "react";
import {
    FilterButton,
    List,
    SearchInput,
    SelectInput,
    TextInput,
    TopToolbar,
    useDelete,
    useListContext,
    useNotify,
    useRefresh,
} from "react-admin";
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    Chip,
    CircularProgress,
    Divider,
    Grid,
    Stack,
    Typography,
} from "@mui/material";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import MyExportButton from "@/page/components/MyExportButton";
import AiParamForm from "./AiParamForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <TextInput source="providerType" label="提供方类型" />,
    <TextInput source="model" label="模型" />,
    <SelectInput
        source="status"
        label="状态"
        choices={[
            { id: "1", name: "common.enums.statusTrue" },
            { id: "0", name: "common.enums.statusFalse" },
        ]}
    />,
];
const defaultValues = {
    providerType: "OPENAI_COMPATIBLE",
    temperature: 0.7,
    topP: 1,
    timeoutMs: 60000,
    streamingEnabled: true,
    status: 1,
};
const truncateText = (value, max = 84) => {
    if (!value) {
        return "--";
    }
    return value.length > max ? `${value.slice(0, max)}...` : value;
};
const AiParamCards = ({ onView, onEdit, onDelete, deleting }) => {
    const { data, isLoading } = useListContext();
    const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
    if (isLoading) {
        return (
            <Box display="flex" justifyContent="center" py={8}>
                <CircularProgress size={28} />
            </Box>
        );
    }
    if (!records.length) {
        return (
            <Box px={2} py={6}>
                <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
                    <Typography variant="subtitle1">暂无 AI 参数配置</Typography>
                    <Typography variant="body2" color="text.secondary" mt={1}>
                        可以先新建一个 OpenAI 兼容模型参数卡片。
                    </Typography>
                </Card>
            </Box>
        );
    }
    return (
        <Box px={2} py={2}>
            <Grid container spacing={2}>
                {records.map((record) => (
                    <Grid item xs={12} md={6} xl={4} key={record.id}>
                        <Card
                            variant="outlined"
                            sx={{
                                height: "100%",
                                borderRadius: 3,
                                boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
                            }}
                        >
                            <CardContent sx={{ pb: 1.5 }}>
                                <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
                                    <Box>
                                        <Typography variant="h6" sx={{ mb: 0.5 }}>
                                            {record.name}
                                        </Typography>
                                        <Typography variant="body2" color="text.secondary">
                                            {record.model || "--"}
                                        </Typography>
                                    </Box>
                                    <Chip
                                        size="small"
                                        color={record.statusBool ? "success" : "default"}
                                        label={record.statusBool ? "启用" : "停用"}
                                    />
                                </Stack>
                                <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
                                    <Chip size="small" variant="outlined" label={record.providerType$ || "OPENAI_COMPATIBLE"} />
                                    <Chip
                                        size="small"
                                        variant="outlined"
                                        color={record.streamingEnabled ? "info" : "default"}
                                        label={record.streamingEnabled ? "流式响应" : "非流式"}
                                    />
                                </Stack>
                                <Divider sx={{ my: 1.5 }} />
                                <Typography variant="body2" color="text.secondary">
                                    Base URL
                                </Typography>
                                <Typography variant="body2" sx={{ mb: 1.5, wordBreak: "break-all" }}>
                                    {truncateText(record.baseUrl, 120)}
                                </Typography>
                                <Grid container spacing={1}>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Temperature</Typography>
                                        <Typography variant="body2">{record.temperature ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Top P</Typography>
                                        <Typography variant="body2">{record.topP ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Max Tokens</Typography>
                                        <Typography variant="body2">{record.maxTokens ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Timeout</Typography>
                                        <Typography variant="body2">{record.timeoutMs ?? "--"} ms</Typography>
                                    </Grid>
                                </Grid>
                                <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                    备注
                                </Typography>
                                <Typography variant="body2">{truncateText(record.memo)}</Typography>
                            </CardContent>
                            <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}>
                                <Stack direction="row" spacing={1}>
                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
                                        详情
                                    </Button>
                                    <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
                                        编辑
                                    </Button>
                                </Stack>
                                <Button
                                    size="small"
                                    color="error"
                                    startIcon={<DeleteOutlineOutlinedIcon />}
                                    onClick={() => onDelete(record)}
                                    disabled={deleting}
                                >
                                    删除
                                </Button>
                            </CardActions>
                        </Card>
                    </Grid>
                ))}
            </Grid>
        </Box>
    );
};
const AiParamList = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    const [deleteOne, { isPending: deleting }] = useDelete();
    const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
    const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
    const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
    const handleDelete = (record) => {
        if (!record?.id || !window.confirm(`确认删除“${record.name}”吗?`)) {
            return;
        }
        deleteOne(
            "aiParam",
            { id: record.id },
            {
                onSuccess: () => {
                    notify("删除成功");
                    refresh();
                },
                onError: (error) => {
                    notify(error?.message || "删除失败", { type: "error" });
                },
            }
        );
    };
    const dialogTitle = {
        create: "新建 AI 参数",
        edit: "编辑 AI 参数",
        show: "查看 AI 参数详情",
    }[dialogState.mode];
    return (
        <>
            <List
                title="menu.aiParam"
                filters={filters}
                sort={{ field: "create_time", order: "desc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                        <Button variant="contained" startIcon={<AddRoundedIcon />} onClick={() => openDialog("create")}>
                            新建
                        </Button>
                        <MyExportButton />
                    </TopToolbar>
                )}
            >
                <AiParamCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
                    onDelete={handleDelete}
                    deleting={deleting}
                />
            </List>
            <AiConfigDialog
                open={dialogState.open}
                mode={dialogState.mode}
                title={dialogTitle}
                resource="aiParam"
                recordId={dialogState.recordId}
                defaultValues={defaultValues}
                maxWidth="md"
                onClose={closeDialog}
            >
                <AiParamForm readOnly={dialogState.mode === "show"} />
            </AiConfigDialog>
        </>
    );
};
export default AiParamList;
rsf-admin/src/page/system/aiParam/index.jsx
New file
@@ -0,0 +1,12 @@
import { ShowGuesser } from "react-admin";
import AiParamList from "./AiParamList";
import AiParamCreate from "./AiParamCreate";
import AiParamEdit from "./AiParamEdit";
export default {
    list: AiParamList,
    create: AiParamCreate,
    edit: AiParamEdit,
    show: ShowGuesser,
    recordRepresentation: (record) => `${record?.name || ''}`,
};
rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx
New file
@@ -0,0 +1,13 @@
import React from "react";
import { Create, SimpleForm } from "react-admin";
import AiPromptForm from "./AiPromptForm";
const AiPromptCreate = () => (
    <Create redirect="list">
        <SimpleForm defaultValues={{ code: "home.default", scene: "home", status: 1 }}>
            <AiPromptForm />
        </SimpleForm>
    </Create>
);
export default AiPromptCreate;
rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx
New file
@@ -0,0 +1,26 @@
import React from "react";
import {
    DeleteButton,
    Edit,
    SaveButton,
    SimpleForm,
    Toolbar,
} from "react-admin";
import AiPromptForm from "./AiPromptForm";
const FormToolbar = () => (
    <Toolbar sx={{ justifyContent: "space-between" }}>
        <SaveButton />
        <DeleteButton mutationMode="pessimistic" />
    </Toolbar>
);
const AiPromptEdit = () => (
    <Edit redirect="list" mutationMode="pessimistic">
        <SimpleForm warnWhenUnsavedChanges toolbar={<FormToolbar />}>
            <AiPromptForm />
        </SimpleForm>
    </Edit>
);
export default AiPromptEdit;
rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx
New file
@@ -0,0 +1,37 @@
import React from "react";
import {
    TextInput,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
const AiPromptForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
        <Grid item xs={12}>
            <Typography variant="h6">Prompt 配置</Typography>
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="name" label="名称" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="code" label="编码" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="scene" label="场景" fullWidth disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="systemPrompt" label="System Prompt" fullWidth multiline minRows={6} disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="userPromptTemplate" label="User Prompt Template" fullWidth multiline minRows={5} disabled={readOnly} />
        </Grid>
        <Grid item xs={12} md={6}>
            <StatusSelectInput disabled={readOnly} />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
    </Grid>
);
export default AiPromptForm;
rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx
New file
@@ -0,0 +1,229 @@
import React, { useMemo, useState } from "react";
import {
    FilterButton,
    List,
    SearchInput,
    SelectInput,
    TextInput,
    TopToolbar,
    useDelete,
    useListContext,
    useNotify,
    useRefresh,
} from "react-admin";
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    Chip,
    CircularProgress,
    Divider,
    Grid,
    Stack,
    Typography,
} from "@mui/material";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import MyExportButton from "@/page/components/MyExportButton";
import AiPromptForm from "./AiPromptForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <TextInput source="code" label="编码" />,
    <TextInput source="scene" label="场景" />,
    <SelectInput
        source="status"
        label="状态"
        choices={[
            { id: "1", name: "common.enums.statusTrue" },
            { id: "0", name: "common.enums.statusFalse" },
        ]}
    />,
];
const defaultValues = {
    code: "home.default",
    scene: "home",
    status: 1,
};
const truncateText = (value, max = 120) => {
    if (!value) {
        return "--";
    }
    return value.length > max ? `${value.slice(0, max)}...` : value;
};
const AiPromptCards = ({ onView, onEdit, onDelete, deleting }) => {
    const { data, isLoading } = useListContext();
    const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
    if (isLoading) {
        return (
            <Box display="flex" justifyContent="center" py={8}>
                <CircularProgress size={28} />
            </Box>
        );
    }
    if (!records.length) {
        return (
            <Box px={2} py={6}>
                <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
                    <Typography variant="subtitle1">暂无 Prompt 配置</Typography>
                    <Typography variant="body2" color="text.secondary" mt={1}>
                        新建一张 Prompt 卡片后,AI 对话会动态加载这里的内容。
                    </Typography>
                </Card>
            </Box>
        );
    }
    return (
        <Box px={2} py={2}>
            <Grid container spacing={2}>
                {records.map((record) => (
                    <Grid item xs={12} md={6} xl={4} key={record.id}>
                        <Card
                            variant="outlined"
                            sx={{
                                height: "100%",
                                borderRadius: 3,
                                boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
                            }}
                        >
                            <CardContent sx={{ pb: 1.5 }}>
                                <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
                                    <Box>
                                        <Typography variant="h6" sx={{ mb: 0.5 }}>
                                            {record.name}
                                        </Typography>
                                        <Typography variant="body2" color="text.secondary">
                                            {record.code || "--"}
                                        </Typography>
                                    </Box>
                                    <Chip
                                        size="small"
                                        color={record.statusBool ? "success" : "default"}
                                        label={record.statusBool ? "启用" : "停用"}
                                    />
                                </Stack>
                                <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
                                    <Chip size="small" variant="outlined" label={`Scene: ${record.scene || "--"}`} />
                                </Stack>
                                <Divider sx={{ my: 1.5 }} />
                                <Typography variant="caption" color="text.secondary">System Prompt</Typography>
                                <Typography variant="body2" sx={{ mt: 0.5 }}>
                                    {truncateText(record.systemPrompt)}
                                </Typography>
                                <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                    User Prompt Template
                                </Typography>
                                <Typography variant="body2">{truncateText(record.userPromptTemplate, 100)}</Typography>
                            </CardContent>
                            <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}>
                                <Stack direction="row" spacing={1}>
                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
                                        详情
                                    </Button>
                                    <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
                                        编辑
                                    </Button>
                                </Stack>
                                <Button
                                    size="small"
                                    color="error"
                                    startIcon={<DeleteOutlineOutlinedIcon />}
                                    onClick={() => onDelete(record)}
                                    disabled={deleting}
                                >
                                    删除
                                </Button>
                            </CardActions>
                        </Card>
                    </Grid>
                ))}
            </Grid>
        </Box>
    );
};
const AiPromptList = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    const [deleteOne, { isPending: deleting }] = useDelete();
    const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
    const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
    const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
    const handleDelete = (record) => {
        if (!record?.id || !window.confirm(`确认删除“${record.name}”吗?`)) {
            return;
        }
        deleteOne(
            "aiPrompt",
            { id: record.id },
            {
                onSuccess: () => {
                    notify("删除成功");
                    refresh();
                },
                onError: (error) => {
                    notify(error?.message || "删除失败", { type: "error" });
                },
            }
        );
    };
    const dialogTitle = {
        create: "新建 Prompt",
        edit: "编辑 Prompt",
        show: "查看 Prompt 详情",
    }[dialogState.mode];
    return (
        <>
            <List
                title="menu.aiPrompt"
                filters={filters}
                sort={{ field: "create_time", order: "desc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                        <Button variant="contained" startIcon={<AddRoundedIcon />} onClick={() => openDialog("create")}>
                            新建
                        </Button>
                        <MyExportButton />
                    </TopToolbar>
                )}
            >
                <AiPromptCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
                    onDelete={handleDelete}
                    deleting={deleting}
                />
            </List>
            <AiConfigDialog
                open={dialogState.open}
                mode={dialogState.mode}
                title={dialogTitle}
                resource="aiPrompt"
                recordId={dialogState.recordId}
                defaultValues={defaultValues}
                maxWidth="lg"
                onClose={closeDialog}
            >
                <AiPromptForm readOnly={dialogState.mode === "show"} />
            </AiConfigDialog>
        </>
    );
};
export default AiPromptList;
rsf-admin/src/page/system/aiPrompt/index.jsx
New file
@@ -0,0 +1,12 @@
import { ShowGuesser } from "react-admin";
import AiPromptList from "./AiPromptList";
import AiPromptCreate from "./AiPromptCreate";
import AiPromptEdit from "./AiPromptEdit";
export default {
    list: AiPromptList,
    create: AiPromptCreate,
    edit: AiPromptEdit,
    show: ShowGuesser,
    recordRepresentation: (record) => `${record?.name || ''}`,
};
rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx
New file
@@ -0,0 +1,99 @@
import React from "react";
import {
    CreateBase,
    EditBase,
    SaveButton,
    SimpleForm,
    Toolbar,
    useNotify,
    useRefresh,
} from "react-admin";
import {
    Button,
    Dialog,
    DialogActions,
    DialogContent,
    DialogTitle,
} from "@mui/material";
const DialogFormToolbar = ({ onClose }) => (
    <Toolbar sx={{ justifyContent: "space-between", px: 0 }}>
        <Button onClick={onClose}>取消</Button>
        <SaveButton />
    </Toolbar>
);
const AiConfigDialog = ({
    open,
    mode,
    title,
    resource,
    recordId,
    defaultValues,
    maxWidth = "md",
    onClose,
    children,
}) => {
    const notify = useNotify();
    const refresh = useRefresh();
    if (!open) {
        return null;
    }
    const handleSuccess = () => {
        notify(mode === "create" ? "保存成功" : "更新成功");
        refresh();
        onClose();
    };
    const handleError = (error) => {
        notify(error?.message || "操作失败", { type: "error" });
    };
    const formContent = (
        <SimpleForm
            defaultValues={mode === "create" ? defaultValues : undefined}
            toolbar={mode === "show" ? false : <DialogFormToolbar onClose={onClose} />}
            sx={{
                "& .RaSimpleForm-form": {
                    maxWidth: "100%",
                },
            }}
        >
            {children}
        </SimpleForm>
    );
    return (
        <Dialog open={open} onClose={onClose} fullWidth maxWidth={maxWidth}>
            <DialogTitle>{title}</DialogTitle>
            <DialogContent dividers>
                {mode === "create" ? (
                    <CreateBase
                        resource={resource}
                        mutationOptions={{ onSuccess: handleSuccess, onError: handleError }}
                    >
                        {formContent}
                    </CreateBase>
                ) : (
                    <EditBase
                        resource={resource}
                        id={recordId}
                        mutationMode="pessimistic"
                        mutationOptions={{ onSuccess: handleSuccess, onError: handleError }}
                    >
                        {formContent}
                    </EditBase>
                )}
            </DialogContent>
            {mode === "show" && (
                <DialogActions>
                    <Button onClick={onClose}>关闭</Button>
                </DialogActions>
            )}
        </Dialog>
    );
};
export default AiConfigDialog;
rsf-server/pom.xml
@@ -17,7 +17,19 @@
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-ai.version>1.1.2</spring-ai.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.vincent</groupId>
@@ -33,6 +45,18 @@
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-mcp</artifactId>
        </dependency>
        <dependency>
            <groupId>RouteUtils</groupId>
            <artifactId>RouteUtils</artifactId>
            <version>1.0.0</version>
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
New file
@@ -0,0 +1,21 @@
package com.vincent.rsf.server.ai.config;
public final class AiDefaults {
    private AiDefaults() {
    }
    public static final String DEFAULT_PROMPT_CODE = "home.default";
    public static final String PROVIDER_OPENAI_COMPATIBLE = "OPENAI_COMPATIBLE";
    public static final String MCP_TRANSPORT_SSE_HTTP = "SSE_HTTP";
    public static final String MCP_TRANSPORT_STDIO = "STDIO";
    public static final String MCP_TRANSPORT_BUILTIN = "BUILTIN";
    public static final String MCP_BUILTIN_RSF_WMS = "RSF_WMS";
    public static final String MCP_BUILTIN_RSF_WMS_STOCK = "RSF_WMS_STOCK";
    public static final String MCP_BUILTIN_RSF_WMS_TASK = "RSF_WMS_TASK";
    public static final String MCP_BUILTIN_RSF_WMS_BASE = "RSF_WMS_BASE";
    public static final long SSE_TIMEOUT_MS = 0L;
    public static final int DEFAULT_TIMEOUT_MS = 60000;
    public static final double DEFAULT_TEMPERATURE = 0.7D;
    public static final double DEFAULT_TOP_P = 1.0D;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
New file
@@ -0,0 +1,44 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.dto.AiChatRequest;
import com.vincent.rsf.server.ai.service.AiChatService;
import com.vincent.rsf.server.system.controller.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequiredArgsConstructor
public class AiChatController extends BaseController {
    private final AiChatService aiChatService;
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/ai/chat/runtime")
    public R runtime(@RequestParam(required = false) String promptCode,
                     @RequestParam(required = false) Long sessionId) {
        return R.ok().add(aiChatService.getRuntime(promptCode, sessionId, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/ai/chat/sessions")
    public R sessions(@RequestParam(required = false) String promptCode) {
        return R.ok().add(aiChatService.listSessions(promptCode, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/remove/{sessionId}")
    public R removeSession(@PathVariable Long sessionId) {
        aiChatService.removeSession(sessionId, getLoginUserId(), getTenantId());
        return R.ok("Delete Success").add(sessionId);
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestBody AiChatRequest request) {
        return aiChatService.stream(request, getLoginUserId(), getTenantId());
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
New file
@@ -0,0 +1,108 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.service.AiMcpMountService;
import com.vincent.rsf.server.common.annotation.OperationLog;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.system.controller.BaseController;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
@RestController
public class AiMcpMountController extends BaseController {
    @Autowired
    private AiMcpMountService aiMcpMountService;
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @PostMapping("/aiMcpMount/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<AiMcpMount, BaseParam> pageParam = new PageParam<>(baseParam, AiMcpMount.class);
        return R.ok().add(aiMcpMountService.page(pageParam, pageParam.buildWrapper(true)));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @PostMapping("/aiMcpMount/list")
    public R list(@RequestBody Map<String, Object> map) {
        return R.ok().add(aiMcpMountService.list());
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @PostMapping({"/aiMcpMount/many/{ids}", "/aiMcpMounts/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(aiMcpMountService.listByIds(Arrays.asList(ids)));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @GetMapping("/aiMcpMount/{id}")
    public R get(@PathVariable("id") Long id) {
        return R.ok().add(aiMcpMountService.getById(id));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @GetMapping("/aiMcpMount/{id}/tools")
    public R previewTools(@PathVariable("id") Long id) {
        return R.ok().add(aiMcpMountService.previewTools(id, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:update')")
    @PostMapping("/aiMcpMount/{id}/tool/test")
    public R testTool(@PathVariable("id") Long id, @RequestBody AiMcpToolTestRequest request) {
        return R.ok().add(aiMcpMountService.testTool(id, getLoginUserId(), getTenantId(), request));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:save')")
    @OperationLog("Create AiMcpMount")
    @PostMapping("/aiMcpMount/save")
    public R save(@RequestBody AiMcpMount aiMcpMount) {
        aiMcpMountService.validateBeforeSave(aiMcpMount);
        aiMcpMount.setCreateBy(getLoginUserId());
        aiMcpMount.setCreateTime(new Date());
        aiMcpMount.setUpdateBy(getLoginUserId());
        aiMcpMount.setUpdateTime(new Date());
        if (!aiMcpMountService.save(aiMcpMount)) {
            return R.error("Save Fail");
        }
        return R.ok("Save Success").add(aiMcpMount);
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:update')")
    @OperationLog("Update AiMcpMount")
    @PostMapping("/aiMcpMount/update")
    public R update(@RequestBody AiMcpMount aiMcpMount) {
        aiMcpMountService.validateBeforeUpdate(aiMcpMount);
        aiMcpMount.setUpdateBy(getLoginUserId());
        aiMcpMount.setUpdateTime(new Date());
        if (!aiMcpMountService.updateById(aiMcpMount)) {
            return R.error("Update Fail");
        }
        return R.ok("Update Success").add(aiMcpMount);
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:remove')")
    @OperationLog("Delete AiMcpMount")
    @PostMapping("/aiMcpMount/remove/{ids}")
    public R remove(@PathVariable Long[] ids) {
        if (!aiMcpMountService.removeByIds(Arrays.asList(ids))) {
            return R.error("Delete Fail");
        }
        return R.ok("Delete Success").add(ids);
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:list')")
    @PostMapping("/aiMcpMount/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(aiMcpMountService.list(), AiMcpMount.class), response);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java
New file
@@ -0,0 +1,95 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.service.AiParamService;
import com.vincent.rsf.server.common.annotation.OperationLog;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.system.controller.BaseController;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
@RestController
public class AiParamController extends BaseController {
    @Autowired
    private AiParamService aiParamService;
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @PostMapping("/aiParam/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<AiParam, BaseParam> pageParam = new PageParam<>(baseParam, AiParam.class);
        return R.ok().add(aiParamService.page(pageParam, pageParam.buildWrapper(true)));
    }
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @PostMapping("/aiParam/list")
    public R list(@RequestBody Map<String, Object> map) {
        return R.ok().add(aiParamService.list());
    }
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @PostMapping({"/aiParam/many/{ids}", "/aiParams/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(aiParamService.listByIds(Arrays.asList(ids)));
    }
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @GetMapping("/aiParam/{id}")
    public R get(@PathVariable("id") Long id) {
        return R.ok().add(aiParamService.getById(id));
    }
    @PreAuthorize("hasAuthority('system:aiParam:save')")
    @OperationLog("Create AiParam")
    @PostMapping("/aiParam/save")
    public R save(@RequestBody AiParam aiParam) {
        aiParamService.validateBeforeSave(aiParam);
        aiParam.setCreateBy(getLoginUserId());
        aiParam.setCreateTime(new Date());
        aiParam.setUpdateBy(getLoginUserId());
        aiParam.setUpdateTime(new Date());
        if (!aiParamService.save(aiParam)) {
            return R.error("Save Fail");
        }
        return R.ok("Save Success").add(aiParam);
    }
    @PreAuthorize("hasAuthority('system:aiParam:update')")
    @OperationLog("Update AiParam")
    @PostMapping("/aiParam/update")
    public R update(@RequestBody AiParam aiParam) {
        aiParamService.validateBeforeUpdate(aiParam);
        aiParam.setUpdateBy(getLoginUserId());
        aiParam.setUpdateTime(new Date());
        if (!aiParamService.updateById(aiParam)) {
            return R.error("Update Fail");
        }
        return R.ok("Update Success").add(aiParam);
    }
    @PreAuthorize("hasAuthority('system:aiParam:remove')")
    @OperationLog("Delete AiParam")
    @PostMapping("/aiParam/remove/{ids}")
    public R remove(@PathVariable Long[] ids) {
        if (!aiParamService.removeByIds(Arrays.asList(ids))) {
            return R.error("Delete Fail");
        }
        return R.ok("Delete Success").add(ids);
    }
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @PostMapping("/aiParam/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(aiParamService.list(), AiParam.class), response);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java
New file
@@ -0,0 +1,95 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.service.AiPromptService;
import com.vincent.rsf.server.common.annotation.OperationLog;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.system.controller.BaseController;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
@RestController
public class AiPromptController extends BaseController {
    @Autowired
    private AiPromptService aiPromptService;
    @PreAuthorize("hasAuthority('system:aiPrompt:list')")
    @PostMapping("/aiPrompt/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<AiPrompt, BaseParam> pageParam = new PageParam<>(baseParam, AiPrompt.class);
        return R.ok().add(aiPromptService.page(pageParam, pageParam.buildWrapper(true)));
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:list')")
    @PostMapping("/aiPrompt/list")
    public R list(@RequestBody Map<String, Object> map) {
        return R.ok().add(aiPromptService.list());
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:list')")
    @PostMapping({"/aiPrompt/many/{ids}", "/aiPrompts/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(aiPromptService.listByIds(Arrays.asList(ids)));
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:list')")
    @GetMapping("/aiPrompt/{id}")
    public R get(@PathVariable("id") Long id) {
        return R.ok().add(aiPromptService.getById(id));
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:save')")
    @OperationLog("Create AiPrompt")
    @PostMapping("/aiPrompt/save")
    public R save(@RequestBody AiPrompt aiPrompt) {
        aiPromptService.validateBeforeSave(aiPrompt);
        aiPrompt.setCreateBy(getLoginUserId());
        aiPrompt.setCreateTime(new Date());
        aiPrompt.setUpdateBy(getLoginUserId());
        aiPrompt.setUpdateTime(new Date());
        if (!aiPromptService.save(aiPrompt)) {
            return R.error("Save Fail");
        }
        return R.ok("Save Success").add(aiPrompt);
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:update')")
    @OperationLog("Update AiPrompt")
    @PostMapping("/aiPrompt/update")
    public R update(@RequestBody AiPrompt aiPrompt) {
        aiPromptService.validateBeforeUpdate(aiPrompt);
        aiPrompt.setUpdateBy(getLoginUserId());
        aiPrompt.setUpdateTime(new Date());
        if (!aiPromptService.updateById(aiPrompt)) {
            return R.error("Update Fail");
        }
        return R.ok("Update Success").add(aiPrompt);
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:remove')")
    @OperationLog("Delete AiPrompt")
    @PostMapping("/aiPrompt/remove/{ids}")
    public R remove(@PathVariable Long[] ids) {
        if (!aiPromptService.removeByIds(Arrays.asList(ids))) {
            return R.error("Delete Fail");
        }
        return R.ok("Delete Success").add(ids);
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:list')")
    @PostMapping("/aiPrompt/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(aiPromptService.list(), AiPrompt.class), response);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java
New file
@@ -0,0 +1,19 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiChatDoneDto {
    private Long sessionId;
    private String model;
    private Integer promptTokens;
    private Integer completionTokens;
    private Integer totalTokens;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java
New file
@@ -0,0 +1,15 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class AiChatMemoryDto {
    private Long sessionId;
    private List<AiChatMessageDto> persistedMessages;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
@Data
public class AiChatMessageDto {
    private String role;
    private String content;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java
New file
@@ -0,0 +1,18 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class AiChatRequest {
    private Long sessionId;
    private List<AiChatMessageDto> messages;
    private String promptCode;
    private Map<String, Object> metadata;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java
New file
@@ -0,0 +1,29 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class AiChatRuntimeDto {
    private Long sessionId;
    private String promptCode;
    private String promptName;
    private String model;
    private Integer configuredMcpCount;
    private Integer mountedMcpCount;
    private List<String> mountedMcpNames;
    private List<String> mountErrors;
    private List<AiChatMessageDto> persistedMessages;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java
New file
@@ -0,0 +1,21 @@
package com.vincent.rsf.server.ai.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
@Data
@Builder
public class AiChatSessionDto {
    private Long sessionId;
    private String title;
    private String promptCode;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastMessageTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java
New file
@@ -0,0 +1,17 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiMcpToolPreviewDto {
    private String name;
    private String description;
    private String inputSchema;
    private Boolean returnDirect;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java
New file
@@ -0,0 +1,15 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiMcpToolTestDto {
    private String toolName;
    private String inputJson;
    private String output;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
@Data
public class AiMcpToolTestRequest {
    private String toolName;
    private String inputJson;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java
New file
@@ -0,0 +1,22 @@
package com.vincent.rsf.server.ai.dto;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class AiResolvedConfig {
    private String promptCode;
    private AiParam aiParam;
    private AiPrompt prompt;
    private List<AiMcpMount> mcpMounts;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java
New file
@@ -0,0 +1,54 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_chat_message")
public class AiChatMessage implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "会话 ID")
    private Long sessionId;
    @ApiModelProperty(value = "消息序号")
    private Integer seqNo;
    @ApiModelProperty(value = "消息角色")
    private String role;
    @ApiModelProperty(value = "消息内容")
    private String content;
    @ApiModelProperty(value = "用户 ID")
    private Long userId;
    @ApiModelProperty(value = "租户 ID")
    private Long tenantId;
    @ApiModelProperty(value = "是否删除")
    private Integer deleted;
    @ApiModelProperty(value = "添加人员")
    private Long createBy;
    @ApiModelProperty(value = "添加时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java
New file
@@ -0,0 +1,64 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_chat_session")
public class AiChatSession implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "标题")
    private String title;
    @ApiModelProperty(value = "Prompt 编码")
    private String promptCode;
    @ApiModelProperty(value = "用户 ID")
    private Long userId;
    @ApiModelProperty(value = "租户 ID")
    private Long tenantId;
    @ApiModelProperty(value = "最后消息时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastMessageTime;
    @ApiModelProperty(value = "状态")
    private Integer status;
    @ApiModelProperty(value = "是否删除")
    private Integer deleted;
    @ApiModelProperty(value = "添加人员")
    private Long createBy;
    @ApiModelProperty(value = "添加时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty(value = "修改人员")
    private Long updateBy;
    @ApiModelProperty(value = "修改时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
New file
@@ -0,0 +1,112 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_mcp_mount")
public class AiMcpMount implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "名称")
    private String name;
    @ApiModelProperty(value = "传输类型")
    private String transportType;
    @ApiModelProperty(value = "内置 MCP 编码")
    private String builtinCode;
    @ApiModelProperty(value = "服务地址")
    private String serverUrl;
    @ApiModelProperty(value = "SSE端点")
    private String endpoint;
    @ApiModelProperty(value = "命令")
    private String command;
    @ApiModelProperty(value = "命令参数JSON")
    private String argsJson;
    @ApiModelProperty(value = "环境变量JSON")
    private String envJson;
    @ApiModelProperty(value = "请求头JSON")
    private String headersJson;
    @ApiModelProperty(value = "超时时间")
    private Integer requestTimeoutMs;
    @ApiModelProperty(value = "排序")
    private Integer sort;
    @ApiModelProperty(value = "状态")
    private Integer status;
    @ApiModelProperty(value = "是否删除")
    private Integer deleted;
    @ApiModelProperty(value = "租户")
    private Long tenantId;
    @ApiModelProperty(value = "添加人员")
    private Long createBy;
    @ApiModelProperty(value = "添加时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty(value = "修改人员")
    private Long updateBy;
    @ApiModelProperty(value = "修改时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
    @ApiModelProperty(value = "备注")
    private String memo;
    public String getTransportType$() {
        return this.transportType;
    }
    public Boolean getStatusBool() {
        if (this.status == null) {
            return null;
        }
        return this.status == 1;
    }
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
    public String getUpdateTime$() {
        if (this.updateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java
New file
@@ -0,0 +1,109 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_param")
public class AiParam implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "名称")
    private String name;
    @ApiModelProperty(value = "提供方类型")
    private String providerType;
    @ApiModelProperty(value = "基础地址")
    private String baseUrl;
    @ApiModelProperty(value = "密钥")
    private String apiKey;
    @ApiModelProperty(value = "模型")
    private String model;
    @ApiModelProperty(value = "temperature")
    private Double temperature;
    @ApiModelProperty(value = "topP")
    private Double topP;
    @ApiModelProperty(value = "maxTokens")
    private Integer maxTokens;
    @ApiModelProperty(value = "timeoutMs")
    private Integer timeoutMs;
    @ApiModelProperty(value = "streamingEnabled")
    private Boolean streamingEnabled;
    @ApiModelProperty(value = "状态")
    private Integer status;
    @ApiModelProperty(value = "是否删除")
    private Integer deleted;
    @ApiModelProperty(value = "租户")
    private Long tenantId;
    @ApiModelProperty(value = "添加人员")
    private Long createBy;
    @ApiModelProperty(value = "添加时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty(value = "修改人员")
    private Long updateBy;
    @ApiModelProperty(value = "修改时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
    @ApiModelProperty(value = "备注")
    private String memo;
    public String getProviderType$() {
        return this.providerType;
    }
    public Boolean getStatusBool() {
        if (this.status == null) {
            return null;
        }
        return this.status == 1;
    }
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
    public String getUpdateTime$() {
        if (this.updateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java
New file
@@ -0,0 +1,90 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_prompt")
public class AiPrompt implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "名称")
    private String name;
    @ApiModelProperty(value = "编码")
    private String code;
    @ApiModelProperty(value = "场景")
    private String scene;
    @ApiModelProperty(value = "系统提示词")
    private String systemPrompt;
    @ApiModelProperty(value = "用户提示词模板")
    private String userPromptTemplate;
    @ApiModelProperty(value = "状态")
    private Integer status;
    @ApiModelProperty(value = "是否删除")
    private Integer deleted;
    @ApiModelProperty(value = "租户")
    private Long tenantId;
    @ApiModelProperty(value = "添加人员")
    private Long createBy;
    @ApiModelProperty(value = "添加时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty(value = "修改人员")
    private Long updateBy;
    @ApiModelProperty(value = "修改时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
    @ApiModelProperty(value = "备注")
    private String memo;
    public Boolean getStatusBool() {
        if (this.status == null) {
            return null;
        }
        return this.status == 1;
    }
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
    public String getUpdateTime$() {
        if (this.updateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java
New file
@@ -0,0 +1,9 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiChatMessage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiChatMessageMapper extends BaseMapper<AiChatMessage> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java
New file
@@ -0,0 +1,9 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiChatSessionMapper extends BaseMapper<AiChatSession> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiMcpMountMapper extends BaseMapper<AiMcpMount> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiParam;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiParamMapper extends BaseMapper<AiParam> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiPromptMapper extends BaseMapper<AiPrompt> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java
New file
@@ -0,0 +1,21 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.dto.AiChatMemoryDto;
import com.vincent.rsf.server.ai.dto.AiChatMessageDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import java.util.List;
public interface AiChatMemoryService {
    AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId);
    List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode);
    AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed);
    void saveRound(AiChatSession session, Long userId, Long tenantId, List<AiChatMessageDto> memoryMessages, String assistantContent);
    void removeSession(Long userId, Long tenantId, Long sessionId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java
New file
@@ -0,0 +1,19 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.dto.AiChatRequest;
import com.vincent.rsf.server.ai.dto.AiChatRuntimeDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
public interface AiChatService {
    AiChatRuntimeDto getRuntime(String promptCode, Long sessionId, Long userId, Long tenantId);
    List<AiChatSessionDto> listSessions(String promptCode, Long userId, Long tenantId);
    SseEmitter stream(AiChatRequest request, Long userId, Long tenantId);
    void removeSession(Long sessionId, Long userId, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java
New file
@@ -0,0 +1,8 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
public interface AiConfigResolverService {
    AiResolvedConfig resolve(String promptCode);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
New file
@@ -0,0 +1,22 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import java.util.List;
public interface AiMcpMountService extends IService<AiMcpMount> {
    List<AiMcpMount> listActiveMounts();
    void validateBeforeSave(AiMcpMount aiMcpMount);
    void validateBeforeUpdate(AiMcpMount aiMcpMount);
    List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId);
    AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java
New file
@@ -0,0 +1,13 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.entity.AiParam;
public interface AiParamService extends IService<AiParam> {
    AiParam getActiveParam();
    void validateBeforeSave(AiParam aiParam);
    void validateBeforeUpdate(AiParam aiParam);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java
New file
@@ -0,0 +1,13 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.entity.AiPrompt;
public interface AiPromptService extends IService<AiPrompt> {
    AiPrompt getActivePrompt(String code);
    void validateBeforeSave(AiPrompt aiPrompt);
    void validateBeforeUpdate(AiPrompt aiPrompt);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java
New file
@@ -0,0 +1,13 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import org.springframework.ai.tool.ToolCallback;
import java.util.List;
public interface BuiltinMcpToolRegistry {
    void validateBuiltinCode(String builtinCode);
    List<ToolCallback> createToolCallbacks(AiMcpMount mount, Long userId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java
New file
@@ -0,0 +1,25 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import org.springframework.ai.tool.ToolCallback;
import java.util.List;
public interface McpMountRuntimeFactory {
    McpMountRuntime create(List<AiMcpMount> mounts, Long userId);
    interface McpMountRuntime extends AutoCloseable {
        ToolCallback[] getToolCallbacks();
        List<String> getMountedNames();
        List<String> getErrors();
        int getMountedCount();
        @Override
        void close();
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
New file
@@ -0,0 +1,288 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.dto.AiChatMemoryDto;
import com.vincent.rsf.server.ai.dto.AiChatMessageDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.entity.AiChatMessage;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import com.vincent.rsf.server.ai.mapper.AiChatMessageMapper;
import com.vincent.rsf.server.ai.mapper.AiChatSessionMapper;
import com.vincent.rsf.server.ai.service.AiChatMemoryService;
import com.vincent.rsf.server.system.enums.StatusType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AiChatMemoryServiceImpl implements AiChatMemoryService {
    private final AiChatSessionMapper aiChatSessionMapper;
    private final AiChatMessageMapper aiChatMessageMapper;
    @Override
    public AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId) {
        ensureIdentity(userId, tenantId);
        String resolvedPromptCode = requirePromptCode(promptCode);
        AiChatSession session = sessionId == null
                ? findLatestSession(userId, tenantId, resolvedPromptCode)
                : getSession(sessionId, userId, tenantId, resolvedPromptCode);
        if (session == null) {
            return AiChatMemoryDto.builder()
                    .sessionId(null)
                    .persistedMessages(List.of())
                    .build();
        }
        return AiChatMemoryDto.builder()
                .sessionId(session.getId())
                .persistedMessages(listMessages(session.getId()))
                .build();
    }
    @Override
    public List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode) {
        ensureIdentity(userId, tenantId);
        String resolvedPromptCode = requirePromptCode(promptCode);
        List<AiChatSession> sessions = aiChatSessionMapper.selectList(new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getUserId, userId)
                .eq(AiChatSession::getTenantId, tenantId)
                .eq(AiChatSession::getPromptCode, resolvedPromptCode)
                .eq(AiChatSession::getDeleted, 0)
                .eq(AiChatSession::getStatus, StatusType.ENABLE.val)
                .orderByDesc(AiChatSession::getLastMessageTime)
                .orderByDesc(AiChatSession::getId));
        if (Cools.isEmpty(sessions)) {
            return List.of();
        }
        List<AiChatSessionDto> result = new ArrayList<>();
        for (AiChatSession session : sessions) {
            result.add(AiChatSessionDto.builder()
                    .sessionId(session.getId())
                    .title(session.getTitle())
                    .promptCode(session.getPromptCode())
                    .lastMessageTime(session.getLastMessageTime())
                    .build());
        }
        return result;
    }
    @Override
    public AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed) {
        ensureIdentity(userId, tenantId);
        String resolvedPromptCode = requirePromptCode(promptCode);
        if (sessionId != null) {
            return getSession(sessionId, userId, tenantId, resolvedPromptCode);
        }
        Date now = new Date();
        AiChatSession session = new AiChatSession()
                .setTitle(buildSessionTitle(titleSeed))
                .setPromptCode(resolvedPromptCode)
                .setUserId(userId)
                .setTenantId(tenantId)
                .setLastMessageTime(now)
                .setStatus(StatusType.ENABLE.val)
                .setDeleted(0)
                .setCreateBy(userId)
                .setCreateTime(now)
                .setUpdateBy(userId)
                .setUpdateTime(now);
        aiChatSessionMapper.insert(session);
        return session;
    }
    @Override
    public void saveRound(AiChatSession session, Long userId, Long tenantId, List<AiChatMessageDto> memoryMessages, String assistantContent) {
        if (session == null || session.getId() == null) {
            throw new CoolException("AI 会话不存在");
        }
        ensureIdentity(userId, tenantId);
        List<AiChatMessageDto> normalizedMessages = normalizeMessages(memoryMessages);
        if (normalizedMessages.isEmpty()) {
            throw new CoolException("本轮没有可保存的对话消息");
        }
        int nextSeqNo = findNextSeqNo(session.getId());
        Date now = new Date();
        for (AiChatMessageDto message : normalizedMessages) {
            aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo++, message.getRole(), message.getContent(), userId, tenantId, now));
        }
        if (StringUtils.hasText(assistantContent)) {
            aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo, "assistant", assistantContent, userId, tenantId, now));
        }
        AiChatSession update = new AiChatSession()
                .setId(session.getId())
                .setTitle(resolveUpdatedTitle(session.getTitle(), normalizedMessages))
                .setLastMessageTime(now)
                .setUpdateBy(userId)
                .setUpdateTime(now);
        aiChatSessionMapper.updateById(update);
    }
    @Override
    public void removeSession(Long userId, Long tenantId, Long sessionId) {
        ensureIdentity(userId, tenantId);
        if (sessionId == null) {
            throw new CoolException("AI 会话 ID 不能为空");
        }
        AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getId, sessionId)
                .eq(AiChatSession::getUserId, userId)
                .eq(AiChatSession::getTenantId, tenantId)
                .eq(AiChatSession::getDeleted, 0)
                .last("limit 1"));
        if (session == null) {
            throw new CoolException("AI 会话不存在或无权删除");
        }
        Date now = new Date();
        AiChatSession updateSession = new AiChatSession()
                .setId(sessionId)
                .setDeleted(1)
                .setUpdateBy(userId)
                .setUpdateTime(now);
        aiChatSessionMapper.updateById(updateSession);
        List<AiChatMessage> messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>()
                .eq(AiChatMessage::getSessionId, sessionId)
                .eq(AiChatMessage::getDeleted, 0));
        for (AiChatMessage message : messages) {
            AiChatMessage updateMessage = new AiChatMessage()
                    .setId(message.getId())
                    .setDeleted(1);
            aiChatMessageMapper.updateById(updateMessage);
        }
    }
    private AiChatSession findLatestSession(Long userId, Long tenantId, String promptCode) {
        return aiChatSessionMapper.selectOne(new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getUserId, userId)
                .eq(AiChatSession::getTenantId, tenantId)
                .eq(AiChatSession::getPromptCode, promptCode)
                .eq(AiChatSession::getDeleted, 0)
                .eq(AiChatSession::getStatus, StatusType.ENABLE.val)
                .orderByDesc(AiChatSession::getLastMessageTime)
                .orderByDesc(AiChatSession::getId)
                .last("limit 1"));
    }
    private AiChatSession getSession(Long sessionId, Long userId, Long tenantId, String promptCode) {
        AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getId, sessionId)
                .eq(AiChatSession::getUserId, userId)
                .eq(AiChatSession::getTenantId, tenantId)
                .eq(AiChatSession::getPromptCode, promptCode)
                .eq(AiChatSession::getDeleted, 0)
                .eq(AiChatSession::getStatus, StatusType.ENABLE.val)
                .last("limit 1"));
        if (session == null) {
            throw new CoolException("AI 会话不存在或无权访问");
        }
        return session;
    }
    private List<AiChatMessageDto> listMessages(Long sessionId) {
        List<AiChatMessage> records = aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>()
                .eq(AiChatMessage::getSessionId, sessionId)
                .eq(AiChatMessage::getDeleted, 0)
                .orderByAsc(AiChatMessage::getSeqNo)
                .orderByAsc(AiChatMessage::getId));
        if (Cools.isEmpty(records)) {
            return List.of();
        }
        List<AiChatMessageDto> messages = new ArrayList<>();
        for (AiChatMessage record : records) {
            if (!StringUtils.hasText(record.getContent())) {
                continue;
            }
            AiChatMessageDto item = new AiChatMessageDto();
            item.setRole(record.getRole());
            item.setContent(record.getContent());
            messages.add(item);
        }
        return messages;
    }
    private List<AiChatMessageDto> normalizeMessages(List<AiChatMessageDto> memoryMessages) {
        List<AiChatMessageDto> normalized = new ArrayList<>();
        if (Cools.isEmpty(memoryMessages)) {
            return normalized;
        }
        for (AiChatMessageDto item : memoryMessages) {
            if (item == null || !StringUtils.hasText(item.getContent())) {
                continue;
            }
            String role = item.getRole() == null ? "user" : item.getRole().toLowerCase();
            if ("system".equals(role)) {
                continue;
            }
            AiChatMessageDto normalizedItem = new AiChatMessageDto();
            normalizedItem.setRole("assistant".equals(role) ? "assistant" : "user");
            normalizedItem.setContent(item.getContent().trim());
            normalized.add(normalizedItem);
        }
        return normalized;
    }
    private int findNextSeqNo(Long sessionId) {
        AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper<AiChatMessage>()
                .eq(AiChatMessage::getSessionId, sessionId)
                .eq(AiChatMessage::getDeleted, 0)
                .orderByDesc(AiChatMessage::getSeqNo)
                .orderByDesc(AiChatMessage::getId)
                .last("limit 1"));
        return lastMessage == null || lastMessage.getSeqNo() == null ? 1 : lastMessage.getSeqNo() + 1;
    }
    private AiChatMessage buildMessageEntity(Long sessionId, int seqNo, String role, String content, Long userId, Long tenantId, Date createTime) {
        return new AiChatMessage()
                .setSessionId(sessionId)
                .setSeqNo(seqNo)
                .setRole(role)
                .setContent(content)
                .setUserId(userId)
                .setTenantId(tenantId)
                .setDeleted(0)
                .setCreateBy(userId)
                .setCreateTime(createTime);
    }
    private String resolveUpdatedTitle(String currentTitle, List<AiChatMessageDto> memoryMessages) {
        if (StringUtils.hasText(currentTitle)) {
            return currentTitle;
        }
        for (AiChatMessageDto item : memoryMessages) {
            if ("user".equals(item.getRole()) && StringUtils.hasText(item.getContent())) {
                return buildSessionTitle(item.getContent());
            }
        }
        return null;
    }
    private String buildSessionTitle(String titleSeed) {
        if (!StringUtils.hasText(titleSeed)) {
            throw new CoolException("AI 会话标题不能为空");
        }
        String title = titleSeed.trim().replace("\r", " ").replace("\n", " ");
        return title.length() > 60 ? title.substring(0, 60) : title;
    }
    private void ensureIdentity(Long userId, Long tenantId) {
        if (userId == null) {
            throw new CoolException("当前登录用户不存在");
        }
        if (tenantId == null) {
            throw new CoolException("当前租户不存在");
        }
    }
    private String requirePromptCode(String promptCode) {
        if (!StringUtils.hasText(promptCode)) {
            throw new CoolException("Prompt 编码不能为空");
        }
        return promptCode;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
New file
@@ -0,0 +1,349 @@
package com.vincent.rsf.server.ai.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiChatDoneDto;
import com.vincent.rsf.server.ai.dto.AiChatMemoryDto;
import com.vincent.rsf.server.ai.dto.AiChatMessageDto;
import com.vincent.rsf.server.ai.dto.AiChatRequest;
import com.vincent.rsf.server.ai.dto.AiChatRuntimeDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import com.vincent.rsf.server.ai.service.AiChatService;
import com.vincent.rsf.server.ai.service.AiChatMemoryService;
import com.vincent.rsf.server.ai.service.AiConfigResolverService;
import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
import io.micrometer.observation.ObservationRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.DefaultToolCallingManager;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
import org.springframework.ai.util.json.schema.SchemaType;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiChatServiceImpl implements AiChatService {
    private final AiConfigResolverService aiConfigResolverService;
    private final AiChatMemoryService aiChatMemoryService;
    private final McpMountRuntimeFactory mcpMountRuntimeFactory;
    private final GenericApplicationContext applicationContext;
    private final ObservationRegistry observationRegistry;
    private final ObjectMapper objectMapper;
    @Override
    public AiChatRuntimeDto getRuntime(String promptCode, Long sessionId, Long userId, Long tenantId) {
        AiResolvedConfig config = aiConfigResolverService.resolve(promptCode);
        AiChatMemoryDto memory = aiChatMemoryService.getMemory(userId, tenantId, config.getPromptCode(), sessionId);
        return AiChatRuntimeDto.builder()
                .sessionId(memory.getSessionId())
                .promptCode(config.getPromptCode())
                .promptName(config.getPrompt().getName())
                .model(config.getAiParam().getModel())
                .configuredMcpCount(config.getMcpMounts().size())
                .mountedMcpCount(config.getMcpMounts().size())
                .mountedMcpNames(config.getMcpMounts().stream().map(item -> item.getName()).toList())
                .mountErrors(List.of())
                .persistedMessages(memory.getPersistedMessages())
                .build();
    }
    @Override
    public List<AiChatSessionDto> listSessions(String promptCode, Long userId, Long tenantId) {
        AiResolvedConfig config = aiConfigResolverService.resolve(promptCode);
        return aiChatMemoryService.listSessions(userId, tenantId, config.getPromptCode());
    }
    @Override
    public void removeSession(Long sessionId, Long userId, Long tenantId) {
        aiChatMemoryService.removeSession(userId, tenantId, sessionId);
    }
    @Override
    public SseEmitter stream(AiChatRequest request, Long userId, Long tenantId) {
        SseEmitter emitter = new SseEmitter(AiDefaults.SSE_TIMEOUT_MS);
        CompletableFuture.runAsync(() -> doStream(request, userId, tenantId, emitter));
        return emitter;
    }
    private void doStream(AiChatRequest request, Long userId, Long tenantId, SseEmitter emitter) {
        try {
            AiResolvedConfig config = aiConfigResolverService.resolve(request.getPromptCode());
            AiChatSession session = aiChatMemoryService.resolveSession(userId, tenantId, config.getPromptCode(), request.getSessionId(), resolveTitleSeed(request.getMessages()));
            AiChatMemoryDto memory = aiChatMemoryService.getMemory(userId, tenantId, config.getPromptCode(), session.getId());
            List<AiChatMessageDto> mergedMessages = mergeMessages(memory.getPersistedMessages(), request.getMessages());
            try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(config.getMcpMounts(), userId)) {
                emit(emitter, "start", AiChatRuntimeDto.builder()
                        .sessionId(session.getId())
                        .promptCode(config.getPromptCode())
                        .promptName(config.getPrompt().getName())
                        .model(config.getAiParam().getModel())
                        .configuredMcpCount(config.getMcpMounts().size())
                        .mountedMcpCount(runtime.getMountedCount())
                        .mountedMcpNames(runtime.getMountedNames())
                        .mountErrors(runtime.getErrors())
                        .persistedMessages(memory.getPersistedMessages())
                        .build());
                Prompt prompt = new Prompt(
                        buildPromptMessages(mergedMessages, config.getPrompt(), request.getMetadata()),
                        buildChatOptions(config.getAiParam(), runtime.getToolCallbacks(), userId, request.getMetadata())
                );
                OpenAiChatModel chatModel = createChatModel(config.getAiParam());
                if (Boolean.FALSE.equals(config.getAiParam().getStreamingEnabled())) {
                    ChatResponse response = chatModel.call(prompt);
                    String content = extractContent(response);
                    aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), content);
                    if (StringUtils.hasText(content)) {
                        emit(emitter, "delta", buildMessagePayload("content", content));
                    }
                    emitDone(emitter, response.getMetadata(), config.getAiParam().getModel(), session.getId());
                    emitter.complete();
                    return;
                }
                Flux<ChatResponse> responseFlux = chatModel.stream(prompt);
                AtomicReference<ChatResponseMetadata> lastMetadata = new AtomicReference<>();
                StringBuilder assistantContent = new StringBuilder();
                responseFlux.doOnNext(response -> {
                            lastMetadata.set(response.getMetadata());
                            String content = extractContent(response);
                            if (StringUtils.hasText(content)) {
                                assistantContent.append(content);
                                emit(emitter, "delta", buildMessagePayload("content", content));
                            }
                        })
                        .doOnError(error -> emit(emitter, "error", buildMessagePayload("message", error == null ? "AI 对话失败" : error.getMessage())))
                        .blockLast();
                aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), assistantContent.toString());
                emitDone(emitter, lastMetadata.get(), config.getAiParam().getModel(), session.getId());
                emitter.complete();
            }
        } catch (Exception e) {
            log.error("AI stream error", e);
            emit(emitter, "error", buildMessagePayload("message", e == null ? "AI 对话失败" : e.getMessage()));
            emitter.completeWithError(e);
        }
    }
    private OpenAiChatModel createChatModel(AiParam aiParam) {
        OpenAiApi openAiApi = buildOpenAiApi(aiParam);
        ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
                .observationRegistry(observationRegistry)
                .toolCallbackResolver(new SpringBeanToolCallbackResolver(applicationContext, SchemaType.OPEN_API_SCHEMA))
                .toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false))
                .build();
        return new OpenAiChatModel(
                openAiApi,
                OpenAiChatOptions.builder()
                        .model(aiParam.getModel())
                        .temperature(aiParam.getTemperature())
                        .topP(aiParam.getTopP())
                        .maxTokens(aiParam.getMaxTokens())
                        .streamUsage(true)
                        .build(),
                toolCallingManager,
                org.springframework.retry.support.RetryTemplate.builder().maxAttempts(1).build(),
                observationRegistry
        );
    }
    private OpenAiApi buildOpenAiApi(AiParam aiParam) {
        int timeoutMs = aiParam.getTimeoutMs() == null ? AiDefaults.DEFAULT_TIMEOUT_MS : aiParam.getTimeoutMs();
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(timeoutMs);
        requestFactory.setReadTimeout(timeoutMs);
        return OpenAiApi.builder()
                .baseUrl(aiParam.getBaseUrl())
                .apiKey(aiParam.getApiKey())
                .restClientBuilder(RestClient.builder().requestFactory(requestFactory))
                .webClientBuilder(WebClient.builder())
                .build();
    }
    private OpenAiChatOptions buildChatOptions(AiParam aiParam, ToolCallback[] toolCallbacks, Long userId, Map<String, Object> metadata) {
        if (userId == null) {
            throw new CoolException("当前登录用户不存在");
        }
        OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder()
                .model(aiParam.getModel())
                .temperature(aiParam.getTemperature())
                .topP(aiParam.getTopP())
                .maxTokens(aiParam.getMaxTokens())
                .streamUsage(true)
                .user(String.valueOf(userId));
        if (!Cools.isEmpty(toolCallbacks)) {
            builder.toolCallbacks(Arrays.asList(toolCallbacks));
        }
        Map<String, String> metadataMap = new LinkedHashMap<>();
        if (metadata != null) {
            metadata.forEach((key, value) -> metadataMap.put(key, value == null ? "" : String.valueOf(value)));
        }
        if (!metadataMap.isEmpty()) {
            builder.metadata(metadataMap);
        }
        return builder.build();
    }
    private List<Message> buildPromptMessages(List<AiChatMessageDto> sourceMessages, AiPrompt aiPrompt, Map<String, Object> metadata) {
        if (Cools.isEmpty(sourceMessages)) {
            throw new CoolException("对话消息不能为空");
        }
        List<Message> messages = new ArrayList<>();
        if (StringUtils.hasText(aiPrompt.getSystemPrompt())) {
            messages.add(new SystemMessage(aiPrompt.getSystemPrompt()));
        }
        int lastUserIndex = -1;
        for (int i = 0; i < sourceMessages.size(); i++) {
            AiChatMessageDto item = sourceMessages.get(i);
            if (item != null && "user".equalsIgnoreCase(item.getRole())) {
                lastUserIndex = i;
            }
        }
        for (int i = 0; i < sourceMessages.size(); i++) {
            AiChatMessageDto item = sourceMessages.get(i);
            if (item == null || !StringUtils.hasText(item.getContent())) {
                continue;
            }
            String role = item.getRole() == null ? "user" : item.getRole().toLowerCase();
            if ("system".equals(role)) {
                continue;
            }
            String content = item.getContent();
            if ("user".equals(role) && i == lastUserIndex) {
                content = renderUserPrompt(aiPrompt.getUserPromptTemplate(), content, metadata);
            }
            if ("assistant".equals(role)) {
                messages.add(new AssistantMessage(content));
            } else {
                messages.add(new UserMessage(content));
            }
        }
        if (messages.stream().noneMatch(item -> item instanceof UserMessage)) {
            throw new CoolException("至少需要一条用户消息");
        }
        return messages;
    }
    private List<AiChatMessageDto> mergeMessages(List<AiChatMessageDto> persistedMessages, List<AiChatMessageDto> memoryMessages) {
        List<AiChatMessageDto> merged = new ArrayList<>();
        if (!Cools.isEmpty(persistedMessages)) {
            merged.addAll(persistedMessages);
        }
        if (!Cools.isEmpty(memoryMessages)) {
            merged.addAll(memoryMessages);
        }
        if (merged.isEmpty()) {
            throw new CoolException("对话消息不能为空");
        }
        return merged;
    }
    private String resolveTitleSeed(List<AiChatMessageDto> messages) {
        if (Cools.isEmpty(messages)) {
            throw new CoolException("对话消息不能为空");
        }
        for (int i = messages.size() - 1; i >= 0; i--) {
            AiChatMessageDto item = messages.get(i);
            if (item != null && "user".equalsIgnoreCase(item.getRole()) && StringUtils.hasText(item.getContent())) {
                return item.getContent();
            }
        }
        throw new CoolException("至少需要一条用户消息");
    }
    private String renderUserPrompt(String userPromptTemplate, String content, Map<String, Object> metadata) {
        if (!StringUtils.hasText(userPromptTemplate)) {
            return content;
        }
        String rendered = userPromptTemplate
                .replace("{{input}}", content)
                .replace("{input}", content);
        if (metadata != null) {
            for (Map.Entry<String, Object> entry : metadata.entrySet()) {
                String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
                rendered = rendered.replace("{{" + entry.getKey() + "}}", value);
                rendered = rendered.replace("{" + entry.getKey() + "}", value);
            }
        }
        if (Objects.equals(rendered, userPromptTemplate)) {
            return userPromptTemplate + "\n\n" + content;
        }
        return rendered;
    }
    private String extractContent(ChatResponse response) {
        if (response == null || response.getResult() == null || response.getResult().getOutput() == null) {
            return null;
        }
        return response.getResult().getOutput().getText();
    }
    private void emitDone(SseEmitter emitter, ChatResponseMetadata metadata, String fallbackModel, Long sessionId) {
        Usage usage = metadata == null ? null : metadata.getUsage();
        emit(emitter, "done", AiChatDoneDto.builder()
                .sessionId(sessionId)
                .model(metadata != null && StringUtils.hasText(metadata.getModel()) ? metadata.getModel() : fallbackModel)
                .promptTokens(usage == null ? null : usage.getPromptTokens())
                .completionTokens(usage == null ? null : usage.getCompletionTokens())
                .totalTokens(usage == null ? null : usage.getTotalTokens())
                .build());
    }
    private Map<String, String> buildMessagePayload(String key, String value) {
        Map<String, String> payload = new LinkedHashMap<>();
        payload.put(key, value == null ? "" : value);
        return payload;
    }
    private void emit(SseEmitter emitter, String eventName, Object payload) {
        try {
            String data = objectMapper.writeValueAsString(payload);
            emitter.send(SseEmitter.event()
                    .name(eventName)
                    .data(data, MediaType.APPLICATION_JSON));
        } catch (IOException e) {
            throw new CoolException("SSE 输出失败: " + e.getMessage());
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java
New file
@@ -0,0 +1,31 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
import com.vincent.rsf.server.ai.service.AiConfigResolverService;
import com.vincent.rsf.server.ai.service.AiMcpMountService;
import com.vincent.rsf.server.ai.service.AiParamService;
import com.vincent.rsf.server.ai.service.AiPromptService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class AiConfigResolverServiceImpl implements AiConfigResolverService {
    private final AiParamService aiParamService;
    private final AiPromptService aiPromptService;
    private final AiMcpMountService aiMcpMountService;
    @Override
    public AiResolvedConfig resolve(String promptCode) {
        String finalPromptCode = StringUtils.hasText(promptCode) ? promptCode : AiDefaults.DEFAULT_PROMPT_CODE;
        return AiResolvedConfig.builder()
                .promptCode(finalPromptCode)
                .aiParam(aiParamService.getActiveParam())
                .prompt(aiPromptService.getActivePrompt(finalPromptCode))
                .mcpMounts(aiMcpMountService.listActiveMounts())
                .build();
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
New file
@@ -0,0 +1,208 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.mapper.AiMcpMountMapper;
import com.vincent.rsf.server.ai.service.AiMcpMountService;
import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry;
import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
import com.vincent.rsf.server.system.enums.StatusType;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Service("aiMcpMountService")
@RequiredArgsConstructor
public class AiMcpMountServiceImpl extends ServiceImpl<AiMcpMountMapper, AiMcpMount> implements AiMcpMountService {
    private final BuiltinMcpToolRegistry builtinMcpToolRegistry;
    private final McpMountRuntimeFactory mcpMountRuntimeFactory;
    private final ObjectMapper objectMapper;
    @Override
    public List<AiMcpMount> listActiveMounts() {
        return this.list(new LambdaQueryWrapper<AiMcpMount>()
                .eq(AiMcpMount::getStatus, StatusType.ENABLE.val)
                .orderByAsc(AiMcpMount::getSort)
                .orderByAsc(AiMcpMount::getId));
    }
    @Override
    public void validateBeforeSave(AiMcpMount aiMcpMount) {
        fillDefaults(aiMcpMount);
        ensureRequiredFields(aiMcpMount);
    }
    @Override
    public void validateBeforeUpdate(AiMcpMount aiMcpMount) {
        fillDefaults(aiMcpMount);
        if (aiMcpMount.getId() == null) {
            throw new CoolException("MCP 挂载 ID 不能为空");
        }
        ensureRequiredFields(aiMcpMount);
    }
    @Override
    public List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId) {
        AiMcpMount mount = requireMount(mountId);
        try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
            List<AiMcpToolPreviewDto> tools = new ArrayList<>();
            for (ToolCallback callback : runtime.getToolCallbacks()) {
                if (callback == null || callback.getToolDefinition() == null) {
                    continue;
                }
                tools.add(AiMcpToolPreviewDto.builder()
                        .name(callback.getToolDefinition().name())
                        .description(callback.getToolDefinition().description())
                        .inputSchema(callback.getToolDefinition().inputSchema())
                        .returnDirect(callback.getToolMetadata() == null ? null : callback.getToolMetadata().returnDirect())
                        .build());
            }
            return tools;
        }
    }
    @Override
    public AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request) {
        if (userId == null) {
            throw new CoolException("当前登录用户不存在");
        }
        if (tenantId == null) {
            throw new CoolException("当前租户不存在");
        }
        if (request == null) {
            throw new CoolException("工具测试参数不能为空");
        }
        if (!StringUtils.hasText(request.getToolName())) {
            throw new CoolException("工具名称不能为空");
        }
        if (!StringUtils.hasText(request.getInputJson())) {
            throw new CoolException("工具输入 JSON 不能为空");
        }
        try {
            objectMapper.readTree(request.getInputJson());
        } catch (Exception e) {
            throw new CoolException("工具输入 JSON 格式错误: " + e.getMessage());
        }
        AiMcpMount mount = requireMount(mountId);
        try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
            ToolCallback callback = Arrays.stream(runtime.getToolCallbacks())
                    .filter(item -> item != null && item.getToolDefinition() != null)
                    .filter(item -> request.getToolName().equals(item.getToolDefinition().name()))
                    .findFirst()
                    .orElseThrow(() -> new CoolException("未找到要测试的工具: " + request.getToolName()));
            String output = callback.call(
                    request.getInputJson(),
                    new ToolContext(Map.of("userId", userId, "tenantId", tenantId, "mountId", mountId))
            );
            return AiMcpToolTestDto.builder()
                    .toolName(request.getToolName())
                    .inputJson(request.getInputJson())
                    .output(output)
                    .build();
        }
    }
    private void fillDefaults(AiMcpMount aiMcpMount) {
        if (!StringUtils.hasText(aiMcpMount.getTransportType())) {
            aiMcpMount.setTransportType(AiDefaults.MCP_TRANSPORT_SSE_HTTP);
        }
        if (aiMcpMount.getRequestTimeoutMs() == null) {
            aiMcpMount.setRequestTimeoutMs(AiDefaults.DEFAULT_TIMEOUT_MS);
        }
        if (aiMcpMount.getSort() == null) {
            aiMcpMount.setSort(0);
        }
        if (aiMcpMount.getStatus() == null) {
            aiMcpMount.setStatus(StatusType.ENABLE.val);
        }
    }
    private void ensureRequiredFields(AiMcpMount aiMcpMount) {
        if (!StringUtils.hasText(aiMcpMount.getName())) {
            throw new CoolException("MCP 挂载名称不能为空");
        }
        if (AiDefaults.MCP_TRANSPORT_BUILTIN.equals(aiMcpMount.getTransportType())) {
            builtinMcpToolRegistry.validateBuiltinCode(aiMcpMount.getBuiltinCode());
            ensureBuiltinConflictFree(aiMcpMount);
            return;
        }
        if (AiDefaults.MCP_TRANSPORT_SSE_HTTP.equals(aiMcpMount.getTransportType())) {
            if (!StringUtils.hasText(aiMcpMount.getServerUrl())) {
                throw new CoolException("远程 MCP 服务地址不能为空");
            }
            return;
        }
        if (AiDefaults.MCP_TRANSPORT_STDIO.equals(aiMcpMount.getTransportType())) {
            if (!StringUtils.hasText(aiMcpMount.getCommand())) {
                throw new CoolException("STDIO MCP 命令不能为空");
            }
            return;
        }
        throw new CoolException("不支持的 MCP 传输类型: " + aiMcpMount.getTransportType());
    }
    private AiMcpMount requireMount(Long mountId) {
        if (mountId == null) {
            throw new CoolException("MCP 挂载 ID 不能为空");
        }
        AiMcpMount mount = this.getById(mountId);
        if (mount == null || (mount.getDeleted() != null && mount.getDeleted() == 1)) {
            throw new CoolException("MCP 挂载不存在");
        }
        return mount;
    }
    private void ensureBuiltinConflictFree(AiMcpMount aiMcpMount) {
        if (aiMcpMount.getStatus() == null || aiMcpMount.getStatus() != StatusType.ENABLE.val) {
            return;
        }
        List<String> conflictCodes = resolveConflictCodes(aiMcpMount.getBuiltinCode());
        if (conflictCodes.isEmpty()) {
            return;
        }
        LambdaQueryWrapper<AiMcpMount> queryWrapper = new LambdaQueryWrapper<AiMcpMount>()
                .eq(AiMcpMount::getTransportType, AiDefaults.MCP_TRANSPORT_BUILTIN)
                .eq(AiMcpMount::getStatus, StatusType.ENABLE.val)
                .in(AiMcpMount::getBuiltinCode, conflictCodes);
        if (aiMcpMount.getId() != null) {
            queryWrapper.ne(AiMcpMount::getId, aiMcpMount.getId());
        }
        List<AiMcpMount> conflictMounts = this.list(queryWrapper);
        if (conflictMounts.isEmpty()) {
            return;
        }
        String conflictNames = String.join("、", conflictMounts.stream().map(AiMcpMount::getName).toList());
        throw new CoolException("当前内置 MCP 与已启用挂载冲突,请关闭后再启用: " + conflictNames);
    }
    private List<String> resolveConflictCodes(String builtinCode) {
        List<String> codes = new ArrayList<>();
        if (AiDefaults.MCP_BUILTIN_RSF_WMS.equals(builtinCode)) {
            codes.add(AiDefaults.MCP_BUILTIN_RSF_WMS_STOCK);
            codes.add(AiDefaults.MCP_BUILTIN_RSF_WMS_TASK);
            codes.add(AiDefaults.MCP_BUILTIN_RSF_WMS_BASE);
            return codes;
        }
        if (AiDefaults.MCP_BUILTIN_RSF_WMS_STOCK.equals(builtinCode)
                || AiDefaults.MCP_BUILTIN_RSF_WMS_TASK.equals(builtinCode)
                || AiDefaults.MCP_BUILTIN_RSF_WMS_BASE.equals(builtinCode)) {
            codes.add(AiDefaults.MCP_BUILTIN_RSF_WMS);
        }
        return codes;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java
New file
@@ -0,0 +1,97 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.mapper.AiParamMapper;
import com.vincent.rsf.server.ai.service.AiParamService;
import com.vincent.rsf.server.system.enums.StatusType;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service("aiParamService")
public class AiParamServiceImpl extends ServiceImpl<AiParamMapper, AiParam> implements AiParamService {
    @Override
    public AiParam getActiveParam() {
        AiParam aiParam = this.getOne(new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getStatus, StatusType.ENABLE.val)
                .last("limit 1"));
        if (aiParam == null) {
            throw new CoolException("未找到启用中的 AI 参数配置");
        }
        return aiParam;
    }
    @Override
    public void validateBeforeSave(AiParam aiParam) {
        fillDefaults(aiParam);
        ensureBaseFields(aiParam);
        ensureSingleActive(aiParam, null);
    }
    @Override
    public void validateBeforeUpdate(AiParam aiParam) {
        fillDefaults(aiParam);
        if (aiParam.getId() == null) {
            throw new CoolException("AI 参数 ID 不能为空");
        }
        ensureBaseFields(aiParam);
        ensureSingleActive(aiParam, aiParam.getId());
    }
    private void ensureBaseFields(AiParam aiParam) {
        if (!StringUtils.hasText(aiParam.getName())) {
            throw new CoolException("AI 参数名称不能为空");
        }
        if (!StringUtils.hasText(aiParam.getProviderType())) {
            aiParam.setProviderType(AiDefaults.PROVIDER_OPENAI_COMPATIBLE);
        }
        if (!StringUtils.hasText(aiParam.getBaseUrl())) {
            throw new CoolException("AI Base URL 不能为空");
        }
        if (!StringUtils.hasText(aiParam.getApiKey())) {
            throw new CoolException("AI API Key 不能为空");
        }
        if (!StringUtils.hasText(aiParam.getModel())) {
            throw new CoolException("AI 模型不能为空");
        }
    }
    private void ensureSingleActive(AiParam aiParam, Long selfId) {
        if (aiParam.getStatus() == null || aiParam.getStatus() != StatusType.ENABLE.val) {
            return;
        }
        LambdaQueryWrapper<AiParam> wrapper = new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getStatus, StatusType.ENABLE.val);
        if (selfId != null) {
            wrapper.ne(AiParam::getId, selfId);
        }
        if (this.count(wrapper) > 0) {
            throw new CoolException("同一租户仅允许一条启用中的 AI 参数配置");
        }
    }
    private void fillDefaults(AiParam aiParam) {
        if (!StringUtils.hasText(aiParam.getProviderType())) {
            aiParam.setProviderType(AiDefaults.PROVIDER_OPENAI_COMPATIBLE);
        }
        if (aiParam.getTemperature() == null) {
            aiParam.setTemperature(AiDefaults.DEFAULT_TEMPERATURE);
        }
        if (aiParam.getTopP() == null) {
            aiParam.setTopP(AiDefaults.DEFAULT_TOP_P);
        }
        if (aiParam.getTimeoutMs() == null) {
            aiParam.setTimeoutMs(AiDefaults.DEFAULT_TIMEOUT_MS);
        }
        if (aiParam.getStreamingEnabled() == null) {
            aiParam.setStreamingEnabled(Boolean.TRUE);
        }
        if (aiParam.getStatus() == null) {
            aiParam.setStatus(StatusType.ENABLE.val);
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java
New file
@@ -0,0 +1,68 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.mapper.AiPromptMapper;
import com.vincent.rsf.server.ai.service.AiPromptService;
import com.vincent.rsf.server.system.enums.StatusType;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service("aiPromptService")
public class AiPromptServiceImpl extends ServiceImpl<AiPromptMapper, AiPrompt> implements AiPromptService {
    @Override
    public AiPrompt getActivePrompt(String code) {
        AiPrompt aiPrompt = this.getOne(new LambdaQueryWrapper<AiPrompt>()
                .eq(AiPrompt::getCode, code)
                .eq(AiPrompt::getStatus, StatusType.ENABLE.val)
                .last("limit 1"));
        if (aiPrompt == null) {
            throw new CoolException("未找到启用中的 Prompt:" + code);
        }
        return aiPrompt;
    }
    @Override
    public void validateBeforeSave(AiPrompt aiPrompt) {
        ensureRequiredFields(aiPrompt);
        ensureUniqueCode(aiPrompt.getCode(), null);
    }
    @Override
    public void validateBeforeUpdate(AiPrompt aiPrompt) {
        if (aiPrompt.getId() == null) {
            throw new CoolException("Prompt ID 不能为空");
        }
        ensureRequiredFields(aiPrompt);
        ensureUniqueCode(aiPrompt.getCode(), aiPrompt.getId());
    }
    private void ensureRequiredFields(AiPrompt aiPrompt) {
        if (!StringUtils.hasText(aiPrompt.getName())) {
            throw new CoolException("Prompt 名称不能为空");
        }
        if (!StringUtils.hasText(aiPrompt.getCode())) {
            throw new CoolException("Prompt 编码不能为空");
        }
        if (!StringUtils.hasText(aiPrompt.getSystemPrompt())) {
            throw new CoolException("系统 Prompt 不能为空");
        }
        if (aiPrompt.getStatus() == null) {
            aiPrompt.setStatus(StatusType.ENABLE.val);
        }
    }
    private void ensureUniqueCode(String code, Long selfId) {
        LambdaQueryWrapper<AiPrompt> wrapper = new LambdaQueryWrapper<AiPrompt>()
                .eq(AiPrompt::getCode, code);
        if (selfId != null) {
            wrapper.ne(AiPrompt::getId, selfId);
        }
        if (this.count(wrapper) > 0) {
            throw new CoolException("Prompt 编码已存在");
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java
New file
@@ -0,0 +1,69 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry;
import com.vincent.rsf.server.ai.tool.RsfWmsBaseTools;
import com.vincent.rsf.server.ai.tool.RsfWmsStockTools;
import com.vincent.rsf.server.ai.tool.RsfWmsTaskTools;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Service
@RequiredArgsConstructor
public class BuiltinMcpToolRegistryImpl implements BuiltinMcpToolRegistry {
    private final RsfWmsStockTools rsfWmsStockTools;
    private final RsfWmsTaskTools rsfWmsTaskTools;
    private final RsfWmsBaseTools rsfWmsBaseTools;
    @Override
    public void validateBuiltinCode(String builtinCode) {
        if (!StringUtils.hasText(builtinCode)) {
            throw new CoolException("内置 MCP 编码不能为空");
        }
        if (!supportedBuiltinCodes().contains(builtinCode)) {
            throw new CoolException("不支持的内置 MCP 编码: " + builtinCode);
        }
    }
    @Override
    public List<ToolCallback> createToolCallbacks(AiMcpMount mount, Long userId) {
        String builtinCode = mount.getBuiltinCode();
        validateBuiltinCode(builtinCode);
        if (AiDefaults.MCP_BUILTIN_RSF_WMS.equals(builtinCode)) {
            List<ToolCallback> callbacks = new ArrayList<>();
            callbacks.addAll(Arrays.asList(ToolCallbacks.from(rsfWmsStockTools)));
            callbacks.addAll(Arrays.asList(ToolCallbacks.from(rsfWmsTaskTools)));
            callbacks.addAll(Arrays.asList(ToolCallbacks.from(rsfWmsBaseTools)));
            return callbacks;
        }
        if (AiDefaults.MCP_BUILTIN_RSF_WMS_STOCK.equals(builtinCode)) {
            return Arrays.asList(ToolCallbacks.from(rsfWmsStockTools));
        }
        if (AiDefaults.MCP_BUILTIN_RSF_WMS_TASK.equals(builtinCode)) {
            return Arrays.asList(ToolCallbacks.from(rsfWmsTaskTools));
        }
        if (AiDefaults.MCP_BUILTIN_RSF_WMS_BASE.equals(builtinCode)) {
            return Arrays.asList(ToolCallbacks.from(rsfWmsBaseTools));
        }
        throw new CoolException("不支持的内置 MCP 编码: " + builtinCode);
    }
    private List<String> supportedBuiltinCodes() {
        return List.of(
                AiDefaults.MCP_BUILTIN_RSF_WMS,
                AiDefaults.MCP_BUILTIN_RSF_WMS_STOCK,
                AiDefaults.MCP_BUILTIN_RSF_WMS_TASK,
                AiDefaults.MCP_BUILTIN_RSF_WMS_BASE
        );
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
New file
@@ -0,0 +1,211 @@
package com.vincent.rsf.server.ai.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry;
import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
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.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class McpMountRuntimeFactoryImpl implements McpMountRuntimeFactory {
    private final ObjectMapper objectMapper;
    private final BuiltinMcpToolRegistry builtinMcpToolRegistry;
    @Override
    public McpMountRuntime create(List<AiMcpMount> mounts, Long userId) {
        List<McpSyncClient> clients = new ArrayList<>();
        List<ToolCallback> callbacks = new ArrayList<>();
        List<String> mountedNames = new ArrayList<>();
        List<String> errors = new ArrayList<>();
        for (AiMcpMount mount : mounts) {
            try {
                if (AiDefaults.MCP_TRANSPORT_BUILTIN.equals(mount.getTransportType())) {
                    callbacks.addAll(builtinMcpToolRegistry.createToolCallbacks(mount, userId));
                    mountedNames.add(mount.getName());
                    continue;
                }
                McpSyncClient client = createClient(mount);
                client.initialize();
                client.listTools();
                clients.add(client);
                mountedNames.add(mount.getName());
            } catch (Exception e) {
                String message = mount.getName() + " 挂载失败: " + e.getMessage();
                errors.add(message);
                log.warn(message, e);
            }
        }
        if (!clients.isEmpty()) {
            callbacks.addAll(Arrays.asList(
                    SyncMcpToolCallbackProvider.builder().mcpClients(clients).build().getToolCallbacks()
            ));
        }
        ensureUniqueToolNames(callbacks);
        return new DefaultMcpMountRuntime(clients, callbacks.toArray(new ToolCallback[0]), mountedNames, errors);
    }
    private void ensureUniqueToolNames(List<ToolCallback> callbacks) {
        LinkedHashSet<String> duplicateNames = new LinkedHashSet<>();
        LinkedHashSet<String> seenNames = new LinkedHashSet<>();
        for (ToolCallback callback : callbacks) {
            if (callback == null || callback.getToolDefinition() == null) {
                continue;
            }
            String name = callback.getToolDefinition().name();
            if (!StringUtils.hasText(name)) {
                continue;
            }
            if (!seenNames.add(name)) {
                duplicateNames.add(name);
            }
        }
        if (!duplicateNames.isEmpty()) {
            throw new CoolException("MCP 工具名称重复,请调整挂载配置: " + String.join(", ", duplicateNames));
        }
    }
    private McpSyncClient createClient(AiMcpMount mount) {
        Duration timeout = Duration.ofMillis(mount.getRequestTimeoutMs() == null
                ? AiDefaults.DEFAULT_TIMEOUT_MS
                : mount.getRequestTimeoutMs());
        JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(objectMapper);
        if (AiDefaults.MCP_TRANSPORT_STDIO.equals(mount.getTransportType())) {
            ServerParameters.Builder parametersBuilder = ServerParameters.builder(mount.getCommand());
            List<String> args = readStringList(mount.getArgsJson());
            if (!args.isEmpty()) {
                parametersBuilder.args(args);
            }
            Map<String, String> env = readStringMap(mount.getEnvJson());
            if (!env.isEmpty()) {
                parametersBuilder.env(env);
            }
            StdioClientTransport transport = new StdioClientTransport(parametersBuilder.build(), jsonMapper);
            transport.setStdErrorHandler(message -> log.warn("MCP STDIO stderr [{}]: {}", mount.getName(), message));
            return McpClient.sync(transport)
                    .requestTimeout(timeout)
                    .initializationTimeout(timeout)
                    .clientInfo(new McpSchema.Implementation("rsf-ai-client", "RSF AI Client", "1.0.0"))
                    .build();
        }
        if (!AiDefaults.MCP_TRANSPORT_SSE_HTTP.equals(mount.getTransportType())) {
            throw new CoolException("不支持的 MCP 传输类型: " + mount.getTransportType());
        }
        if (!StringUtils.hasText(mount.getServerUrl())) {
            throw new CoolException("MCP 服务地址不能为空");
        }
        HttpClientSseClientTransport.Builder transportBuilder = HttpClientSseClientTransport.builder(mount.getServerUrl())
                .jsonMapper(jsonMapper)
                .connectTimeout(timeout);
        if (StringUtils.hasText(mount.getEndpoint())) {
            transportBuilder.sseEndpoint(mount.getEndpoint());
        }
        Map<String, String> headers = readStringMap(mount.getHeadersJson());
        if (!headers.isEmpty()) {
            transportBuilder.customizeRequest(builder -> headers.forEach(builder::header));
        }
        return McpClient.sync(transportBuilder.build())
                .requestTimeout(timeout)
                .initializationTimeout(timeout)
                .clientInfo(new McpSchema.Implementation("rsf-ai-client", "RSF AI Client", "1.0.0"))
                .build();
    }
    private List<String> readStringList(String json) {
        if (!StringUtils.hasText(json)) {
            return Collections.emptyList();
        }
        try {
            return objectMapper.readValue(json, new TypeReference<List<String>>() {
            });
        } catch (Exception e) {
            throw new CoolException("解析 MCP 列表配置失败: " + e.getMessage());
        }
    }
    private Map<String, String> readStringMap(String json) {
        if (!StringUtils.hasText(json)) {
            return Collections.emptyMap();
        }
        try {
            Map<String, String> result = objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, String>>() {
            });
            return result == null ? Collections.emptyMap() : result;
        } catch (Exception e) {
            throw new CoolException("解析 MCP Map 配置失败: " + e.getMessage());
        }
    }
    private static class DefaultMcpMountRuntime implements McpMountRuntime {
        private final List<McpSyncClient> clients;
        private final ToolCallback[] callbacks;
        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;
            this.mountedNames = mountedNames;
            this.errors = errors;
        }
        @Override
        public ToolCallback[] getToolCallbacks() {
            return callbacks;
        }
        @Override
        public List<String> getMountedNames() {
            return mountedNames;
        }
        @Override
        public List<String> getErrors() {
            return errors;
        }
        @Override
        public int getMountedCount() {
            return mountedNames.size();
        }
        @Override
        public void close() {
            for (McpSyncClient client : clients) {
                try {
                    client.close();
                } catch (Exception e) {
                    log.warn("关闭 MCP Client 失败", e);
                }
            }
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java
New file
@@ -0,0 +1,147 @@
package com.vincent.rsf.server.ai.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.manager.entity.BasStation;
import com.vincent.rsf.server.manager.entity.Warehouse;
import com.vincent.rsf.server.manager.service.BasStationService;
import com.vincent.rsf.server.manager.service.WarehouseService;
import com.vincent.rsf.server.system.entity.DictData;
import com.vincent.rsf.server.system.service.DictDataService;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class RsfWmsBaseTools {
    private final WarehouseService warehouseService;
    private final BasStationService basStationService;
    private final DictDataService dictDataService;
    @Tool(name = "rsf_query_warehouses", description = "按仓库编码或名称查询仓库基础信息。")
    public List<Map<String, Object>> queryWarehouses(
            @ToolParam(description = "仓库编码,可选") String code,
            @ToolParam(description = "仓库名称,可选") String name,
            @ToolParam(description = "返回条数,默认 10,最大 50") Integer limit) {
        LambdaQueryWrapper<Warehouse> queryWrapper = new LambdaQueryWrapper<>();
        int finalLimit = normalizeLimit(limit, 10, 50);
        if (StringUtils.hasText(code)) {
            queryWrapper.like(Warehouse::getCode, code);
        }
        if (StringUtils.hasText(name)) {
            queryWrapper.like(Warehouse::getName, name);
        }
        queryWrapper.orderByAsc(Warehouse::getCode).last("LIMIT " + finalLimit);
        List<Warehouse> warehouses = warehouseService.list(queryWrapper);
        List<Map<String, Object>> result = new ArrayList<>();
        for (Warehouse warehouse : warehouses) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", warehouse.getId());
            item.put("code", warehouse.getCode());
            item.put("name", warehouse.getName());
            item.put("factory", warehouse.getFactory());
            item.put("address", warehouse.getAddress());
            item.put("status", warehouse.getStatus());
            item.put("statusLabel", warehouse.getStatus$());
            item.put("memo", warehouse.getMemo());
            result.add(item);
        }
        return result;
    }
    @Tool(name = "rsf_query_bas_stations", description = "按站点编号、站点名称或使用状态查询基础站点。")
    public List<Map<String, Object>> queryBasStations(
            @ToolParam(description = "站点编号,可选") String stationName,
            @ToolParam(description = "站点名称,可选") String stationId,
            @ToolParam(description = "使用状态,可选") String useStatus,
            @ToolParam(description = "返回条数,默认 10,最大 50") Integer limit) {
        LambdaQueryWrapper<BasStation> queryWrapper = new LambdaQueryWrapper<>();
        int finalLimit = normalizeLimit(limit, 10, 50);
        if (StringUtils.hasText(stationName)) {
            queryWrapper.like(BasStation::getStationName, stationName);
        }
        if (StringUtils.hasText(stationId)) {
            queryWrapper.like(BasStation::getStationId, stationId);
        }
        if (StringUtils.hasText(useStatus)) {
            queryWrapper.eq(BasStation::getUseStatus, useStatus);
        }
        queryWrapper.orderByAsc(BasStation::getStationName).last("LIMIT " + finalLimit);
        List<BasStation> stations = basStationService.list(queryWrapper);
        List<Map<String, Object>> result = new ArrayList<>();
        for (BasStation station : stations) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", station.getId());
            item.put("stationName", station.getStationName());
            item.put("stationId", station.getStationId());
            item.put("type", station.getType());
            item.put("typeLabel", station.getType$());
            item.put("useStatus", station.getUseStatus());
            item.put("useStatusLabel", station.getUseStatus$());
            item.put("area", station.getArea());
            item.put("areaLabel", station.getArea$());
            item.put("isWcs", station.getIsWcs());
            item.put("inAble", station.getInAble());
            item.put("outAble", station.getOutAble());
            item.put("status", station.getStatus());
            result.add(item);
        }
        return result;
    }
    @Tool(name = "rsf_query_dict_data", description = "根据字典类型编码查询字典数据,可按值或标签进一步过滤。")
    public List<Map<String, Object>> queryDictData(
            @ToolParam(required = true, description = "字典类型编码") String dictTypeCode,
            @ToolParam(description = "字典值,可选") String value,
            @ToolParam(description = "字典标签,可选") String label,
            @ToolParam(description = "返回条数,默认 20,最大 100") Integer limit) {
        if (!StringUtils.hasText(dictTypeCode)) {
            throw new CoolException("字典类型编码不能为空");
        }
        int finalLimit = normalizeLimit(limit, 20, 100);
        LambdaQueryWrapper<DictData> queryWrapper = new LambdaQueryWrapper<DictData>()
                .eq(DictData::getDictTypeCode, dictTypeCode);
        if (StringUtils.hasText(value)) {
            queryWrapper.like(DictData::getValue, value);
        }
        if (StringUtils.hasText(label)) {
            queryWrapper.like(DictData::getLabel, label);
        }
        queryWrapper.orderByAsc(DictData::getSort).last("LIMIT " + finalLimit);
        List<DictData> dictDataList = dictDataService.list(queryWrapper);
        List<Map<String, Object>> result = new ArrayList<>();
        for (DictData dictData : dictDataList) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", dictData.getId());
            item.put("dictTypeCode", dictData.getDictTypeCode());
            item.put("value", dictData.getValue());
            item.put("label", dictData.getLabel());
            item.put("sort", dictData.getSort());
            item.put("color", dictData.getColor());
            item.put("group", dictData.getGroup());
            item.put("status", dictData.getStatus());
            item.put("statusLabel", dictData.getStatus$());
            result.add(item);
        }
        return result;
    }
    private int normalizeLimit(Integer limit, int defaultValue, int maxValue) {
        if (limit == null) {
            return defaultValue;
        }
        if (limit < 1 || limit > maxValue) {
            throw new CoolException("limit 必须在 1 到 " + maxValue + " 之间");
        }
        return limit;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java
New file
@@ -0,0 +1,98 @@
package com.vincent.rsf.server.ai.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.common.utils.FieldsUtils;
import com.vincent.rsf.server.manager.entity.DeviceSite;
import com.vincent.rsf.server.manager.entity.LocItem;
import com.vincent.rsf.server.manager.enums.LocStsType;
import com.vincent.rsf.server.manager.service.DeviceSiteService;
import com.vincent.rsf.server.manager.service.LocItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Component
@RequiredArgsConstructor
public class RsfWmsStockTools {
    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,
            @ToolParam(description = "物料名称,当没有物料编码时使用") String maktx) {
        if (!StringUtils.hasText(matnr) && !StringUtils.hasText(maktx)) {
            throw new CoolException("物料编码或物料名称至少需要提供一个");
        }
        LambdaQueryWrapper<LocItem> queryWrapper = new LambdaQueryWrapper<>();
        if (StringUtils.hasText(matnr)) {
            queryWrapper.eq(LocItem::getMatnrCode, matnr);
        } else {
            queryWrapper.eq(LocItem::getMaktx, maktx);
        }
        queryWrapper.apply(
                "EXISTS (SELECT 1 FROM man_loc ml WHERE ml.use_status = {0} AND ml.id = man_loc_item.loc_id)",
                LocStsType.LOC_STS_TYPE_F.type
        );
        List<LocItem> locItems = locItemService.list(queryWrapper);
        List<Map<String, Object>> result = new ArrayList<>();
        for (LocItem locItem : locItems) {
            if (!Objects.isNull(locItem.getFieldsIndex())) {
                locItem.setExtendFields(FieldsUtils.getFields(locItem.getFieldsIndex()));
            }
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", locItem.getId());
            item.put("locId", locItem.getLocId());
            item.put("locCode", locItem.getLocCode());
            item.put("matnrCode", locItem.getMatnrCode());
            item.put("maktx", locItem.getMaktx());
            item.put("trackCode", locItem.getTrackCode());
            item.put("batch", locItem.getBatch());
            item.put("spec", locItem.getSpec());
            item.put("model", locItem.getModel());
            item.put("unit", locItem.getUnit());
            item.put("anfme", locItem.getAnfme());
            item.put("status", locItem.getStatus());
            item.put("extendFields", locItem.getExtendFields());
            result.add(item);
        }
        return result;
    }
    @Tool(name = "rsf_query_station_list", description = "根据作业类型列表查询可用站点,返回站点编号、名称、目标位置和状态等信息。")
    public List<Map<String, Object>> queryStationList(
            @ToolParam(required = true, description = "作业类型列表") List<String> types) {
        if (types == null || types.isEmpty()) {
            throw new CoolException("站点类型列表不能为空");
        }
        List<DeviceSite> sites = deviceSiteService.list(new LambdaQueryWrapper<DeviceSite>()
                .in(DeviceSite::getType, types));
        List<Map<String, Object>> result = new ArrayList<>();
        for (DeviceSite site : sites) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("id", site.getId());
            item.put("type", site.getType());
            item.put("site", site.getSite());
            item.put("name", site.getName());
            item.put("target", site.getTarget());
            item.put("label", site.getLabel());
            item.put("device", site.getDevice());
            item.put("deviceCode", site.getDeviceCode());
            item.put("deviceSite", site.getDeviceSite());
            item.put("channel", site.getChannel());
            item.put("status", site.getStatus());
            result.add(item);
        }
        return result;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java
New file
@@ -0,0 +1,123 @@
package com.vincent.rsf.server.ai.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.manager.entity.Task;
import com.vincent.rsf.server.manager.service.TaskService;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class RsfWmsTaskTools {
    private final TaskService taskService;
    @Tool(name = "rsf_query_task_list", description = "按任务号、状态、任务类型、源站点、目标站点等条件查询任务列表。")
    public List<Map<String, Object>> queryTaskList(
            @ToolParam(description = "任务号,可模糊查询") String taskCode,
            @ToolParam(description = "任务状态,可选") Integer taskStatus,
            @ToolParam(description = "任务类型,可选") Integer taskType,
            @ToolParam(description = "源站点,可选") String orgSite,
            @ToolParam(description = "目标站点,可选") String targSite,
            @ToolParam(description = "返回条数,默认 10,最大 50") Integer limit) {
        LambdaQueryWrapper<Task> queryWrapper = new LambdaQueryWrapper<>();
        int finalLimit = normalizeLimit(limit, 10, 50);
        if (StringUtils.hasText(taskCode)) {
            queryWrapper.like(Task::getTaskCode, taskCode);
        }
        if (taskStatus != null) {
            queryWrapper.eq(Task::getTaskStatus, taskStatus);
        }
        if (taskType != null) {
            queryWrapper.eq(Task::getTaskType, taskType);
        }
        if (StringUtils.hasText(orgSite)) {
            queryWrapper.eq(Task::getOrgSite, orgSite);
        }
        if (StringUtils.hasText(targSite)) {
            queryWrapper.eq(Task::getTargSite, targSite);
        }
        queryWrapper.orderByDesc(Task::getCreateTime).last("LIMIT " + finalLimit);
        List<Task> tasks = taskService.list(queryWrapper);
        List<Map<String, Object>> result = new ArrayList<>();
        for (Task task : tasks) {
            result.add(buildTaskSummary(task));
        }
        return result;
    }
    @Tool(name = "rsf_query_task_detail", description = "根据任务 ID 或任务号查询任务详情。")
    public Map<String, Object> queryTaskDetail(
            @ToolParam(description = "任务 ID") Long taskId,
            @ToolParam(description = "任务号") String taskCode) {
        if (taskId == null && !StringUtils.hasText(taskCode)) {
            throw new CoolException("任务 ID 和任务号至少需要提供一个");
        }
        Task task;
        if (taskId != null) {
            task = taskService.getById(taskId);
        } else {
            task = taskService.getOne(new LambdaQueryWrapper<Task>().eq(Task::getTaskCode, taskCode));
        }
        if (task == null) {
            throw new CoolException("未查询到任务");
        }
        Map<String, Object> result = buildTaskSummary(task);
        result.put("resource", task.getResource());
        result.put("exceStatus", task.getExceStatus());
        result.put("orgLoc", task.getOrgLoc());
        result.put("targLoc", task.getTargLoc());
        result.put("orgSite", task.getOrgSite());
        result.put("orgSiteLabel", task.getOrgSite$());
        result.put("targSite", task.getTargSite());
        result.put("targSiteLabel", task.getTargSite$());
        result.put("barcode", task.getBarcode());
        result.put("robotCode", task.getRobotCode());
        result.put("memo", task.getMemo());
        result.put("expCode", task.getExpCode());
        result.put("expDesc", task.getExpDesc());
        result.put("startTime", task.getStartTime$());
        result.put("endTime", task.getEndTime$());
        result.put("createTime", task.getCreateTime$());
        result.put("updateTime", task.getUpdateTime$());
        return result;
    }
    private Map<String, Object> buildTaskSummary(Task task) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("id", task.getId());
        item.put("taskCode", task.getTaskCode());
        item.put("taskStatus", task.getTaskStatus());
        item.put("taskStatusLabel", task.getTaskStatus$());
        item.put("taskType", task.getTaskType());
        item.put("taskTypeLabel", task.getTaskType$());
        item.put("orgSite", task.getOrgSite());
        item.put("orgSiteLabel", task.getOrgSite$());
        item.put("targSite", task.getTargSite());
        item.put("targSiteLabel", task.getTargSite$());
        item.put("status", task.getStatus());
        item.put("statusLabel", task.getStatus$());
        item.put("createTime", task.getCreateTime$());
        item.put("updateTime", task.getUpdateTime$());
        return item;
    }
    private int normalizeLimit(Integer limit, int defaultValue, int maxValue) {
        if (limit == null) {
            return defaultValue;
        }
        if (limit < 1 || limit > maxValue) {
            throw new CoolException("limit 必须在 1 到 " + maxValue + " 之间");
        }
        return limit;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java
@@ -289,7 +289,7 @@
        if (Cools.isEmpty(param.getTransferStationNo())) {
            return R.error("无参数");
        }
        BasStation basStation = basStationService.getOne(new LambdaQueryWrapper<BasStation>().eq(BasStation::getStationId, param.getTransferStationNo()));
        BasStation basStation = basStationService.getOne(new LambdaQueryWrapper<BasStation>().eq(BasStation::getStationName, param.getTransferStationNo()));
        if (Cools.isEmpty(basStation)) {
            return R.error("未找到匹配站点");
        }
rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
@@ -17,6 +17,7 @@
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource;
@@ -69,6 +70,7 @@
    public SecurityFilterChain securityFilterChain(org.springframework.security.config.annotation.web.builders.HttpSecurity http)
            throws Exception {
        http.authorizeHttpRequests(authorize -> authorize
                        .dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        .requestMatchers(HttpMethod.GET, "/file/**", "/captcha", "/").permitAll()
                        .requestMatchers(FILTER_PATH).permitAll()
version/db/ai_feature.sql
New file
@@ -0,0 +1,225 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `sys_ai_param` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(255) NOT NULL COMMENT '名称',
  `provider_type` varchar(64) NOT NULL COMMENT '提供方类型',
  `base_url` varchar(500) NOT NULL COMMENT '基础地址',
  `api_key` varchar(1024) NOT NULL COMMENT 'API Key',
  `model` varchar(255) NOT NULL COMMENT '模型',
  `temperature` decimal(10,4) DEFAULT NULL COMMENT 'temperature',
  `top_p` decimal(10,4) DEFAULT NULL COMMENT 'topP',
  `max_tokens` int(11) DEFAULT NULL COMMENT '最大Token',
  `timeout_ms` int(11) DEFAULT NULL COMMENT '超时时间',
  `streaming_enabled` tinyint(1) DEFAULT '1' COMMENT '是否启用流式响应',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `memo` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_param_tenant_status` (`tenant_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 参数配置';
CREATE TABLE IF NOT EXISTS `sys_ai_prompt` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(255) NOT NULL COMMENT '名称',
  `code` varchar(128) NOT NULL COMMENT '编码',
  `scene` varchar(128) DEFAULT NULL COMMENT '场景',
  `system_prompt` text COMMENT '系统 Prompt',
  `user_prompt_template` text COMMENT '用户 Prompt 模板',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `memo` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_sys_ai_prompt_code_tenant` (`tenant_id`,`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI Prompt 配置';
CREATE TABLE IF NOT EXISTS `sys_ai_mcp_mount` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(255) NOT NULL COMMENT '名称',
  `transport_type` varchar(64) NOT NULL COMMENT '传输类型',
  `builtin_code` varchar(128) DEFAULT NULL COMMENT '内置 MCP 编码',
  `server_url` varchar(500) DEFAULT NULL COMMENT '服务地址',
  `endpoint` varchar(255) DEFAULT NULL COMMENT 'SSE Endpoint',
  `command` varchar(500) DEFAULT NULL COMMENT '本地命令',
  `args_json` text COMMENT '命令参数 JSON',
  `env_json` text COMMENT '环境变量 JSON',
  `headers_json` text COMMENT '请求头 JSON',
  `request_timeout_ms` int(11) DEFAULT NULL COMMENT '请求超时时间',
  `sort` int(11) DEFAULT '0' COMMENT '排序',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `memo` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_mcp_mount_tenant_status` (`tenant_id`,`status`,`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP 挂载配置';
CREATE TABLE IF NOT EXISTS `sys_ai_chat_session` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `title` varchar(255) NOT NULL COMMENT '会话标题',
  `prompt_code` varchar(128) NOT NULL COMMENT 'Prompt 编码',
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_chat_session_user_prompt` (`tenant_id`,`user_id`,`prompt_code`,`last_message_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话会话';
CREATE TABLE IF NOT EXISTS `sys_ai_chat_message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `session_id` bigint(20) NOT NULL COMMENT '会话 ID',
  `seq_no` int(11) NOT NULL COMMENT '消息序号',
  `role` varchar(32) NOT NULL COMMENT '消息角色',
  `content` longtext COMMENT '消息内容',
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_chat_message_session_seq` (`session_id`,`seq_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话消息';
SET @builtin_code_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_mcp_mount'
    AND `COLUMN_NAME` = 'builtin_code'
);
SET @builtin_code_sql := IF(
  @builtin_code_exists = 0,
  'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `builtin_code` varchar(128) DEFAULT NULL COMMENT ''内置 MCP 编码'' AFTER `transport_type`',
  'SELECT 1'
);
PREPARE builtin_code_stmt FROM @builtin_code_sql;
EXECUTE builtin_code_stmt;
DEALLOCATE PREPARE builtin_code_stmt;
BEGIN;
INSERT INTO `sys_ai_prompt`
(`id`, `name`, `code`, `scene`, `system_prompt`, `user_prompt_template`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
VALUES
(1, '首页默认助手', 'home.default', 'home', '你是 RSF 系统的 AI 助手,请结合当前上下文为用户提供准确、简洁、可执行的帮助。', '请基于当前页面上下文回答用户问题:{{input}}', 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, '首页默认 Prompt')
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`scene` = VALUES(`scene`),
`system_prompt` = VALUES(`system_prompt`),
`user_prompt_template` = VALUES(`user_prompt_template`),
`status` = VALUES(`status`),
`deleted` = VALUES(`deleted`),
`update_time` = VALUES(`update_time`),
`update_by` = VALUES(`update_by`),
`memo` = VALUES(`memo`);
INSERT INTO `sys_ai_mcp_mount`
(`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
SELECT 'RSF WMS 内置 MCP', 'BUILTIN', 'RSF_WMS', 60000, 0, 1, 1, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置 WMS 查询与任务工具'
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_ai_mcp_mount`
  WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS' AND `deleted` = 0
);
INSERT INTO `sys_ai_mcp_mount`
(`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
SELECT 'RSF WMS 库存作业内置 MCP', 'BUILTIN', 'RSF_WMS_STOCK', 60000, 1, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置库存查询和站点查询工具'
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_ai_mcp_mount`
  WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_STOCK' AND `deleted` = 0
);
INSERT INTO `sys_ai_mcp_mount`
(`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
SELECT 'RSF WMS 任务查询内置 MCP', 'BUILTIN', 'RSF_WMS_TASK', 60000, 2, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置任务列表与任务详情查询工具'
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_ai_mcp_mount`
  WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_TASK' AND `deleted` = 0
);
INSERT INTO `sys_ai_mcp_mount`
(`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
SELECT 'RSF WMS 基础资料内置 MCP', 'BUILTIN', 'RSF_WMS_BASE', 60000, 3, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置仓库、基础站点和字典数据查询工具'
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_ai_mcp_mount`
  WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_BASE' AND `deleted` = 0
);
INSERT INTO `sys_menu`
(`id`, `name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)
VALUES
(5301, 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 11, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5302, 'Query AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5303, 'Create AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5304, 'Update AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5305, 'Delete AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5306, 'menu.aiPrompt', 1, 'menu.system', '1', 'menu.system', '/system/aiPrompt', 'aiPrompt', NULL, NULL, 0, NULL, 'PsychologyAlt', 12, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5307, 'Query AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5308, 'Create AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5309, 'Update AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5310, 'Delete AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5311, 'menu.aiMcpMount', 1, 'menu.system', '1', 'menu.system', '/system/aiMcpMount', 'aiMcpMount', NULL, NULL, 0, NULL, 'Cable', 13, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5312, 'Query AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5313, 'Create AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5314, 'Update AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`parent_id` = VALUES(`parent_id`),
`parent_name` = VALUES(`parent_name`),
`path` = VALUES(`path`),
`path_name` = VALUES(`path_name`),
`route` = VALUES(`route`),
`component` = VALUES(`component`),
`authority` = VALUES(`authority`),
`icon` = VALUES(`icon`),
`sort` = VALUES(`sort`),
`tenant_id` = VALUES(`tenant_id`),
`status` = VALUES(`status`),
`deleted` = VALUES(`deleted`),
`update_time` = VALUES(`update_time`),
`update_by` = VALUES(`update_by`);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`)
VALUES
(5301, 1, 5301),
(5302, 1, 5302),
(5303, 1, 5303),
(5304, 1, 5304),
(5305, 1, 5305),
(5306, 1, 5306),
(5307, 1, 5307),
(5308, 1, 5308),
(5309, 1, 5309),
(5310, 1, 5310),
(5311, 1, 5311),
(5312, 1, 5312),
(5313, 1, 5313),
(5314, 1, 5314),
(5315, 1, 5315)
ON DUPLICATE KEY UPDATE
`role_id` = VALUES(`role_id`),
`menu_id` = VALUES(`menu_id`);
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;