延续上一篇SpringAI的内容,这一篇主要是AI会话记忆实现与权限控制

项目代码Gitee地址:https://gitee.com/Luoyi_good/aitest

喜欢可以start一下

一、会话记忆(Redis)

1.创建Msg类

创建Msg类用于钻换Message类型,因为Message没有构造方法

package com.Luoyi.AITest.entiy;

import org.springframework.ai.chat.messages.*;

import java.util.List;
import java.util.Map;


public class Msg {
    MessageType messageType;
    String text;
    Map<String, Object> metadata;

    public Msg() {
    }

    public Msg(Message message) {
        this.messageType = message.getMessageType();
        this.text = message.getText();
        this.metadata = message.getMetadata();
    }

    public Message toMessage() {
        return switch (messageType) {
            case SYSTEM -> new SystemMessage(text);
            case USER -> new UserMessage(text);
            case ASSISTANT -> new AssistantMessage(text, metadata, List.of(), List.of());
            default -> throw new IllegalArgumentException("Unsupported message type: " + messageType);
        };
    }

    public MessageType getMessageType() {
        return messageType;
    }

    public void setMessageType(MessageType messageType) {
        this.messageType = messageType;
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }

    public Map<String, Object> getMetadata() {
        return metadata;
    }

    public void setMetadata(Map<String, Object> metadata) {
        this.metadata = metadata;
    }
}

2.实现接口ChatMemory

不同会话之间需要区别开来,所以这里引入conversationId 会话id用于会话隔离。

package com.Luoyi.repository;

import com.Luoyi.AITest.entiy.Msg;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.concurrent.TimeUnit;


@Component
public class RedisChatMemory implements ChatMemory {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ObjectMapper objectMapper;

    private final static String PREFIX = "chat:";

    /**
     * @Description: 添加消息,message没有构造器,只能通过Msg类转换
     * @param conversationId
     * @param messages
     * @return void
     */
    @Override
    public void add(String conversationId, List<Message> messages) {
        // 检查消息列表是否为空
        if (messages == null || messages.isEmpty()) {
            return;
        }

        // 将Message对象转换为Msg对象,再序列化为JSON字符串
        List<String> list = messages.stream()
            .map(Msg::new)
            .map(msg -> {
                try {
                    // 使用ObjectMapper将Msg对象序列化为JSON字符串
                    return objectMapper.writeValueAsString(msg);
                } catch (JsonProcessingException e) {
                    throw new RuntimeException(e);
                }
            })
            .toList();
        redisTemplate.opsForList().leftPushAll(PREFIX + conversationId, list);
        // 设置过期时间为1天
        redisTemplate.expire(PREFIX + conversationId, 1, TimeUnit.DAYS);
    }
    /**
     * @Description: 获取消息
     * @param conversationId
     * @return java.util.List<org.springframework.ai.chat.messages.Message>
     */
    @Override

    public List<Message> get(String conversationId) {
        // 从Redis列表中获取指定会话的所有消息,0到-1表示获取全部
        List<String> list = redisTemplate.opsForList().range(PREFIX + conversationId, 0, -1);
        // 检查消息列表是否为空
        if (list == null || list.isEmpty()) {
            return List.of();
        }
        // 将JSON字符串反序列化为Msg对象,再转换为Message对象
        return list.stream()
            .map(s -> {
                try {
                    return objectMapper.readValue(s, Msg.class);
                } catch (JsonProcessingException e) {
                    throw new RuntimeException(e);
                }
            })
            .map(Msg::toMessage)
            .toList();
    }
    /**
     * @Description: 清除消息
     * @param conversationId
     * @return void
     */
    @Override
    public void clear(String conversationId) {
        redisTemplate.delete(PREFIX + conversationId);
    }
}

3.在配置文件中加入ChatMemory

@Configuration
public class AIConfig {

    @Autowired
    private DeepSeekChatModel deepSeekChatModel;
    @Autowired
    private OllamaApi ollamaApi;
    @Autowired
    private OllamaChatProperties options;
    @Autowired
    private ChatMemory chatMemory;
    @Autowired
    private UserTool userTool;


    /**
     * @Description: deepseek模型
     * @param
     * @return org.springframework.ai.chat.client.ChatClient
     */
    @Bean
    public ChatClient deepseek(){
        return ChatClient.builder(deepSeekChatModel)
                .defaultAdvisors(new SimpleLoggerAdvisor())//记录日志
                .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
    /**
     * @Description: 本地ollama模型
     * @param
     * @return org.springframework.ai.chat.client.ChatClient
     */
    @Bean
    public ChatClient ollama() {
        OllamaChatModel ollamaChatModel = OllamaChatModel.builder()
                .ollamaApi(ollamaApi)
                .defaultOptions(OllamaOptions.builder().model(options.getModel()).build())
                .build();

        return ChatClient.builder(ollamaChatModel)
                .defaultAdvisors(new SimpleLoggerAdvisor())//记录日志
                .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

}


4.修改MultiModelsController 增加功能

(1)编写service接口

public interface IChatService {
    /**
     * @Description: 获取会话
     * @param
     * @return java.util.Set<java.lang.String>
     */
    Set<String> getChatList();
    /**
     * @Description: 获取聊天记录
     * @param conversationId
     * @return java.util.List<org.springframework.ai.chat.messages.Message>
     */
    List<Message> getHisList(String conversationId);

}

(2)实现接口

@Service
public class ChatServiceImpl implements IChatService {

    @Autowired
    private RedisChatMemory redisChatMemory;
    @Autowired
    private RedisTemplate redisTemplate;
    @Override
    public Set<String> getChatList() {
        Set<String> keys = new HashSet<>();
        // 使用ScanOptions构建扫描选项,获取20个会话
        ScanOptions options = ScanOptions.scanOptions().match("chat:*").count(20).build();

        Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection().scan(options);

        while (cursor.hasNext()) {
            keys.add(new String(cursor.next()));
        }
        // 关闭cursor
        cursor.close();
        return keys;
    }

    @Override
    public List<Message> getHisList(String conversationId) {
        List<Message> messageList = redisChatMemory.get(conversationId);
        return messageList;
    }
}

(3)增加功能

public class MultiModelsController {

    //注册ChatClient的bean
    @Autowired
    private Map<String, ChatClient> chatClientMap;
    @Autowired
    private IChatService chatService;
    /**
     * @Description: 模型对话流式输出
     * @param message 消息
     * @param model 指定模型
     * @return reactor.core.publisher.Flux<java.lang.String>
     */
    @RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
    public Flux<String> generation(@RequestParam String message,
                            @RequestParam String model,
                            @RequestParam String conversationId) {
        ChatClient chatClient = chatClientMap.get(model);
        Flux<String> content = chatClient.prompt()//创建聊天请求对象
                .user(message)//设置用户输入信息
                //接受一个 AdvisorSpec 类型的参数 (Consumer 方式) 对该参数执行某些操作(配置)不返回任何结果
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,conversationId))
                .stream()//开启流式响应并获取内容
                .content();

        return content;
    }
    /**
     * @Description: 获取会话
     * @param
     * @return java.util.List<java.lang.String>
     */
    @RequestMapping("/chatlist")
    public Set<String> getHis(){
        Set<String> chatList = chatService.getChatList();
        if (chatList == null) {
            return new HashSet<>();
        }
        return chatList ;
    }
    /**
     * @Description: 获取聊天记录
     * @param conversationId
     * @return java.util.List<org.springframework.ai.chat.messages.Message>
     */
    @RequestMapping("/chatmes/{conversationId}")
    public List<Message> getMessages(@PathVariable String conversationId){
        return chatService.getHisList(conversationId);
    }


}

5.编写页面

我的html页面如下,大家也可以自行编写或者用ai直接生成,这样方便查看效果。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Spring AI 多模型聊天</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }

        body {
            background-color: #f5f7fb;
            color: #333;
            line-height: 1.6;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }

        .header {
            background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
            color: white;
            padding: 1.2rem 2rem;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .logo {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .logo i {
            font-size: 1.8rem;
        }

        .logo h1 {
            font-size: 1.8rem;
            font-weight: 600;
        }

        .container {
            display: flex;
            flex: 1;
            overflow: hidden;
        }

        .sidebar {
            width: 300px;
            background-color: white;
            border-right: 1px solid #e0e0e0;
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .sidebar-header {
            padding: 1.2rem;
            border-bottom: 1px solid #e0e0e0;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .sidebar-header h2 {
            font-size: 1.2rem;
            font-weight: 600;
        }

        .new-chat-btn {
            background-color: #6a11cb;
            color: white;
            border: none;
            border-radius: 50%;
            width: 36px;
            height: 36px;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .new-chat-btn:hover {
            background-color: #5a0db9;
            transform: scale(1.05);
        }

        .chat-list {
            flex: 1;
            overflow-y: auto;
            padding: 0.5rem;
        }

        .chat-item {
            padding: 0.8rem 1rem;
            border-radius: 8px;
            margin-bottom: 0.5rem;
            cursor: pointer;
            transition: all 0.2s ease;
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .chat-item:hover {
            background-color: #f0f4ff;
        }

        .chat-item.active {
            background-color: #e6eeff;
            border-left: 3px solid #6a11cb;
        }

        .chat-item i {
            color: #6a11cb;
        }

        .chat-content {
            flex: 1;
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .chat-header {
            background-color: white;
            padding: 1.2rem 2rem;
            border-bottom: 1px solid #e0e0e0;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .model-selector {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .model-selector select {
            padding: 0.5rem 1rem;
            border-radius: 20px;
            border: 1px solid #ddd;
            background-color: white;
            font-size: 0.9rem;
            outline: none;
            cursor: pointer;
        }

        .messages-container {
            flex: 1;
            padding: 1.5rem 2rem;
            overflow-y: auto;
            display: flex;
            flex-direction: column;
            gap: 1.5rem;
        }

        .message {
            max-width: 80%;
            padding: 1rem 1.5rem;
            border-radius: 18px;
            position: relative;
            animation: fadeIn 0.3s ease;
        }

        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .user-message {
            align-self: flex-end;
            background-color: #6a11cb;
            color: white;
            border-bottom-right-radius: 4px;
        }

        .assistant-message {
            align-self: flex-start;
            background-color: white;
            border: 1px solid #e0e0e0;
            border-bottom-left-radius: 4px;
        }

        .message-header {
            font-size: 0.8rem;
            margin-bottom: 0.5rem;
            opacity: 0.8;
        }

        .input-container {
            padding: 1.5rem 2rem;
            background-color: white;
            border-top: 1px solid #e0e0e0;
        }

        .input-form {
            display: flex;
            gap: 10px;
        }

        .message-input {
            flex: 1;
            padding: 1rem 1.5rem;
            border-radius: 24px;
            border: 1px solid #ddd;
            outline: none;
            font-size: 1rem;
            resize: none;
            height: 56px;
            line-height: 1.5;
        }

        .send-btn {
            background-color: #6a11cb;
            color: white;
            border: none;
            border-radius: 50%;
            width: 56px;
            height: 56px;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .send-btn:hover {
            background-color: #5a0db9;
            transform: scale(1.05);
        }

        .send-btn:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
            transform: none;
        }

        .typing-indicator {
            display: none;
            align-self: flex-start;
            background-color: white;
            border: 1px solid #e0e0e0;
            border-radius: 18px;
            padding: 1rem 1.5rem;
            margin-bottom: 1rem;
        }

        .typing-dots {
            display: flex;
            gap: 5px;
        }

        .typing-dots span {
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background-color: #6a11cb;
            animation: typing 1.4s infinite ease-in-out;
        }

        .typing-dots span:nth-child(1) { animation-delay: 0s; }
        .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
        .typing-dots span:nth-child(3) { animation-delay: 0.4s; }

        @keyframes typing {
            0%, 60%, 100% { transform: translateY(0); }
            30% { transform: translateY(-10px); }
        }

        .empty-state {
            text-align: center;
            padding: 3rem 2rem;
            color: #777;
        }

        .empty-state i {
            font-size: 3rem;
            margin-bottom: 1rem;
            color: #ddd;
        }

        .markdown-content {
            line-height: 1.6;
        }

        .markdown-content ul {
            margin-left: 1.5rem;
            margin-bottom: 1rem;
        }

        .markdown-content li {
            margin-bottom: 0.5rem;
        }

        @media (max-width: 768px) {
            .container {
                flex-direction: column;
            }

            .sidebar {
                width: 100%;
                height: 200px;
            }

            .message {
                max-width: 90%;
            }
        }
    </style>
</head>
<body>
<div class="header">
    <div class="logo">
        <i class="fas fa-robot"></i>
        <h1>Spring AI 多模型聊天</h1>
    </div>
    <div class="header-info">
        <span>多模型AI对话系统</span>
    </div>
</div>

<div class="container">
    <div class="sidebar">
        <div class="sidebar-header">
            <h2>会话列表</h2>
            <button class="new-chat-btn" id="newChatBtn">
                <i class="fas fa-plus"></i>
            </button>
        </div>
        <div class="chat-list" id="chatList">
            <!-- 会话列表将通过JS动态生成 -->
        </div>
    </div>

    <div class="chat-content">
        <div class="chat-header">
            <div class="current-chat">
                <h3 id="currentChatTitle">新会话</h3>
            </div>
            <div class="model-selector">
                <span>模型:</span>
                <select id="modelSelect">
                    <option value="deepseek">DeepSeek</option>
                    <option value="ollama">Gemma3</option>
                </select>
            </div>
        </div>

        <div class="messages-container" id="messagesContainer">
            <div class="empty-state" id="emptyState">
                <i class="fas fa-comments"></i>
                <h3>开始新的对话</h3>
                <p>选择一个会话或创建新会话开始与AI聊天</p>
            </div>
            <!-- 消息将通过JS动态生成 -->
        </div>

        <div class="typing-indicator" id="typingIndicator">
            <div class="typing-dots">
                <span></span>
                <span></span>
                <span></span>
            </div>
        </div>

        <div class="input-container">
            <form class="input-form" id="messageForm">
                <textarea class="message-input" id="messageInput" placeholder="输入消息..." rows="1"></textarea>
                <button type="submit" class="send-btn" id="sendBtn">
                    <i class="fas fa-paper-plane"></i>
                </button>
            </form>
        </div>
    </div>
</div>

<script>
    // 全局变量
    let currentConversationId = null;
    let currentConversationDisplayId = null;
    let chatList = [];

    // DOM 元素
    const chatListElement = document.getElementById('chatList');
    const messagesContainer = document.getElementById('messagesContainer');
    const messageForm = document.getElementById('messageForm');
    const messageInput = document.getElementById('messageInput');
    const sendBtn = document.getElementById('sendBtn');
    const modelSelect = document.getElementById('modelSelect');
    const newChatBtn = document.getElementById('newChatBtn');
    const currentChatTitle = document.getElementById('currentChatTitle');
    const typingIndicator = document.getElementById('typingIndicator');
    const emptyState = document.getElementById('emptyState');

    // 初始化
    document.addEventListener('DOMContentLoaded', function() {
        loadChatList();
        setupEventListeners();
    });

    // 设置事件监听器
    function setupEventListeners() {
        messageForm.addEventListener('submit', sendMessage);
        newChatBtn.addEventListener('click', createNewChat);
        messageInput.addEventListener('input', autoResizeTextarea);

        // 初始调整textarea高度
        autoResizeTextarea();
    }

    // 自动调整textarea高度
    function autoResizeTextarea() {
        messageInput.style.height = 'auto';
        messageInput.style.height = Math.min(messageInput.scrollHeight, 120) + 'px';
    }

    // 从会话ID中提取数字部分
    function extractConversationId(conversationId) {
        // 如果会话ID包含冒号,提取冒号后面的部分
        if (conversationId.includes(':')) {
            return conversationId.split(':')[1];
        }
        return conversationId;
    }

    // 加载会话列表
    function loadChatList() {
        fetch('http://localhost:8080/chatlist')
            .then(response => response.json())
            .then(data => {
                chatList = data;
                renderChatList();
            })
            .catch(error => {
                console.error('加载会话列表失败:', error);
                // 使用示例数据作为备用
                chatList = ["chat:1", "chat:100"];
                renderChatList();
            });
    }

    // 渲染会话列表
    function renderChatList() {
        chatListElement.innerHTML = '';

        if (chatList.length === 0) {
            chatListElement.innerHTML = '<div class="empty-state"><p>暂无会话</p></div>';
            return;
        }

        chatList.forEach(chatId => {
            const chatItem = document.createElement('div');
            chatItem.className = 'chat-item';
            chatItem.innerHTML = `
                    <i class="fas fa-comment"></i>
                    <div class="chat-info">
                        <div class="chat-name">${chatId}</div>
                    </div>
                `;

            chatItem.addEventListener('click', () => {
                // 移除所有active类
                document.querySelectorAll('.chat-item').forEach(item => {
                    item.classList.remove('active');
                });

                // 添加active类到当前项
                chatItem.classList.add('active');

                // 加载会话消息
                loadChatMessages(chatId);
            });

            chatListElement.appendChild(chatItem);
        });
    }

    // 加载聊天消息
    function loadChatMessages(conversationDisplayId) {
        // 保存显示用的会话ID
        currentConversationDisplayId = conversationDisplayId;
        // 提取数字部分用于API调用
        currentConversationId = extractConversationId(conversationDisplayId);

        currentChatTitle.textContent = `会话: ${conversationDisplayId}`;
        emptyState.style.display = 'none';

        fetch(`http://localhost:8080/chatmes/${currentConversationId}`)
            .then(response => response.json())
            .then(messages => {
                renderMessages(messages.reverse()); // 反转消息顺序,从旧到新
            })
            .catch(error => {
                console.error('加载消息失败:', error);
                // 使用示例数据作为备用
                const exampleMessages = [
                    {
                        "messageType": "USER",
                        "text": "你能干什么?"
                    },
                    {
                        "messageType": "ASSISTANT",
                        "text": "我是DeepSeek,我可以为你提供很多帮助呢!✨\n\n**我能做的事情包括:**\n- 📝 回答各种问题,从学术知识到生活常识\n- 🔍 帮你分析和处理文本内容\n- 📄 读取和处理你上传的文档(图片、PDF、Word、Excel等)\n- 💡 提供创意想法和解决方案\n- 🧮 协助计算和数据分析\n- ✍️ 帮你写作、翻译、总结内容\n- 💬 陪你聊天交流\n\n**特别提醒:**\n- 完全免费使用,没有任何收费计划\n- 支持128K上下文,能记住我们较长的对话\n- 可以通过官方App商店下载应用\n\n德德,有什么具体想让我帮你做的吗?我很乐意为你服务!😊"
                    }
                ];
                renderMessages(exampleMessages);
            });
    }

    // 渲染消息
    function renderMessages(messages) {
        messagesContainer.innerHTML = '';

        if (messages.length === 0) {
            emptyState.style.display = 'block';
            return;
        }

        messages.forEach(message => {
            const messageElement = document.createElement('div');
            const isUser = message.messageType === 'USER';
            messageElement.className = `message ${isUser ? 'user-message' : 'assistant-message'}`;

            const messageHeader = isUser ? '你' : 'AI助手';
            messageElement.innerHTML = `
                    <div class="message-header">${messageHeader}</div>
                    <div class="markdown-content">${formatMessageText(message.text)}</div>
                `;

            messagesContainer.appendChild(messageElement);
        });

        // 滚动到底部
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }

    // 格式化消息文本(简单的Markdown支持)
    function formatMessageText(text) {
        // 处理粗体
        text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');

        // 处理列表
        text = text.replace(/^- (.*)/gm, '<li>$1</li>');
        text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');

        // 处理换行
        text = text.replace(/\n/g, '<br>');

        return text;
    }

    // 发送消息
    function sendMessage(e) {
        e.preventDefault();

        const message = messageInput.value.trim();
        if (!message || !currentConversationId) return;

        // 添加用户消息到界面
        addMessageToUI(message, 'USER');

        // 清空输入框
        messageInput.value = '';
        autoResizeTextarea();

        // 禁用发送按钮
        sendBtn.disabled = true;

        // 显示打字指示器
        typingIndicator.style.display = 'block';
        messagesContainer.scrollTop = messagesContainer.scrollHeight;

        // 获取选择的模型
        const selectedModel = modelSelect.value;

        // 发送请求到服务器
        fetch(`http://localhost:8080/chat?message=${encodeURIComponent(message)}&model=${selectedModel}&conversationId=${currentConversationId}`)
            .then(response => {
                const reader = response.body.getReader();
                const decoder = new TextDecoder();

                // 创建AI消息元素
                const aiMessageElement = document.createElement('div');
                aiMessageElement.className = 'message assistant-message';
                aiMessageElement.innerHTML = `
                        <div class="message-header">AI助手</div>
                        <div class="markdown-content"></div>
                    `;

                messagesContainer.appendChild(aiMessageElement);

                // 隐藏打字指示器
                typingIndicator.style.display = 'none';

                // 读取流式响应
                function readStream() {
                    reader.read().then(({done, value}) => {
                        if (done) {
                            sendBtn.disabled = false;
                            return;
                        }

                        const text = decoder.decode(value);
                        const contentElement = aiMessageElement.querySelector('.markdown-content');
                        contentElement.innerHTML += formatMessageText(text);

                        // 滚动到底部
                        messagesContainer.scrollTop = messagesContainer.scrollHeight;

                        // 继续读取
                        readStream();
                    }).catch(error => {
                        console.error('读取流失败:', error);
                        sendBtn.disabled = false;
                        typingIndicator.style.display = 'none';
                    });
                }

                readStream();
            })
            .catch(error => {
                console.error('发送消息失败:', error);
                sendBtn.disabled = false;
                typingIndicator.style.display = 'none';

                // 添加错误消息
                const errorMessageElement = document.createElement('div');
                errorMessageElement.className = 'message assistant-message';
                errorMessageElement.innerHTML = `
                        <div class="message-header">AI助手</div>
                        <div class="markdown-content">抱歉,发送消息时出现错误。请检查网络连接或稍后重试。</div>
                    `;

                messagesContainer.appendChild(errorMessageElement);
                messagesContainer.scrollTop = messagesContainer.scrollHeight;
            });
    }

    // 添加消息到UI
    function addMessageToUI(message, type) {
        emptyState.style.display = 'none';

        const messageElement = document.createElement('div');
        const isUser = type === 'USER';
        messageElement.className = `message ${isUser ? 'user-message' : 'assistant-message'}`;

        const messageHeader = isUser ? '你' : 'AI助手';
        messageElement.innerHTML = `
                <div class="message-header">${messageHeader}</div>
                <div class="markdown-content">${formatMessageText(message)}</div>
            `;

        messagesContainer.appendChild(messageElement);
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }

    // 创建新会话
    function createNewChat() {
        // 生成新的会话ID(显示用)
        const newDisplayId = 'chat:' + Math.floor(Math.random() * 1000);
        // 提取数字部分用于API调用
        const newId = extractConversationId(newDisplayId);

        // 添加到会话列表
        chatList.push(newDisplayId);
        renderChatList();

        // 清空消息区域
        messagesContainer.innerHTML = '';
        emptyState.style.display = 'block';

        // 设置当前会话
        currentConversationId = newId;
        currentConversationDisplayId = newDisplayId;
        currentChatTitle.textContent = `会话: ${newDisplayId}`;

        // 激活新会话项
        const chatItems = document.querySelectorAll('.chat-item');
        if (chatItems.length > 0) {
            chatItems.forEach(item => item.classList.remove('active'));
            chatItems[chatItems.length - 1].classList.add('active');
        }
    }
</script>
</body>
</html>

6.运行

把html放入到resource/static下,运行项目访问localhost:8080自动访问页面了,如下:

不要忘了启动redis

可以编写一个redis的启动脚本,我的给大家参考一下,把目录替换成你自己的就ok了

@echo off
:: 关闭命令行回显,使输出更简洁

:: ==============================================
:: 修改为你的Redis安装目录(包含redis-server.exe的文件夹)
set REDIS_PATH=D:\Redis
:: ==============================================

:: 检查Redis目录是否存在
if not exist "%REDIS_PATH%\redis-server.exe" (
    echo 错误:未在 %REDIS_PATH% 找到 redis-server.exe
    echo 请检查Redis安装路径是否正确,修改脚本中的 REDIS_PATH 变量
    pause
    exit /b 1
)

:: 切换到Redis目录
cd /d "%REDIS_PATH%"

:: 启动Redis服务(默认使用 redis.windows.conf 配置文件)
echo 正在启动Redis服务...
echo Redis安装目录:%REDIS_PATH%
echo 启动命令:redis-server.exe redis.windows.conf
echo.
redis-server.exe redis.windows.conf

:: 如果启动失败,暂停窗口以便查看错误
if %errorlevel% neq 0 (
    echo.
    echo Redis启动失败!错误代码:%errorlevel%
    pause
    exit /b %errorlevel%
)

如此实现了通过redis进行会话记忆的功能

二、工具调用

如何让AI调用指定的方法进行操作返回相应呢?例如查询本系统中的用户信息,增加用户修改用户。。。使用工具即可完成

1.创建用户类

package com.Luoyi.AITest.entiy;

/**
 * @author Luoyi
 * @description: 用户类
 */
public class User {
    private Long id;
    private String name;
    private Integer age;
    private String sex;

    //getter/setter,构造 
}

2.编写工具类

 @Tool(description = "根据用户名字获取用户信息"),这是工具方法的描述注解,说明方法作用让AI理解并调用。
@ToolParam(description = "用户姓名(必填项,若用户未输入则传空)"),这是传入参数的描述,尽量参数不要超过5个,太多会让AI理解出问题进而出错。同时可以对参数描述添加显示大模型有时会为了强行适配参数而胡说。

/**
 * @author Luoyi
 * @description: 用户信息工具
 */
@Service
public class UserTool {

    ArrayList<User> users = new ArrayList<>();

    public UserTool() {
        // 初始化
        users.add(new User(1L,"噜噜",22,"男"));
        users.add(new User(2L,"小志",28,"男"));
        users.add(new User(3L,"二一",21,"女"));
    }

    /**
     * 获取当前所有用户信息
     */
    @Tool(description = "获取当前所有用户信息")
    List<User> getUserMessages() {
        return users;
    }

    /**
     * 根据用户名字获取用户信息
     */
    @Tool(description = "根据用户名字获取用户信息")
    User getUserMessagesByName(@ToolParam(description = "用户姓名(必填项,若用户未输入则传空)") String name) {
        for (User user : users) {
            if (user.getName().equals(name)){
                return user;
            }
        }
        return new User();
    }

    /**
     * 新增用户信息
     */
    @Tool(description = "新增用户信息")
    void addUser(User user) {
        users.add(user);
    }



}

3.在配置中添加工具

public ChatClient deepseek(){
        return ChatClient.builder(deepSeekChatModel)
                .defaultAdvisors(new SimpleLoggerAdvisor())//记录日志
                .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
                .defaultTools(userTool)
                .build();
    }

4.运行

运行询问AI即可自动调用工具

现在有了新的问题,在一个系统中是有权限的不能你是员工却能访问上级的信息,因此我们需要对其进行权限控制。

三、添加权限访问

我们通过使用Spring Security来解决这个问题。

1.引入依赖

 <!--Spring Security-->
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
 </dependency>

2.配置 SecurityConfig配置类

package com.Luoyi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

/**
 * @author Luoyi
 * @description: Security权限配置
 */
@Configuration
@EnableWebSecurity // 启用Spring Security的Web安全功能
@EnableMethodSecurity // 启用方法级权限控制,允许在方法上使用@PreAuthorize等注解
public class SecurityConfig {

    /**
     * 配置HTTP安全策略
     * 这个方法定义了哪些URL需要认证,哪些可以直接访问
     * @param http Spring Security提供的HTTP安全配置对象
     * @return 配置好的安全过滤器链
     * @throws Exception 配置过程中可能抛出的异常
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 配置URL访问权限
            .authorizeHttpRequests(auth -> auth
                // 这些路径需要登录后才能访问覆盖使用路径
                .requestMatchers("/", "/index.html", "/chat", "/chatlist", "/chatmes/**").authenticated()
                // 其他所有请求都允许访问
                .anyRequest().permitAll()
            )
            // 配置表单登录
            .formLogin(form -> form.permitAll()) // 登录页面允许所有人访问
            // 配置登出功能
            .logout(logout -> logout.permitAll()); // 登出功能允许所有人访问
            // 禁用CSRF(跨站请求伪造)保护
//            .csrf(csrf -> csrf.disable());

        return http.build();
    }

    /**
     * 配置用户信息服务
     * @return 用户详情服务,用于Spring Security进行身份验证
     */
    @Bean
    public UserDetailsService userDetailsService() {
        // 创建普通用户:user/user123,拥有USER角色
        UserDetails user = User.builder()
                .username("user")
                .password(passwordEncoder().encode("user123"))
                .roles("USER")
                .build();

        // 创建管理员:admin/admin123,拥有ADMIN和USER两个角色
        UserDetails admin = User.builder()
                .username("admin")
                .password(passwordEncoder().encode("admin123"))
                .roles("ADMIN", "USER")
                .build();
        return new InMemoryUserDetailsManager(user, admin);
    }

    /**
     * 配置密码加密器
     * @return 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 使用BCrypt加密算法
    }
}

3.修改UserTool方法

/**
     * 获取当前所有用户信息
     * 权限要求:仅管理员(ADMIN)可以查看所有用户信息
     */
    @Tool(description = "获取当前所有用户信息")
//    @PreAuthorize("hasRole('ADMIN')")
    List<User> getUserMessages() {
        // 手动进行权限检查,防止AI工具调用绕过Spring Security
        if (!hasRole("ADMIN")) {
            throw new SecurityException("权限不足:只有管理员才能查看所有用户信息");
        }
        return users;
    }

    /**
     * 根据用户名字获取用户信息
     * 权限要求:普通用户(USER)可以查询,但只能查自己;管理员(ADMIN)可以查所有人
     */
    @Tool(description = "查询指定用户的信息,根据用户名字获取用户信息")
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    User getUserMessagesByName(@ToolParam(description = "用户姓名(必填项,若用户未输入则传空)") String name) {
        for (User user : users) {
            if (user.getName().equals(name)){
                return user;
            }
        }
        return new User();
    }

    /**
     * 新增用户信息
     * 权限要求:仅管理员(ADMIN)可以新增用户
     */
    @Tool(description = "新增用户信息")
    @PreAuthorize("hasRole('ADMIN')")
    void addUser(User user) {
        // 手动进行权限检查,防止AI工具调用绕过Spring Security
        if (!hasRole("ADMIN")) {
            throw new SecurityException("权限不足:只有管理员才能新增用户");
        }
        users.add(user);
    }

    /**
     * 检查当前用户是否拥有指定角色
     * @param role 角色名称(不需要ROLE_前缀)
     * @return 是否拥有该角色
     */
    private boolean hasRole(String role) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            return false;
        }

        // Spring Security的角色会自动添加"ROLE_"前缀
        String roleWithPrefix = "ROLE_" + role;
        return authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .anyMatch(auth -> auth.equals(roleWithPrefix));
    }

这里存在一个问题,如果注册工具采用new方法创建工具类的化,使用@PreAuthorize注解进行权限控制会出现不生效的问题,我查了是因为@PreAuthorize 注解依赖于 Spring AOP 代理,但当 AI 模型通过工具调用时,可能是直接反射调用方法,绕过了 Spring 的代理机制。

如果使用new的方式的话,我建议需要在方法内部显式地进行权限检查。

而我是采用注入的方式让Spring自己管理这样我感觉会方便。

4.启动项目

这次我们就会被拦截到登录页,使用对应身份进行登录 admin/admin123

如果我们使用用户登录 user/user123,就无法访问admin角色能访问的方法

四、后续更新MCP、RAG方面的功能,以及一些补充功能

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐