#AI
zhou zhou
3 天以前 e6369c9b64a82eeada6b6f0658d2ae786787e101
#AI
9个文件已修改
46个文件已添加
4411 ■■■■■ 已修改文件
pom.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/ai/AiChatWidget.jsx 755 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/index.js 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/en.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/zh.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AppBarToolbar.jsx 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/ResourceContent.js 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamList.jsx 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/index.jsx 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/gateway-run.log 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/pom.xml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java 267 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-ai-gateway/src/main/resources/application.yml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/skills/rsf-server-maintainer/SKILL.md 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/skills/rsf-server-maintainer/agents/openai.yaml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/skills/rsf-server-maintainer/references/repo-map.md 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application-dev.yml 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application-prod.yml 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/application.yml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/mapper/system/AiParamMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/20260311_ai_param.sql 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/20260311_ai_param_menu.sql 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/init.sql 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml
@@ -21,6 +21,7 @@
        <module>rsf-framework</module>
        <module>rsf-server</module>
        <module>rsf-open-api</module>
        <module>rsf-ai-gateway</module>
    </modules>
    <properties>
rsf-admin/src/ai/AiChatWidget.jsx
New file
@@ -0,0 +1,755 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
    Alert,
    Avatar,
    Box,
    Button,
    Chip,
    CircularProgress,
    Divider,
    Drawer,
    Fab,
    IconButton,
    List,
    ListItemButton,
    ListItemText,
    MenuItem,
    Select,
    Stack,
    TextField,
    Tooltip,
    Typography
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
import CloseIcon from '@mui/icons-material/Close';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SendIcon from '@mui/icons-material/Send';
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
import { createAiSession, getAiMessages, getAiModels, getAiSessions, removeAiSession, stopAiChat } from '@/api/ai';
import { PREFIX_BASE_URL, TOKEN_HEADER_NAME } from '@/config/setting';
import { getToken } from '@/utils/token-util';
const DRAWER_WIDTH = 720;
const SESSION_WIDTH = 220;
const parseSseChunk = (chunk, onEvent) => {
    const blocks = chunk.split('\n\n');
    const remain = blocks.pop();
    blocks.forEach((block) => {
        if (!block.trim()) {
            return;
        }
        let eventName = 'message';
        const dataLines = [];
        block.split('\n').forEach((line) => {
            if (line.startsWith('event:')) {
                eventName = line.substring(6).trim();
            }
            if (line.startsWith('data:')) {
                dataLines.push(line.substring(5).trim());
            }
        });
        if (!dataLines.length) {
            return;
        }
        try {
            onEvent(eventName, JSON.parse(dataLines.join('\n')));
        } catch (error) {
            console.error(error);
        }
    });
    return remain || '';
};
const buildAssistantMessage = (sessionId, modelCode) => ({
    id: `assistant-${Date.now()}`,
    sessionId,
    role: 'assistant',
    content: '',
    modelCode,
    createTime: new Date().toISOString()
});
const buildFallbackAnswer = (modelCode, question) => `当前为演示模式,模型[${modelCode}]已收到你的问题:${question}`;
const shouldUseMockFallback = (models, modelCode) => {
    const model = (models || []).find((item) => item.code === modelCode);
    return model ? model.provider === 'mock' : String(modelCode || '').startsWith('mock');
};
const formatTime = (value) => {
    if (!value) {
        return '';
    }
    const date = new Date(value);
    if (Number.isNaN(date.getTime())) {
        return '';
    }
    return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
};
const formatPreview = (value) => {
    if (!value) {
        return '开始一个新的对话';
    }
    const normalized = String(value).replace(/\s+/g, ' ').trim();
    return normalized.length > 22 ? `${normalized.slice(0, 22)}...` : normalized;
};
export const AiChatWidget = ({
    trigger = 'fab',
    buttonText = 'AI 对话',
    buttonVariant = 'contained',
    buttonSx = {}
}) => {
    const [open, setOpen] = useState(false);
    const [loading, setLoading] = useState(false);
    const [sending, setSending] = useState(false);
    const [models, setModels] = useState([]);
    const [sessions, setSessions] = useState([]);
    const [activeSessionId, setActiveSessionId] = useState('');
    const [messagesBySession, setMessagesBySession] = useState({});
    const [draft, setDraft] = useState('');
    const [error, setError] = useState('');
    const streamControllerRef = useRef(null);
    const messagesEndRef = useRef(null);
    const scrollToBottom = (behavior = 'auto') => {
        window.setTimeout(() => {
            window.requestAnimationFrame(() => {
                messagesEndRef.current?.scrollIntoView({ behavior, block: 'end' });
            });
        }, 0);
    };
    const activeSession = useMemo(
        () => sessions.find((item) => item.id === activeSessionId) || null,
        [sessions, activeSessionId]
    );
    const activeMessages = messagesBySession[activeSessionId] || [];
    useEffect(() => {
        if (open) {
            bootstrap();
        }
    }, [open]);
    useEffect(() => {
        if (!open) {
            return undefined;
        }
        const timer = window.setTimeout(() => {
            scrollToBottom('auto');
        }, 180);
        return () => window.clearTimeout(timer);
    }, [open, activeSessionId]);
    useEffect(() => {
        if (open && activeSessionId) {
            loadMessages(activeSessionId);
        }
    }, [open, activeSessionId]);
    useEffect(() => {
        if (open && messagesEndRef.current) {
            scrollToBottom('smooth');
        }
    }, [open, activeMessages]);
    const bootstrap = async () => {
        setLoading(true);
        setError('');
        try {
            const [modelList, sessionList] = await Promise.all([getAiModels(), getAiSessions()]);
            setModels(modelList);
            if (sessionList.length > 0) {
                setSessions(sessionList);
                setActiveSessionId((prev) => prev || sessionList[0].id);
            } else {
                const created = await createAiSession({ modelCode: modelList[0]?.code });
                setSessions(created ? [created] : []);
                setActiveSessionId(created?.id || '');
                if (created?.id) {
                    setMessagesBySession((prev) => ({ ...prev, [created.id]: [] }));
                }
            }
        } catch (bootstrapError) {
            setError(bootstrapError.message || 'AI助手初始化失败');
        } finally {
            setLoading(false);
        }
    };
    const refreshSessions = async (preferSessionId) => {
        try {
            const sessionList = await getAiSessions();
            if (sessionList.length > 0) {
                setSessions(sessionList);
                setActiveSessionId(preferSessionId || sessionList[0].id);
            }
        } catch (refreshError) {
            setError(refreshError.message || '刷新会话失败');
        }
    };
    const loadMessages = async (sessionId) => {
        if (!sessionId || messagesBySession[sessionId]) {
            return;
        }
        try {
            const messageList = await getAiMessages(sessionId);
            setMessagesBySession((prev) => {
                const current = prev[sessionId];
                if (current && current.length > 0) {
                    return prev;
                }
                return { ...prev, [sessionId]: messageList };
            });
            scrollToBottom('auto');
        } catch (messageError) {
            setError(messageError.message || '加载消息失败');
        }
    };
    const handleCreateSession = async () => {
        try {
            const created = await createAiSession({ modelCode: activeSession?.modelCode || models[0]?.code });
            setSessions((prev) => [created, ...prev]);
            setActiveSessionId(created.id);
            setMessagesBySession((prev) => ({ ...prev, [created.id]: [] }));
        } catch (createError) {
            setError(createError.message || '创建会话失败');
        }
    };
    const handleDeleteSession = async (sessionId) => {
        try {
            await removeAiSession(sessionId);
            const nextSessions = sessions.filter((item) => item.id !== sessionId);
            setSessions(nextSessions);
            setMessagesBySession((prev) => {
                const next = { ...prev };
                delete next[sessionId];
                return next;
            });
            if (nextSessions.length > 0) {
                setActiveSessionId(nextSessions[0].id);
            } else {
                const created = await createAiSession({ modelCode: models[0]?.code });
                if (created?.id) {
                    setSessions([created]);
                    setActiveSessionId(created.id);
                    setMessagesBySession({ [created.id]: [] });
                }
            }
        } catch (deleteError) {
            setError(deleteError.message || '删除会话失败');
        }
    };
    const handleModelChange = (modelCode) => {
        setSessions((prev) => prev.map((item) => item.id === activeSessionId ? { ...item, modelCode } : item));
    };
    const handleStop = async () => {
        if (!activeSessionId) {
            return;
        }
        if (streamControllerRef.current) {
            streamControllerRef.current.abort();
        }
        try {
            await stopAiChat({ sessionId: activeSessionId });
        } catch (stopError) {
            console.error(stopError);
        } finally {
            setSending(false);
        }
    };
    const handleSend = async () => {
        if (!draft.trim() || !activeSessionId || sending) {
            return;
        }
        const userContent = draft.trim();
        const sessionId = activeSessionId;
        const modelCode = activeSession?.modelCode || models[0]?.code;
        setDraft('');
        setError('');
        setSending(true);
        setMessagesBySession((prev) => {
            const current = prev[sessionId] || [];
            return {
                ...prev,
                [sessionId]: [
                    ...current,
                    {
                        id: `user-${Date.now()}`,
                        sessionId,
                        role: 'user',
                        content: userContent,
                        modelCode,
                        createTime: new Date().toISOString()
                    },
                    buildAssistantMessage(sessionId, modelCode)
                ]
            };
        });
        const controller = new AbortController();
        streamControllerRef.current = controller;
        let buffer = '';
        let receivedDelta = false;
        try {
            const response = await fetch(`${PREFIX_BASE_URL}ai/chat/stream`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'text/event-stream',
                    [TOKEN_HEADER_NAME]: getToken()
                },
                body: JSON.stringify({
                    sessionId,
                    message: userContent,
                    modelCode
                }),
                signal: controller.signal
            });
            if (!response.ok || !response.body) {
                throw new Error('AI流式请求失败');
            }
            const reader = response.body.getReader();
            const decoder = new TextDecoder('utf-8');
            while (true) {
                const { value, done } = await reader.read();
                if (done) {
                    break;
                }
                buffer += decoder.decode(value, { stream: true });
                buffer = parseSseChunk(buffer, (eventName, payload) => {
                    if (eventName === 'session' && payload.sessionId) {
                        setSessions((prev) => prev.map((item) => item.id === sessionId ? { ...item, modelCode: payload.modelCode || modelCode } : item));
                    }
                    if (eventName === 'delta') {
                        receivedDelta = true;
                        setMessagesBySession((prev) => {
                            const current = [...(prev[sessionId] || [])];
                            if (!current.length) {
                                return prev;
                            }
                            const last = current[current.length - 1];
                            current[current.length - 1] = { ...last, content: `${last.content || ''}${payload.content || ''}` };
                            return { ...prev, [sessionId]: current };
                        });
                    }
                    if (eventName === 'error') {
                        setError(payload.message || 'AI服务异常');
                    }
                });
            }
            const latestMessages = await getAiMessages(sessionId);
            setMessagesBySession((prev) => {
                const current = prev[sessionId] || [];
                if (!receivedDelta && current.length > 0 && shouldUseMockFallback(models, modelCode)) {
                    const fallbackAnswer = buildFallbackAnswer(modelCode, userContent);
                    const next = [...current];
                    const last = next[next.length - 1];
                    if (last && last.role === 'assistant' && !last.content) {
                        next[next.length - 1] = { ...last, content: fallbackAnswer };
                        return { ...prev, [sessionId]: next };
                    }
                }
                if (latestMessages.length === 0 && current.length > 0) {
                    return prev;
                }
                return { ...prev, [sessionId]: latestMessages };
            });
            setSessions((prev) => prev.map((item) => {
                if (item.id !== sessionId) {
                    return item;
                }
                const lastMessage = assistantReplyText(latestMessages) || item.lastMessage || userContent;
                return {
                    ...item,
                    lastMessage,
                    lastMessageAt: new Date().toISOString(),
                    updateTime: new Date().toISOString()
                };
            }));
            await refreshSessions(sessionId);
        } catch (sendError) {
            if (sendError.name !== 'AbortError') {
                setError(sendError.message || '发送消息失败');
            }
        } finally {
            setSending(false);
            streamControllerRef.current = null;
        }
    };
    const assistantReplyText = (messageList) => {
        const assistantMessages = (messageList || []).filter((item) => item.role === 'assistant');
        if (!assistantMessages.length) {
            return '';
        }
        return assistantMessages[assistantMessages.length - 1].content || '';
    };
    return (
        <>
            {trigger === 'fab' ? (
                <Fab
                    color="secondary"
                    onClick={() => setOpen(true)}
                    sx={{ position: 'fixed', right: 24, bottom: 24, zIndex: 1301 }}
                >
                    <ChatBubbleOutlineIcon />
                </Fab>
            ) : (
                <Button
                    variant={buttonVariant}
                    startIcon={<ChatBubbleOutlineIcon />}
                    onClick={() => setOpen(true)}
                    sx={buttonSx}
                >
                    {buttonText}
                </Button>
            )}
            <Drawer
                anchor="right"
                open={open}
                onClose={() => setOpen(false)}
                PaperProps={{
                    sx: {
                        width: DRAWER_WIDTH,
                        maxWidth: '100vw',
                        backgroundColor: '#f5f7fa'
                    }
                }}
            >
                <Stack sx={{ height: '100%' }}>
                    <Stack
                        direction="row"
                        alignItems="center"
                        spacing={1.5}
                        sx={{
                            px: 2,
                            py: 1.5,
                            backgroundColor: '#fff',
                            color: 'text.primary',
                            borderBottom: '1px solid rgba(0, 0, 0, 0.08)'
                        }}
                    >
                        <Avatar
                            sx={{
                                width: 38,
                                height: 38,
                                bgcolor: 'rgba(25, 118, 210, 0.12)',
                                color: 'primary.main',
                                fontSize: 16,
                                fontWeight: 700
                            }}
                        >
                            AI
                        </Avatar>
                        <Box sx={{ flex: 1, minWidth: 0 }}>
                            <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
                                智能对话
                            </Typography>
                            <Typography variant="caption" color="text.secondary">
                                多会话、多模型、流式响应
                            </Typography>
                        </Box>
                        <Chip
                            size="small"
                            label={sending ? '生成中' : '在线'}
                            sx={{
                                bgcolor: sending ? 'rgba(255, 167, 38, 0.14)' : 'rgba(76, 175, 80, 0.12)',
                                color: sending ? '#ad6800' : '#2e7d32',
                                border: '1px solid rgba(0, 0, 0, 0.06)'
                            }}
                        />
                        <Tooltip title="新建会话">
                            <IconButton color="primary" onClick={handleCreateSession}>
                                <AddIcon />
                            </IconButton>
                        </Tooltip>
                        <IconButton color="default" onClick={() => setOpen(false)}>
                            <CloseIcon />
                        </IconButton>
                    </Stack>
                    <Divider />
                    {error ? <Alert severity="error" sx={{ m: 1.5, borderRadius: 2 }}>{error}</Alert> : null}
                    <Stack direction="row" sx={{ minHeight: 0, flex: 1 }}>
                        <Box sx={{
                            width: SESSION_WIDTH,
                            borderRight: '1px solid',
                            borderColor: 'rgba(0, 0, 0, 0.08)',
                            display: 'flex',
                            flexDirection: 'column',
                            backgroundColor: '#f7f9fc'
                        }}>
                            <Box sx={{ px: 1.5, py: 1.25 }}>
                                <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
                                    会话列表
                                </Typography>
                            </Box>
                            <List sx={{ flex: 1, overflowY: 'auto', py: 0, px: 1 }}>
                                {sessions.map((item) => (
                                    <ListItemButton
                                        key={item.id}
                                        selected={item.id === activeSessionId}
                                        onClick={() => setActiveSessionId(item.id)}
                                        sx={{
                                            alignItems: 'flex-start',
                                            pr: 1,
                                            mb: 0.75,
                                            borderRadius: 2,
                                            border: '1px solid',
                                            borderColor: item.id === activeSessionId ? 'rgba(25, 118, 210, 0.28)' : 'rgba(0, 0, 0, 0.06)',
                                            backgroundColor: item.id === activeSessionId ? '#eaf3fe' : '#fff',
                                            boxShadow: item.id === activeSessionId ? '0 6px 16px rgba(25, 118, 210, 0.08)' : 'none'
                                        }}
                                    >
                                        <ListItemText
                                            primary={item.title || '新对话'}
                                            secondary={`${formatPreview(item.lastMessage || item.modelCode)}${formatTime(item.updateTime || item.lastMessageAt) ? ` · ${formatTime(item.updateTime || item.lastMessageAt)}` : ''}`}
                                            primaryTypographyProps={{ noWrap: true, fontSize: 13, fontWeight: 700 }}
                                            secondaryTypographyProps={{ noWrap: true, fontSize: 11.5, color: 'text.secondary' }}
                                        />
                                        <IconButton
                                            size="small"
                                            edge="end"
                                            sx={{ mt: 0.25 }}
                                            onClick={(event) => {
                                                event.stopPropagation();
                                                handleDeleteSession(item.id);
                                            }}
                                        >
                                            <DeleteOutlineIcon fontSize="inherit" />
                                        </IconButton>
                                    </ListItemButton>
                                ))}
                            </List>
                        </Box>
                        <Stack sx={{ flex: 1, minWidth: 0 }}>
                            <Stack
                                direction="row"
                                alignItems="center"
                                spacing={1}
                                sx={{
                                    px: 2,
                                    py: 1.5,
                                    backgroundColor: '#fff'
                                }}
                            >
                                <Box sx={{ flex: 1, minWidth: 0 }}>
                                    <Typography variant="body2" color="text.secondary">
                                        当前模型
                                    </Typography>
                                    <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
                                        {activeSession?.title || '未命名会话'}
                                    </Typography>
                                </Box>
                                <Select
                                    size="small"
                                    value={activeSession?.modelCode || models[0]?.code || ''}
                                    onChange={(event) => handleModelChange(event.target.value)}
                                    sx={{
                                        minWidth: 176,
                                        borderRadius: 2,
                                        backgroundColor: '#fff',
                                        boxShadow: '0 1px 2px rgba(15, 23, 42, 0.06)'
                                    }}
                                >
                                    {models.map((model) => (
                                        <MenuItem key={model.code} value={model.code}>
                                            {model.name}
                                        </MenuItem>
                                    ))}
                                </Select>
                            </Stack>
                            <Divider />
                            <Box
                                sx={{
                                    flex: 1,
                                    overflowY: 'auto',
                                    px: 2.5,
                                    py: 2,
                                    backgroundColor: '#f5f7fa'
                                }}
                            >
                                {loading ? (
                                    <Stack alignItems="center" justifyContent="center" sx={{ height: '100%' }}>
                                        <CircularProgress size={28} />
                                    </Stack>
                                ) : activeMessages.length === 0 ? (
                                    <Stack
                                        alignItems="center"
                                        justifyContent="center"
                                        spacing={1.25}
                                        sx={{
                                            height: '100%',
                                            textAlign: 'center',
                                            color: 'text.secondary',
                                            px: 3
                                        }}
                                    >
                                        <Avatar sx={{ width: 56, height: 56, bgcolor: 'rgba(25,118,210,0.1)', color: 'primary.main' }}>
                                            AI
                                        </Avatar>
                                        <Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'text.primary' }}>
                                            开始新的智能对话
                                        </Typography>
                                        <Typography variant="body2">
                                            可以直接提问仓储业务问题,或切换模型开始新的会话。
                                        </Typography>
                                    </Stack>
                                ) : (
                                    <Stack spacing={2}>
                                        {activeMessages.map((message) => (
                                            <Box
                                                key={message.id}
                                                sx={{
                                                    alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
                                                    maxWidth: '88%'
                                                }}
                                            >
                                                <Stack
                                                    direction={message.role === 'user' ? 'row-reverse' : 'row'}
                                                    spacing={1}
                                                    alignItems="flex-start"
                                                >
                                                    <Avatar
                                                        sx={{
                                                            width: 30,
                                                            height: 30,
                                                            mt: 0.25,
                                                            bgcolor: message.role === 'user' ? '#1976d2' : '#eaf3fe',
                                                            color: message.role === 'user' ? '#fff' : '#1976d2',
                                                            fontSize: 12,
                                                            fontWeight: 700
                                                        }}
                                                    >
                                                        {message.role === 'user' ? '我' : 'AI'}
                                                    </Avatar>
                                                    <Box
                                                        sx={{
                                                            maxWidth: 'calc(100% - 42px)',
                                                            display: 'flex',
                                                            flexDirection: 'column',
                                                            alignItems: message.role === 'user' ? 'flex-end' : 'flex-start'
                                                        }}
                                                    >
                                                        <Stack
                                                            direction="row"
                                                            spacing={0.75}
                                                            alignItems="center"
                                                            justifyContent={message.role === 'user' ? 'flex-end' : 'flex-start'}
                                                            sx={{ px: 0.5, mb: 0.5 }}
                                                        >
                                                            <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
                                                                {message.role === 'assistant' ? message.modelCode || activeSession?.modelCode : '我'}
                                                            </Typography>
                                                            <Typography variant="caption" color="text.disabled">
                                                                {formatTime(message.createTime)}
                                                            </Typography>
                                                        </Stack>
                                                        <Box
                                                            sx={{
                                                                px: 1.8,
                                                                py: 1.35,
                                                                minWidth: message.role === 'user' ? 96 : 'auto',
                                                                maxWidth: message.role === 'user' ? '82%' : '100%',
                                                                borderRadius: message.role === 'user' ? '20px 20px 8px 20px' : '20px 20px 20px 8px',
                                                                background: '#fff',
                                                                color: 'text.primary',
                                                                boxShadow: '0 8px 18px rgba(15, 23, 42, 0.05)',
                                                                border: '1px solid rgba(25, 118, 210, 0.08)',
                                                                display: 'inline-block',
                                                                whiteSpace: 'pre-wrap',
                                                                wordBreak: 'normal',
                                                                overflowWrap: 'anywhere',
                                                                lineHeight: 1.7,
                                                                fontSize: 14
                                                            }}
                                                        >
                                                            {message.content || (message.role === 'assistant' && sending ? '正在生成回复...' : '')}
                                                        </Box>
                                                    </Box>
                                                </Stack>
                                            </Box>
                                        ))}
                                        <div ref={messagesEndRef} />
                                    </Stack>
                                )}
                            </Box>
                            <Divider />
                            <Box
                                sx={{
                                    p: 1.5,
                                    backgroundColor: '#fff'
                                }}
                            >
                                <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
                                    <Chip
                                        size="small"
                                        label={activeSession?.modelCode || '未选择模型'}
                                        sx={{ bgcolor: 'rgba(25,118,210,0.08)', color: 'primary.main' }}
                                    />
                                    <Typography variant="caption" color="text.secondary">
                                        `Enter` 发送,`Shift + Enter` 换行
                                    </Typography>
                                </Stack>
                                <TextField
                                    fullWidth
                                    multiline
                                    minRows={3}
                                    maxRows={6}
                                    value={draft}
                                    onChange={(event) => setDraft(event.target.value)}
                                    placeholder="输入问题,支持多会话和模型切换"
                                    onKeyDown={(event) => {
                                        if (event.key === 'Enter' && !event.shiftKey) {
                                            event.preventDefault();
                                            handleSend();
                                        }
                                    }}
                                    sx={{
                                        '& .MuiOutlinedInput-root': {
                                            borderRadius: 3,
                                            backgroundColor: '#fff'
                                        }
                                    }}
                                />
                                <Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: 1.2 }}>
                                    <Tooltip title="停止生成">
                                        <span>
                                            <IconButton color="warning" disabled={!sending} onClick={handleStop}>
                                                <StopCircleOutlinedIcon />
                                            </IconButton>
                                        </span>
                                    </Tooltip>
                                    <Tooltip title="发送">
                                        <span>
                                            <IconButton color="primary" disabled={sending || !draft.trim()} onClick={handleSend}>
                                                <SendIcon />
                                            </IconButton>
                                        </span>
                                    </Tooltip>
                                </Stack>
                            </Box>
                        </Stack>
                    </Stack>
                </Stack>
            </Drawer>
        </>
    );
};
rsf-admin/src/api/ai/index.js
New file
@@ -0,0 +1,43 @@
import request from '../../utils/request';
export async function getAiModels() {
    const res = await request.get('/ai/model/list');
    if (res.data.code === 200) {
        return res.data.data || [];
    }
    return Promise.reject(new Error(res.data.msg || 'Load models failed'));
}
export async function getAiSessions() {
    const res = await request.get('/ai/session/list');
    if (res.data.code === 200) {
        return res.data.data || [];
    }
    return Promise.reject(new Error(res.data.msg || 'Load sessions failed'));
}
export async function createAiSession(payload = {}) {
    const res = await request.post('/ai/session/create', payload);
    if (res.data.code === 200) {
        return res.data.data;
    }
    return Promise.reject(new Error(res.data.msg || 'Create session failed'));
}
export async function removeAiSession(sessionId) {
    const res = await request.post(`/ai/session/remove/${sessionId}`);
    return res.data;
}
export async function getAiMessages(sessionId) {
    const res = await request.get(`/ai/session/${sessionId}/messages`);
    if (res.data.code === 200) {
        return res.data.data || [];
    }
    return Promise.reject(new Error(res.data.msg || 'Load messages failed'));
}
export async function stopAiChat(payload) {
    const res = await request.post('/ai/chat/stop', payload);
    return res.data;
}
rsf-admin/src/i18n/en.js
@@ -150,6 +150,7 @@
        token: 'Token',
        operation: 'Operation',
        config: 'Config',
        aiParam: 'AI Params',
        tenant: 'Tenant',
        userLogin: 'Token',
        customer: 'Customer',
@@ -401,6 +402,19 @@
                content: "content",
                type: "type",
            },
            aiParam: {
                uuid: "uuid",
                name: "name",
                modelCode: "model code",
                provider: "provider",
                chatUrl: "chat url",
                apiKey: "api key",
                modelName: "model name",
                systemPrompt: "system prompt",
                maxContextMessages: "max context",
                defaultFlag: "default",
                sort: "sort",
            },
            tenant: {
                name: "name",
                flag: "flag",
rsf-admin/src/i18n/zh.js
@@ -151,6 +151,7 @@
        token: '登录日志',
        operation: '操作日志',
        config: '配置参数',
        aiParam: 'AI参数',
        tenant: '租户管理',
        userLogin: '登录日志',
        customer: '客户表',
@@ -430,6 +431,19 @@
                content: "配置内容",
                type: "数据类型",
            },
            aiParam: {
                uuid: "编号",
                name: "名称",
                modelCode: "模型编码",
                provider: "供应商",
                chatUrl: "聊天地址",
                apiKey: "API密钥",
                modelName: "模型名称",
                systemPrompt: "系统提示词",
                maxContextMessages: "上下文轮数",
                defaultFlag: "默认模型",
                sort: "排序",
            },
            tenant: {
                name: "租户名",
                flag: "代码",
rsf-admin/src/layout/AppBarToolbar.jsx
@@ -1,12 +1,28 @@
import { LoadingIndicator, LocalesMenuButton } from 'react-admin';
import { ThemeSwapper } from '../themes/ThemeSwapper';
import { TenantTip } from './TenantTip';
import { AiChatWidget } from '@/ai/AiChatWidget';
export const AppBarToolbar = () => (
    <>
        <LocalesMenuButton />
        <ThemeSwapper />
        <LoadingIndicator />
        <AiChatWidget
            trigger="button"
            buttonText="AI 对话"
            buttonVariant="text"
            buttonSx={{
                minWidth: 'auto',
                px: 1.25,
                color: '#fff',
                borderRadius: 2,
                whiteSpace: 'nowrap',
                '&:hover': {
                    backgroundColor: 'rgba(255,255,255,0.12)'
                }
            }}
        />
        <TenantTip />
    </>
);
rsf-admin/src/page/ResourceContent.js
@@ -6,6 +6,7 @@
import host from "./system/host";
import config from "./system/config";
import aiParam from "./system/aiParam";
import tenant from "./system/tenant";
import role from "./system/role";
import userLogin from "./system/userLogin";
@@ -77,6 +78,8 @@
      return host;
    case "config":
      return config;
    case "aiParam":
      return aiParam;
    case "tenant":
      return tenant;
    case "role":
@@ -216,4 +219,4 @@
  }
};
export default ResourceContent;
export default ResourceContent;
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
New file
@@ -0,0 +1,175 @@
import React from "react";
import {
    CreateBase,
    useTranslate,
    TextInput,
    NumberInput,
    SaveButton,
    SelectInput,
    Toolbar,
    useNotify,
    Form,
} from 'react-admin';
import {
    Dialog,
    DialogActions,
    DialogContent,
    DialogTitle,
    Stack,
    Grid,
    Box,
} from '@mui/material';
import DialogCloseButton from "@/page/components/DialogCloseButton";
import StatusSelectInput from "@/page/components/StatusSelectInput";
import MemoInput from "@/page/components/MemoInput";
const yesNoChoices = [
    { id: 1, name: 'common.enums.true' },
    { id: 0, name: 'common.enums.false' },
];
const providerChoices = [
    { id: 'openai', name: 'OpenAI Compatible' },
    { id: 'mock', name: 'Mock' },
];
const AiParamCreate = (props) => {
    const { open, setOpen } = props;
    const translate = useTranslate();
    const notify = useNotify();
    const handleClose = (event, reason) => {
        if (reason !== "backdropClick") {
            setOpen(false);
        }
    };
    const handleSuccess = async () => {
        setOpen(false);
        notify('common.response.success');
    };
    const handleError = async (error) => {
        notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } });
    };
    return (
        <CreateBase
            record={{ defaultFlag: 0, sort: 0, maxContextMessages: 12, status: 1, provider: 'openai' }}
            transform={(data) => {
                return data;
            }}
            mutationOptions={{ onSuccess: handleSuccess, onError: handleError }}
        >
            <Dialog
                open={open}
                onClose={handleClose}
                aria-labelledby="form-dialog-title"
                fullWidth
                disableRestoreFocus
                maxWidth="md"
            >
                <Form>
                    <DialogTitle id="form-dialog-title" sx={{
                        position: 'sticky',
                        top: 0,
                        backgroundColor: 'background.paper',
                        zIndex: 1000
                    }}
                    >
                        {translate('create.title')}
                        <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}>
                            <DialogCloseButton onClose={handleClose} />
                        </Box>
                    </DialogTitle>
                    <DialogContent sx={{ mt: 2 }}>
                        <Grid container rowSpacing={2} columnSpacing={2}>
                            <Grid item xs={6} display="flex" gap={1}>
                                <TextInput label="table.field.aiParam.name" source="name" parse={v => v} fullWidth />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <TextInput label="table.field.aiParam.modelCode" source="modelCode" parse={v => v} fullWidth />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <SelectInput
                                    label="table.field.aiParam.provider"
                                    source="provider"
                                    choices={providerChoices}
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <TextInput
                                    label="table.field.aiParam.modelName"
                                    source="modelName"
                                    parse={v => v}
                                    helperText="填写真实模型名,例如 gpt-4o-mini、deepseek-chat"
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={12} display="flex" gap={1}>
                                <TextInput
                                    label="table.field.aiParam.chatUrl"
                                    source="chatUrl"
                                    parse={v => v}
                                    helperText="支持填写 baseUrl,如 https://api.openai.com 或 https://api.siliconflow.cn,系统会自动补全为 /v1/chat/completions"
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={12} display="flex" gap={1}>
                                <TextInput
                                    label="table.field.aiParam.apiKey"
                                    source="apiKey"
                                    parse={v => v}
                                    type="password"
                                    helperText="OpenAI 接口模式下填写 Bearer Token,无需手动加 Bearer 前缀"
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <NumberInput label="table.field.aiParam.maxContextMessages" source="maxContextMessages" fullWidth />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <NumberInput label="table.field.aiParam.sort" source="sort" fullWidth />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <SelectInput
                                    label="table.field.aiParam.defaultFlag"
                                    source="defaultFlag"
                                    choices={yesNoChoices}
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={6} display="flex" gap={1}>
                                <StatusSelectInput fullWidth />
                            </Grid>
                            <Grid item xs={12} display="flex" gap={1}>
                                <TextInput
                                    label="table.field.aiParam.systemPrompt"
                                    source="systemPrompt"
                                    parse={v => v}
                                    fullWidth
                                    multiline
                                    minRows={4}
                                />
                            </Grid>
                            <Grid item xs={12} display="flex" gap={1}>
                                <Stack direction="column" spacing={1} width={'100%'}>
                                    <MemoInput />
                                </Stack>
                            </Grid>
                        </Grid>
                    </DialogContent>
                    <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}>
                        <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}  >
                            <SaveButton />
                        </Toolbar>
                    </DialogActions>
                </Form>
            </Dialog>
        </CreateBase>
    )
}
export default AiParamCreate;
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
New file
@@ -0,0 +1,136 @@
import React from "react";
import {
    Edit,
    SimpleForm,
    useTranslate,
    TextInput,
    NumberInput,
    SaveButton,
    SelectInput,
    Toolbar,
    DeleteButton,
} from 'react-admin';
import { useFormContext } from "react-hook-form";
import { Stack, Grid, Typography } from '@mui/material';
import { EDIT_MODE } from '@/config/setting';
import EditBaseAside from "@/page/components/EditBaseAside";
import CustomerTopToolBar from "@/page/components/EditTopToolBar";
import MemoInput from "@/page/components/MemoInput";
import StatusSelectInput from "@/page/components/StatusSelectInput";
const yesNoChoices = [
    { id: 1, name: 'common.enums.true' },
    { id: 0, name: 'common.enums.false' },
];
const providerChoices = [
    { id: 'openai', name: 'OpenAI Compatible' },
    { id: 'mock', name: 'Mock' },
];
const FormToolbar = () => {
    const { getValues } = useFormContext();
    return (
        <Toolbar sx={{ justifyContent: 'space-between' }}>
            <SaveButton />
            <DeleteButton mutationMode="optimistic" />
        </Toolbar>
    )
}
const AiParamEdit = () => {
    const translate = useTranslate();
    return (
        <Edit
            redirect="list"
            mutationMode={EDIT_MODE}
            actions={<CustomerTopToolBar />}
            aside={<EditBaseAside />}
        >
            <SimpleForm
                shouldUnregister
                warnWhenUnsavedChanges
                toolbar={<FormToolbar />}
                mode="onTouched"
                defaultValues={{}}
            >
                <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}>
                    <Grid item xs={12} md={8}>
                        <Typography variant="h6" gutterBottom>
                            {translate('common.edit.title.main')}
                        </Typography>
                        <Stack direction='row' gap={2}>
                            <TextInput label="table.field.aiParam.uuid" source="uuid" parse={v => v} disabled />
                            <TextInput label="table.field.aiParam.name" source="name" parse={v => v} />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <TextInput label="table.field.aiParam.modelCode" source="modelCode" parse={v => v} />
                            <SelectInput
                                label="table.field.aiParam.provider"
                                source="provider"
                                choices={providerChoices}
                            />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <TextInput
                                label="table.field.aiParam.modelName"
                                source="modelName"
                                parse={v => v}
                                helperText="填写真实模型名,例如 gpt-4o-mini、deepseek-chat"
                            />
                            <NumberInput label="table.field.aiParam.maxContextMessages" source="maxContextMessages" />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <NumberInput label="table.field.aiParam.sort" source="sort" />
                            <SelectInput
                                label="table.field.aiParam.defaultFlag"
                                source="defaultFlag"
                                choices={yesNoChoices}
                            />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <TextInput
                                label="table.field.aiParam.chatUrl"
                                source="chatUrl"
                                parse={v => v}
                                helperText="支持填写 baseUrl,如 https://api.openai.com 或 https://api.siliconflow.cn,系统会自动补全为 /v1/chat/completions"
                                fullWidth
                            />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <TextInput
                                label="table.field.aiParam.apiKey"
                                source="apiKey"
                                parse={v => v}
                                type="password"
                                helperText="OpenAI 接口模式下填写 Bearer Token,无需手动加 Bearer 前缀"
                                fullWidth
                            />
                        </Stack>
                        <Stack direction='row' gap={2}>
                            <TextInput
                                label="table.field.aiParam.systemPrompt"
                                source="systemPrompt"
                                parse={v => v}
                                fullWidth
                                multiline
                                minRows={5}
                            />
                        </Stack>
                    </Grid>
                    <Grid item xs={12} md={4}>
                        <Typography variant="h6" gutterBottom>
                            {translate('common.edit.title.common')}
                        </Typography>
                        <StatusSelectInput />
                        <MemoInput />
                    </Grid>
                </Grid>
            </SimpleForm>
        </Edit >
    )
}
export default AiParamEdit;
rsf-admin/src/page/system/aiParam/AiParamList.jsx
New file
@@ -0,0 +1,120 @@
import React, { useState } from "react";
import {
    List,
    DatagridConfigurable,
    SearchInput,
    TopToolbar,
    SelectColumnsButton,
    EditButton,
    FilterButton,
    BulkDeleteButton,
    WrapperField,
    TextField,
    NumberField,
    DateField,
    BooleanField,
    TextInput,
    DateInput,
    SelectInput,
    DeleteButton,
} from 'react-admin';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import EmptyData from "@/page/components/EmptyData";
import MyCreateButton from "@/page/components/MyCreateButton";
import MyExportButton from '@/page/components/MyExportButton';
import { OPERATE_MODE, DEFAULT_PAGE_SIZE } from '@/config/setting';
import AiParamCreate from "./AiParamCreate";
const StyledDatagrid = styled(DatagridConfigurable)(({ theme }) => ({
    '& .css-1vooibu-MuiSvgIcon-root': {
        height: '.9em'
    },
    '& .RaDatagrid-row': {
        cursor: 'auto'
    },
    '& .opt': {
        width: 200
    },
}));
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <DateInput label='common.time.after' source="timeStart" alwaysOn />,
    <DateInput label='common.time.before' source="timeEnd" alwaysOn />,
    <TextInput source="name" label="table.field.aiParam.name" />,
    <TextInput source="modelCode" label="table.field.aiParam.modelCode" />,
    <TextInput source="provider" label="table.field.aiParam.provider" />,
    <TextInput source="modelName" label="table.field.aiParam.modelName" />,
    <SelectInput
        source="defaultFlag"
        label="table.field.aiParam.defaultFlag"
        choices={[
            { id: '1', name: 'common.enums.true' },
            { id: '0', name: 'common.enums.false' },
        ]}
    />,
    <TextInput label="common.field.memo" source="memo" />,
    <SelectInput
        label="common.field.status"
        source="status"
        choices={[
            { id: '1', name: 'common.enums.statusTrue' },
            { id: '0', name: 'common.enums.statusFalse' },
        ]}
    />,
]
const AiParamList = () => {
    const [createDialog, setCreateDialog] = useState(false);
    return (
        <Box display="flex">
            <List
                title={"menu.aiParam"}
                empty={<EmptyData onClick={() => { setCreateDialog(true) }} />}
                filters={filters}
                sort={{ field: "sort", order: "asc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                        <MyCreateButton onClick={() => { setCreateDialog(true) }} />
                        <SelectColumnsButton preferenceKey='aiParam' />
                        <MyExportButton />
                    </TopToolbar>
                )}
                perPage={DEFAULT_PAGE_SIZE}
            >
                <StyledDatagrid
                    preferenceKey='aiParam'
                    bulkActionButtons={() => <BulkDeleteButton mutationMode={OPERATE_MODE} />}
                    rowClick={false}
                    omit={['id', 'createTime', 'memo', 'statusBool', 'defaultFlagBool']}
                >
                    <NumberField source="id" />
                    <TextField source="uuid" label="table.field.aiParam.uuid" />
                    <TextField source="name" label="table.field.aiParam.name" />
                    <TextField source="modelCode" label="table.field.aiParam.modelCode" />
                    <TextField source="provider" label="table.field.aiParam.provider" />
                    <TextField source="modelName" label="table.field.aiParam.modelName" />
                    <NumberField source="maxContextMessages" label="table.field.aiParam.maxContextMessages" />
                    <NumberField source="sort" label="table.field.aiParam.sort" />
                    <BooleanField source="defaultFlagBool" label="table.field.aiParam.defaultFlag" sortable={false} />
                    <BooleanField source="statusBool" label="common.field.status" sortable={false} />
                    <DateField source="updateTime" label="common.field.updateTime" showTime />
                    <TextField source="memo" label="common.field.memo" sortable={false} />
                    <WrapperField cellClassName="opt" label="common.field.opt">
                        <EditButton sx={{ padding: '1px', fontSize: '.75rem' }} />
                        <DeleteButton sx={{ padding: '1px', fontSize: '.75rem' }} mutationMode={OPERATE_MODE} />
                    </WrapperField>
                </StyledDatagrid>
            </List>
            <AiParamCreate
                open={createDialog}
                setOpen={setCreateDialog}
            />
        </Box>
    )
}
export default AiParamList;
rsf-admin/src/page/system/aiParam/index.jsx
New file
@@ -0,0 +1,15 @@
import {
    ShowGuesser,
} from "react-admin";
import AiParamList from "./AiParamList";
import AiParamEdit from "./AiParamEdit";
export default {
    list: AiParamList,
    edit: AiParamEdit,
    show: ShowGuesser,
    recordRepresentation: (record) => {
        return `${record.name}`
    }
};
rsf-ai-gateway/gateway-run.log
New file
@@ -0,0 +1,60 @@
[INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.vincent:rsf-server:jar:1.0.0
[WARNING] 'dependencies.dependency.systemPath' for RouteUtils:RouteUtils:jar should not point at files within the project directory, ${project.basedir}/src/main/resources/lib/RouteUtils.jar will be unresolvable by dependent projects @ line 41, column 16
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.
[WARNING]
[INFO]
[INFO] ---------------------< com.vincent:rsf-ai-gateway >---------------------
[INFO] Building rsf-ai-gateway 1.0.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] >>> spring-boot:2.5.3:run (default-cli) > test-compile @ rsf-ai-gateway >>>
[INFO]
[INFO] --- resources:3.2.0:resources (default-resources) @ rsf-ai-gateway ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] Copying 1 resource
[INFO] Copying 0 resource
[INFO]
[INFO] --- compiler:3.8.1:compile (default-compile) @ rsf-ai-gateway ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- resources:3.2.0:testResources (default-testResources) @ rsf-ai-gateway ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] skip non existing resourceDirectory C:\env\code\wms-master\rsf-ai-gateway\src\test\resources
[INFO]
[INFO] --- compiler:3.8.1:testCompile (default-testCompile) @ rsf-ai-gateway ---
[INFO] No sources to compile
[INFO]
[INFO] <<< spring-boot:2.5.3:run (default-cli) < test-compile @ rsf-ai-gateway <<<
[INFO]
[INFO]
[INFO] --- spring-boot:2.5.3:run (default-cli) @ rsf-ai-gateway ---
[INFO] Attaching agents: []
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.3)
2026-03-11 15:04:08.306  INFO 23404 --- [           main] com.vincent.rsf.ai.gateway.GatewayBoot   : Starting GatewayBoot using Java 17.0.14 on WIN-P7MH6EA0OTE with PID 23404 (C:\env\code\wms-master\rsf-ai-gateway\target\classes started by Administrator in C:\env\code\wms-master\rsf-ai-gateway)
2026-03-11 15:04:08.307  INFO 23404 --- [           main] com.vincent.rsf.ai.gateway.GatewayBoot   : No active profile set, falling back to default profiles: default
2026-03-11 15:04:08.788  INFO 23404 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8086 (http)
2026-03-11 15:04:08.795  INFO 23404 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2026-03-11 15:04:08.795  INFO 23404 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.50]
2026-03-11 15:04:08.835  INFO 23404 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2026-03-11 15:04:08.835  INFO 23404 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 500 ms
2026-03-11 15:04:09.014  INFO 23404 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8086 (http) with context path ''
2026-03-11 15:04:09.019  INFO 23404 --- [           main] com.vincent.rsf.ai.gateway.GatewayBoot   : Started GatewayBoot in 0.93 seconds (JVM running for 1.099)
2026-03-11 15:04:33.345  INFO 23404 --- [nio-8086-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-03-11 15:04:33.345  INFO 23404 --- [nio-8086-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2026-03-11 15:04:33.346  INFO 23404 --- [nio-8086-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 1 ms
rsf-ai-gateway/pom.xml
New file
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <name>rsf-ai-gateway</name>
    <artifactId>rsf-ai-gateway</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <parent>
        <groupId>com.vincent</groupId>
        <artifactId>rsf</artifactId>
        <version>1.0.0</version>
    </parent>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
    <build>
        <finalName>rsf-ai-gateway</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java
New file
@@ -0,0 +1,18 @@
package com.vincent.rsf.ai.gateway;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.SpringApplication;
@SpringBootApplication(exclude = {
        DataSourceAutoConfiguration.class,
        DruidDataSourceAutoConfigure.class
})
public class GatewayBoot {
    public static void main(String[] args) {
        SpringApplication.run(GatewayBoot.class, args);
    }
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java
New file
@@ -0,0 +1,34 @@
package com.vincent.rsf.ai.gateway.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
@Data
@Configuration
@ConfigurationProperties(prefix = "gateway.ai")
public class AiGatewayProperties {
    private String defaultModelCode = "mock-general";
    private Integer connectTimeoutMillis = 10000;
    private Integer readTimeoutMillis = 0;
    private List<ModelConfig> models = new ArrayList<>();
    @Data
    public static class ModelConfig {
        private String code;
        private String name;
        private String provider = "mock";
        private String chatUrl;
        private String apiKey;
        private String modelName;
        private Boolean enabled = true;
    }
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java
New file
@@ -0,0 +1,42 @@
package com.vincent.rsf.ai.gateway.controller;
import com.vincent.rsf.ai.gateway.dto.GatewayChatRequest;
import com.vincent.rsf.ai.gateway.service.AiGatewayService;
import com.vincent.rsf.ai.gateway.service.GatewayStreamEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import javax.annotation.Resource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/internal/chat")
public class AiGatewayController {
    @Resource
    private AiGatewayService aiGatewayService;
    @Resource
    private ObjectMapper objectMapper;
    @PostMapping(value = "/stream", produces = "application/x-ndjson")
    public StreamingResponseBody stream(@RequestBody GatewayChatRequest request) {
        return outputStream -> {
            try {
                aiGatewayService.stream(request, event -> {
                    String json = objectMapper.writeValueAsString(event) + "\n";
                    outputStream.write(json.getBytes(StandardCharsets.UTF_8));
                    outputStream.flush();
                });
            } catch (Exception e) {
                throw new IOException(e);
            }
        };
    }
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java
New file
@@ -0,0 +1,14 @@
package com.vincent.rsf.ai.gateway.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class GatewayChatMessage implements Serializable {
    private String role;
    private String content;
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java
New file
@@ -0,0 +1,26 @@
package com.vincent.rsf.ai.gateway.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class GatewayChatRequest implements Serializable {
    private String sessionId;
    private String modelCode;
    private String systemPrompt;
    private String chatUrl;
    private String apiKey;
    private String modelName;
    private List<GatewayChatMessage> messages = new ArrayList<>();
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java
New file
@@ -0,0 +1,267 @@
package com.vincent.rsf.ai.gateway.service;
import com.vincent.rsf.ai.gateway.config.AiGatewayProperties;
import com.vincent.rsf.ai.gateway.dto.GatewayChatMessage;
import com.vincent.rsf.ai.gateway.dto.GatewayChatRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class AiGatewayService {
    @Resource
    private AiGatewayProperties aiGatewayProperties;
    @Resource
    private ObjectMapper objectMapper;
    public interface EventConsumer {
        void accept(GatewayStreamEvent event) throws Exception;
    }
    public void stream(GatewayChatRequest request, EventConsumer consumer) throws Exception {
        AiGatewayProperties.ModelConfig modelConfig = resolveModel(request);
        if (modelConfig == null || modelConfig.getChatUrl() == null || modelConfig.getChatUrl().trim().isEmpty()) {
            mockStream(request, modelConfig, consumer);
            return;
        }
        openAiCompatibleStream(request, modelConfig, consumer);
    }
    private AiGatewayProperties.ModelConfig resolveModel(GatewayChatRequest request) {
        String modelCode = request.getModelCode();
        String targetCode = (modelCode == null || modelCode.trim().isEmpty())
                ? aiGatewayProperties.getDefaultModelCode()
                : modelCode;
        for (AiGatewayProperties.ModelConfig model : aiGatewayProperties.getModels()) {
            if (Boolean.TRUE.equals(model.getEnabled()) && targetCode.equals(model.getCode())) {
                return mergeRequestOverride(model, request);
            }
        }
        if ((request.getChatUrl() != null && !request.getChatUrl().trim().isEmpty())
                || (request.getModelName() != null && !request.getModelName().trim().isEmpty())) {
            AiGatewayProperties.ModelConfig modelConfig = new AiGatewayProperties.ModelConfig();
            modelConfig.setCode(targetCode);
            modelConfig.setName(targetCode);
            modelConfig.setProvider("custom");
            modelConfig.setEnabled(true);
            return mergeRequestOverride(modelConfig, request);
        }
        return null;
    }
    private AiGatewayProperties.ModelConfig mergeRequestOverride(AiGatewayProperties.ModelConfig source,
                                                                 GatewayChatRequest request) {
        AiGatewayProperties.ModelConfig target = new AiGatewayProperties.ModelConfig();
        target.setCode(source.getCode());
        target.setName(source.getName());
        target.setProvider(source.getProvider());
        target.setChatUrl(normalizeChatUrl(source.getChatUrl()));
        target.setApiKey(source.getApiKey());
        target.setModelName(source.getModelName());
        target.setEnabled(source.getEnabled());
        if (request.getChatUrl() != null && !request.getChatUrl().trim().isEmpty()) {
            target.setChatUrl(normalizeChatUrl(request.getChatUrl().trim()));
        }
        if (request.getApiKey() != null && !request.getApiKey().trim().isEmpty()) {
            target.setApiKey(request.getApiKey().trim());
        }
        if (request.getModelName() != null && !request.getModelName().trim().isEmpty()) {
            target.setModelName(request.getModelName().trim());
        }
        return target;
    }
    private void mockStream(GatewayChatRequest request, AiGatewayProperties.ModelConfig modelConfig,
                            EventConsumer consumer) throws Exception {
        String modelCode = modelConfig == null ? aiGatewayProperties.getDefaultModelCode() : modelConfig.getCode();
        String lastQuestion = "";
        List<GatewayChatMessage> messages = request.getMessages();
        for (int i = messages.size() - 1; i >= 0; i--) {
            GatewayChatMessage message = messages.get(i);
            if ("user".equalsIgnoreCase(message.getRole())) {
                lastQuestion = message.getContent();
                break;
            }
        }
        String answer = "当前为演示模式,模型[" + modelCode + "]已收到你的问题:" + lastQuestion;
        for (char c : answer.toCharArray()) {
            consumer.accept(new GatewayStreamEvent()
                    .setType("delta")
                    .setModelCode(modelCode)
                    .setContent(String.valueOf(c)));
            Thread.sleep(20L);
        }
        consumer.accept(new GatewayStreamEvent()
                .setType("done")
                .setModelCode(modelCode));
    }
    private void openAiCompatibleStream(GatewayChatRequest request, AiGatewayProperties.ModelConfig modelConfig,
                                        EventConsumer consumer) throws Exception {
        HttpURLConnection connection = null;
        try {
            connection = (HttpURLConnection) new URL(modelConfig.getChatUrl()).openConnection();
            connection.setConnectTimeout(aiGatewayProperties.getConnectTimeoutMillis());
            connection.setReadTimeout(aiGatewayProperties.getReadTimeoutMillis());
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setRequestProperty("Content-Type", "application/json");
            connection.setRequestProperty("Accept", "text/event-stream");
            if (modelConfig.getApiKey() != null && !modelConfig.getApiKey().trim().isEmpty()) {
                connection.setRequestProperty("Authorization", "Bearer " + modelConfig.getApiKey().trim());
            }
            Map<String, Object> body = new LinkedHashMap<>();
            body.put("model", modelConfig.getModelName());
            body.put("stream", true);
            body.put("messages", buildMessages(request));
            try (OutputStream outputStream = connection.getOutputStream()) {
                outputStream.write(objectMapper.writeValueAsBytes(body));
                outputStream.flush();
            }
            int statusCode = connection.getResponseCode();
            InputStream inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream();
            if (inputStream == null) {
                consumer.accept(new GatewayStreamEvent()
                        .setType("error")
                        .setModelCode(modelConfig.getCode())
                        .setMessage("模型服务无响应"));
                return;
            }
            if (statusCode >= 400) {
                consumer.accept(new GatewayStreamEvent()
                        .setType("error")
                        .setModelCode(modelConfig.getCode())
                        .setMessage(readErrorMessage(inputStream, statusCode)));
                return;
            }
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (line.trim().isEmpty() || !line.startsWith("data:")) {
                        continue;
                    }
                    String payload = line.substring(5).trim();
                    if ("[DONE]".equals(payload)) {
                        consumer.accept(new GatewayStreamEvent()
                                .setType("done")
                                .setModelCode(modelConfig.getCode()));
                        break;
                    }
                    JsonNode root = objectMapper.readTree(payload);
                    JsonNode choice = root.path("choices").path(0);
                    JsonNode delta = choice.path("delta");
                    JsonNode contentNode = delta.path("content");
                    if (!contentNode.isMissingNode() && !contentNode.isNull()) {
                        consumer.accept(new GatewayStreamEvent()
                                .setType("delta")
                                .setModelCode(modelConfig.getCode())
                                .setContent(contentNode.asText()));
                    }
                    JsonNode finishReason = choice.path("finish_reason");
                    if (!finishReason.isMissingNode() && !finishReason.isNull()) {
                        consumer.accept(new GatewayStreamEvent()
                                .setType("done")
                                .setModelCode(modelConfig.getCode()));
                        break;
                    }
                }
            }
        } catch (Exception e) {
            consumer.accept(new GatewayStreamEvent()
                    .setType("error")
                    .setModelCode(modelConfig.getCode())
                    .setMessage(e.getMessage()));
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }
    private List<Map<String, String>> buildMessages(GatewayChatRequest request) {
        List<Map<String, String>> output = new ArrayList<>();
        if (request.getSystemPrompt() != null && !request.getSystemPrompt().trim().isEmpty()) {
            Map<String, String> systemMessage = new LinkedHashMap<>();
            systemMessage.put("role", "system");
            systemMessage.put("content", request.getSystemPrompt());
            output.add(systemMessage);
        }
        for (GatewayChatMessage message : request.getMessages()) {
            Map<String, String> item = new LinkedHashMap<>();
            item.put("role", message.getRole());
            item.put("content", message.getContent());
            output.add(item);
        }
        return output;
    }
    private String normalizeChatUrl(String chatUrl) {
        if (chatUrl == null) {
            return null;
        }
        String normalized = chatUrl.trim();
        if (normalized.isEmpty()) {
            return normalized;
        }
        if (normalized.endsWith("/chat/completions") || normalized.endsWith("/v1/chat/completions")) {
            return normalized;
        }
        if (normalized.endsWith("/v1")) {
            return normalized + "/chat/completions";
        }
        if (normalized.contains("/v1/")) {
            return normalized;
        }
        if (normalized.endsWith("/")) {
            return normalized + "v1/chat/completions";
        }
        return normalized + "/v1/chat/completions";
    }
    private String readErrorMessage(InputStream inputStream, int statusCode) {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
            StringBuilder builder = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                builder.append(line);
            }
            String body = builder.toString();
            if (body.isEmpty()) {
                return "模型服务调用失败,状态码:" + statusCode;
            }
            JsonNode root = objectMapper.readTree(body);
            JsonNode errorNode = root.path("error");
            if (!errorNode.isMissingNode() && !errorNode.isNull()) {
                String message = errorNode.path("message").asText("");
                if (!message.isEmpty()) {
                    return message;
                }
            }
            if (root.path("message").isTextual()) {
                return root.path("message").asText();
            }
            return body;
        } catch (Exception ignore) {
            return "模型服务调用失败,状态码:" + statusCode;
        }
    }
}
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java
New file
@@ -0,0 +1,20 @@
package com.vincent.rsf.ai.gateway.service;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
@Data
@Accessors(chain = true)
public class GatewayStreamEvent implements Serializable {
    private String type;
    private String content;
    private String message;
    private String modelCode;
}
rsf-ai-gateway/src/main/resources/application.yml
New file
@@ -0,0 +1,23 @@
server:
  port: 8086
spring:
  application:
    name: rsf-ai-gateway
gateway:
  ai:
    default-model-code: mock-general
    connect-timeout-millis: 10000
    read-timeout-millis: 0
    models:
      - code: mock-general
        name: Mock General
        provider: mock
        model-name: mock-general
        enabled: true
      - code: mock-creative
        name: Mock Creative
        provider: mock
        model-name: mock-creative
        enabled: true
rsf-server/skills/rsf-server-maintainer/SKILL.md
New file
@@ -0,0 +1,98 @@
---
name: rsf-server-maintainer
description: Maintain and extend the Java Spring Boot rsf-server module in wms-master. Use when tasks involve reading or changing API/controller logic, manager or system domain services, MyBatis mapper XML SQL, security permissions, profile configuration, or module-level build checks that include rsf-server and its sibling modules.
---
# Rsf Server Maintainer
## Overview
Use this skill to make safe, minimal, and consistent backend changes in `rsf-server`.
Follow the repository's existing controller -> service -> mapper -> XML flow and validate changes before finishing.
## Workflow
1. Identify the domain before editing.
- Use `api` for external integration endpoints (`erp`, `mes`, `mcp`, `pda`, `wcs`).
- Use `manager` for warehouse business CRUD and workflows.
- Use `system` for auth, menu, role, tenant, and shared platform settings.
2. Locate related files quickly.
- Run `python skills/rsf-server-maintainer/scripts/locate_module.py <keyword>`.
- If needed, use `rg`:
```powershell
rg -n "<keyword>" src/main/java/com/vincent/rsf/server
rg -n "<keyword>" src/main/resources/mapper
```
3. Apply the minimum consistent change set.
- For endpoint changes, update controller method, request/response model, and service call together.
- For data access changes, keep mapper interface method signatures and XML `namespace`/`id` aligned.
- For entity schema changes, update entity annotations and dependent params/dto wrappers in the same pass.
- Preserve existing response style (`R.ok().add(...)`, `R.error(...)`) and method-level `@PreAuthorize`.
4. Validate before finishing.
- Run targeted checks with `rg` to confirm all required symbols and paths exist.
- Build from workspace root (`wms-master`) so parent modules resolve:
```powershell
mvn -pl rsf-server -am -DskipTests package
```
- If full build is heavy, run at least:
```powershell
mvn -pl rsf-server -am -DskipTests compile
```
## Repository Conventions
1. Keep package boundaries stable.
- Java root is `src/main/java/com/vincent/rsf/server`.
- Mapper XML root is `src/main/resources/mapper/{manager,system}`.
- `mybatis-plus.mapper-locations` uses `classpath:mapper/*/*.xml`.
2. Follow existing layered patterns.
- Most services use `IService<T>` + `ServiceImpl<Mapper, Entity>`.
- Many mapper interfaces extend `BaseMapper<T>`.
- Controller methods often extend `BaseController` helpers (`buildParam`, `getLoginUserId`).
3. Respect security and tenancy behavior.
- Public endpoint whitelist lives in `common/security/SecurityConfig.java` (`FILTER_PATH`).
- Internal auth checks use `@PreAuthorize` on controller methods.
- Tenant filtering is enforced by `common/config/MybatisPlusConfig.java`; avoid bypassing tenant constraints accidentally.
4. Keep runtime assumptions unchanged unless requested.
- Active profile is selected in `src/main/resources/application.yml`.
- Project uses a local system-scope jar at `src/main/resources/lib/RouteUtils.jar`.
- Keep existing text literals and encoding style unless the task explicitly asks for normalization.
5. Keep encoding and diff size stable.
- Do not change file encoding unless explicitly requested.
- Keep edited files in UTF-8.
- Prefer the smallest possible code change that satisfies the requirement.
## Change Playbooks
1. Add or update an endpoint.
- Locate controller by route keyword.
- Confirm service method exists; add/update in interface and impl together.
- Add authorization annotation when endpoint is not in public `FILTER_PATH`.
2. Add or update SQL.
- Prefer `LambdaQueryWrapper` when simple CRUD is enough.
- If XML is required, update mapper interface and XML in the same edit.
- Keep XML `namespace` equal to mapper interface FQCN.
3. Add or update an entity field.
- Update entity with MyBatis annotations (`@TableField`, `@TableLogic`, `typeHandler`) as needed.
- Update request params/dto and controller mapping logic if the field crosses API boundaries.
## Resources
1. Use `references/repo-map.md` for fast path lookup and command snippets.
2. Use `scripts/locate_module.py` to locate likely controller/service/mapper/entity/XML files by keyword.
## Done Criteria
1. Keep code changes scoped to the requested behavior.
2. Keep controller/service/mapper/XML references consistent.
3. Confirm security and tenant behavior remain coherent.
4. Run at least one build or compile command, or explain why it could not run.
rsf-server/skills/rsf-server-maintainer/agents/openai.yaml
New file
@@ -0,0 +1,4 @@
interface:
  display_name: "RSF Server Maintainer"
  short_description: "Maintain RSF server backend workflows"
  default_prompt: "Use $rsf-server-maintainer to analyze and safely modify the RSF server backend codebase."
rsf-server/skills/rsf-server-maintainer/references/repo-map.md
New file
@@ -0,0 +1,69 @@
# RSF Server Repo Map
## 1. Module Layout
- Workspace root: `C:/env/code/wms-master`
- Parent pom: `pom.xml` (modules: `rsf-common`, `rsf-framework`, `rsf-server`, `rsf-open-api`)
- Server module: `rsf-server`
- Server boot class: `rsf-server/src/main/java/com/vincent/rsf/server/ServerBoot.java`
## 2. Core Paths Inside rsf-server
- Java root: `src/main/java/com/vincent/rsf/server`
- Integration API controllers: `src/main/java/com/vincent/rsf/server/api/controller`
- Warehouse business domain: `src/main/java/com/vincent/rsf/server/manager`
- Platform/system domain: `src/main/java/com/vincent/rsf/server/system`
- Shared infrastructure/config/security/utils: `src/main/java/com/vincent/rsf/server/common`
- Mapper XML root: `src/main/resources/mapper`
- Manager mapper XML: `src/main/resources/mapper/manager`
- System mapper XML: `src/main/resources/mapper/system`
- Runtime config: `src/main/resources/application*.yml`
## 3. Observed Size (for search strategy)
- `manager/controller`: about 107 files
- `api/controller`: about 46 files
- `system/controller`: about 38 files
- `manager/service/impl`: about 68 files
- `mapper/manager`: about 67 XML files
- `mapper/system`: about 30 XML files
Use keyword-first narrowing before opening files.
## 4. Request and Data Flow Pattern
1. Controller receives request, often returns `R.ok().add(...)` or `R.error(...)`.
2. Service interface in `service/` and implementation in `service/impl/`.
3. Mapper interface in `mapper/` extends `BaseMapper<T>`.
4. Optional custom SQL in mapper XML with matching `namespace` and statement `id`.
5. Entity annotations (`@TableName`, `@TableField`, `@TableLogic`) define persistence behavior.
## 5. Security and Tenancy Anchors
- Public route whitelist: `common/security/SecurityConfig.java` (`FILTER_PATH`)
- Method-level permission: `@PreAuthorize(...)`
- Tenant interceptor: `common/config/MybatisPlusConfig.java`
Check these files whenever endpoint visibility or cross-tenant behavior changes.
## 6. Useful Commands
```powershell
# Find files by keyword in server Java
rg -n "<keyword>" rsf-server/src/main/java/com/vincent/rsf/server
# Find SQL/XML references
rg -n "<keyword>" rsf-server/src/main/resources/mapper
# Build rsf-server with dependent modules from workspace root
mvn -pl rsf-server -am -DskipTests compile
# Full package build
mvn -pl rsf-server -am -DskipTests package
```
## 7. Constraints and Notes
- No `src/test` directory is currently present in `rsf-server`.
- `rsf-server` depends on sibling modules; run Maven from workspace root for reliable resolution.
- A local system-scope jar is referenced at `rsf-server/src/main/resources/lib/RouteUtils.jar`.
rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py
New file
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Locate likely RSF backend files for a feature/module keyword.
Usage:
    python skills/rsf-server-maintainer/scripts/locate_module.py basStation
    python skills/rsf-server-maintainer/scripts/locate_module.py order --repo C:/env/code/wms-master/rsf-server
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
from typing import Iterable, List, Tuple
def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Locate related controller/service/mapper/entity/XML files by keyword.")
    parser.add_argument("keyword", help="Module keyword, for example: basStation, order, user")
    parser.add_argument(
        "--repo",
        default=str(Path(__file__).resolve().parents[3]),
        help="Path to rsf-server repository root (default: auto-detect from script location)",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=80,
        help="Max results per section (default: 80)",
    )
    return parser.parse_args()
def normalize_keyword(value: str) -> str:
    return value.strip().lower()
def collect_files(root: Path, patterns: Iterable[str]) -> List[Path]:
    files: List[Path] = []
    for pattern in patterns:
        files.extend(root.glob(pattern))
    return [p for p in files if p.is_file()]
def find_name_matches(files: Iterable[Path], keyword: str) -> List[Path]:
    matches = []
    for file in files:
        text = str(file).lower()
        if keyword in text:
            matches.append(file)
    return sorted(set(matches))
def find_line_matches(files: Iterable[Path], keyword: str, pattern: re.Pattern[str]) -> List[Tuple[Path, int, str]]:
    out: List[Tuple[Path, int, str]] = []
    for file in files:
        try:
            content = file.read_text(encoding="utf-8", errors="ignore").splitlines()
        except OSError:
            continue
        for index, line in enumerate(content, start=1):
            lower = line.lower()
            if keyword in lower and pattern.search(line):
                out.append((file, index, line.strip()))
    return out
def print_file_section(title: str, repo: Path, files: List[Path], limit: int) -> None:
    print(f"\n[{title}] {len(files)}")
    for path in files[:limit]:
        rel = path.relative_to(repo)
        print(f"  - {rel.as_posix()}")
    if len(files) > limit:
        print(f"  ... ({len(files) - limit} more)")
def print_line_section(title: str, repo: Path, rows: List[Tuple[Path, int, str]], limit: int) -> None:
    print(f"\n[{title}] {len(rows)}")
    for file, line_no, line in rows[:limit]:
        rel = file.relative_to(repo)
        print(f"  - {rel.as_posix()}:{line_no}: {line}")
    if len(rows) > limit:
        print(f"  ... ({len(rows) - limit} more)")
def main() -> int:
    args = parse_args()
    repo = Path(args.repo).resolve()
    keyword = normalize_keyword(args.keyword)
    if not keyword:
        raise SystemExit("keyword must not be empty")
    java_root = repo / "src/main/java/com/vincent/rsf/server"
    mapper_root = repo / "src/main/resources/mapper"
    if not java_root.exists() or not mapper_root.exists():
        raise SystemExit(f"Invalid repo root for rsf-server: {repo}")
    java_files = collect_files(
        repo,
        [
            "src/main/java/com/vincent/rsf/server/**/*.java",
            "src/main/resources/mapper/**/*.xml",
        ],
    )
    name_matches = find_name_matches(java_files, keyword)
    controller_files = collect_files(repo, ["src/main/java/com/vincent/rsf/server/**/controller/**/*.java"])
    mapping_pattern = re.compile(r"@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping)")
    authority_pattern = re.compile(r"@PreAuthorize")
    endpoint_rows = find_line_matches(controller_files, keyword, mapping_pattern)
    authority_rows = find_line_matches(controller_files, keyword, authority_pattern)
    print(f"repo: {repo}")
    print(f"keyword: {keyword}")
    print_file_section("Path matches", repo, name_matches, args.limit)
    print_line_section("Endpoint annotation matches", repo, endpoint_rows, args.limit)
    print_line_section("Authority annotation matches", repo, authority_rows, args.limit)
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java
New file
@@ -0,0 +1,47 @@
package com.vincent.rsf.server.ai.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Data
@Configuration
@ConfigurationProperties(prefix = "ai")
public class AiProperties {
    private String gatewayBaseUrl = "http://127.0.0.1:8086";
    private Integer sessionTtlSeconds = 86400;
    private Integer maxContextMessages = 12;
    private String systemPrompt = "你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。";
    private String defaultModelCode = "mock-general";
    private List<ModelConfig> models = new ArrayList<>();
    public List<ModelConfig> getEnabledModels() {
        return models.stream().filter(model -> Boolean.TRUE.equals(model.getEnabled())).collect(Collectors.toList());
    }
    public String resolveDefaultModelCode() {
        if (defaultModelCode != null && !defaultModelCode.trim().isEmpty()) {
            return defaultModelCode;
        }
        return getEnabledModels().isEmpty() ? "mock-general" : getEnabledModels().get(0).getCode();
    }
    @Data
    public static class ModelConfig {
        private String code;
        private String name;
        private String provider;
        private Boolean enabled = true;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java
New file
@@ -0,0 +1,260 @@
package com.vincent.rsf.server.ai.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.config.AiProperties;
import com.vincent.rsf.server.ai.dto.AiChatStreamRequest;
import com.vincent.rsf.server.ai.dto.AiSessionCreateRequest;
import com.vincent.rsf.server.ai.dto.AiSessionRenameRequest;
import com.vincent.rsf.server.ai.dto.GatewayChatMessage;
import com.vincent.rsf.server.ai.dto.GatewayChatRequest;
import com.vincent.rsf.server.ai.model.AiChatMessage;
import com.vincent.rsf.server.ai.model.AiChatSession;
import com.vincent.rsf.server.ai.model.AiPromptContext;
import com.vincent.rsf.server.ai.service.AiGatewayClient;
import com.vincent.rsf.server.ai.service.AiPromptContextService;
import com.vincent.rsf.server.ai.service.AiRuntimeConfigService;
import com.vincent.rsf.server.ai.service.AiSessionService;
import com.vincent.rsf.server.system.controller.BaseController;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/ai")
public class AiController extends BaseController {
    @Resource
    private AiSessionService aiSessionService;
    @Resource
    private AiProperties aiProperties;
    @Resource
    private AiGatewayClient aiGatewayClient;
    @Resource
    private AiRuntimeConfigService aiRuntimeConfigService;
    @Resource
    private AiPromptContextService aiPromptContextService;
    @GetMapping("/model/list")
    public R modelList() {
        List<Map<String, Object>> models = new java.util.ArrayList<>();
        for (AiRuntimeConfigService.ModelRuntimeConfig model : aiRuntimeConfigService.listEnabledModels()) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("code", model.getCode());
            item.put("name", model.getName());
            item.put("provider", model.getProvider());
            item.put("enabled", model.getEnabled());
            models.add(item);
        }
        return R.ok().add(models);
    }
    @GetMapping("/session/list")
    public R sessionList() {
        return R.ok().add(aiSessionService.listSessions(getTenantId(), getLoginUserId()));
    }
    @PostMapping("/session/create")
    public R createSession(@RequestBody(required = false) AiSessionCreateRequest request) {
        AiChatSession session = aiSessionService.createSession(
                getTenantId(),
                getLoginUserId(),
                request == null ? null : request.getTitle(),
                request == null ? null : request.getModelCode()
        );
        return R.ok().add(session);
    }
    @PostMapping("/session/{sessionId}/rename")
    public R renameSession(@PathVariable("sessionId") String sessionId, @RequestBody AiSessionRenameRequest request) {
        AiChatSession session = aiSessionService.renameSession(getTenantId(), getLoginUserId(), sessionId, request.getTitle());
        return R.ok().add(session);
    }
    @PostMapping("/session/remove/{sessionId}")
    public R removeSession(@PathVariable("sessionId") String sessionId) {
        aiSessionService.removeSession(getTenantId(), getLoginUserId(), sessionId);
        return R.ok();
    }
    @GetMapping("/session/{sessionId}/messages")
    public R messageList(@PathVariable("sessionId") String sessionId) {
        return R.ok().add(aiSessionService.listMessages(getTenantId(), getLoginUserId(), sessionId));
    }
    @PostMapping("/chat/stop")
    public R stop(@RequestBody AiChatStreamRequest request) {
        if (request != null && request.getSessionId() != null) {
            aiSessionService.requestStop(request.getSessionId());
        }
        return R.ok();
    }
    @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter chatStream(@RequestBody AiChatStreamRequest request) {
        SseEmitter emitter = new SseEmitter(0L);
        Long tenantId = getTenantId();
        Long userId = getLoginUserId();
        if (tenantId == null || userId == null) {
            completeWithError(emitter, "请先登录后再使用AI助手");
            return emitter;
        }
        if (request == null || request.getMessage() == null || request.getMessage().trim().isEmpty()) {
            completeWithError(emitter, "消息内容不能为空");
            return emitter;
        }
        AiChatSession session = aiSessionService.ensureSession(tenantId, userId, request.getSessionId(), request.getModelCode());
        aiSessionService.clearStopFlag(session.getId());
        aiSessionService.appendMessage(tenantId, userId, session.getId(), "user", request.getMessage(), session.getModelCode());
        AiRuntimeConfigService.ModelRuntimeConfig modelRuntimeConfig = aiRuntimeConfigService.resolveModel(session.getModelCode());
        List<AiChatMessage> contextMessages = aiSessionService.listContextMessages(
                tenantId,
                userId,
                session.getId(),
                modelRuntimeConfig.getMaxContextMessages()
        );
        Thread thread = new Thread(() -> {
            StringBuilder assistantReply = new StringBuilder();
            boolean doneSent = false;
            try {
                emitter.send(SseEmitter.event().name("session").data(buildSessionPayload(session), MediaType.APPLICATION_JSON));
                GatewayChatRequest gatewayChatRequest = buildGatewayRequest(
                        tenantId,
                        userId,
                        session,
                        contextMessages,
                        modelRuntimeConfig,
                        request.getMessage()
                );
                aiGatewayClient.stream(gatewayChatRequest, event -> handleGatewayEvent(
                        emitter,
                        event,
                        session,
                        assistantReply
                ));
                if (aiSessionService.isStopRequested(session.getId())) {
                    if (assistantReply.length() > 0) {
                        aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), session.getModelCode());
                    }
                    emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, true), MediaType.APPLICATION_JSON));
                    doneSent = true;
                }
            } catch (Exception e) {
                try {
                    emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(e.getMessage()), MediaType.APPLICATION_JSON));
                } catch (IOException ignore) {
                }
            } finally {
                if (!doneSent && assistantReply.length() > 0) {
                    aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), session.getModelCode());
                    try {
                        emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, false), MediaType.APPLICATION_JSON));
                    } catch (IOException ignore) {
                    }
                }
                emitter.complete();
                aiSessionService.clearStopFlag(session.getId());
            }
        }, "ai-chat-stream-" + session.getId());
        thread.setDaemon(true);
        thread.start();
        return emitter;
    }
    private boolean handleGatewayEvent(SseEmitter emitter, JsonNode event, AiChatSession session,
                                       StringBuilder assistantReply) throws Exception {
        if (aiSessionService.isStopRequested(session.getId())) {
            return false;
        }
        String type = event.path("type").asText();
        if ("delta".equals(type)) {
            String content = event.path("content").asText("");
            assistantReply.append(content);
            emitter.send(SseEmitter.event().name("delta").data(buildDeltaPayload(session, content), MediaType.APPLICATION_JSON));
            return true;
        }
        if ("error".equals(type)) {
            emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(event.path("message").asText("模型调用失败")), MediaType.APPLICATION_JSON));
            return false;
        }
        if ("done".equals(type)) {
            return false;
        }
        return true;
    }
    private GatewayChatRequest buildGatewayRequest(Long tenantId, Long userId, AiChatSession session, List<AiChatMessage> contextMessages,
                                                   AiRuntimeConfigService.ModelRuntimeConfig modelRuntimeConfig,
                                                   String latestQuestion) {
        GatewayChatRequest request = new GatewayChatRequest();
        request.setSessionId(session.getId());
        request.setModelCode(session.getModelCode());
        request.setSystemPrompt(aiPromptContextService.buildSystemPrompt(
                modelRuntimeConfig.getSystemPrompt(),
                new AiPromptContext()
                        .setTenantId(tenantId)
                        .setUserId(userId)
                        .setSessionId(session.getId())
                        .setModelCode(session.getModelCode())
                        .setQuestion(latestQuestion)
        ));
        request.setChatUrl(modelRuntimeConfig.getChatUrl());
        request.setApiKey(modelRuntimeConfig.getApiKey());
        request.setModelName(modelRuntimeConfig.getModelName());
        for (AiChatMessage contextMessage : contextMessages) {
            GatewayChatMessage item = new GatewayChatMessage();
            item.setRole(contextMessage.getRole());
            item.setContent(contextMessage.getContent());
            request.getMessages().add(item);
        }
        return request;
    }
    private Map<String, Object> buildSessionPayload(AiChatSession session) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("sessionId", session.getId());
        payload.put("title", session.getTitle());
        payload.put("modelCode", session.getModelCode());
        return payload;
    }
    private Map<String, Object> buildDeltaPayload(AiChatSession session, String content) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("sessionId", session.getId());
        payload.put("modelCode", session.getModelCode());
        payload.put("content", content);
        payload.put("timestamp", new Date().getTime());
        return payload;
    }
    private Map<String, Object> buildDonePayload(AiChatSession session, boolean stopped) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("sessionId", session.getId());
        payload.put("modelCode", session.getModelCode());
        payload.put("stopped", stopped);
        return payload;
    }
    private Map<String, Object> buildErrorPayload(String message) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("message", message == null ? "AI服务异常" : message);
        return payload;
    }
    private void completeWithError(SseEmitter emitter, String message) {
        try {
            emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(message), MediaType.APPLICATION_JSON));
        } catch (IOException ignore) {
        }
        emitter.complete();
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java
New file
@@ -0,0 +1,16 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AiChatStreamRequest implements Serializable {
    private String sessionId;
    private String message;
    private String modelCode;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java
New file
@@ -0,0 +1,14 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AiSessionCreateRequest implements Serializable {
    private String title;
    private String modelCode;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java
New file
@@ -0,0 +1,12 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AiSessionRenameRequest implements Serializable {
    private String title;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java
New file
@@ -0,0 +1,14 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class GatewayChatMessage implements Serializable {
    private String role;
    private String content;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java
New file
@@ -0,0 +1,26 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class GatewayChatRequest implements Serializable {
    private String sessionId;
    private String modelCode;
    private String systemPrompt;
    private String chatUrl;
    private String apiKey;
    private String modelName;
    private List<GatewayChatMessage> messages = new ArrayList<>();
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java
New file
@@ -0,0 +1,25 @@
package com.vincent.rsf.server.ai.model;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
@Data
@Accessors(chain = true)
public class AiChatMessage implements Serializable {
    private String id;
    private String sessionId;
    private String role;
    private String content;
    private String modelCode;
    private Date createTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java
New file
@@ -0,0 +1,27 @@
package com.vincent.rsf.server.ai.model;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
@Data
@Accessors(chain = true)
public class AiChatSession implements Serializable {
    private String id;
    private String title;
    private String modelCode;
    private String lastMessage;
    private Date lastMessageAt;
    private Date createTime;
    private Date updateTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java
New file
@@ -0,0 +1,21 @@
package com.vincent.rsf.server.ai.model;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
@Data
@Accessors(chain = true)
public class AiPromptContext implements Serializable {
    private Long tenantId;
    private Long userId;
    private String sessionId;
    private String modelCode;
    private String question;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java
New file
@@ -0,0 +1,68 @@
package com.vincent.rsf.server.ai.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.server.ai.config.AiProperties;
import com.vincent.rsf.server.ai.dto.GatewayChatRequest;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@Component
public class AiGatewayClient {
    @Resource
    private AiProperties aiProperties;
    @Resource
    private ObjectMapper objectMapper;
    public interface StreamCallback {
        boolean handle(JsonNode event) throws Exception;
    }
    public void stream(GatewayChatRequest request, StreamCallback callback) throws Exception {
        HttpURLConnection connection = null;
        try {
            String url = aiProperties.getGatewayBaseUrl() + "/internal/chat/stream";
            connection = (HttpURLConnection) new URL(url).openConnection();
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setConnectTimeout(10000);
            connection.setReadTimeout(0);
            connection.setRequestProperty("Content-Type", "application/json");
            connection.setRequestProperty("Accept", "application/x-ndjson");
            try (OutputStream outputStream = connection.getOutputStream()) {
                outputStream.write(objectMapper.writeValueAsBytes(request));
                outputStream.flush();
            }
            InputStream inputStream = connection.getResponseCode() >= 400 ? connection.getErrorStream() : connection.getInputStream();
            if (inputStream == null) {
                return;
            }
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (line.trim().isEmpty()) {
                        continue;
                    }
                    JsonNode event = objectMapper.readTree(line);
                    if (!callback.handle(event)) {
                        break;
                    }
                }
            }
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java
New file
@@ -0,0 +1,10 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.model.AiPromptContext;
public interface AiPromptContextProvider {
    boolean supports(AiPromptContext context);
    String buildContext(AiPromptContext context);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java
New file
@@ -0,0 +1,37 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.model.AiPromptContext;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AiPromptContextService {
    private final List<AiPromptContextProvider> providers;
    public AiPromptContextService(List<AiPromptContextProvider> providers) {
        this.providers = providers == null ? new ArrayList<>() : providers;
    }
    public String buildSystemPrompt(String basePrompt, AiPromptContext context) {
        List<String> promptParts = new ArrayList<>();
        if (basePrompt != null && !basePrompt.trim().isEmpty()) {
            promptParts.add(basePrompt.trim());
        }
        for (AiPromptContextProvider provider : providers) {
            if (!provider.supports(context)) {
                continue;
            }
            String providerPrompt = provider.buildContext(context);
            if (providerPrompt != null && !providerPrompt.trim().isEmpty()) {
                promptParts.add(providerPrompt.trim());
            }
        }
        if (promptParts.isEmpty()) {
            return null;
        }
        return String.join("\n\n", promptParts);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java
New file
@@ -0,0 +1,119 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.config.AiProperties;
import com.vincent.rsf.server.system.entity.AiParam;
import com.vincent.rsf.server.system.service.AiParamService;
import lombok.Data;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
@Service
public class AiRuntimeConfigService {
    @Resource
    private AiProperties aiProperties;
    @Resource
    private AiParamService aiParamService;
    public List<ModelRuntimeConfig> listEnabledModels() {
        List<ModelRuntimeConfig> output = new ArrayList<>();
        try {
            List<AiParam> list = aiParamService.listEnabledModels();
            for (AiParam item : list) {
                output.add(toRuntimeConfig(item));
            }
        } catch (Exception ignore) {
        }
        if (!output.isEmpty()) {
            return output;
        }
        for (AiProperties.ModelConfig model : aiProperties.getEnabledModels()) {
            ModelRuntimeConfig config = new ModelRuntimeConfig();
            config.setCode(model.getCode());
            config.setName(model.getName());
            config.setProvider(model.getProvider());
            config.setSystemPrompt(aiProperties.getSystemPrompt());
            config.setMaxContextMessages(aiProperties.getMaxContextMessages());
            config.setEnabled(model.getEnabled());
            output.add(config);
        }
        return output;
    }
    public ModelRuntimeConfig resolveModel(String modelCode) {
        try {
            AiParam aiParam;
            if (modelCode == null || modelCode.trim().isEmpty()) {
                aiParam = aiParamService.getDefaultModel();
            } else {
                aiParam = aiParamService.getEnabledModel(modelCode);
            }
            if (aiParam != null) {
                return toRuntimeConfig(aiParam);
            }
        } catch (Exception ignore) {
        }
        String targetCode = modelCode;
        if (targetCode == null || targetCode.trim().isEmpty()) {
            targetCode = aiProperties.resolveDefaultModelCode();
        }
        for (AiProperties.ModelConfig model : aiProperties.getEnabledModels()) {
            if (targetCode.equals(model.getCode())) {
                ModelRuntimeConfig config = new ModelRuntimeConfig();
                config.setCode(model.getCode());
                config.setName(model.getName());
                config.setProvider(model.getProvider());
                config.setSystemPrompt(aiProperties.getSystemPrompt());
                config.setMaxContextMessages(aiProperties.getMaxContextMessages());
                config.setEnabled(model.getEnabled());
                return config;
            }
        }
        ModelRuntimeConfig config = new ModelRuntimeConfig();
        config.setCode(aiProperties.resolveDefaultModelCode());
        config.setName(aiProperties.resolveDefaultModelCode());
        config.setProvider("mock");
        config.setSystemPrompt(aiProperties.getSystemPrompt());
        config.setMaxContextMessages(aiProperties.getMaxContextMessages());
        config.setEnabled(true);
        return config;
    }
    public String resolveDefaultModelCode() {
        return resolveModel(null).getCode();
    }
    private ModelRuntimeConfig toRuntimeConfig(AiParam aiParam) {
        ModelRuntimeConfig config = new ModelRuntimeConfig();
        config.setCode(aiParam.getModelCode());
        config.setName(aiParam.getName());
        config.setProvider(aiParam.getProvider());
        config.setChatUrl(aiParam.getChatUrl());
        config.setApiKey(aiParam.getApiKey());
        config.setModelName(aiParam.getModelName());
        config.setSystemPrompt(aiParam.getSystemPrompt() == null || aiParam.getSystemPrompt().trim().isEmpty()
                ? aiProperties.getSystemPrompt()
                : aiParam.getSystemPrompt());
        config.setMaxContextMessages(aiParam.getMaxContextMessages() == null || aiParam.getMaxContextMessages() <= 0
                ? aiProperties.getMaxContextMessages()
                : aiParam.getMaxContextMessages());
        config.setEnabled(Integer.valueOf(1).equals(aiParam.getStatus()));
        return config;
    }
    @Data
    public static class ModelRuntimeConfig {
        private String code;
        private String name;
        private String provider;
        private String chatUrl;
        private String apiKey;
        private String modelName;
        private String systemPrompt;
        private Integer maxContextMessages;
        private Boolean enabled;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java
New file
@@ -0,0 +1,34 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.model.AiChatMessage;
import com.vincent.rsf.server.ai.model.AiChatSession;
import java.util.List;
public interface AiSessionService {
    List<AiChatSession> listSessions(Long tenantId, Long userId);
    AiChatSession createSession(Long tenantId, Long userId, String title, String modelCode);
    AiChatSession ensureSession(Long tenantId, Long userId, String sessionId, String modelCode);
    AiChatSession getSession(Long tenantId, Long userId, String sessionId);
    AiChatSession renameSession(Long tenantId, Long userId, String sessionId, String title);
    void removeSession(Long tenantId, Long userId, String sessionId);
    List<AiChatMessage> listMessages(Long tenantId, Long userId, String sessionId);
    List<AiChatMessage> listContextMessages(Long tenantId, Long userId, String sessionId, int maxCount);
    AiChatMessage appendMessage(Long tenantId, Long userId, String sessionId, String role, String content, String modelCode);
    void clearStopFlag(String sessionId);
    void requestStop(String sessionId);
    boolean isStopRequested(String sessionId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java
New file
@@ -0,0 +1,136 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.server.ai.model.AiPromptContext;
import com.vincent.rsf.server.manager.entity.Task;
import com.vincent.rsf.server.manager.enums.TaskStsType;
import com.vincent.rsf.server.manager.enums.TaskType;
import com.vincent.rsf.server.manager.mapper.TaskMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
@Service
public class AiTaskSummaryService implements AiPromptContextProvider {
    @Resource
    private TaskMapper taskMapper;
    @Override
    public boolean supports(AiPromptContext context) {
        if (context == null || context.getQuestion() == null) {
            return false;
        }
        String normalized = context.getQuestion().toLowerCase(Locale.ROOT);
        return normalized.contains("task")
                || normalized.contains("任务")
                || normalized.contains("出库任务")
                || normalized.contains("入库任务")
                || normalized.contains("移库任务")
                || normalized.contains("备货任务");
    }
    @Override
    public String buildContext(AiPromptContext context) {
        List<Task> activeTasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
                .select(Task::getTaskCode, Task::getTaskStatus, Task::getTaskType, Task::getOrgLoc, Task::getTargLoc, Task::getUpdateTime)
                .eq(Task::getStatus, 1));
        Map<Integer, Long> statusCounters = new LinkedHashMap<>();
        Map<Integer, Long> typeCounters = new LinkedHashMap<>();
        for (Task task : activeTasks) {
            Integer taskStatus = task.getTaskStatus();
            Integer taskType = task.getTaskType();
            statusCounters.put(taskStatus, statusCounters.getOrDefault(taskStatus, 0L) + 1);
            typeCounters.put(taskType, typeCounters.getOrDefault(taskType, 0L) + 1);
        }
        List<Task> latestTasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
                .select(Task::getTaskCode, Task::getTaskStatus, Task::getTaskType, Task::getOrgLoc, Task::getTargLoc, Task::getUpdateTime)
                .eq(Task::getStatus, 1)
                .orderByDesc(Task::getUpdateTime)
                .last("limit 5"));
        StringBuilder summary = new StringBuilder();
        summary.append("以下是基于 man_task 的实时汇总,请优先依据这些任务数据回答;如果用户问题超出任务表范围,请明确说明。");
        summary.append("\n任务总览:共 ")
                .append(activeTasks.size())
                .append(" 条有效任务。");
        if (!statusCounters.isEmpty()) {
            summary.append("\n任务状态分布:")
                    .append(formatStatuses(statusCounters))
                    .append("。");
        }
        if (!typeCounters.isEmpty()) {
            summary.append("\n任务类型分布:")
                    .append(formatTypes(typeCounters))
                    .append("。");
        }
        if (!latestTasks.isEmpty()) {
            summary.append("\n最近更新任务 TOP5:")
                    .append(formatLatestTasks(latestTasks))
                    .append("。");
        }
        return summary.toString();
    }
    private String formatStatuses(Map<Integer, Long> rows) {
        List<String> parts = new ArrayList<>();
        for (Map.Entry<Integer, Long> row : rows.entrySet()) {
            parts.add(resolveTaskStatus(row.getKey()) + " " + row.getValue() + " 条");
        }
        return String.join(",", parts);
    }
    private String formatTypes(Map<Integer, Long> rows) {
        List<String> parts = new ArrayList<>();
        for (Map.Entry<Integer, Long> row : rows.entrySet()) {
            parts.add(resolveTaskType(row.getKey()) + " " + row.getValue() + " 条");
        }
        return String.join(",", parts);
    }
    private String formatLatestTasks(List<Task> tasks) {
        List<String> parts = new ArrayList<>();
        for (Task task : tasks) {
            parts.add((task.getTaskCode() == null ? "未命名任务" : task.getTaskCode())
                    + "("
                    + resolveTaskType(task.getTaskType())
                    + ","
                    + resolveTaskStatus(task.getTaskStatus())
                    + ",源库位 " + emptyToDash(task.getOrgLoc())
                    + ",目标库位 " + emptyToDash(task.getTargLoc())
                    + ")");
        }
        return String.join(";", parts);
    }
    private String resolveTaskStatus(Integer taskStatus) {
        if (taskStatus == null) {
            return "未知状态";
        }
        for (TaskStsType item : TaskStsType.values()) {
            if (item.id.equals(taskStatus)) {
                return item.desc;
            }
        }
        return "状态" + taskStatus;
    }
    private String resolveTaskType(Integer taskType) {
        if (taskType == null) {
            return "未知类型";
        }
        for (TaskType item : TaskType.values()) {
            if (item.type.equals(taskType)) {
                return item.desc;
            }
        }
        return "类型" + taskType;
    }
    private String emptyToDash(String value) {
        return value == null || value.trim().isEmpty() ? "-" : value;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java
New file
@@ -0,0 +1,215 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.server.ai.model.AiPromptContext;
import com.vincent.rsf.server.manager.entity.Loc;
import com.vincent.rsf.server.manager.entity.LocItem;
import com.vincent.rsf.server.manager.mapper.LocItemMapper;
import com.vincent.rsf.server.manager.mapper.LocMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.*;
@Service
public class AiWarehouseSummaryService implements AiPromptContextProvider {
    private static final Map<String, String> LOC_STATUS_LABELS = new LinkedHashMap<>();
    static {
        LOC_STATUS_LABELS.put("O", "空库");
        LOC_STATUS_LABELS.put("D", "空板");
        LOC_STATUS_LABELS.put("R", "预约出库");
        LOC_STATUS_LABELS.put("S", "预约入库");
        LOC_STATUS_LABELS.put("X", "禁用");
        LOC_STATUS_LABELS.put("F", "在库");
    }
    @Resource
    private LocMapper locMapper;
    @Resource
    private LocItemMapper locItemMapper;
    @Override
    public boolean supports(AiPromptContext context) {
        return context != null && shouldSummarize(context.getQuestion());
    }
    @Override
    public String buildContext(AiPromptContext context) {
        if (!supports(context)) {
            return "";
        }
        List<Loc> activeLocs = locMapper.selectList(new LambdaQueryWrapper<Loc>()
                .select(Loc::getUseStatus)
                .eq(Loc::getStatus, 1));
        long totalLoc = activeLocs.size();
        List<LocItem> activeLocItems = locItemMapper.selectList(new LambdaQueryWrapper<LocItem>()
                .select(LocItem::getLocCode, LocItem::getMatnrCode, LocItem::getMaktx, LocItem::getAnfme)
                .eq(LocItem::getStatus, 1));
        Map<String, Long> locStatusCounters = new LinkedHashMap<>();
        for (Loc loc : activeLocs) {
            String useStatus = loc.getUseStatus();
            locStatusCounters.put(useStatus, locStatusCounters.getOrDefault(useStatus, 0L) + 1);
        }
        long itemRows = activeLocItems.size();
        Set<String> locCodes = new HashSet<>();
        Set<String> materialCodes = new HashSet<>();
        BigDecimal totalQty = BigDecimal.ZERO;
        Map<String, LocAggregate> locAggregates = new LinkedHashMap<>();
        Map<String, MaterialAggregate> materialAggregates = new LinkedHashMap<>();
        for (LocItem item : activeLocItems) {
            if (item.getLocCode() != null && !item.getLocCode().trim().isEmpty()) {
                locCodes.add(item.getLocCode());
            }
            if (item.getMatnrCode() != null && !item.getMatnrCode().trim().isEmpty()) {
                materialCodes.add(item.getMatnrCode());
            }
            totalQty = totalQty.add(toDecimal(item.getAnfme()));
            String locKey = item.getLocCode();
            if (locKey != null && !locKey.trim().isEmpty()) {
                LocAggregate locAggregate = locAggregates.computeIfAbsent(locKey, key -> new LocAggregate());
                locAggregate.totalQty = locAggregate.totalQty.add(toDecimal(item.getAnfme()));
                if (item.getMatnrCode() != null && !item.getMatnrCode().trim().isEmpty()) {
                    locAggregate.materialCodes.add(item.getMatnrCode());
                }
            }
            String materialKey = item.getMatnrCode();
            if (materialKey != null && !materialKey.trim().isEmpty()) {
                MaterialAggregate materialAggregate = materialAggregates.computeIfAbsent(materialKey, key -> new MaterialAggregate());
                materialAggregate.matnrCode = materialKey;
                materialAggregate.maktx = item.getMaktx();
                materialAggregate.totalQty = materialAggregate.totalQty.add(toDecimal(item.getAnfme()));
                if (item.getLocCode() != null && !item.getLocCode().trim().isEmpty()) {
                    materialAggregate.locCodes.add(item.getLocCode());
                }
            }
        }
        List<Map.Entry<String, LocAggregate>> topLocRows = new ArrayList<>(locAggregates.entrySet());
        topLocRows.sort((left, right) -> right.getValue().totalQty.compareTo(left.getValue().totalQty));
        if (topLocRows.size() > 5) {
            topLocRows = topLocRows.subList(0, 5);
        }
        List<MaterialAggregate> topMaterialRows = new ArrayList<>(materialAggregates.values());
        topMaterialRows.sort((left, right) -> right.totalQty.compareTo(left.totalQty));
        if (topMaterialRows.size() > 5) {
            topMaterialRows = topMaterialRows.subList(0, 5);
        }
        StringBuilder summary = new StringBuilder();
        summary.append("以下是基于 man_loc 和 man_loc_item 的实时汇总,请优先依据这些数据回答;如果超出这两张表可推断的范围,请明确说明。");
        summary.append("\n库位概况:总库位 ")
                .append(totalLoc)
                .append(" 个;状态分布:")
                .append(formatLocStatuses(locStatusCounters))
                .append("。");
        summary.append("\n库存概况:库存记录 ")
                .append(itemRows)
                .append(" 条,覆盖库位 ")
                .append(locCodes.size())
                .append(" 个,涉及物料 ")
                .append(materialCodes.size())
                .append(" 种,总数量 ")
                .append(formatDecimal(totalQty))
                .append("。");
        if (!topLocRows.isEmpty()) {
            summary.append("\n库存最多的库位 TOP5:")
                    .append(formatTopLocs(topLocRows))
                    .append("。");
        }
        if (!topMaterialRows.isEmpty()) {
            summary.append("\n库存最多的物料 TOP5:")
                    .append(formatTopMaterials(topMaterialRows))
                    .append("。");
        }
        return summary.toString();
    }
    private boolean shouldSummarize(String question) {
        if (question == null || question.trim().isEmpty()) {
            return false;
        }
        String normalized = question.toLowerCase(Locale.ROOT);
        return normalized.contains("loc")
                || normalized.contains("库位")
                || normalized.contains("货位")
                || normalized.contains("库区")
                || normalized.contains("库存")
                || normalized.contains("物料")
                || normalized.contains("巷道")
                || normalized.contains("储位");
    }
    private String formatLocStatuses(Map<String, Long> counters) {
        if (counters == null || counters.isEmpty()) {
            return "暂无数据";
        }
        List<String> parts = new ArrayList<>();
        for (Map.Entry<String, String> entry : LOC_STATUS_LABELS.entrySet()) {
            parts.add(entry.getValue() + " " + counters.getOrDefault(entry.getKey(), 0L) + " 个");
        }
        return String.join(",", parts);
    }
    private String formatTopLocs(List<Map.Entry<String, LocAggregate>> rows) {
        List<String> parts = new ArrayList<>();
        for (Map.Entry<String, LocAggregate> row : rows) {
            parts.add(row.getKey()
                    + "(数量 " + formatDecimal(row.getValue().totalQty)
                    + ",物料 " + row.getValue().materialCodes.size() + " 种)");
        }
        return String.join(";", parts);
    }
    private String formatTopMaterials(List<MaterialAggregate> rows) {
        List<String> parts = new ArrayList<>();
        for (MaterialAggregate row : rows) {
            String matnrCode = row.matnrCode;
            String maktx = Objects.toString(row.maktx, "");
            String title = maktx == null || maktx.trim().isEmpty() ? matnrCode : matnrCode + "/" + maktx;
            parts.add(title
                    + "(数量 " + formatDecimal(row.totalQty)
                    + ",分布库位 " + row.locCodes.size() + " 个)");
        }
        return String.join(";", parts);
    }
    private String formatDecimal(Object value) {
        BigDecimal decimal = toDecimal(value);
        return decimal.stripTrailingZeros().toPlainString();
    }
    private BigDecimal toDecimal(Object value) {
        if (value == null) {
            return BigDecimal.ZERO;
        }
        if (value instanceof BigDecimal) {
            return (BigDecimal) value;
        }
        if (value instanceof Number) {
            return BigDecimal.valueOf(((Number) value).doubleValue());
        }
        return new BigDecimal(String.valueOf(value));
    }
    private static class LocAggregate {
        private BigDecimal totalQty = BigDecimal.ZERO;
        private final Set<String> materialCodes = new HashSet<>();
    }
    private static class MaterialAggregate {
        private String matnrCode;
        private String maktx;
        private BigDecimal totalQty = BigDecimal.ZERO;
        private final Set<String> locCodes = new HashSet<>();
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java
New file
@@ -0,0 +1,221 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.server.ai.model.AiChatMessage;
import com.vincent.rsf.server.ai.model.AiChatSession;
import com.vincent.rsf.server.ai.service.AiRuntimeConfigService;
import com.vincent.rsf.server.ai.service.AiSessionService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Service
public class AiSessionServiceImpl implements AiSessionService {
    private static final ConcurrentMap<String, List<AiChatSession>> LOCAL_SESSION_CACHE = new ConcurrentHashMap<>();
    private static final ConcurrentMap<String, List<AiChatMessage>> LOCAL_MESSAGE_CACHE = new ConcurrentHashMap<>();
    private static final ConcurrentMap<String, String> LOCAL_STOP_CACHE = new ConcurrentHashMap<>();
    @Resource
    private AiRuntimeConfigService aiRuntimeConfigService;
    @Override
    public synchronized List<AiChatSession> listSessions(Long tenantId, Long userId) {
        List<AiChatSession> sessions = getSessions(tenantId, userId);
        sessions.sort(Comparator.comparing(AiChatSession::getUpdateTime, Comparator.nullsLast(Date::compareTo)).reversed());
        return sessions;
    }
    @Override
    public synchronized AiChatSession createSession(Long tenantId, Long userId, String title, String modelCode) {
        List<AiChatSession> sessions = getSessions(tenantId, userId);
        Date now = new Date();
        AiChatSession session = new AiChatSession()
                .setId(UUID.randomUUID().toString().replace("-", ""))
                .setTitle(resolveTitle(title, sessions.size() + 1))
                .setModelCode(resolveModelCode(modelCode))
                .setCreateTime(now)
                .setUpdateTime(now)
                .setLastMessageAt(now);
        sessions.add(0, session);
        saveSessions(tenantId, userId, sessions);
        saveMessages(session.getId(), new ArrayList<>());
        return session;
    }
    @Override
    public synchronized AiChatSession ensureSession(Long tenantId, Long userId, String sessionId, String modelCode) {
        AiChatSession session = getSession(tenantId, userId, sessionId);
        if (session == null) {
            return createSession(tenantId, userId, null, modelCode);
        }
        if (modelCode != null && !modelCode.trim().isEmpty() && !modelCode.equals(session.getModelCode())) {
            session.setModelCode(modelCode);
            session.setUpdateTime(new Date());
            refreshSession(tenantId, userId, session);
        }
        return session;
    }
    @Override
    public synchronized AiChatSession getSession(Long tenantId, Long userId, String sessionId) {
        if (sessionId == null || sessionId.trim().isEmpty()) {
            return null;
        }
        for (AiChatSession session : getSessions(tenantId, userId)) {
            if (sessionId.equals(session.getId())) {
                return session;
            }
        }
        return null;
    }
    @Override
    public synchronized AiChatSession renameSession(Long tenantId, Long userId, String sessionId, String title) {
        AiChatSession session = getSession(tenantId, userId, sessionId);
        if (session == null) {
            return null;
        }
        session.setTitle(resolveTitle(title, 1));
        session.setUpdateTime(new Date());
        refreshSession(tenantId, userId, session);
        return session;
    }
    @Override
    public synchronized void removeSession(Long tenantId, Long userId, String sessionId) {
        List<AiChatSession> sessions = getSessions(tenantId, userId);
        sessions.removeIf(session -> sessionId.equals(session.getId()));
        saveSessions(tenantId, userId, sessions);
        LOCAL_MESSAGE_CACHE.remove(sessionId);
        LOCAL_STOP_CACHE.remove(sessionId);
    }
    @Override
    public synchronized List<AiChatMessage> listMessages(Long tenantId, Long userId, String sessionId) {
        AiChatSession session = getSession(tenantId, userId, sessionId);
        if (session == null) {
            return new ArrayList<>();
        }
        return getMessages(sessionId);
    }
    @Override
    public synchronized List<AiChatMessage> listContextMessages(Long tenantId, Long userId, String sessionId, int maxCount) {
        List<AiChatMessage> messages = listMessages(tenantId, userId, sessionId);
        if (messages.size() <= maxCount) {
            return messages;
        }
        return new ArrayList<>(messages.subList(messages.size() - maxCount, messages.size()));
    }
    @Override
    public synchronized AiChatMessage appendMessage(Long tenantId, Long userId, String sessionId, String role, String content, String modelCode) {
        AiChatSession session = getSession(tenantId, userId, sessionId);
        if (session == null) {
            return null;
        }
        List<AiChatMessage> messages = getMessages(sessionId);
        AiChatMessage message = new AiChatMessage()
                .setId(UUID.randomUUID().toString().replace("-", ""))
                .setSessionId(sessionId)
                .setRole(role)
                .setContent(content)
                .setModelCode(resolveModelCode(modelCode))
                .setCreateTime(new Date());
        messages.add(message);
        saveMessages(sessionId, messages);
        session.setLastMessage(buildPreview(content));
        session.setLastMessageAt(message.getCreateTime());
        session.setUpdateTime(message.getCreateTime());
        if (modelCode != null && !modelCode.trim().isEmpty()) {
            session.setModelCode(modelCode);
        }
        if ((session.getTitle() == null || session.getTitle().startsWith("新对话")) && "user".equalsIgnoreCase(role)) {
            session.setTitle(buildPreview(content));
        }
        refreshSession(tenantId, userId, session);
        return message;
    }
    @Override
    public void clearStopFlag(String sessionId) {
        LOCAL_STOP_CACHE.remove(sessionId);
    }
    @Override
    public void requestStop(String sessionId) {
        LOCAL_STOP_CACHE.put(sessionId, "1");
    }
    @Override
    public boolean isStopRequested(String sessionId) {
        String stopFlag = LOCAL_STOP_CACHE.get(sessionId);
        return "1".equals(stopFlag);
    }
    private List<AiChatSession> getSessions(Long tenantId, Long userId) {
        String ownerKey = buildOwnerKey(tenantId, userId);
        List<AiChatSession> sessions = LOCAL_SESSION_CACHE.get(ownerKey);
        return sessions == null ? new ArrayList<>() : new ArrayList<>(sessions);
    }
    private void saveSessions(Long tenantId, Long userId, List<AiChatSession> sessions) {
        String ownerKey = buildOwnerKey(tenantId, userId);
        List<AiChatSession> cachedSessions = new ArrayList<>(sessions);
        LOCAL_SESSION_CACHE.put(ownerKey, cachedSessions);
    }
    private List<AiChatMessage> getMessages(String sessionId) {
        List<AiChatMessage> messages = LOCAL_MESSAGE_CACHE.get(sessionId);
        return messages == null ? new ArrayList<>() : new ArrayList<>(messages);
    }
    private void saveMessages(String sessionId, List<AiChatMessage> messages) {
        List<AiChatMessage> cachedMessages = new ArrayList<>(messages);
        LOCAL_MESSAGE_CACHE.put(sessionId, cachedMessages);
    }
    private void refreshSession(Long tenantId, Long userId, AiChatSession target) {
        List<AiChatSession> sessions = getSessions(tenantId, userId);
        for (int i = 0; i < sessions.size(); i++) {
            if (target.getId().equals(sessions.get(i).getId())) {
                sessions.set(i, target);
                saveSessions(tenantId, userId, sessions);
                return;
            }
        }
        sessions.add(target);
        saveSessions(tenantId, userId, sessions);
    }
    private String buildOwnerKey(Long tenantId, Long userId) {
        return String.valueOf(tenantId) + ":" + String.valueOf(userId);
    }
    private String resolveModelCode(String modelCode) {
        return modelCode == null || modelCode.trim().isEmpty() ? aiRuntimeConfigService.resolveDefaultModelCode() : modelCode;
    }
    private String resolveTitle(String title, int index) {
        if (title == null || title.trim().isEmpty()) {
            return "新对话 " + index;
        }
        return buildPreview(title);
    }
    private String buildPreview(String content) {
        if (content == null || content.trim().isEmpty()) {
            return "新对话";
        }
        String normalized = content.replace("\r", " ").replace("\n", " ").trim();
        return normalized.length() > 24 ? normalized.substring(0, 24) : normalized;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java
New file
@@ -0,0 +1,138 @@
package com.vincent.rsf.server.system.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.framework.common.SnowflakeIdWorker;
import com.vincent.rsf.server.common.annotation.OperationLog;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.KeyValVo;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.system.entity.AiParam;
import com.vincent.rsf.server.system.service.AiParamService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
@RestController
public class AiParamController extends BaseController {
    @Autowired
    private AiParamService aiParamService;
    @Autowired
    private SnowflakeIdWorker snowflakeIdWorker;
    @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) {
        if (Cools.isEmpty(aiParam.getModelCode())) {
            return R.error("模型编码不能为空");
        }
        if (aiParamService.existsModelCode(aiParam.getModelCode(), null)) {
            return R.error("模型编码已存在");
        }
        Date now = new Date();
        aiParam.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3));
        aiParam.setCreateBy(getLoginUserId());
        aiParam.setCreateTime(now);
        aiParam.setUpdateBy(getLoginUserId());
        aiParam.setUpdateTime(now);
        if (aiParam.getDefaultFlag() == null) {
            aiParam.setDefaultFlag(0);
        }
        if (!aiParamService.save(aiParam)) {
            return R.error("Save Fail");
        }
        if (Integer.valueOf(1).equals(aiParam.getDefaultFlag())) {
            aiParamService.resetDefaultFlag(aiParam.getId());
        }
        return R.ok("Save Success").add(aiParam);
    }
    @PreAuthorize("hasAuthority('system:aiParam:update')")
    @OperationLog("Update AiParam")
    @PostMapping("/aiParam/update")
    public R update(@RequestBody AiParam aiParam) {
        if (Cools.isEmpty(aiParam.getModelCode())) {
            return R.error("模型编码不能为空");
        }
        if (aiParamService.existsModelCode(aiParam.getModelCode(), aiParam.getId())) {
            return R.error("模型编码已存在");
        }
        aiParam.setUpdateBy(getLoginUserId());
        aiParam.setUpdateTime(new Date());
        if (!aiParamService.updateById(aiParam)) {
            return R.error("Update Fail");
        }
        if (Integer.valueOf(1).equals(aiParam.getDefaultFlag())) {
            aiParamService.resetDefaultFlag(aiParam.getId());
        }
        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/query")
    public R query(@RequestParam(required = false) String condition) {
        List<KeyValVo> vos = new ArrayList<>();
        LambdaQueryWrapper<AiParam> wrapper = new LambdaQueryWrapper<>();
        if (!Cools.isEmpty(condition)) {
            wrapper.like(AiParam::getName, condition).or().like(AiParam::getModelCode, condition);
        }
        aiParamService.page(new Page<>(1, 30), wrapper).getRecords().forEach(
                item -> vos.add(new KeyValVo(item.getId(), item.getName()))
        );
        return R.ok().add(vos);
    }
    @PreAuthorize("hasAuthority('system:aiParam:list')")
    @PostMapping("/aiParam/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(aiParamService.list(), AiParam.class), response);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java
New file
@@ -0,0 +1,169 @@
package com.vincent.rsf.server.system.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 com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.common.SpringUtils;
import com.vincent.rsf.server.system.service.TenantService;
import com.vincent.rsf.server.system.service.UserService;
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 uuid;
    @ApiModelProperty(value= "名称")
    private String name;
    @ApiModelProperty(value= "模型编码")
    private String modelCode;
    @ApiModelProperty(value= "供应商")
    private String provider;
    @ApiModelProperty(value= "聊天地址")
    private String chatUrl;
    @ApiModelProperty(value= "API密钥")
    private String apiKey;
    @ApiModelProperty(value= "模型名称")
    private String modelName;
    @ApiModelProperty(value= "系统提示词")
    private String systemPrompt;
    @ApiModelProperty(value= "上下文轮数")
    private Integer maxContextMessages;
    @ApiModelProperty(value= "默认模型 1: 是  0: 否")
    private Integer defaultFlag;
    @ApiModelProperty(value= "排序")
    private Integer sort;
    @ApiModelProperty(value= "状态 1: 正常  0: 冻结  ")
    private Integer status;
    @ApiModelProperty(value= "是否删除 1: 是  0: 否  ")
    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 getTenantId$(){
        TenantService service = SpringUtils.getBean(TenantService.class);
        Tenant tenant = service.getById(this.tenantId);
        if (!Cools.isEmpty(tenant)){
            return String.valueOf(tenant.getName());
        }
        return null;
    }
    public String getCreateBy$(){
        UserService service = SpringUtils.getBean(UserService.class);
        User user = service.getById(this.createBy);
        if (!Cools.isEmpty(user)){
            return String.valueOf(user.getNickname());
        }
        return null;
    }
    public String getCreateTime$(){
        if (Cools.isEmpty(this.createTime)){
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
    public String getUpdateBy$(){
        UserService service = SpringUtils.getBean(UserService.class);
        User user = service.getById(this.updateBy);
        if (!Cools.isEmpty(user)){
            return String.valueOf(user.getNickname());
        }
        return null;
    }
    public String getUpdateTime$(){
        if (Cools.isEmpty(this.updateTime)){
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
    public Boolean getStatusBool(){
        if (null == this.status){ return null; }
        switch (this.status){
            case 1:
                return true;
            case 0:
                return false;
            default:
                return null;
        }
    }
    public Boolean getDefaultFlagBool(){
        if (null == this.defaultFlag){ return null; }
        switch (this.defaultFlag){
            case 1:
                return true;
            case 0:
                return false;
            default:
                return null;
        }
    }
    public String getDefaultFlag$(){
        if (null == this.defaultFlag){ return null; }
        switch (this.defaultFlag){
            case 1:
                return "是";
            case 0:
                return "否";
            default:
                return String.valueOf(this.defaultFlag);
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java
New file
@@ -0,0 +1,12 @@
package com.vincent.rsf.server.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.system.entity.AiParam;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiParamMapper extends BaseMapper<AiParam> {
}
rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java
New file
@@ -0,0 +1,19 @@
package com.vincent.rsf.server.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.system.entity.AiParam;
import java.util.List;
public interface AiParamService extends IService<AiParam> {
    boolean existsModelCode(String modelCode, Long excludeId);
    void resetDefaultFlag(Long excludeId);
    List<AiParam> listEnabledModels();
    AiParam getEnabledModel(String modelCode);
    AiParam getDefaultModel();
}
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java
New file
@@ -0,0 +1,66 @@
package com.vincent.rsf.server.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.server.system.entity.AiParam;
import com.vincent.rsf.server.system.mapper.AiParamMapper;
import com.vincent.rsf.server.system.service.AiParamService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service("aiParamService")
public class AiParamServiceImpl extends ServiceImpl<AiParamMapper, AiParam> implements AiParamService {
    @Override
    public boolean existsModelCode(String modelCode, Long excludeId) {
        LambdaQueryWrapper<AiParam> wrapper = new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getModelCode, modelCode);
        if (excludeId != null) {
            wrapper.ne(AiParam::getId, excludeId);
        }
        return this.count(wrapper) > 0;
    }
    @Override
    public void resetDefaultFlag(Long excludeId) {
        LambdaUpdateWrapper<AiParam> wrapper = new LambdaUpdateWrapper<AiParam>()
                .set(AiParam::getDefaultFlag, 0)
                .eq(AiParam::getDefaultFlag, 1);
        if (excludeId != null) {
            wrapper.ne(AiParam::getId, excludeId);
        }
        this.update(wrapper);
    }
    @Override
    public List<AiParam> listEnabledModels() {
        return this.list(new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getStatus, 1)
                .orderByDesc(AiParam::getDefaultFlag)
                .orderByAsc(AiParam::getSort, AiParam::getId));
    }
    @Override
    public AiParam getEnabledModel(String modelCode) {
        return this.getOne(new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getModelCode, modelCode)
                .eq(AiParam::getStatus, 1)
                .last("limit 1"));
    }
    @Override
    public AiParam getDefaultModel() {
        AiParam aiParam = this.getOne(new LambdaQueryWrapper<AiParam>()
                .eq(AiParam::getDefaultFlag, 1)
                .eq(AiParam::getStatus, 1)
                .orderByAsc(AiParam::getSort, AiParam::getId)
                .last("limit 1"));
        if (aiParam != null) {
            return aiParam;
        }
        List<AiParam> list = listEnabledModels();
        return list.isEmpty() ? null : list.get(0);
    }
}
rsf-server/src/main/resources/application-dev.yml
@@ -99,6 +99,9 @@
      #端口号
      port: 8081
ai:
  gateway-base-url: http://127.0.0.1:8086
#仓库功能参数配置
stock:
  #是否允许打印货物标签, 默认允许打印,也可由供应商提供标签
@@ -110,4 +113,4 @@
    #判断是后检验合格后,才允许上架
    flagAvailable: true
    #判断是否校验合格后,才允许收货
    flagReceiving: false
    flagReceiving: false
rsf-server/src/main/resources/application-prod.yml
@@ -103,4 +103,7 @@
    #判断是后检验合格后,才允许上架
    flagAvailable: true
    #判断是否校验合格后,才允许收货
    flagReceiving: false
    flagReceiving: false
ai:
  gateway-base-url: http://127.0.0.1:8086
rsf-server/src/main/resources/application.yml
@@ -44,6 +44,21 @@
  file:
    path: logs/@pom.artifactId@
ai:
  session-ttl-seconds: 86400
  max-context-messages: 12
  default-model-code: mock-general
  system-prompt: 你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。
  models:
    - code: mock-general
      name: Mock General
      provider: mock
      enabled: true
    - code: mock-creative
      name: Mock Creative
      provider: mock
      enabled: true
# 下位机配置
wcs-slave:
  agv: false
rsf-server/src/main/resources/mapper/system/AiParamMapper.xml
New file
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.vincent.rsf.server.system.mapper.AiParamMapper">
</mapper>
version/db/20260311_ai_param.sql
New file
@@ -0,0 +1,270 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `sys_ai_param` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `uuid` varchar(255) DEFAULT NULL COMMENT '编号',
  `name` varchar(255) DEFAULT NULL COMMENT '名称',
  `model_code` varchar(255) DEFAULT NULL COMMENT '模型编码',
  `provider` varchar(255) DEFAULT NULL COMMENT '供应商',
  `chat_url` varchar(512) DEFAULT NULL COMMENT '聊天地址',
  `api_key` varchar(512) DEFAULT NULL COMMENT 'API密钥',
  `model_name` varchar(255) DEFAULT NULL COMMENT '模型名称',
  `system_prompt` text COMMENT '系统提示词',
  `max_context_messages` int(11) DEFAULT NULL COMMENT '上下文轮数',
  `default_flag` int(1) NOT NULL DEFAULT '0' COMMENT '默认模型{1:是,0:否}',
  `sort` int(11) DEFAULT NULL COMMENT '排序',
  `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}',
  `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户[sys_tenant]',
  `create_by` bigint(20) DEFAULT NULL COMMENT '添加人员[sys_user]',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '修改人员[sys_user]',
  `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `memo` varchar(255) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_ai_param_model_code` (`model_code`),
  KEY `idx_ai_param_deleted_code` (`deleted`,`model_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `sys_ai_param`
(`uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`)
SELECT '6702082748514305', '通用助手', 'mock-general', 'mock', NULL, NULL, 'mock-general', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', 12, 1, 1, 1, 0, 1, 2, NOW(), 2, NOW(), '默认演示模型'
FROM DUAL
WHERE NOT EXISTS (
    SELECT 1
    FROM `sys_ai_param`
    WHERE `model_code` = 'mock-general'
);
INSERT INTO `sys_ai_param`
(`uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`)
SELECT '6702082748514306', '创意助手', 'mock-creative', 'mock', NULL, NULL, 'mock-creative', '你是WMS系统内的智能助手,回答时可以更灵活地组织表达,但结论必须准确。', 12, 0, 2, 1, 0, 1, 2, NOW(), 2, NOW(), '演示创意模型'
FROM DUAL
WHERE NOT EXISTS (
    SELECT 1
    FROM `sys_ai_param`
    WHERE `model_code` = 'mock-creative'
);
SET @ai_parent_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `component` = 'aiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 9, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_parent_menu_id IS NULL;
SET @ai_parent_menu_id := COALESCE(
    @ai_parent_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `component` = 'aiParam'
        LIMIT 1
    )
);
SET @ai_query_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Query AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Query AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_query_menu_id IS NULL;
SET @ai_query_menu_id := COALESCE(
    @ai_query_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Query AiParam'
        LIMIT 1
    )
);
SET @ai_create_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Create AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Create AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_create_menu_id IS NULL;
SET @ai_create_menu_id := COALESCE(
    @ai_create_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Create AiParam'
        LIMIT 1
    )
);
SET @ai_update_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Update AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Update AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_update_menu_id IS NULL;
SET @ai_update_menu_id := COALESCE(
    @ai_update_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Update AiParam'
        LIMIT 1
    )
);
SET @ai_delete_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Delete AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Delete AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_delete_menu_id IS NULL;
SET @ai_delete_menu_id := COALESCE(
    @ai_delete_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Delete AiParam'
        LIMIT 1
    )
);
SET @ai_export_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Export AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Export AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 4, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_export_menu_id IS NULL;
SET @ai_export_menu_id := COALESCE(
    @ai_export_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Export AiParam'
        LIMIT 1
    )
);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_parent_menu_id
FROM DUAL
WHERE @ai_parent_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_parent_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_query_menu_id
FROM DUAL
WHERE @ai_query_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_query_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_create_menu_id
FROM DUAL
WHERE @ai_create_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_create_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_update_menu_id
FROM DUAL
WHERE @ai_update_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_update_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_delete_menu_id
FROM DUAL
WHERE @ai_delete_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_delete_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_export_menu_id
FROM DUAL
WHERE @ai_export_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_export_menu_id
  );
SET FOREIGN_KEY_CHECKS = 1;
version/db/20260311_ai_param_menu.sql
New file
@@ -0,0 +1,224 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
SET @ai_parent_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `component` = 'aiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 9, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_parent_menu_id IS NULL;
SET @ai_parent_menu_id := COALESCE(
    @ai_parent_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `component` = 'aiParam'
        LIMIT 1
    )
);
SET @ai_query_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Query AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Query AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_query_menu_id IS NULL;
SET @ai_query_menu_id := COALESCE(
    @ai_query_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Query AiParam'
        LIMIT 1
    )
);
SET @ai_create_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Create AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Create AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_create_menu_id IS NULL;
SET @ai_create_menu_id := COALESCE(
    @ai_create_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Create AiParam'
        LIMIT 1
    )
);
SET @ai_update_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Update AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Update AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_update_menu_id IS NULL;
SET @ai_update_menu_id := COALESCE(
    @ai_update_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Update AiParam'
        LIMIT 1
    )
);
SET @ai_delete_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Delete AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Delete AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_delete_menu_id IS NULL;
SET @ai_delete_menu_id := COALESCE(
    @ai_delete_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Delete AiParam'
        LIMIT 1
    )
);
SET @ai_export_menu_id := (
    SELECT `id`
    FROM `sys_menu`
    WHERE `parent_id` = @ai_parent_menu_id
      AND `name` = 'Export AiParam'
    LIMIT 1
);
INSERT INTO `sys_menu`
(`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`)
SELECT 'Export AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 4, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL
FROM DUAL
WHERE @ai_export_menu_id IS NULL;
SET @ai_export_menu_id := COALESCE(
    @ai_export_menu_id,
    LAST_INSERT_ID(),
    (
        SELECT `id`
        FROM `sys_menu`
        WHERE `parent_id` = @ai_parent_menu_id
          AND `name` = 'Export AiParam'
        LIMIT 1
    )
);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_parent_menu_id
FROM DUAL
WHERE @ai_parent_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_parent_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_query_menu_id
FROM DUAL
WHERE @ai_query_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_query_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_create_menu_id
FROM DUAL
WHERE @ai_create_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_create_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_update_menu_id
FROM DUAL
WHERE @ai_update_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_update_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_delete_menu_id
FROM DUAL
WHERE @ai_delete_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_delete_menu_id
  );
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 1, @ai_export_menu_id
FROM DUAL
WHERE @ai_export_menu_id IS NOT NULL
  AND NOT EXISTS (
      SELECT 1
      FROM `sys_role_menu`
      WHERE `role_id` = 1
        AND `menu_id` = @ai_export_menu_id
  );
SET FOREIGN_KEY_CHECKS = 1;
version/db/init.sql
@@ -50,6 +50,44 @@
COMMIT;
-- ----------------------------
-- Table structure for sys_ai_param
-- ----------------------------
DROP TABLE IF EXISTS `sys_ai_param`;
CREATE TABLE `sys_ai_param` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `uuid` varchar(255) DEFAULT NULL COMMENT '编号',
  `name` varchar(255) DEFAULT NULL COMMENT '名称',
  `model_code` varchar(255) DEFAULT NULL COMMENT '模型编码',
  `provider` varchar(255) DEFAULT NULL COMMENT '供应商',
  `chat_url` varchar(512) DEFAULT NULL COMMENT '聊天地址',
  `api_key` varchar(512) DEFAULT NULL COMMENT 'API密钥',
  `model_name` varchar(255) DEFAULT NULL COMMENT '模型名称',
  `system_prompt` text COMMENT '系统提示词',
  `max_context_messages` int(11) DEFAULT NULL COMMENT '上下文轮数',
  `default_flag` int(1) NOT NULL DEFAULT '0' COMMENT '默认模型{1:是,0:否}',
  `sort` int(11) DEFAULT NULL COMMENT '排序',
  `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}',
  `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户[sys_tenant]',
  `create_by` bigint(20) DEFAULT NULL COMMENT '添加人员[sys_user]',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '修改人员[sys_user]',
  `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `memo` varchar(255) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_ai_param_model_code` (`model_code`),
  KEY `idx_ai_param_deleted_code` (`deleted`,`model_code`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of sys_ai_param
-- ----------------------------
BEGIN;
INSERT INTO `sys_ai_param` (`id`, `uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) VALUES (1, '6702082748514305', '通用助手', 'mock-general', 'mock', NULL, NULL, 'mock-general', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', 12, 1, 1, 1, 0, 1, 2, '2025-02-05 14:16:51', 2, '2025-02-05 14:16:51', '默认演示模型');
INSERT INTO `sys_ai_param` (`id`, `uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) VALUES (2, '6702082748514306', '创意助手', 'mock-creative', 'mock', NULL, NULL, 'mock-creative', '你是WMS系统内的智能助手,回答时可以更灵活地组织表达,但结论必须准确。', 12, 0, 2, 1, 0, 1, 2, '2025-02-05 14:16:51', 2, '2025-02-05 14:16:51', '演示创意模型');
COMMIT;
-- ----------------------------
-- Table structure for sys_dept
-- ----------------------------
DROP TABLE IF EXISTS `sys_dept`;
@@ -198,6 +236,12 @@
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 (44, 'Create Tenant', 42, NULL, '1.42', NULL, NULL, NULL, NULL, NULL, 1, 'system:tenant:save', NULL, 1, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (45, 'Update Tenant', 42, NULL, '1.42', NULL, NULL, NULL, NULL, NULL, 1, 'system:tenant:update', NULL, 2, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (46, 'Delete Tenant', 42, NULL, '1.42', NULL, NULL, NULL, NULL, NULL, 1, 'system:tenant:remove', NULL, 3, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (47, 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 9, NULL, 1, 1, 0, NULL, NULL, '2024-09-10 15:08:30', 2, NULL);
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 (48, 'Query AiParam', 47, NULL, '1,47', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (49, 'Create AiParam', 47, NULL, '1,47', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (50, 'Update AiParam', 47, NULL, '1,47', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (51, 'Delete AiParam', 47, NULL, '1,47', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
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 (52, 'Export AiParam', 47, NULL, '1,47', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 4, NULL, 1, 1, 0, NULL, NULL, NULL, NULL, NULL);
COMMIT;
-- ----------------------------
@@ -345,6 +389,12 @@
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (44, 1, 44);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (45, 1, 45);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (46, 1, 46);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (47, 1, 47);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (48, 1, 48);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (49, 1, 49);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (50, 1, 50);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (51, 1, 51);
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES (52, 1, 52);
COMMIT;
-- ----------------------------