【大模型应用开发】SpringBoot 整合基于 Ollama 的 DeepSeek,并对接前端( 全部代码 !!!)

发布于:2025-06-15 ⋅ 阅读:(17) ⋅ 点赞:(0)

1. 前述


在前面的内容中,我们已经在本地部署了deepseek-r1:1.5b,详情可参考文章:
【大模型应用开发】Ollama 介绍及基于 Ollama 部署本地 DeepSeek

那么下面我们需要将该大模型整合到SpringBoot中,实现接口访问,同时对接前端代码。本文拥有全部的代码 !!!

2. AI 基础对话


在创建AI大模型机器人时,我们现需要了解几个重要概念

  • system:表示系统的默认设置,即给大模型设置任务背景
  • user:即用户的提问
  • assistant:大模型生成的历史消息

首先创建一个项目,其中Java的版本要在17以上

image-20250611160157100

下载相关依赖,其中SpringBoot版本不能过低,否则没有AI依赖,可按需选择deepseek或者openai

在这里插入图片描述

创建好项目后,编辑配置文件application.yaml

spring:
  application:
    name: SpringAI
  ai:
    ollama:
      # ollama固定的本地访问地址
      base-url: http://localhost:11434
      chat:
        model: deepseek-r1:1.5b

SpringAI利用ChatClient来访问大模型,创建配置文件类CommonConfiguration

@Configuration
public class CommonConfiguration {
    @Bean
    public ChatClient chatClint(OllamaChatModel ollamaModel){
        return ChatClient
            .builder(ollamaModel)
            .build();
    }
}

创建控制器ChatController,利用阻塞访问输出结果。所谓阻塞式访问,就是需要等待全部回答结束后才会展现给用户

// 用来实例化 chatClient
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {

    private final ChatClient chatClient;
		
    // 其中call表示阻塞式调用,即答案会一口气出来
    @RequestMapping("/chat")
    // prompt是提示词,就是发送给ai的问题
    public String chat(String prompt){
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }
}

启动springboot项目,此时要保证ollama在后台运行,在浏览器中测试访问

http://localhost:8080/ai/chat?prompt=你是谁?

image-20250611172403877

倘若需要使用流式输出(更常用,也就是符合现在的流行的回答方式),即回答是一个字一个字展示出来的,则可利用下面的方法

// 用来实例化 chatClient
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {

    private final ChatClient chatClient;

    // 流式访问,答案会一个字一个字的回答
    @RequestMapping(value = "/chat",produces = "text/html;charset=utf-8")
    public Flux<String> streamChat(String prompt){
        return chatClient.prompt()
                .user(prompt)
                .stream()
                .content();
    }
}

再次测试访问即可

http://localhost:8080/ai/chat?prompt=你是谁?

于此同时,我们可以修改配置文件类CommonConfiguration,保留默认的系统设置

@Configuration
public class CommonConfiguration {
    @Bean
    public ChatClient chatClint(OllamaChatModel ollamaModel){
        return ChatClient
              .builder(ollamaModel)
              .defaultSystem("你是一个计算机研究生,你叫作小新,请你以他的身份和我对话")
              .build();
    }
}

此时,再次询问大模型你是谁,它便会按照默认的defaultSystem设置,回答你它是小新

3. AI 会话日志


日志的重要性不言而喻,SpringAI利用AOP原理提供了AI会话时的拦截、增强等功能,也就是Advisor

修改配置文件类CommonConfiguration

@Configuration
public class CommonConfiguration {
    @Bean
    public ChatClient chatClint(OllamaChatModel ollamaModel){
        return ChatClient
              .builder(ollamaModel)   //构建ai
              .defaultAdvisors(new SimpleLoggerAdvisor())  //日志记录
              .defaultSystem("你是一个计算机研究生,你叫作小新,请你以他的身份和口吻与我对话")
              .build();
    }
}

并且修改application.yaml来设定需要日志记录的项目包位置

spring:
  application:
    name: SpringAI
  ai:
    ollama:
      # ollama固定的本地访问地址
      base-url: http://localhost:11434
      chat:
        model: deepseek-r1:1.5b
logging:
  level:
      # 日志记录的项目包位置
    org.springframework.ai.chat.client.advisor: debug
    com.yzx.springai: debug

再次测试访问,控制台可输出对应的日志信息

http://localhost:8080/ai/chat?prompt=今天天气非常好

image-20250612120508853

4. 前端样式


我用官网的DeepSeek生成了几个简单的前端页面,以及对应的样式,在项目中是这样的位置
在这里插入图片描述

前端我并不是很懂,所以下面代码可能并不是很完善,或者说存在部分缺陷,但可以运行,大家可自行修改。首先是style.css

:root {
    --primary-color: #6c63ff;
    --sidebar-width: 260px;
}
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}
body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f7f7f8;
    color: #333;
    display: flex;
    height: 100vh;
    overflow: hidden;
}
/* 侧边栏样式 */
.sidebar {
    width: var(--sidebar-width);
    background-color: #f0f0f0;
    border-right: 1px solid #e0e0e0;
    display: flex;
    flex-direction: column;
    height: 100%;
}
.new-chat-btn {
    margin: 10px;
    padding: 10px 15px;
    background-color: var(--primary-color);
    color: white;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 14px;
}
.new-chat-btn:hover {
    background-color: #5a52d6;
}
.new-chat-btn i {
    font-size: 16px;
}
.history-list {
    flex: 1;
    overflow-y: auto;
    padding: 10px;
}
.history-item {
    padding: 10px 12px;
    border-radius: 5px;
    margin-bottom: 5px;
    cursor: pointer;
    font-size: 14px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.history-item:hover {
    background-color: #e0e0e0;
}
.history-item.active {
    background-color: #d6d6ff;
}
/* 主聊天区域 */
.main-content {
    flex: 1;
    display: flex;
    flex-direction: column;
    height: 100%;
}
.chat-header {
    padding: 15px 20px;
    border-bottom: 1px solid #e0e0e0;
    background-color: white;
    font-weight: bold;
}
.chat-messages {
    flex: 1;
    overflow-y: auto;
    padding: 20px;
    background-color: white;
}
.message {
    max-width: 80%;
    margin-bottom: 20px;
    padding: 12px 16px;
    border-radius: 8px;
    line-height: 1.5;
}
.user-message {
    margin-left: auto;
    background-color: var(--primary-color);
    color: white;
    border-bottom-right-radius: 2px;
}
.ai-message {
    margin-right: auto;
    background-color: #f0f0f0;
    color: #333;
    border-bottom-left-radius: 2px;
}
.input-area {
    padding: 15px;
    border-top: 1px solid #e0e0e0;
    background-color: white;
}
.input-container {
    max-width: 800px;
    margin: 0 auto;
    display: flex;
    position: relative;
}
#user-input {
    flex: 1;
    padding: 12px 15px;
    border: 1px solid #ddd;
    border-radius: 20px;
    outline: none;
    font-size: 1em;
    padding-right: 50px;
}
#send-button {
    position: absolute;
    right: 5px;
    top: 5px;
    width: 36px;
    height: 36px;
    background-color: var(--primary-color);
    color: white;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
}
#send-button:hover {
    background-color: #5a52d6;
}
#send-button:disabled {
    background-color: #ccc;
    cursor: not-allowed;
}
/* 打字指示器 */
.typing-indicator {
    display: inline-block;
    padding: 10px 15px;
    background-color: #f0f0f0;
    border-radius: 18px;
    margin-bottom: 15px;
}
.typing-dot {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: #999;
    margin: 0 2px;
    animation: typingAnimation 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(2) {
    animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
    animation-delay: 0.4s;
}
@keyframes typingAnimation {
    0%, 60%, 100% { transform: translateY(0); }
    30% { transform: translateY(-5px); }
}
/* 图标字体 */
.icon {
    display: inline-block;
    width: 1em;
    height: 1em;
    stroke-width: 0;
    stroke: currentColor;
    fill: currentColor;
}
/* 响应式调整 */
@media (max-width: 768px) {
    .sidebar {
        width: 220px;
    }
}



/* 历史记录项样式 */
.history-item {
    cursor: pointer;
    display: flex;
    align-items: center;
    transition: background-color 0.2s;
}

.history-item:hover .delete-chat-btn {
    opacity: 1;
}

/* 删除按钮样式 */
.delete-chat-btn {
    margin-left: 8px;
    padding: 4px;
    border-radius: 4px;
    transition: all 0.2s;
}

.delete-chat-btn:hover {
    background-color: rgba(255, 99, 71, 0.1);
}

/* 标题和ID样式 */
.history-item-title {
    font-size: 1rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.history-item-id {
    font-size: 0.8rem;
    color: #666;
}

其次是app.js

// DOM元素
const chatMessages = document.getElementById('chat-messages');
const userInput = document.getElementById('user-input');
const sendButton = document.getElementById('send-button');
const newChatBtn = document.getElementById('new-chat-btn');
const historyList = document.getElementById('history-list');
const chatTitle = document.getElementById('chat-title');

// 对话状态管理(不再使用localStorage)
let currentChatId = generateChatId();
let chats = {
    [currentChatId]: {
        title: '新对话',
        messages: [],
        createdAt: new Date().toISOString()
    }
};

// 初始化
renderHistoryList();
displayChat(currentChatId);

// 生成更随机的聊天ID(6位字母数字组合)
function generateChatId() {
    const chars = '0123456789';
    let result = '';
    for (let i = 0; i < 6; i++) {
        result += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return result;
}

// 渲染历史记录列表(显示会话ID)
function renderHistoryList() {
    historyList.innerHTML = '';

    // 按创建时间倒序排列
    const sortedChats = Object.entries(chats).sort((a, b) =>
        new Date(b[1].createdAt) - new Date(a[1].createdAt)
    );

    sortedChats.forEach(([id, chat]) => {
        const item = document.createElement('div');
        item.className = 'history-item';
        if (id === currentChatId) {
            item.classList.add('active');
        }

        const content = document.createElement('div');
        content.className = 'history-item-content';

        const title = document.createElement('span');
        title.className = 'history-item-title';
        title.textContent = chat.title || '新对话';

        const idSpan = document.createElement('span');
        idSpan.className = 'history-item-id';
        idSpan.textContent = ` #${id}`;

        content.appendChild(title);
        content.appendChild(idSpan);
        item.appendChild(content);

        item.addEventListener('click', () => {
            if (currentChatId !== id) {
                currentChatId = id;
                displayChat(id);
                document.querySelectorAll('.history-item').forEach(el =>
                    el.classList.remove('active')
                );
                item.classList.add('active');
            }
        });

        // 添加删除按钮
        const deleteBtn = document.createElement('span');
        deleteBtn.className = 'history-item-delete';
        deleteBtn.innerHTML = '&times;';
        deleteBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (confirm('确定要删除此对话吗?')) {
                delete chats[id];
                if (currentChatId === id) {
                    currentChatId = generateChatId();
                    chats[currentChatId] = {
                        title: '新对话',
                        messages: [],
                        createdAt: new Date().toISOString()
                    };
                }
                renderHistoryList();
                displayChat(currentChatId);
            }
        });
        item.appendChild(deleteBtn);

        historyList.appendChild(item);
    });
}

// 显示指定聊天记录
function displayChat(chatId) {
    chatMessages.innerHTML = '';
    const chat = chats[chatId];

    chatTitle.textContent = chat.title;
    chat.messages.forEach(msg => {
        if (msg.role === 'user') {
            addUserMessage(msg.content, false); // 不触发重新渲染
        } else {
            addAIMessage(msg.content, false); // 不触发重新渲染
        }
    });
    chatMessages.scrollTop = chatMessages.scrollHeight;
}

// 添加用户消息
function addUserMessage(message, saveToHistory = true) {
    const el = document.createElement('div');
    el.className = 'message user-message';
    el.textContent = message;
    chatMessages.appendChild(el);

    if (saveToHistory) {
        const chat = chats[currentChatId];
        chat.messages.push({
            role: 'user',
            content: message,
            timestamp: new Date().toISOString()
        });

        // 如果是第一条消息,设置为标题
        if (chat.messages.length === 1) {
            chat.title = message.length > 20
                ? message.substring(0, 20) + '...'
                : message;
            chatTitle.textContent = chat.title;
            renderHistoryList();
        }
    }
    chatMessages.scrollTop = chatMessages.scrollHeight;
}

// 添加AI消息
function addAIMessage(message, saveToHistory = true) {
    const el = document.createElement('div');
    el.className = 'message ai-message';
    el.textContent = message;
    chatMessages.appendChild(el);

    if (saveToHistory && chats[currentChatId]) {
        chats[currentChatId].messages.push({
            role: 'assistant',
            content: message,
            timestamp: new Date().toISOString()
        });
    }
    chatMessages.scrollTop = chatMessages.scrollHeight;
}

// 流式消息处理
function addAIMessageStream() {
    const el = document.createElement('div');
    el.className = 'message ai-message';
    chatMessages.appendChild(el);

    const typingIndicator = document.createElement('div');
    typingIndicator.className = 'typing-indicator';
    typingIndicator.innerHTML = `<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>`;
    chatMessages.appendChild(typingIndicator);
    chatMessages.scrollTop = chatMessages.scrollHeight;

    return {
        append: (text) => {
            if (typingIndicator.parentNode) {
                chatMessages.removeChild(typingIndicator);
            }
            el.textContent += text;
            chatMessages.scrollTop = chatMessages.scrollHeight;
        },
        complete: () => {
            if (typingIndicator.parentNode) {
                chatMessages.removeChild(typingIndicator);
            }
            if (chats[currentChatId]) {
                chats[currentChatId].messages.push({
                    role: 'assistant',
                    content: el.textContent,
                    timestamp: new Date().toISOString()
                });
            }
        }
    };
}

// 发送消息
async function sendMessage() {
    const prompt = userInput.value.trim();
    if (!prompt) return;

    userInput.value = '';
    userInput.disabled = true;
    sendButton.disabled = true;

    addUserMessage(prompt);
    const aiMessage = addAIMessageStream();

    try {
        const eventSource = new EventSource(`/ai/chat?prompt=${encodeURIComponent(prompt)}`);

        eventSource.onmessage = (e) => {
            if (e.data === '[DONE]') {
                eventSource.close();
                aiMessage.complete();
            } else {
                aiMessage.append(e.data);
            }
        };

        eventSource.onerror = () => {
            eventSource.close();
            aiMessage.append('\n\n【对话结束】');
            aiMessage.complete();
        };
    } catch (error) {
        aiMessage.append(`\n\n【错误: ${error.message}`);
        aiMessage.complete();
    } finally {
        userInput.disabled = false;
        sendButton.disabled = false;
        userInput.focus();
    }
}

// 新建对话(修改后的核心功能)
newChatBtn.addEventListener('click', () => {
    currentChatId = generateChatId();
    chats[currentChatId] = {
        title: '新对话',
        messages: [],
        createdAt: new Date().toISOString()
    };
    displayChat(currentChatId);
    renderHistoryList();
});

// 事件监听
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', (e) => {
    if (e.key === 'Enter') sendMessage();
});
userInput.focus();

最后是index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AI 聊天助手</title>
  <link rel="stylesheet" href="/css/style.css">  <!-- 引用静态CSS -->
</head>
<body>
<!-- 侧边栏 -->
<div class="sidebar">
  <button class="new-chat-btn" id="new-chat-btn">
    <svg class="icon" viewBox="0 0 24 24">
      <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
    </svg>
    新对话
  </button>
  <div class="history-list" id="history-list">
    <!-- 历史记录会在这里动态添加 -->
  </div>
</div>

<!-- 主聊天区域 -->
<div class="main-content">
  <div class="chat-header" id="chat-title">新对话</div>
  <div class="chat-messages" id="chat-messages">
    <!-- 消息会在这里动态添加 -->
  </div>
  <div class="input-area">
    <div class="input-container">
      <input type="text" id="user-input" placeholder="输入您的问题..." autocomplete="off">
      <button id="send-button">
        <svg class="icon" viewBox="0 0 24 24">
          <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
        </svg>
      </button>
    </div>
  </div>
</div>
<script src="/js/app.js"></script>  <!-- 引用静态JS -->
</body>
</html>

创建控制器,用于访问前端页面

@RequiredArgsConstructor
@Controller
@RequestMapping("/view")
public class IndexController {

    @GetMapping("/chat")
    public String chatPage() {
        return "index";  // 返回模板名称(自动查找templates/index.html)
    }

}

此时,可以尝试启动项目,访问前端 http://localhost:8080/view/chat,大概就是下面这个样子,还挺那么一回事儿的

在这里插入图片描述

5. 对接前端对话,并实现 AI 会话记忆


正如前文所述,我们需要对话可以保留上次的记忆,则需要调整assistant,否则每次的对话都是一个新的开始

修改配置文件类CommonConfiguration,创建记忆仓库

@Configuration
public class CommonConfiguration {

    @Bean
    //记忆存储
    public ChatMemoryRepository chatMemoryRepository(){
        return new InMemoryChatMemoryRepository();
    };

    //记忆实例化
    @Bean
    public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository){
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)
                .maxMessages(20)
                .build();
    }

    //默认设置
    @Bean
    public ChatClient chatClint(OllamaChatModel ollamaModel, ChatMemory chatMemory){
        return ChatClient
                .builder(ollamaModel)   //构建ai
                .defaultAdvisors(
                    new SimpleLoggerAdvisor(),
            		//将记忆历史导入
                    MessageChatMemoryAdvisor.builder(chatMemory).build())  
            		//日志记录
                .defaultSystem("你是一个计算机研究生,你叫作小新")
                .build();
    }
}

于此同时,为了记忆不混乱,还需要保证每次对话只保存到对应的记忆中

即每次对话产生一个对话id,只把当前历史保存到相应的记忆中,调整控制器ChatController,这里需要做出更多的修改

  1. 首先修改输出样式produces 以适配html
  2. 添加注解@RequestParam 用以接受相关参数
  3. String prompt用以接受用户输入问题, String chatId用于接收当前对话id
  4. 将当前对话id绑定到ChatMemory.CONVERSATION_ID
  5. 为了保证不和前端冲突,此时的访问url/ai/chat
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {

    private final ChatClient chatClient;

     // 流式接口(修正SSE格式)
    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> streamChat(@RequestParam String prompt, @RequestParam String chatId) {
        return chatClient.prompt()
                .user(prompt)
            //历史对话id
                .advisors(a->a.param(ChatMemory.CONVERSATION_ID, chatId))
                .stream()
                .content()
                .map(content -> ServerSentEvent.builder(content).build())
                .concatWithValues(ServerSentEvent.builder("[DONE]").build());
    }
}

与此同时,前端js部分也需要做出部分修改。当发送对话时,应该将随机生成的对话id一同发送到后端。这里修改很简单,找到sendMessage()方法,修改发送的请求参数,添加当前对话id

 const eventSource = new EventSource(`/ai/chat?prompt=${encodeURIComponent(prompt)}&chatId=${currentChatId}`);

此时的逻辑是,用户访问前端页面http://localhost:8080/view/chat,输入问题,点击发送后,请求将发送到后端的http://localhost:8080/ai/chat进行处理,最后将回答的问题发聩给前端。测试访问如下

在这里插入图片描述

7. 存在的问题及未来的改进


  • 我不是很懂前端,所以前端传递的id好像没有随机性?
  • 没有做历史记录功能,也没有持久化到数据库
  • 没有文件上传和解析功能