【大模型应用开发】SpringBoot 整合基于 Ollama 的 DeepSeek,并对接前端( 全部代码 !!!)
文章目录
- 1. 前述
- 2. AI 基础对话
- 3. AI 会话日志
- 4. 前端样式
- 5. 对接前端对话,并实现 AI 会话记忆
- 7. 存在的问题及未来的改进
1. 前述
在前面的内容中,我们已经在本地部署了deepseek-r1:1.5b
,详情可参考文章:
【大模型应用开发】Ollama 介绍及基于 Ollama 部署本地 DeepSeek
那么下面我们需要将该大模型整合到SpringBoot
中,实现接口访问,同时对接前端代码。本文拥有全部的代码 !!!
2. AI 基础对话
在创建AI
大模型机器人时,我们现需要了解几个重要概念
system
:表示系统的默认设置,即给大模型设置任务背景user
:即用户的提问assistant
:大模型生成的历史消息
首先创建一个项目,其中Java
的版本要在17
以上
下载相关依赖,其中SpringBoot
版本不能过低,否则没有AI
依赖,可按需选择deepseek
或者openai
等
创建好项目后,编辑配置文件application.yaml
spring:application:name: SpringAIai:ollama:# ollama固定的本地访问地址base-url: http://localhost:11434chat:model: deepseek-r1:1.5b
SpringAI
利用ChatClient
来访问大模型,创建配置文件类CommonConfiguration
@Configuration
public class CommonConfiguration {@Beanpublic 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=你是谁?
倘若需要使用流式输出(更常用,也就是符合现在的流行的回答方式),即回答是一个字一个字展示出来的,则可利用下面的方法
// 用来实例化 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 {@Beanpublic ChatClient chatClint(OllamaChatModel ollamaModel){return ChatClient.builder(ollamaModel).defaultSystem("你是一个计算机研究生,你叫作小新,请你以他的身份和我对话").build();}
}
此时,再次询问大模型你是谁,它便会按照默认的defaultSystem
设置,回答你它是小新
3. AI 会话日志
日志的重要性不言而喻,SpringAI
利用AOP
原理提供了AI
会话时的拦截、增强等功能,也就是Advisor
修改配置文件类CommonConfiguration
@Configuration
public class CommonConfiguration {@Beanpublic ChatClient chatClint(OllamaChatModel ollamaModel){return ChatClient.builder(ollamaModel) //构建ai.defaultAdvisors(new SimpleLoggerAdvisor()) //日志记录.defaultSystem("你是一个计算机研究生,你叫作小新,请你以他的身份和口吻与我对话").build();}
}
并且修改application.yaml
来设定需要日志记录的项目包位置
spring:application:name: SpringAIai:ollama:# ollama固定的本地访问地址base-url: http://localhost:11434chat:model: deepseek-r1:1.5b
logging:level:# 日志记录的项目包位置org.springframework.ai.chat.client.advisor: debugcom.yzx.springai: debug
再次测试访问,控制台可输出对应的日志信息
http://localhost:8080/ai/chat?prompt=今天天气非常好
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 = '×';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();};//记忆实例化@Beanpublic ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository){return MessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).maxMessages(20).build();}//默认设置@Beanpublic ChatClient chatClint(OllamaChatModel ollamaModel, ChatMemory chatMemory){return ChatClient.builder(ollamaModel) //构建ai.defaultAdvisors(new SimpleLoggerAdvisor(),//将记忆历史导入MessageChatMemoryAdvisor.builder(chatMemory).build()) //日志记录.defaultSystem("你是一个计算机研究生,你叫作小新").build();}
}
于此同时,为了记忆不混乱,还需要保证每次对话只保存到对应的记忆中
即每次对话产生一个对话id
,只把当前历史保存到相应的记忆中,调整控制器ChatController
,这里需要做出更多的修改
- 首先修改输出样式
produces
以适配html
- 添加注解
@RequestParam
用以接受相关参数 String prompt
用以接受用户输入问题,String chatId
用于接收当前对话id
- 将当前对话
id
绑定到ChatMemory.CONVERSATION_ID
中 - 为了保证不和前端冲突,此时的访问
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
好像没有随机性? - 没有做历史记录功能,也没有持久化到数据库
- 没有文件上传和解析功能