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: 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=你是谁?
倘若需要使用流式输出(更常用,也就是符合现在的流行的回答方式),即回答是一个字一个字展示出来的,则可利用下面的方法
// 用来实例化 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=今天天气非常好
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();
};
//记忆实例化
@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
,这里需要做出更多的修改
- 首先修改输出样式
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
好像没有随机性? - 没有做历史记录功能,也没有持久化到数据库
- 没有文件上传和解析功能