From 8a3fa0452075df8290d4542e64ced002ff4b476d Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 19 三月 2026 09:51:53 +0800
Subject: [PATCH] #AI
---
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java | 109 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java | 211 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java | 19
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java | 15
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java | 31
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java | 98 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java | 11
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java | 68
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java | 208 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java | 90 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java | 11
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java | 18
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java | 349 ++++
rsf-admin/src/i18n/en.js | 3
rsf-admin/src/page/ResourceContent.js | 9
rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx | 205 ++
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx | 26
rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx | 13
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java | 21
rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx | 26
rsf-admin/src/page/system/aiParam/index.jsx | 12
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java | 22
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java | 112 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java | 8
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx | 93 +
rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx | 13
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java | 11
rsf-admin/src/config/authProvider.js | 3
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java | 19
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java | 123 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java | 54
rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx | 229 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java | 11
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java | 9
rsf-admin/src/page/system/aiParam/AiParamForm.jsx | 59
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java | 95 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java | 22
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java | 21
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java | 25
rsf-admin/src/api/ai/mcpMount.js | 19
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java | 15
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java | 21
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java | 288 +++
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java | 64
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java | 69
rsf-admin/src/api/ai/chat.js | 103 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java | 29
rsf-admin/src/page/system/aiParam/AiParamList.jsx | 258 +++
rsf-admin/src/page/system/aiPrompt/index.jsx | 12
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java | 95 +
rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java | 2
rsf-admin/src/i18n/zh.js | 3
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java | 44
rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java | 2
rsf-server/pom.xml | 24
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java | 97 +
rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx | 37
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx | 13
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java | 108 +
rsf-admin/src/layout/AppBarToolbar.jsx | 30
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java | 11
version/db/ai_feature.sql | 225 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java | 147 +
rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx | 26
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java | 17
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java | 9
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java | 13
rsf-admin/src/page/system/aiMcpMount/index.jsx | 12
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java | 13
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx | 254 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java | 13
rsf-admin/src/layout/AiChatDrawer.jsx | 475 +++++
rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx | 99 +
73 files changed, 5,117 insertions(+), 12 deletions(-)
diff --git a/rsf-admin/src/api/ai/chat.js b/rsf-admin/src/api/ai/chat.js
new file mode 100644
index 0000000..b11da93
--- /dev/null
+++ b/rsf-admin/src/api/ai/chat.js
@@ -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);
+ }
+};
diff --git a/rsf-admin/src/api/ai/mcpMount.js b/rsf-admin/src/api/ai/mcpMount.js
new file mode 100644
index 0000000..399d0c9
--- /dev/null
+++ b/rsf-admin/src/api/ai/mcpMount.js
@@ -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 || "宸ュ叿娴嬭瘯澶辫触");
+};
diff --git a/rsf-admin/src/config/authProvider.js b/rsf-admin/src/config/authProvider.js
index cb8083e..24655a3 100644
--- a/rsf-admin/src/config/authProvider.js
+++ b/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 = []) =>
diff --git a/rsf-admin/src/i18n/en.js b/rsf-admin/src/i18n/en.js
index 00fd50a..1eaeaa2 100644
--- a/rsf-admin/src/i18n/en.js
+++ b/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',
diff --git a/rsf-admin/src/i18n/zh.js b/rsf-admin/src/i18n/zh.js
index f54083c..95e5cb2 100644
--- a/rsf-admin/src/i18n/zh.js
+++ b/rsf-admin/src/i18n/zh.js
@@ -151,6 +151,9 @@
token: '鐧诲綍鏃ュ織',
operation: '鎿嶄綔鏃ュ織',
config: '閰嶇疆鍙傛暟',
+ aiParam: 'AI 鍙傛暟',
+ aiPrompt: 'Prompt 绠$悊',
+ aiMcpMount: 'MCP 鎸傝浇',
tenant: '绉熸埛绠$悊',
userLogin: '鐧诲綍鏃ュ織',
customer: '瀹㈡埛琛�',
diff --git a/rsf-admin/src/layout/AiChatDrawer.jsx b/rsf-admin/src/layout/AiChatDrawer.jsx
new file mode 100644
index 0000000..d837069
--- /dev/null
+++ b/rsf-admin/src/layout/AiChatDrawer.jsx
@@ -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}>
+ 姝e湪鍔犺浇 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 鍥炲銆備綘涔熷彲浠ュ厛鍘讳笂闈㈢殑蹇嵎鍏ュ彛缁存姢鍙傛暟銆丳rompt 鍜� 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;
diff --git a/rsf-admin/src/layout/AppBarToolbar.jsx b/rsf-admin/src/layout/AppBarToolbar.jsx
index 45cf9db..50c0f60 100644
--- a/rsf-admin/src/layout/AppBarToolbar.jsx
+++ b/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)} />
+ </>
+ );
+};
diff --git a/rsf-admin/src/page/ResourceContent.js b/rsf-admin/src/page/ResourceContent.js
index 22fdb9a..910f2c5 100644
--- a/rsf-admin/src/page/ResourceContent.js
+++ b/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:
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx
new file mode 100644
index 0000000..1363252
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountCreate.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx
new file mode 100644
index 0000000..60bd6e4
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountEdit.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
new file mode 100644
index 0000000..b88807a
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
new file mode 100644
index 0000000..a543ed8
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
new file mode 100644
index 0000000..1901478
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
@@ -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">
+ 褰撳墠鎸傝浇瑙f瀽鍑虹殑鍏ㄩ儴宸ュ叿閮芥樉绀哄湪杩欓噷锛屽彲鐩存帴杈撳叆 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;
diff --git a/rsf-admin/src/page/system/aiMcpMount/index.jsx b/rsf-admin/src/page/system/aiMcpMount/index.jsx
new file mode 100644
index 0000000..4ad923c
--- /dev/null
+++ b/rsf-admin/src/page/system/aiMcpMount/index.jsx
@@ -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 || ''}`,
+};
diff --git a/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx b/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
new file mode 100644
index 0000000..d6309e9
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx b/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
new file mode 100644
index 0000000..e1ec9b8
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamForm.jsx b/rsf-admin/src/page/system/aiParam/AiParamForm.jsx
new file mode 100644
index 0000000..07fbd25
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamForm.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamList.jsx b/rsf-admin/src/page/system/aiParam/AiParamList.jsx
new file mode 100644
index 0000000..f7d167f
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamList.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiParam/index.jsx b/rsf-admin/src/page/system/aiParam/index.jsx
new file mode 100644
index 0000000..85adece
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/index.jsx
@@ -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 || ''}`,
+};
diff --git a/rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx b/rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx
new file mode 100644
index 0000000..5354b5c
--- /dev/null
+++ b/rsf-admin/src/page/system/aiPrompt/AiPromptCreate.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx b/rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx
new file mode 100644
index 0000000..dd336e7
--- /dev/null
+++ b/rsf-admin/src/page/system/aiPrompt/AiPromptEdit.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx b/rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx
new file mode 100644
index 0000000..5920bd5
--- /dev/null
+++ b/rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx b/rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx
new file mode 100644
index 0000000..325b6dc
--- /dev/null
+++ b/rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiPrompt/index.jsx b/rsf-admin/src/page/system/aiPrompt/index.jsx
new file mode 100644
index 0000000..776c963
--- /dev/null
+++ b/rsf-admin/src/page/system/aiPrompt/index.jsx
@@ -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 || ''}`,
+};
diff --git a/rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx b/rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx
new file mode 100644
index 0000000..ea5de3e
--- /dev/null
+++ b/rsf-admin/src/page/system/aiShared/AiConfigDialog.jsx
@@ -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;
diff --git a/rsf-server/pom.xml b/rsf-server/pom.xml
index 87d51aa..b6aecea 100644
--- a/rsf-server/pom.xml
+++ b/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>
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
new file mode 100644
index 0000000..37e43df
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
new file mode 100644
index 0000000..63a2073
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
@@ -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());
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
new file mode 100644
index 0000000..57cf3ca
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java
new file mode 100644
index 0000000..8f1d8b7
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java
new file mode 100644
index 0000000..8c40c39
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java
new file mode 100644
index 0000000..4d158c3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatDoneDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java
new file mode 100644
index 0000000..afd6421
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java
new file mode 100644
index 0000000..f86f188
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMessageDto.java
@@ -0,0 +1,11 @@
+package com.vincent.rsf.server.ai.dto;
+
+import lombok.Data;
+
+@Data
+public class AiChatMessageDto {
+
+ private String role;
+
+ private String content;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java
new file mode 100644
index 0000000..c1edb09
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRequest.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java
new file mode 100644
index 0000000..78e25e3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java
new file mode 100644
index 0000000..a54b23c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java
new file mode 100644
index 0000000..1d59c15
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolPreviewDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java
new file mode 100644
index 0000000..1016b11
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java
new file mode 100644
index 0000000..f80f21a
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpToolTestRequest.java
@@ -0,0 +1,11 @@
+package com.vincent.rsf.server.ai.dto;
+
+import lombok.Data;
+
+@Data
+public class AiMcpToolTestRequest {
+
+ private String toolName;
+
+ private String inputJson;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java
new file mode 100644
index 0000000..e96332f
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiResolvedConfig.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java
new file mode 100644
index 0000000..41753d0
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java
new file mode 100644
index 0000000..18a8bab
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
new file mode 100644
index 0000000..ffaec3c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
@@ -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 = "璇锋眰澶碕SON")
+ 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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java
new file mode 100644
index 0000000..49ee4cc
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java
new file mode 100644
index 0000000..6b61576
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiPrompt.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java
new file mode 100644
index 0000000..81a0c64
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatMessageMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java
new file mode 100644
index 0000000..c26a10d
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiChatSessionMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java
new file mode 100644
index 0000000..87e7a09
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpMountMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java
new file mode 100644
index 0000000..1cb1090
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiParamMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java
new file mode 100644
index 0000000..044fdf7
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiPromptMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java
new file mode 100644
index 0000000..404f30b
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java
new file mode 100644
index 0000000..27ecf4f
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java
new file mode 100644
index 0000000..23b125c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigResolverService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
new file mode 100644
index 0000000..79faeb5
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java
new file mode 100644
index 0000000..9cd1f9e
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java
new file mode 100644
index 0000000..f8a7913
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java
new file mode 100644
index 0000000..8d26eeb
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/BuiltinMcpToolRegistry.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java
new file mode 100644
index 0000000..8f3daa3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/McpMountRuntimeFactory.java
@@ -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();
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
new file mode 100644
index 0000000..b663a6a
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
new file mode 100644
index 0000000..7fc31fc
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -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());
+ }
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java
new file mode 100644
index 0000000..366e1a9
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigResolverServiceImpl.java
@@ -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();
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
new file mode 100644
index 0000000..822d55c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java
new file mode 100644
index 0000000..41a014b
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java
@@ -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);
+ }
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java
new file mode 100644
index 0000000..7603c97
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java
@@ -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 缂栫爜宸插瓨鍦�");
+ }
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java
new file mode 100644
index 0000000..01e72e0
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/BuiltinMcpToolRegistryImpl.java
@@ -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
+ );
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
new file mode 100644
index 0000000..6b36785
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
@@ -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("瑙f瀽 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("瑙f瀽 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);
+ }
+ }
+ }
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java
new file mode 100644
index 0000000..0a8350d
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsBaseTools.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java
new file mode 100644
index 0000000..78994a2
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsStockTools.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java
new file mode 100644
index 0000000..ba6d622
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/tool/RsfWmsTaskTools.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java
index ee84fdf..bb74163 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/api/service/impl/InBoundServiceImpl.java
+++ b/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("鏈壘鍒板尮閰嶇珯鐐�");
}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java b/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
index d3bfe01..4efd031 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
+++ b/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()
diff --git a/version/db/ai_feature.sql b/version/db/ai_feature.sql
new file mode 100644
index 0000000..d5cd06f
--- /dev/null
+++ b/version/db/ai_feature.sql
@@ -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 '鏈�澶oken',
+ `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;
--
Gitblit v1.9.1