| New file |
| | |
| | | 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); |
| | | } |
| | | }; |
| New file |
| | |
| | | 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 || "工具测试失败"); |
| | | }; |
| | |
| | | import avatar from '/avatar.jpg' |
| | | |
| | | const AI_COMPONENTS = new Set([ |
| | | 'aiParam', |
| | | 'aiPrompt', |
| | | 'aiDiagnosis', |
| | | 'aiDiagnosisPlan', |
| | | 'aiCallLog', |
| | | 'aiRoute', |
| | | 'aiToolConfig', |
| | | 'aiMcpMount', |
| | | ]); |
| | | |
| | | const filterAiMenus = (items = []) => |
| | |
| | | token: 'Token', |
| | | operation: 'Operation', |
| | | config: 'Config', |
| | | aiParam: 'AI Params', |
| | | aiPrompt: 'Prompts', |
| | | aiMcpMount: 'MCP Mounts', |
| | | tenant: 'Tenant', |
| | | userLogin: 'Token', |
| | | customer: 'Customer', |
| | |
| | | token: '登录日志', |
| | | operation: '操作日志', |
| | | config: '配置参数', |
| | | aiParam: 'AI 参数', |
| | | aiPrompt: 'Prompt 管理', |
| | | aiMcpMount: 'MCP 挂载', |
| | | tenant: '租户管理', |
| | | userLogin: '登录日志', |
| | | customer: '客户表', |
| New file |
| | |
| | | import React, { useEffect, useMemo, useRef, useState } from "react"; |
| | | import { useLocation, useNavigate } from "react-router-dom"; |
| | | import { useNotify } from "react-admin"; |
| | | import { |
| | | Alert, |
| | | Box, |
| | | Button, |
| | | Chip, |
| | | Divider, |
| | | Drawer, |
| | | IconButton, |
| | | List, |
| | | ListItemButton, |
| | | ListItemText, |
| | | Paper, |
| | | Stack, |
| | | TextField, |
| | | Typography, |
| | | } from "@mui/material"; |
| | | import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined"; |
| | | import SendRoundedIcon from "@mui/icons-material/SendRounded"; |
| | | import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined"; |
| | | import SettingsSuggestOutlinedIcon from "@mui/icons-material/SettingsSuggestOutlined"; |
| | | import PsychologyAltOutlinedIcon from "@mui/icons-material/PsychologyAltOutlined"; |
| | | import CableOutlinedIcon from "@mui/icons-material/CableOutlined"; |
| | | import CloseIcon from "@mui/icons-material/Close"; |
| | | import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined"; |
| | | import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined"; |
| | | import { getAiRuntime, getAiSessions, removeAiSession, streamAiChat } from "@/api/ai/chat"; |
| | | |
| | | const DEFAULT_PROMPT_CODE = "home.default"; |
| | | |
| | | const quickLinks = [ |
| | | { label: "AI 参数", path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> }, |
| | | { label: "Prompt", path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> }, |
| | | { label: "MCP", path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> }, |
| | | ]; |
| | | |
| | | const AiChatDrawer = ({ open, onClose }) => { |
| | | const navigate = useNavigate(); |
| | | const location = useLocation(); |
| | | const notify = useNotify(); |
| | | const abortRef = useRef(null); |
| | | const [runtime, setRuntime] = useState(null); |
| | | const [sessionId, setSessionId] = useState(null); |
| | | const [sessions, setSessions] = useState([]); |
| | | const [persistedMessages, setPersistedMessages] = useState([]); |
| | | const [messages, setMessages] = useState([]); |
| | | const [input, setInput] = useState(""); |
| | | const [loadingRuntime, setLoadingRuntime] = useState(false); |
| | | const [streaming, setStreaming] = useState(false); |
| | | const [usage, setUsage] = useState(null); |
| | | const [drawerError, setDrawerError] = useState(""); |
| | | |
| | | const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE; |
| | | |
| | | const runtimeSummary = useMemo(() => { |
| | | return { |
| | | promptName: runtime?.promptName || "--", |
| | | model: runtime?.model || "--", |
| | | mountedMcpCount: runtime?.mountedMcpCount ?? 0, |
| | | }; |
| | | }, [runtime]); |
| | | |
| | | useEffect(() => { |
| | | if (open) { |
| | | initializeDrawer(); |
| | | } else { |
| | | stopStream(false); |
| | | } |
| | | }, [open]); |
| | | |
| | | useEffect(() => () => { |
| | | stopStream(false); |
| | | }, []); |
| | | |
| | | const initializeDrawer = async (targetSessionId = null) => { |
| | | await Promise.all([ |
| | | loadRuntime(targetSessionId), |
| | | loadSessions(), |
| | | ]); |
| | | }; |
| | | |
| | | const loadRuntime = async (targetSessionId = null) => { |
| | | setLoadingRuntime(true); |
| | | setDrawerError(""); |
| | | try { |
| | | const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId); |
| | | const historyMessages = data?.persistedMessages || []; |
| | | setRuntime(data); |
| | | setSessionId(data?.sessionId || null); |
| | | setPersistedMessages(historyMessages); |
| | | setMessages(historyMessages); |
| | | } catch (error) { |
| | | const message = error.message || "获取 AI 运行时失败"; |
| | | setDrawerError(message); |
| | | } finally { |
| | | setLoadingRuntime(false); |
| | | } |
| | | }; |
| | | |
| | | const loadSessions = async () => { |
| | | try { |
| | | const data = await getAiSessions(DEFAULT_PROMPT_CODE); |
| | | setSessions(data); |
| | | } catch (error) { |
| | | const message = error.message || "获取 AI 会话列表失败"; |
| | | setDrawerError(message); |
| | | } |
| | | }; |
| | | |
| | | const startNewSession = () => { |
| | | if (streaming) { |
| | | return; |
| | | } |
| | | setSessionId(null); |
| | | setPersistedMessages([]); |
| | | setMessages([]); |
| | | setUsage(null); |
| | | setDrawerError(""); |
| | | }; |
| | | |
| | | const handleSwitchSession = async (targetSessionId) => { |
| | | if (streaming || targetSessionId === sessionId) { |
| | | return; |
| | | } |
| | | setUsage(null); |
| | | await loadRuntime(targetSessionId); |
| | | }; |
| | | |
| | | const handleDeleteSession = async (targetSessionId) => { |
| | | if (streaming || !targetSessionId) { |
| | | return; |
| | | } |
| | | try { |
| | | await removeAiSession(targetSessionId); |
| | | notify("会话已删除"); |
| | | if (targetSessionId === sessionId) { |
| | | startNewSession(); |
| | | await loadRuntime(null); |
| | | } |
| | | await loadSessions(); |
| | | } catch (error) { |
| | | const message = error.message || "删除 AI 会话失败"; |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const stopStream = (showTip = true) => { |
| | | if (abortRef.current) { |
| | | abortRef.current.abort(); |
| | | abortRef.current = null; |
| | | setStreaming(false); |
| | | if (showTip) { |
| | | notify("已停止当前对话输出"); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | const appendAssistantDelta = (delta) => { |
| | | setMessages((prev) => { |
| | | const next = [...prev]; |
| | | const last = next[next.length - 1]; |
| | | if (last && last.role === "assistant") { |
| | | next[next.length - 1] = { |
| | | ...last, |
| | | content: `${last.content || ""}${delta}`, |
| | | }; |
| | | return next; |
| | | } |
| | | next.push({ role: "assistant", content: delta }); |
| | | return next; |
| | | }); |
| | | }; |
| | | |
| | | const ensureAssistantPlaceholder = (seedMessages) => { |
| | | const next = [...seedMessages]; |
| | | const last = next[next.length - 1]; |
| | | if (!last || last.role !== "assistant") { |
| | | next.push({ role: "assistant", content: "" }); |
| | | } |
| | | return next; |
| | | }; |
| | | |
| | | const handleSend = async () => { |
| | | const content = input.trim(); |
| | | if (!content || streaming) { |
| | | return; |
| | | } |
| | | const memoryMessages = [{ role: "user", content }]; |
| | | const nextMessages = [...messages, ...memoryMessages]; |
| | | setInput(""); |
| | | setUsage(null); |
| | | setDrawerError(""); |
| | | setMessages(ensureAssistantPlaceholder(nextMessages)); |
| | | setStreaming(true); |
| | | |
| | | const controller = new AbortController(); |
| | | abortRef.current = controller; |
| | | |
| | | let completed = false; |
| | | let completedSessionId = sessionId; |
| | | |
| | | try { |
| | | await streamAiChat( |
| | | { |
| | | sessionId, |
| | | promptCode, |
| | | messages: memoryMessages, |
| | | metadata: { |
| | | path: location.pathname, |
| | | }, |
| | | }, |
| | | { |
| | | signal: controller.signal, |
| | | onEvent: (eventName, payload) => { |
| | | if (eventName === "start") { |
| | | setRuntime(payload); |
| | | if (payload?.sessionId) { |
| | | setSessionId(payload.sessionId); |
| | | completedSessionId = payload.sessionId; |
| | | } |
| | | } |
| | | if (eventName === "delta") { |
| | | appendAssistantDelta(payload?.content || ""); |
| | | } |
| | | if (eventName === "done") { |
| | | setUsage(payload); |
| | | completed = true; |
| | | if (payload?.sessionId) { |
| | | completedSessionId = payload.sessionId; |
| | | } |
| | | } |
| | | if (eventName === "error") { |
| | | const message = payload?.message || "AI 对话失败"; |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | }, |
| | | } |
| | | ); |
| | | } catch (error) { |
| | | if (error?.name !== "AbortError") { |
| | | const message = error.message || "AI 对话失败"; |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | } finally { |
| | | abortRef.current = null; |
| | | setStreaming(false); |
| | | if (completed) { |
| | | await Promise.all([ |
| | | loadRuntime(completedSessionId), |
| | | loadSessions(), |
| | | ]); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | const handleKeyDown = (event) => { |
| | | if (event.key === "Enter" && !event.shiftKey) { |
| | | event.preventDefault(); |
| | | handleSend(); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <Drawer |
| | | anchor="right" |
| | | open={open} |
| | | onClose={onClose} |
| | | ModalProps={{ keepMounted: true }} |
| | | sx={{ |
| | | zIndex: 1400, |
| | | "& .MuiDrawer-paper": { |
| | | top: 0, |
| | | height: "100vh", |
| | | width: { xs: "100vw", md: "50vw" }, |
| | | }, |
| | | }} |
| | | > |
| | | <Box display="flex" flexDirection="column" height="100%"> |
| | | <Stack direction="row" alignItems="center" spacing={1} px={2} py={1.5}> |
| | | <SmartToyOutlinedIcon color="primary" /> |
| | | <Typography variant="h6" flex={1}> |
| | | AI 对话 |
| | | </Typography> |
| | | <IconButton size="small" onClick={startNewSession} title="新建会话" disabled={streaming}> |
| | | <AddCommentOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | | <IconButton size="small" onClick={onClose} title="关闭"> |
| | | <CloseIcon fontSize="small" /> |
| | | </IconButton> |
| | | </Stack> |
| | | <Divider /> |
| | | |
| | | <Box flex={1} display="flex" flexDirection={{ xs: "column", md: "row" }} minHeight={0}> |
| | | <Box |
| | | width={{ xs: "100%", md: 240 }} |
| | | borderRight={{ xs: "none", md: "1px solid rgba(224, 224, 224, 1)" }} |
| | | borderBottom={{ xs: "1px solid rgba(224, 224, 224, 1)", md: "none" }} |
| | | display="flex" |
| | | flexDirection="column" |
| | | minHeight={0} |
| | | > |
| | | <Box px={2} py={1.5}> |
| | | <Stack direction="row" alignItems="center" justifyContent="space-between" mb={1}> |
| | | <Typography variant="subtitle2">会话列表</Typography> |
| | | <Button size="small" onClick={startNewSession} disabled={streaming}> |
| | | 新建会话 |
| | | </Button> |
| | | </Stack> |
| | | <Paper variant="outlined" sx={{ overflow: "hidden" }}> |
| | | {!sessions.length ? ( |
| | | <Box px={1.5} py={1.25}> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 暂无历史会话 |
| | | </Typography> |
| | | </Box> |
| | | ) : ( |
| | | <List disablePadding sx={{ maxHeight: { xs: 180, md: "calc(100vh - 260px)" }, overflow: "auto" }}> |
| | | {sessions.map((item) => ( |
| | | <ListItemButton |
| | | key={item.sessionId} |
| | | selected={item.sessionId === sessionId} |
| | | onClick={() => handleSwitchSession(item.sessionId)} |
| | | disabled={streaming} |
| | | alignItems="flex-start" |
| | | > |
| | | <ListItemText |
| | | primary={item.title || `会话 ${item.sessionId}`} |
| | | secondary={item.lastMessageTime || `Session ${item.sessionId}`} |
| | | primaryTypographyProps={{ |
| | | noWrap: true, |
| | | fontSize: 14, |
| | | }} |
| | | secondaryTypographyProps={{ |
| | | noWrap: true, |
| | | fontSize: 12, |
| | | }} |
| | | /> |
| | | <IconButton |
| | | size="small" |
| | | edge="end" |
| | | disabled={streaming} |
| | | onClick={(event) => { |
| | | event.stopPropagation(); |
| | | handleDeleteSession(item.sessionId); |
| | | }} |
| | | title="删除会话" |
| | | > |
| | | <DeleteOutlineOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | | </ListItemButton> |
| | | ))} |
| | | </List> |
| | | )} |
| | | </Paper> |
| | | </Box> |
| | | </Box> |
| | | |
| | | <Box flex={1} display="flex" flexDirection="column" minHeight={0}> |
| | | <Box px={2} py={1.5}> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> |
| | | <Chip size="small" label={`Session: ${sessionId || "--"}`} /> |
| | | <Chip size="small" label={`Prompt: ${runtimeSummary.promptName}`} /> |
| | | <Chip size="small" label={`Model: ${runtimeSummary.model}`} /> |
| | | <Chip size="small" label={`MCP: ${runtimeSummary.mountedMcpCount}`} /> |
| | | <Chip size="small" label={`History: ${persistedMessages.length}`} /> |
| | | </Stack> |
| | | <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap> |
| | | {quickLinks.map((item) => ( |
| | | <Button |
| | | key={item.path} |
| | | size="small" |
| | | variant="outlined" |
| | | startIcon={item.icon} |
| | | onClick={() => navigate(item.path)} |
| | | > |
| | | {item.label} |
| | | </Button> |
| | | ))} |
| | | </Stack> |
| | | {loadingRuntime && ( |
| | | <Typography variant="body2" color="text.secondary" mt={1}> |
| | | 正在加载 AI 运行时信息... |
| | | </Typography> |
| | | )} |
| | | {!!drawerError && ( |
| | | <Alert severity="warning" sx={{ mt: 1.5 }}> |
| | | {drawerError} |
| | | </Alert> |
| | | )} |
| | | </Box> |
| | | |
| | | <Divider /> |
| | | |
| | | <Box flex={1} overflow="auto" px={2} py={2} display="flex" flexDirection="column" gap={1.5}> |
| | | {!messages.length && ( |
| | | <Paper variant="outlined" sx={{ p: 2, bgcolor: "grey.50" }}> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。 |
| | | </Typography> |
| | | </Paper> |
| | | )} |
| | | {messages.map((message, index) => ( |
| | | <Box |
| | | key={`${message.role}-${index}`} |
| | | display="flex" |
| | | justifyContent={message.role === "user" ? "flex-end" : "flex-start"} |
| | | > |
| | | <Paper |
| | | elevation={0} |
| | | sx={{ |
| | | px: 1.5, |
| | | py: 1.25, |
| | | maxWidth: "85%", |
| | | borderRadius: 2, |
| | | bgcolor: message.role === "user" ? "primary.main" : "grey.100", |
| | | color: message.role === "user" ? "primary.contrastText" : "text.primary", |
| | | whiteSpace: "pre-wrap", |
| | | wordBreak: "break-word", |
| | | }} |
| | | > |
| | | <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}> |
| | | {message.role === "user" ? "你" : "AI"} |
| | | </Typography> |
| | | <Typography variant="body2"> |
| | | {message.content || (streaming && index === messages.length - 1 ? "思考中..." : "")} |
| | | </Typography> |
| | | </Paper> |
| | | </Box> |
| | | ))} |
| | | </Box> |
| | | |
| | | <Divider /> |
| | | |
| | | <Box px={2} py={1.5}> |
| | | {usage?.totalTokens != null && ( |
| | | <Typography variant="caption" color="text.secondary" display="block" mb={1}> |
| | | Tokens: prompt {usage?.promptTokens ?? 0} / completion {usage?.completionTokens ?? 0} / total {usage?.totalTokens ?? 0} |
| | | </Typography> |
| | | )} |
| | | <TextField |
| | | value={input} |
| | | onChange={(event) => setInput(event.target.value)} |
| | | onKeyDown={handleKeyDown} |
| | | fullWidth |
| | | multiline |
| | | minRows={3} |
| | | maxRows={6} |
| | | placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行" |
| | | /> |
| | | <Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}> |
| | | <Button onClick={() => setInput("")}>清空输入</Button> |
| | | {streaming ? ( |
| | | <Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}> |
| | | 停止 |
| | | </Button> |
| | | ) : ( |
| | | <Button variant="contained" startIcon={<SendRoundedIcon />} onClick={handleSend}> |
| | | 发送 |
| | | </Button> |
| | | )} |
| | | </Stack> |
| | | </Box> |
| | | </Box> |
| | | </Box> |
| | | </Box> |
| | | </Drawer> |
| | | ); |
| | | }; |
| | | |
| | | export default AiChatDrawer; |
| | |
| | | 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 = () => ( |
| | | 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)} /> |
| | | </> |
| | | ); |
| | | }; |
| | |
| | | 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) { |
| | |
| | | return taskPathTemplateMerge; |
| | | case 'basStationArea': |
| | | return basStationArea; |
| | | case "aiParam": |
| | | return aiParam; |
| | | case "aiPrompt": |
| | | return aiPrompt; |
| | | case "aiMcpMount": |
| | | return aiMcpMount; |
| | | // case "locItem": |
| | | // return locItem; |
| | | default: |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | import React, { useEffect, useState } from "react"; |
| | | import { |
| | | Accordion, |
| | | AccordionDetails, |
| | | AccordionSummary, |
| | | Alert, |
| | | Box, |
| | | Button, |
| | | Card, |
| | | CardContent, |
| | | CircularProgress, |
| | | Divider, |
| | | Grid, |
| | | Stack, |
| | | TextField, |
| | | Typography, |
| | | } from "@mui/material"; |
| | | import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; |
| | | import PreviewOutlinedIcon from "@mui/icons-material/PreviewOutlined"; |
| | | import ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined"; |
| | | import { useNotify } from "react-admin"; |
| | | import { previewMcpTools, testMcpTool } from "@/api/ai/mcpMount"; |
| | | |
| | | const AiMcpMountToolsPanel = ({ mountId }) => { |
| | | const notify = useNotify(); |
| | | const [loading, setLoading] = useState(false); |
| | | const [tools, setTools] = useState([]); |
| | | const [error, setError] = useState(""); |
| | | const [inputs, setInputs] = useState({}); |
| | | const [outputs, setOutputs] = useState({}); |
| | | const [testingToolName, setTestingToolName] = useState(""); |
| | | |
| | | useEffect(() => { |
| | | if (!mountId) { |
| | | setTools([]); |
| | | setInputs({}); |
| | | setOutputs({}); |
| | | setError(""); |
| | | return; |
| | | } |
| | | loadTools(); |
| | | }, [mountId]); |
| | | |
| | | const loadTools = async () => { |
| | | setLoading(true); |
| | | setError(""); |
| | | try { |
| | | const data = await previewMcpTools(mountId); |
| | | setTools(data); |
| | | setOutputs({}); |
| | | } catch (requestError) { |
| | | setError(requestError.message || "获取工具列表失败"); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }; |
| | | |
| | | const handleInputChange = (toolName, value) => { |
| | | setInputs((prev) => ({ |
| | | ...prev, |
| | | [toolName]: value, |
| | | })); |
| | | }; |
| | | |
| | | const handleTest = async (toolName) => { |
| | | const inputJson = inputs[toolName]; |
| | | if (!inputJson || !inputJson.trim()) { |
| | | notify("请输入工具测试 JSON", { type: "warning" }); |
| | | return; |
| | | } |
| | | setTestingToolName(toolName); |
| | | try { |
| | | const result = await testMcpTool(mountId, { |
| | | toolName, |
| | | inputJson, |
| | | }); |
| | | setOutputs((prev) => ({ |
| | | ...prev, |
| | | [toolName]: result?.output || "", |
| | | })); |
| | | notify(`工具 ${toolName} 测试完成`); |
| | | } catch (requestError) { |
| | | const message = requestError.message || "工具测试失败"; |
| | | setOutputs((prev) => ({ |
| | | ...prev, |
| | | [toolName]: message, |
| | | })); |
| | | notify(message, { type: "error" }); |
| | | } finally { |
| | | setTestingToolName(""); |
| | | } |
| | | }; |
| | | |
| | | if (!mountId) { |
| | | return ( |
| | | <Alert severity="info" sx={{ mt: 2 }}> |
| | | 保存挂载后即可预览工具并执行测试。 |
| | | </Alert> |
| | | ); |
| | | } |
| | | |
| | | return ( |
| | | <Box mt={3}> |
| | | <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}> |
| | | <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}> |
| | | <Box flex={1}> |
| | | <Typography variant="h6">工具预览与测试</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 当前挂载解析出的全部工具都显示在这里,可直接输入 JSON 做测试。 |
| | | </Typography> |
| | | </Box> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Stack direction="row" justifyContent="flex-end" alignItems="center" mb={1.5}> |
| | | <Button size="small" startIcon={<PreviewOutlinedIcon />} onClick={loadTools} disabled={loading}> |
| | | 刷新工具 |
| | | </Button> |
| | | </Stack> |
| | | {loading && ( |
| | | <Box display="flex" justifyContent="center" py={4}> |
| | | <CircularProgress size={28} /> |
| | | </Box> |
| | | )} |
| | | {!!error && !loading && ( |
| | | <Alert severity="warning" sx={{ mb: 2 }}> |
| | | {error} |
| | | </Alert> |
| | | )} |
| | | {!loading && !error && !tools.length && ( |
| | | <Alert severity="info">当前挂载未解析出任何工具。</Alert> |
| | | )} |
| | | <Grid container spacing={2}> |
| | | {tools.map((tool) => ( |
| | | <Grid item xs={12} key={tool.name}> |
| | | <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}> |
| | | <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1">{tool.name}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {tool.description || "暂无描述"} |
| | | </Typography> |
| | | </Box> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {tool.returnDirect ? "returnDirect" : "normal"} |
| | | </Typography> |
| | | </Stack> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Card variant="outlined" sx={{ borderRadius: 3 }}> |
| | | <CardContent> |
| | | <TextField |
| | | label="Input Schema" |
| | | value={tool.inputSchema || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={12} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | <TextField |
| | | label="测试输入 JSON" |
| | | value={inputs[tool.name] || ""} |
| | | onChange={(event) => handleInputChange(tool.name, event.target.value)} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={12} |
| | | sx={{ mt: 2 }} |
| | | placeholder='例如:{"code":"A01"}' |
| | | /> |
| | | <Stack direction="row" justifyContent="flex-end" mt={1.5}> |
| | | <Button |
| | | variant="contained" |
| | | startIcon={<PlayCircleOutlineOutlinedIcon />} |
| | | onClick={() => handleTest(tool.name)} |
| | | disabled={testingToolName === tool.name} |
| | | > |
| | | {testingToolName === tool.name ? "测试中..." : "执行测试"} |
| | | </Button> |
| | | </Stack> |
| | | <TextField |
| | | label="测试结果" |
| | | value={outputs[tool.name] || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={16} |
| | | sx={{ mt: 2 }} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | </CardContent> |
| | | </Card> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | export default AiMcpMountToolsPanel; |
| New file |
| | |
| | | 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 || ''}`, |
| | | }; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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 || ''}`, |
| | | }; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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 || ''}`, |
| | | }; |
| New file |
| | |
| | | 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; |
| | |
| | | <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> |
| | |
| | | <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> |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | @Data |
| | | public class AiChatMessageDto { |
| | | |
| | | private String role; |
| | | |
| | | private String content; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | @Data |
| | | public class AiMcpToolTestRequest { |
| | | |
| | | private String toolName; |
| | | |
| | | private String inputJson; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_mcp_mount") |
| | | public class AiMcpMount implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "ID") |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty(value = "名称") |
| | | private String name; |
| | | |
| | | @ApiModelProperty(value = "传输类型") |
| | | private String transportType; |
| | | |
| | | @ApiModelProperty(value = "内置 MCP 编码") |
| | | private String builtinCode; |
| | | |
| | | @ApiModelProperty(value = "服务地址") |
| | | private String serverUrl; |
| | | |
| | | @ApiModelProperty(value = "SSE端点") |
| | | private String endpoint; |
| | | |
| | | @ApiModelProperty(value = "命令") |
| | | private String command; |
| | | |
| | | @ApiModelProperty(value = "命令参数JSON") |
| | | private String argsJson; |
| | | |
| | | @ApiModelProperty(value = "环境变量JSON") |
| | | private String envJson; |
| | | |
| | | @ApiModelProperty(value = "请求头JSON") |
| | | private String headersJson; |
| | | |
| | | @ApiModelProperty(value = "超时时间") |
| | | private Integer requestTimeoutMs; |
| | | |
| | | @ApiModelProperty(value = "排序") |
| | | private Integer sort; |
| | | |
| | | @ApiModelProperty(value = "状态") |
| | | private Integer status; |
| | | |
| | | @ApiModelProperty(value = "是否删除") |
| | | private Integer deleted; |
| | | |
| | | @ApiModelProperty(value = "租户") |
| | | private Long tenantId; |
| | | |
| | | @ApiModelProperty(value = "添加人员") |
| | | private Long createBy; |
| | | |
| | | @ApiModelProperty(value = "添加时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | @ApiModelProperty(value = "修改人员") |
| | | private Long updateBy; |
| | | |
| | | @ApiModelProperty(value = "修改时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | @ApiModelProperty(value = "备注") |
| | | private String memo; |
| | | |
| | | public String getTransportType$() { |
| | | return this.transportType; |
| | | } |
| | | |
| | | public Boolean getStatusBool() { |
| | | if (this.status == null) { |
| | | return null; |
| | | } |
| | | return this.status == 1; |
| | | } |
| | | |
| | | public String getCreateTime$() { |
| | | if (this.createTime == null) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime); |
| | | } |
| | | |
| | | public String getUpdateTime$() { |
| | | if (this.updateTime == null) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import com.vincent.rsf.server.ai.dto.AiResolvedConfig; |
| | | |
| | | public interface AiConfigResolverService { |
| | | |
| | | AiResolvedConfig resolve(String promptCode); |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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 编码已存在"); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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 |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.impl; |
| | | |
| | | import com.fasterxml.jackson.core.type.TypeReference; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.framework.exception.CoolException; |
| | | import com.vincent.rsf.server.ai.config.AiDefaults; |
| | | import com.vincent.rsf.server.ai.entity.AiMcpMount; |
| | | import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry; |
| | | import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory; |
| | | import io.modelcontextprotocol.client.McpClient; |
| | | import io.modelcontextprotocol.client.McpSyncClient; |
| | | import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; |
| | | import io.modelcontextprotocol.client.transport.ServerParameters; |
| | | import io.modelcontextprotocol.client.transport.StdioClientTransport; |
| | | import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; |
| | | import io.modelcontextprotocol.spec.McpSchema; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; |
| | | import org.springframework.ai.tool.ToolCallback; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.util.StringUtils; |
| | | |
| | | import java.time.Duration; |
| | | import java.util.ArrayList; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Slf4j |
| | | @Service |
| | | @RequiredArgsConstructor |
| | | public class McpMountRuntimeFactoryImpl implements McpMountRuntimeFactory { |
| | | |
| | | private final ObjectMapper objectMapper; |
| | | private final BuiltinMcpToolRegistry builtinMcpToolRegistry; |
| | | |
| | | @Override |
| | | public McpMountRuntime create(List<AiMcpMount> mounts, Long userId) { |
| | | List<McpSyncClient> clients = new ArrayList<>(); |
| | | List<ToolCallback> callbacks = new ArrayList<>(); |
| | | List<String> mountedNames = new ArrayList<>(); |
| | | List<String> errors = new ArrayList<>(); |
| | | for (AiMcpMount mount : mounts) { |
| | | try { |
| | | if (AiDefaults.MCP_TRANSPORT_BUILTIN.equals(mount.getTransportType())) { |
| | | callbacks.addAll(builtinMcpToolRegistry.createToolCallbacks(mount, userId)); |
| | | mountedNames.add(mount.getName()); |
| | | continue; |
| | | } |
| | | McpSyncClient client = createClient(mount); |
| | | client.initialize(); |
| | | client.listTools(); |
| | | clients.add(client); |
| | | mountedNames.add(mount.getName()); |
| | | } catch (Exception e) { |
| | | String message = mount.getName() + " 挂载失败: " + e.getMessage(); |
| | | errors.add(message); |
| | | log.warn(message, e); |
| | | } |
| | | } |
| | | if (!clients.isEmpty()) { |
| | | callbacks.addAll(Arrays.asList( |
| | | SyncMcpToolCallbackProvider.builder().mcpClients(clients).build().getToolCallbacks() |
| | | )); |
| | | } |
| | | ensureUniqueToolNames(callbacks); |
| | | return new DefaultMcpMountRuntime(clients, callbacks.toArray(new ToolCallback[0]), mountedNames, errors); |
| | | } |
| | | |
| | | private void ensureUniqueToolNames(List<ToolCallback> callbacks) { |
| | | LinkedHashSet<String> duplicateNames = new LinkedHashSet<>(); |
| | | LinkedHashSet<String> seenNames = new LinkedHashSet<>(); |
| | | for (ToolCallback callback : callbacks) { |
| | | if (callback == null || callback.getToolDefinition() == null) { |
| | | continue; |
| | | } |
| | | String name = callback.getToolDefinition().name(); |
| | | if (!StringUtils.hasText(name)) { |
| | | continue; |
| | | } |
| | | if (!seenNames.add(name)) { |
| | | duplicateNames.add(name); |
| | | } |
| | | } |
| | | if (!duplicateNames.isEmpty()) { |
| | | throw new CoolException("MCP 工具名称重复,请调整挂载配置: " + String.join(", ", duplicateNames)); |
| | | } |
| | | } |
| | | |
| | | private McpSyncClient createClient(AiMcpMount mount) { |
| | | Duration timeout = Duration.ofMillis(mount.getRequestTimeoutMs() == null |
| | | ? AiDefaults.DEFAULT_TIMEOUT_MS |
| | | : mount.getRequestTimeoutMs()); |
| | | JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(objectMapper); |
| | | if (AiDefaults.MCP_TRANSPORT_STDIO.equals(mount.getTransportType())) { |
| | | ServerParameters.Builder parametersBuilder = ServerParameters.builder(mount.getCommand()); |
| | | List<String> args = readStringList(mount.getArgsJson()); |
| | | if (!args.isEmpty()) { |
| | | parametersBuilder.args(args); |
| | | } |
| | | Map<String, String> env = readStringMap(mount.getEnvJson()); |
| | | if (!env.isEmpty()) { |
| | | parametersBuilder.env(env); |
| | | } |
| | | StdioClientTransport transport = new StdioClientTransport(parametersBuilder.build(), jsonMapper); |
| | | transport.setStdErrorHandler(message -> log.warn("MCP STDIO stderr [{}]: {}", mount.getName(), message)); |
| | | return McpClient.sync(transport) |
| | | .requestTimeout(timeout) |
| | | .initializationTimeout(timeout) |
| | | .clientInfo(new McpSchema.Implementation("rsf-ai-client", "RSF AI Client", "1.0.0")) |
| | | .build(); |
| | | } |
| | | if (!AiDefaults.MCP_TRANSPORT_SSE_HTTP.equals(mount.getTransportType())) { |
| | | throw new CoolException("不支持的 MCP 传输类型: " + mount.getTransportType()); |
| | | } |
| | | |
| | | if (!StringUtils.hasText(mount.getServerUrl())) { |
| | | throw new CoolException("MCP 服务地址不能为空"); |
| | | } |
| | | HttpClientSseClientTransport.Builder transportBuilder = HttpClientSseClientTransport.builder(mount.getServerUrl()) |
| | | .jsonMapper(jsonMapper) |
| | | .connectTimeout(timeout); |
| | | if (StringUtils.hasText(mount.getEndpoint())) { |
| | | transportBuilder.sseEndpoint(mount.getEndpoint()); |
| | | } |
| | | Map<String, String> headers = readStringMap(mount.getHeadersJson()); |
| | | if (!headers.isEmpty()) { |
| | | transportBuilder.customizeRequest(builder -> headers.forEach(builder::header)); |
| | | } |
| | | return McpClient.sync(transportBuilder.build()) |
| | | .requestTimeout(timeout) |
| | | .initializationTimeout(timeout) |
| | | .clientInfo(new McpSchema.Implementation("rsf-ai-client", "RSF AI Client", "1.0.0")) |
| | | .build(); |
| | | } |
| | | |
| | | private List<String> readStringList(String json) { |
| | | if (!StringUtils.hasText(json)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | try { |
| | | return objectMapper.readValue(json, new TypeReference<List<String>>() { |
| | | }); |
| | | } catch (Exception e) { |
| | | throw new CoolException("解析 MCP 列表配置失败: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | private Map<String, String> readStringMap(String json) { |
| | | if (!StringUtils.hasText(json)) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | try { |
| | | Map<String, String> result = objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, String>>() { |
| | | }); |
| | | return result == null ? Collections.emptyMap() : result; |
| | | } catch (Exception e) { |
| | | throw new CoolException("解析 MCP Map 配置失败: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | private static class DefaultMcpMountRuntime implements McpMountRuntime { |
| | | |
| | | private final List<McpSyncClient> clients; |
| | | private final ToolCallback[] callbacks; |
| | | private final List<String> mountedNames; |
| | | private final List<String> errors; |
| | | |
| | | private DefaultMcpMountRuntime(List<McpSyncClient> clients, ToolCallback[] callbacks, List<String> mountedNames, List<String> errors) { |
| | | this.clients = clients; |
| | | this.callbacks = callbacks; |
| | | this.mountedNames = mountedNames; |
| | | this.errors = errors; |
| | | } |
| | | |
| | | @Override |
| | | public ToolCallback[] getToolCallbacks() { |
| | | return callbacks; |
| | | } |
| | | |
| | | @Override |
| | | public List<String> getMountedNames() { |
| | | return mountedNames; |
| | | } |
| | | |
| | | @Override |
| | | public List<String> getErrors() { |
| | | return errors; |
| | | } |
| | | |
| | | @Override |
| | | public int getMountedCount() { |
| | | return mountedNames.size(); |
| | | } |
| | | |
| | | @Override |
| | | public void close() { |
| | | for (McpSyncClient client : clients) { |
| | | try { |
| | | client.close(); |
| | | } catch (Exception e) { |
| | | log.warn("关闭 MCP Client 失败", e); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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("未找到匹配站点"); |
| | | } |
| | |
| | | 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; |
| | |
| | | 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() |
| New file |
| | |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_param` ( |
| | | `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `name` varchar(255) NOT NULL COMMENT '名称', |
| | | `provider_type` varchar(64) NOT NULL COMMENT '提供方类型', |
| | | `base_url` varchar(500) NOT NULL COMMENT '基础地址', |
| | | `api_key` varchar(1024) NOT NULL COMMENT 'API Key', |
| | | `model` varchar(255) NOT NULL COMMENT '模型', |
| | | `temperature` decimal(10,4) DEFAULT NULL COMMENT 'temperature', |
| | | `top_p` decimal(10,4) DEFAULT NULL COMMENT 'topP', |
| | | `max_tokens` int(11) DEFAULT NULL COMMENT '最大Token', |
| | | `timeout_ms` int(11) DEFAULT NULL COMMENT '超时时间', |
| | | `streaming_enabled` tinyint(1) DEFAULT '1' COMMENT '是否启用流式响应', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `status` int(11) DEFAULT '1' COMMENT '状态', |
| | | `deleted` int(11) DEFAULT '0' COMMENT '删除标记', |
| | | `create_time` datetime DEFAULT NULL COMMENT '创建时间', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `update_time` datetime DEFAULT NULL COMMENT '更新时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `memo` varchar(500) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_sys_ai_param_tenant_status` (`tenant_id`,`status`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 参数配置'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_prompt` ( |
| | | `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `name` varchar(255) NOT NULL COMMENT '名称', |
| | | `code` varchar(128) NOT NULL COMMENT '编码', |
| | | `scene` varchar(128) DEFAULT NULL COMMENT '场景', |
| | | `system_prompt` text COMMENT '系统 Prompt', |
| | | `user_prompt_template` text COMMENT '用户 Prompt 模板', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `status` int(11) DEFAULT '1' COMMENT '状态', |
| | | `deleted` int(11) DEFAULT '0' COMMENT '删除标记', |
| | | `create_time` datetime DEFAULT NULL COMMENT '创建时间', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `update_time` datetime DEFAULT NULL COMMENT '更新时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `memo` varchar(500) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_sys_ai_prompt_code_tenant` (`tenant_id`,`code`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI Prompt 配置'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_mcp_mount` ( |
| | | `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `name` varchar(255) NOT NULL COMMENT '名称', |
| | | `transport_type` varchar(64) NOT NULL COMMENT '传输类型', |
| | | `builtin_code` varchar(128) DEFAULT NULL COMMENT '内置 MCP 编码', |
| | | `server_url` varchar(500) DEFAULT NULL COMMENT '服务地址', |
| | | `endpoint` varchar(255) DEFAULT NULL COMMENT 'SSE Endpoint', |
| | | `command` varchar(500) DEFAULT NULL COMMENT '本地命令', |
| | | `args_json` text COMMENT '命令参数 JSON', |
| | | `env_json` text COMMENT '环境变量 JSON', |
| | | `headers_json` text COMMENT '请求头 JSON', |
| | | `request_timeout_ms` int(11) DEFAULT NULL COMMENT '请求超时时间', |
| | | `sort` int(11) DEFAULT '0' COMMENT '排序', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `status` int(11) DEFAULT '1' COMMENT '状态', |
| | | `deleted` int(11) DEFAULT '0' COMMENT '删除标记', |
| | | `create_time` datetime DEFAULT NULL COMMENT '创建时间', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `update_time` datetime DEFAULT NULL COMMENT '更新时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `memo` varchar(500) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_sys_ai_mcp_mount_tenant_status` (`tenant_id`,`status`,`sort`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP 挂载配置'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_chat_session` ( |
| | | `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `title` varchar(255) NOT NULL COMMENT '会话标题', |
| | | `prompt_code` varchar(128) NOT NULL COMMENT 'Prompt 编码', |
| | | `user_id` bigint(20) NOT NULL COMMENT '用户 ID', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间', |
| | | `status` int(11) DEFAULT '1' COMMENT '状态', |
| | | `deleted` int(11) DEFAULT '0' COMMENT '删除标记', |
| | | `create_time` datetime DEFAULT NULL COMMENT '创建时间', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `update_time` datetime DEFAULT NULL COMMENT '更新时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_sys_ai_chat_session_user_prompt` (`tenant_id`,`user_id`,`prompt_code`,`last_message_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话会话'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_chat_message` ( |
| | | `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `session_id` bigint(20) NOT NULL COMMENT '会话 ID', |
| | | `seq_no` int(11) NOT NULL COMMENT '消息序号', |
| | | `role` varchar(32) NOT NULL COMMENT '消息角色', |
| | | `content` longtext COMMENT '消息内容', |
| | | `user_id` bigint(20) NOT NULL COMMENT '用户 ID', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `deleted` int(11) DEFAULT '0' COMMENT '删除标记', |
| | | `create_time` datetime DEFAULT NULL COMMENT '创建时间', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_sys_ai_chat_message_session_seq` (`session_id`,`seq_no`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话消息'; |
| | | |
| | | SET @builtin_code_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |
| | | WHERE `TABLE_SCHEMA` = DATABASE() |
| | | AND `TABLE_NAME` = 'sys_ai_mcp_mount' |
| | | AND `COLUMN_NAME` = 'builtin_code' |
| | | ); |
| | | SET @builtin_code_sql := IF( |
| | | @builtin_code_exists = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `builtin_code` varchar(128) DEFAULT NULL COMMENT ''内置 MCP 编码'' AFTER `transport_type`', |
| | | 'SELECT 1' |
| | | ); |
| | | PREPARE builtin_code_stmt FROM @builtin_code_sql; |
| | | EXECUTE builtin_code_stmt; |
| | | DEALLOCATE PREPARE builtin_code_stmt; |
| | | |
| | | BEGIN; |
| | | INSERT INTO `sys_ai_prompt` |
| | | (`id`, `name`, `code`, `scene`, `system_prompt`, `user_prompt_template`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | VALUES |
| | | (1, '首页默认助手', 'home.default', 'home', '你是 RSF 系统的 AI 助手,请结合当前上下文为用户提供准确、简洁、可执行的帮助。', '请基于当前页面上下文回答用户问题:{{input}}', 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, '首页默认 Prompt') |
| | | ON DUPLICATE KEY UPDATE |
| | | `name` = VALUES(`name`), |
| | | `scene` = VALUES(`scene`), |
| | | `system_prompt` = VALUES(`system_prompt`), |
| | | `user_prompt_template` = VALUES(`user_prompt_template`), |
| | | `status` = VALUES(`status`), |
| | | `deleted` = VALUES(`deleted`), |
| | | `update_time` = VALUES(`update_time`), |
| | | `update_by` = VALUES(`update_by`), |
| | | `memo` = VALUES(`memo`); |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'RSF WMS 内置 MCP', 'BUILTIN', 'RSF_WMS', 60000, 0, 1, 1, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置 WMS 查询与任务工具' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_mcp_mount` |
| | | WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS' AND `deleted` = 0 |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'RSF WMS 库存作业内置 MCP', 'BUILTIN', 'RSF_WMS_STOCK', 60000, 1, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置库存查询和站点查询工具' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_mcp_mount` |
| | | WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_STOCK' AND `deleted` = 0 |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'RSF WMS 任务查询内置 MCP', 'BUILTIN', 'RSF_WMS_TASK', 60000, 2, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置任务列表与任务详情查询工具' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_mcp_mount` |
| | | WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_TASK' AND `deleted` = 0 |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`name`, `transport_type`, `builtin_code`, `request_timeout_ms`, `sort`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'RSF WMS 基础资料内置 MCP', 'BUILTIN', 'RSF_WMS_BASE', 60000, 3, 1, 0, 0, '2026-03-19 10:00:00', 2, '2026-03-19 10:00:00', 2, '内置仓库、基础站点和字典数据查询工具' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_mcp_mount` |
| | | WHERE `tenant_id` = 1 AND `transport_type` = 'BUILTIN' AND `builtin_code` = 'RSF_WMS_BASE' AND `deleted` = 0 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`id`, `name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | VALUES |
| | | (5301, 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 11, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5302, 'Query AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5303, 'Create AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5304, 'Update AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5305, 'Delete AI Param', 5301, NULL, '1,5301', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5306, 'menu.aiPrompt', 1, 'menu.system', '1', 'menu.system', '/system/aiPrompt', 'aiPrompt', NULL, NULL, 0, NULL, 'PsychologyAlt', 12, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5307, 'Query AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5308, 'Create AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5309, 'Update AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5310, 'Delete AI Prompt', 5306, NULL, '1,5306', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5311, 'menu.aiMcpMount', 1, 'menu.system', '1', 'menu.system', '/system/aiMcpMount', 'aiMcpMount', NULL, NULL, 0, NULL, 'Cable', 13, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5312, 'Query AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5313, 'Create AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5314, 'Update AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL), |
| | | (5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL) |
| | | ON DUPLICATE KEY UPDATE |
| | | `name` = VALUES(`name`), |
| | | `parent_id` = VALUES(`parent_id`), |
| | | `parent_name` = VALUES(`parent_name`), |
| | | `path` = VALUES(`path`), |
| | | `path_name` = VALUES(`path_name`), |
| | | `route` = VALUES(`route`), |
| | | `component` = VALUES(`component`), |
| | | `authority` = VALUES(`authority`), |
| | | `icon` = VALUES(`icon`), |
| | | `sort` = VALUES(`sort`), |
| | | `tenant_id` = VALUES(`tenant_id`), |
| | | `status` = VALUES(`status`), |
| | | `deleted` = VALUES(`deleted`), |
| | | `update_time` = VALUES(`update_time`), |
| | | `update_by` = VALUES(`update_by`); |
| | | |
| | | INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) |
| | | VALUES |
| | | (5301, 1, 5301), |
| | | (5302, 1, 5302), |
| | | (5303, 1, 5303), |
| | | (5304, 1, 5304), |
| | | (5305, 1, 5305), |
| | | (5306, 1, 5306), |
| | | (5307, 1, 5307), |
| | | (5308, 1, 5308), |
| | | (5309, 1, 5309), |
| | | (5310, 1, 5310), |
| | | (5311, 1, 5311), |
| | | (5312, 1, 5312), |
| | | (5313, 1, 5313), |
| | | (5314, 1, 5314), |
| | | (5315, 1, 5315) |
| | | ON DUPLICATE KEY UPDATE |
| | | `role_id` = VALUES(`role_id`), |
| | | `menu_id` = VALUES(`menu_id`); |
| | | COMMIT; |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |