From e6369c9b64a82eeada6b6f0658d2ae786787e101 Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 12 三月 2026 10:23:32 +0800
Subject: [PATCH] #AI
---
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java | 42
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java | 12
version/db/20260311_ai_param_menu.sql | 224 +++
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java | 14
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java | 34
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java | 16
rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py | 129 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java | 47
rsf-ai-gateway/pom.xml | 33
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java | 12
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java | 21
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java | 20
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java | 25
rsf-server/skills/rsf-server-maintainer/SKILL.md | 98 +
rsf-admin/src/i18n/en.js | 14
rsf-admin/src/page/ResourceContent.js | 5
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java | 267 +++
version/db/20260311_ai_param.sql | 270 +++
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java | 14
rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java | 27
rsf-admin/src/page/system/aiParam/AiParamEdit.jsx | 136 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java | 260 +++
rsf-admin/src/page/system/aiParam/index.jsx | 15
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java | 26
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java | 34
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java | 18
rsf-ai-gateway/src/main/resources/application.yml | 23
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java | 221 ++
rsf-server/skills/rsf-server-maintainer/agents/openai.yaml | 4
rsf-server/skills/rsf-server-maintainer/references/repo-map.md | 69
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java | 66
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java | 14
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java | 119 +
rsf-ai-gateway/gateway-run.log | 60
pom.xml | 1
rsf-admin/src/page/system/aiParam/AiParamList.jsx | 120 +
rsf-admin/src/api/ai/index.js | 43
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java | 169 ++
rsf-server/src/main/resources/mapper/system/AiParamMapper.xml | 5
rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java | 19
rsf-server/src/main/resources/application.yml | 15
rsf-admin/src/i18n/zh.js | 14
rsf-server/src/main/resources/application-dev.yml | 5
version/db/init.sql | 50
rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java | 26
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java | 68
rsf-admin/src/page/system/aiParam/AiParamCreate.jsx | 175 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java | 10
rsf-admin/src/layout/AppBarToolbar.jsx | 16
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java | 138 +
rsf-server/src/main/resources/application-prod.yml | 5
rsf-admin/src/ai/AiChatWidget.jsx | 755 ++++++++++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java | 215 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java | 37
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java | 136 +
55 files changed, 4,408 insertions(+), 3 deletions(-)
diff --git a/pom.xml b/pom.xml
index f8d401c..828541a 100644
--- a/pom.xml
+++ b/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>
diff --git a/rsf-admin/src/ai/AiChatWidget.jsx b/rsf-admin/src/ai/AiChatWidget.jsx
new file mode 100644
index 0000000..9c713e7
--- /dev/null
+++ b/rsf-admin/src/ai/AiChatWidget.jsx
@@ -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 ? '姝e湪鐢熸垚鍥炲...' : '')}
+ </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>
+ </>
+ );
+};
diff --git a/rsf-admin/src/api/ai/index.js b/rsf-admin/src/api/ai/index.js
new file mode 100644
index 0000000..4bb5fab
--- /dev/null
+++ b/rsf-admin/src/api/ai/index.js
@@ -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;
+}
diff --git a/rsf-admin/src/i18n/en.js b/rsf-admin/src/i18n/en.js
index 00fd50a..9c32fd3 100644
--- a/rsf-admin/src/i18n/en.js
+++ b/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",
diff --git a/rsf-admin/src/i18n/zh.js b/rsf-admin/src/i18n/zh.js
index f54083c..a351480 100644
--- a/rsf-admin/src/i18n/zh.js
+++ b/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: "浠g爜",
diff --git a/rsf-admin/src/layout/AppBarToolbar.jsx b/rsf-admin/src/layout/AppBarToolbar.jsx
index 45cf9db..ff7cd2b 100644
--- a/rsf-admin/src/layout/AppBarToolbar.jsx
+++ b/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 />
</>
);
diff --git a/rsf-admin/src/page/ResourceContent.js b/rsf-admin/src/page/ResourceContent.js
index b9aefe3..a4f7965 100644
--- a/rsf-admin/src/page/ResourceContent.js
+++ b/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;
\ No newline at end of file
+export default ResourceContent;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx b/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
new file mode 100644
index 0000000..15ed6ac
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamCreate.jsx
@@ -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銆乨eepseek-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;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx b/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
new file mode 100644
index 0000000..6165be5
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamEdit.jsx
@@ -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銆乨eepseek-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;
diff --git a/rsf-admin/src/page/system/aiParam/AiParamList.jsx b/rsf-admin/src/page/system/aiParam/AiParamList.jsx
new file mode 100644
index 0000000..8f12399
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/AiParamList.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiParam/index.jsx b/rsf-admin/src/page/system/aiParam/index.jsx
new file mode 100644
index 0000000..917e915
--- /dev/null
+++ b/rsf-admin/src/page/system/aiParam/index.jsx
@@ -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}`
+ }
+};
diff --git a/rsf-ai-gateway/gateway-run.log b/rsf-ai-gateway/gateway-run.log
new file mode 100644
index 0000000..23a675c
--- /dev/null
+++ b/rsf-ai-gateway/gateway-run.log
@@ -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
diff --git a/rsf-ai-gateway/pom.xml b/rsf-ai-gateway/pom.xml
new file mode 100644
index 0000000..77581f5
--- /dev/null
+++ b/rsf-ai-gateway/pom.xml
@@ -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>
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java
new file mode 100644
index 0000000..652213e
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/GatewayBoot.java
@@ -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);
+ }
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java
new file mode 100644
index 0000000..0b7fcd4
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/config/AiGatewayProperties.java
@@ -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;
+ }
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java
new file mode 100644
index 0000000..f8842d9
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/controller/AiGatewayController.java
@@ -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);
+ }
+ };
+ }
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java
new file mode 100644
index 0000000..fcf8f78
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatMessage.java
@@ -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;
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java
new file mode 100644
index 0000000..cf105b9
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/dto/GatewayChatRequest.java
@@ -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<>();
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java
new file mode 100644
index 0000000..da04faa
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/AiGatewayService.java
@@ -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;
+ }
+ }
+
+}
diff --git a/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java
new file mode 100644
index 0000000..7ea9d37
--- /dev/null
+++ b/rsf-ai-gateway/src/main/java/com/vincent/rsf/ai/gateway/service/GatewayStreamEvent.java
@@ -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;
+
+}
diff --git a/rsf-ai-gateway/src/main/resources/application.yml b/rsf-ai-gateway/src/main/resources/application.yml
new file mode 100644
index 0000000..78797bf
--- /dev/null
+++ b/rsf-ai-gateway/src/main/resources/application.yml
@@ -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
diff --git a/rsf-server/skills/rsf-server-maintainer/SKILL.md b/rsf-server/skills/rsf-server-maintainer/SKILL.md
new file mode 100644
index 0000000..93fef0e
--- /dev/null
+++ b/rsf-server/skills/rsf-server-maintainer/SKILL.md
@@ -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.
diff --git a/rsf-server/skills/rsf-server-maintainer/agents/openai.yaml b/rsf-server/skills/rsf-server-maintainer/agents/openai.yaml
new file mode 100644
index 0000000..363026c
--- /dev/null
+++ b/rsf-server/skills/rsf-server-maintainer/agents/openai.yaml
@@ -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."
diff --git a/rsf-server/skills/rsf-server-maintainer/references/repo-map.md b/rsf-server/skills/rsf-server-maintainer/references/repo-map.md
new file mode 100644
index 0000000..6c7ba72
--- /dev/null
+++ b/rsf-server/skills/rsf-server-maintainer/references/repo-map.md
@@ -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`.
diff --git a/rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py b/rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py
new file mode 100644
index 0000000..8805eb6
--- /dev/null
+++ b/rsf-server/skills/rsf-server-maintainer/scripts/locate_module.py
@@ -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())
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java
new file mode 100644
index 0000000..751d469
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiProperties.java
@@ -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;
+ }
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java
new file mode 100644
index 0000000..3768be3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiController.java
@@ -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();
+ }
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java
new file mode 100644
index 0000000..fca5bbf
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java
new file mode 100644
index 0000000..1bf463c
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionCreateRequest.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java
new file mode 100644
index 0000000..b5eab2d
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiSessionRenameRequest.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java
new file mode 100644
index 0000000..c50659a
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatMessage.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java
new file mode 100644
index 0000000..439092a
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/GatewayChatRequest.java
@@ -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<>();
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java
new file mode 100644
index 0000000..f124919
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatMessage.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java
new file mode 100644
index 0000000..725efc8
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiChatSession.java
@@ -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;
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java
new file mode 100644
index 0000000..5179597
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java
new file mode 100644
index 0000000..b41bb2d
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiGatewayClient.java
@@ -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();
+ }
+ }
+ }
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java
new file mode 100644
index 0000000..2592d9f
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextProvider.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java
new file mode 100644
index 0000000..3a91504
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptContextService.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java
new file mode 100644
index 0000000..9790dda
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiRuntimeConfigService.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java
new file mode 100644
index 0000000..189fcab
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiSessionService.java
@@ -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);
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java
new file mode 100644
index 0000000..73029a5
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java
@@ -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;
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java
new file mode 100644
index 0000000..202fed3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java
@@ -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<>();
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java
new file mode 100644
index 0000000..87f9d20
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiSessionServiceImpl.java
@@ -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;
+ }
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java
new file mode 100644
index 0000000..badcaec
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AiParamController.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java
new file mode 100644
index 0000000..1cac3f7
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/entity/AiParam.java
@@ -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: 姝e父 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);
+ }
+ }
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java
new file mode 100644
index 0000000..8c2d513
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/AiParamMapper.java
@@ -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> {
+
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java
new file mode 100644
index 0000000..39900e8
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/AiParamService.java
@@ -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();
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java
new file mode 100644
index 0000000..d4518f7
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/AiParamServiceImpl.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/resources/application-dev.yml b/rsf-server/src/main/resources/application-dev.yml
index 2bef992..dcced99 100644
--- a/rsf-server/src/main/resources/application-dev.yml
+++ b/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
\ No newline at end of file
+ flagReceiving: false
diff --git a/rsf-server/src/main/resources/application-prod.yml b/rsf-server/src/main/resources/application-prod.yml
index ac13fe0..86bec2e 100644
--- a/rsf-server/src/main/resources/application-prod.yml
+++ b/rsf-server/src/main/resources/application-prod.yml
@@ -103,4 +103,7 @@
#鍒ゆ柇鏄悗妫�楠屽悎鏍煎悗锛屾墠鍏佽涓婃灦
flagAvailable: true
#鍒ゆ柇鏄惁鏍¢獙鍚堟牸鍚庯紝鎵嶅厑璁告敹璐�
- flagReceiving: false
\ No newline at end of file
+ flagReceiving: false
+
+ai:
+ gateway-base-url: http://127.0.0.1:8086
diff --git a/rsf-server/src/main/resources/application.yml b/rsf-server/src/main/resources/application.yml
index 0c99aef..8fae7b8 100644
--- a/rsf-server/src/main/resources/application.yml
+++ b/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
diff --git a/rsf-server/src/main/resources/mapper/system/AiParamMapper.xml b/rsf-server/src/main/resources/mapper/system/AiParamMapper.xml
new file mode 100644
index 0000000..43b0299
--- /dev/null
+++ b/rsf-server/src/main/resources/mapper/system/AiParamMapper.xml
@@ -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>
diff --git a/version/db/20260311_ai_param.sql b/version/db/20260311_ai_param.sql
new file mode 100644
index 0000000..9349c1f
--- /dev/null
+++ b/version/db/20260311_ai_param.sql
@@ -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:姝e父,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;
diff --git a/version/db/20260311_ai_param_menu.sql b/version/db/20260311_ai_param_menu.sql
new file mode 100644
index 0000000..0ec3a0b
--- /dev/null
+++ b/version/db/20260311_ai_param_menu.sql
@@ -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;
diff --git a/version/db/init.sql b/version/db/init.sql
index 6c56f06..c84ebee 100644
--- a/version/db/init.sql
+++ b/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:姝e父,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;
-- ----------------------------
--
Gitblit v1.9.1