
博客网站(springboot)整合deepseek实现在线调用(优化版)
🎉🎉🎉🎉🎉🎉欢迎访问个人博客:https://swzbk.site/,有兴趣挣点零花钱的,加好友,拉你入福利群🎉🎉🎉🎉🎉🎉。
·
🎉🎉🎉🎉🎉🎉
欢迎访问个人博客:https://swzbk.site/
最近建了个「副业交流群」,群里会分享一些兼职小技巧
想一起交流的朋友,加我v 拉你入群~
🎉🎉🎉🎉🎉🎉
第一版是实现参考如下:
https://blog.csdn.net/qq_37534947/article/details/146281729
1、功能优化
- 前端优化,包括获取后端流式输出、发送后按钮优化、等待消息响应旋转效果、获取历史聊天记录、利用markdown格式化后端响应
- 后端优化,增加后端WebFlux流式输出,保存所有聊天记录到数据库
- 数据库优化,增加历史记录表chat_history
2、实现效果
3、前端代码
<template>
<div class="chat-wrapper">
<div class="chat-container">
<div class="chat-header">
<h2>DeepSeek 对话</h2>
</div>
<div class="chat-messages" ref="chatMessages">
<!-- <div v-for="(message, index) in chatMessages" :key="index" :class="['message', message.isUser ? 'user-message' : 'bot-message']">
<span>{{ message.content }}</span>
</div> -->
<div
v-for="message in chatMessages"
:key="message.id"
:class="['message', message.isUser ? 'user-message' : 'bot-message']"
>
<div v-if="!message.isLoading" v-html="parseMarkdown(message.content)"></div>
<div v-if="message.isLoading" class="loading-spinner"></div>
</div>
</div>
<div class="input-container">
<input v-model="userInput" type="text" placeholder="输入你的问题" @keydown.enter="sendMessage" :disabled="isSending">
<button @click="sendMessage" :disabled="isSending" :class="{ 'disabled-btn': isSending }">发送</button>
</div>
</div>
</div>
</template>
<script>
import MarkdownIt from 'markdown-it';
export default {
data() {
return {
userInput: '',
chatMessages: [],
isSending: false,
currentEventSource: null,
isGet: true,
md: new MarkdownIt({
html: true, // 允许 HTML 标签(需配合安全过滤)
linkify: true, // 自动将 URL 转换为链接
typographer: true // 转换特殊符号(如 -- 转 em dash)
}),
chatHistory: []
};
},
mounted() {
this.getChatHistory();
},
methods: {
async getChatHistory() {
try {
//这里localhost换成自己的服务IP
const response = await fetch('http://localhost:8090/chat-history');
const data = await response.json();
this.chatMessages = data;
// 使用 $nextTick 确保 DOM 更新后再执行回调函数
this.$nextTick(() => {
// 将聊天消息容器滚动到最底部,保证最新消息可见
this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight;
});
//this.chatMessages.push(data);
} catch (error) {
console.error('获取聊天记录失败:', error);
}
},
sendMessage() {
if (this.userInput.trim() === '') return;
if (this.isSending) return;
this.isSending = true;
//this.chatMessages.push({ content: this.userInput, isUser: true });
const userMessage = {
content: this.userInput,
isUser: true,
id: Date.now(),
isLoading: false // 添加加载状态字段
};
this.chatMessages.push(userMessage);
// 使用 $nextTick 确保 DOM 更新后再执行回调函数
this.$nextTick(() => {
// 将聊天消息容器滚动到最底部,保证最新消息可见
this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight;
});
const currentInput = this.userInput;
this.userInput = '';
// 关闭之前的连接
if (this.currentEventSource) {
this.currentEventSource.close();
}
//这里localhost换成自己的服务IP
this.currentEventSource = new EventSource(`http://localhost:8090/deepseek?prompt=${encodeURIComponent(currentInput)}`);
this.isGet = true;
this.chatMessages.push({ isLoading:true, isUser: false, id: Date.now() });
this.currentEventSource.onmessage = (event) => {
// 检查接收到的消息是否为结束标记 "[DONE]"
if (event.data.trim() === "[DONE]") {
console.log('连接已关闭');
this.currentEventSource.close();
this.isSending = false;
return;
}
if (this.isGet == true && event.data.trim() != "[DONE]") {
this.chatMessages.pop();
this.isGet = false;
}
// 获取聊天消息列表中的最后一条消息
const lastMessage = this.chatMessages[this.chatMessages.length - 1];
// 判断最后一条消息是否为机器人消息
if (!lastMessage.isUser) {
// 如果是机器人消息,将新接收到的消息内容追加到最后一条消息的内容后面
lastMessage.content += event.data;
} else {
// 如果最后一条消息是用户消息,创建一条新的机器人消息并添加到聊天消息列表中
//this.chatMessages.push({ content: event.data, isUser: false });
this.chatMessages.push({ content: event.data, isLoading:false, isUser: false, id: Date.now() });
}
// 使用 $nextTick 确保 DOM 更新后再执行回调函数
this.$nextTick(() => {
// 将聊天消息容器滚动到最底部,保证最新消息可见
this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight;
});
}
this.currentEventSource.onerror = (error) => {
console.error('连接错误:', error);
this.isSending = false;
this.currentEventSource.close();
};
},
parseMarkdown(markdown) {
// 安全增强:过滤危险标签(可选)
const safeHtml = this.sanitizeHtml(this.md.render(markdown));
return safeHtml;
},
sanitizeHtml(html) {
// 简单 XSS 过滤(生产环境建议使用专业库如 DOMPurify)
const temp = document.createElement('div');
temp.innerHTML = html;
// 允许的标签和属性
const allowedTags = ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'br'];
const allowedAttrs = ['href', 'title'];
temp.querySelectorAll('*').forEach((el) => {
if (!allowedTags.includes(el.tagName.toLowerCase())) {
el.parentNode.removeChild(el);
return;
}
Object.keys(el.attributes).forEach((attr) => {
if (!allowedAttrs.includes(attr)) {
el.removeAttribute(attr);
}
});
});
return temp.innerHTML;
}
}
}
</script>
<style scoped>
.chat-wrapper {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f4f4f9;
}
.chat-container {
width: 80%;
height: 80vh;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
background-color: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
}
.chat-header {
background-color: #007bff;
color: #fff;
padding: 15px 20px;
text-align: center;
flex-shrink: 0;
}
.chat-header h2 {
margin: 0;
font-size: 1.3rem;
}
.chat-messages {
flex-grow: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.message {
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 10px;
max-width: 80%;
word-wrap: break-word;
}
.user-message {
background-color: #e0f7fa;
align-self: flex-end;
color: #212121;
}
.bot-message {
background-color: #f1f8e9;
align-self: flex-start;
color: #212121;
}
.bot-message ul,
.bot-message ol {
padding-left: 20px; /* 缩进列表 */
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.input-container {
display: flex;
padding: 15px 20px;
border-top: 1px solid #e0e0e0;
flex-shrink: 0;
}
.input-container input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 6px;
margin-right: 10px;
font-size: 1rem;
}
.input-container button {
padding: 10px 20px;
background-color: #007bff;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
}
.input-container button:hover {
background-color: #0056b3;
}
.input-container button.disabled-btn {
background-color: #ccc !important;
cursor: not-allowed;
}
</style>
4、后端代码优化
4.1 controller层
package top.naccl.controller;
import com.fasterxml.jackson.databind.JsonNode;
import okhttp3.*;
import okhttp3.ResponseBody;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import com.fasterxml.jackson.databind.ObjectMapper;
import top.naccl.entity.ChatMessage;
import top.naccl.mapper.ChatMessageMapper;
import top.naccl.model.dto.ChatMessageDTO;
import javax.servlet.http.HttpSession;
@RestController
public class DeepseekController {
private static final ObjectMapper objectMapper = new ObjectMapper();
//改成自己的api-key
private static final String DEEPSEEK_API_KEY = "sk-xxxxxxxx";
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
@Autowired
private ChatMessageMapper chatMessageMapper;
@GetMapping(value = "/deepseek", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> handleDeepSeekRequest(@RequestParam("prompt") String prompt) {
// 保存用户消息到数据库
ChatMessage userMessage = new ChatMessage();
userMessage.setContent(prompt);
userMessage.setUser(true);
chatMessageMapper.insertChatMessage(userMessage);
OkHttpClient client = new OkHttpClient.Builder()
.readTimeout(120, TimeUnit.SECONDS)
.build();
okhttp3.MediaType JSON = okhttp3.MediaType.parse("application/json; charset=utf-8");
String json = "{\"model\": \"deepseek-chat\", \"messages\": [{\"role\": \"user\", \"content\": \"" + prompt + "\"}], \"stream\": true}";
okhttp3.RequestBody body = okhttp3.RequestBody.create(JSON, json.getBytes());
Request apiRequest = new Request.Builder()
.url(DEEPSEEK_API_URL)
.post(body)
.addHeader("Authorization", "Bearer " + DEEPSEEK_API_KEY)
.addHeader("Content-Type", "application/json")
.build();
return Flux.create(emitter -> {
try {
client.newCall(apiRequest).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
System.err.println("请求 DeepSeek API 失败: " + e.getMessage());
emitter.error(e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (ResponseBody responseBody = response.body()) {
if (responseBody != null) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseBody.byteStream()));
String line;
StringBuilder assistantReply = new StringBuilder();
while ((line = reader.readLine()) != null) {
if (!line.startsWith("data: ")) {
//System.err.println("跳过无效行: " + line);
continue;
}
String data = line.substring(6).trim();
if (data.equals("[DONE]")) {
// 确保正确发送 [DONE] 信号
emitter.next(ServerSentEvent.builder("[DONE]").build());
emitter.complete();
// 保存助手的回复到数据库
ChatMessage assistantMessage = new ChatMessage();
assistantMessage.setContent(assistantReply.toString());
assistantMessage.setUser(false);
chatMessageMapper.insertChatMessage(assistantMessage);
break;
}
try {
JsonNode jsonNode = objectMapper.readTree(data);
if (jsonNode.has("choices") && !jsonNode.get("choices").isEmpty()) {
JsonNode deltaNode = jsonNode.get("choices").get(0).get("delta");
if (deltaNode != null && deltaNode.has("content")) {
String content = deltaNode.get("content").asText();
assistantReply.append(content);
emitter.next(ServerSentEvent.builder(content).build());
}
}
} catch (Exception e) {
System.err.println("解析错误: " + e.getMessage());
emitter.error(e);
break;
}
}
}
}
}
});
} catch (Exception e) {
System.err.println("处理请求时出现异常: " + e.getMessage());
emitter.error(e);
}
});
}
@GetMapping("/chat-history")
public List<ChatMessageDTO> getChatHistory() {
List<ChatMessage> chatMessages = chatMessageMapper.getAllChatMessages();
List<ChatMessageDTO> chatMessageDTOS = new ArrayList<>();
for (ChatMessage chatMessage : chatMessages) {
chatMessageDTOS.add(new ChatMessageDTO(
chatMessage.getId(),
chatMessage.getContent(),
chatMessage.isUser(),
chatMessage.isLoading()
));
}
return chatMessageDTOS;
}
}
4.2 entity(实体类,数据库返回字段)
package top.naccl.entity;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
@Getter
@Setter
public class ChatMessage {
private Integer id;
private String content;
private boolean isUser;
private boolean isLoading = false;
private Date createdAt;
private String role = "user";
}
4.3 mapper层(数据库交互)
package top.naccl.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;
import top.naccl.entity.ChatMessage;
import java.util.List;
@Mapper
@Repository
public interface ChatMessageMapper {
@Insert("INSERT INTO chat_history (content, is_user) VALUES (#{content}, #{isUser})")
void insertChatMessage(ChatMessage chatMessage);
@Select("SELECT id, content, is_user as isUser, false as isLoading, created_at as createdAt FROM chat_history ORDER BY created_at ASC")
List<ChatMessage> getAllChatMessages();
}
4.4 dto类(返回给前端的历史数据)
package top.naccl.model.dto;
import lombok.NoArgsConstructor;
import lombok.ToString;
@NoArgsConstructor
@ToString
public class ChatMessageDTO {
private Integer id;
private String content;
private boolean isUser;
private boolean isLoading;
public ChatMessageDTO(Integer id, String content, boolean isUser, boolean isLoading) {
this.id = id;
this.content = content;
this.isUser = isUser;
this.isLoading = isLoading;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public boolean getIsUser() {
return isUser;
}
public void setIsUser(boolean isUser) {
this.isUser = isUser;
}
public boolean isLoading() {
return isLoading;
}
public void setLoading(boolean loading) {
isLoading = loading;
}
}
5、新增数据库表
-- nblog.chat_history definition
CREATE TABLE `chat_history` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role` varchar(20) CHARACTER SET utf8 NOT NULL DEFAULT '“user”',
`content` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_user` tinyint(1) DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=40 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
注意:这里需要把content字段定义为utf8mb4_unicode_ci类型,因为deepseek返回的是可能带有符号的。
6、待优化
- 目前历史数据是所有的,待优化成按时间维度(某一天)进行展示
- 目前我的api只充值了money 5,计划每天的api调用访问控制在1k以内(可能没那么多人用)
7、常见问题解决
https://blog.csdn.net/qq_37534947/article/details/146463314
🎉🎉🎉🎉🎉🎉
欢迎访问个人博客:https://swzbk.site/
最近建了个「副业交流群」,群里会分享一些兼职小技巧
想一起交流的朋友,加我v 拉你入群~
🎉🎉🎉🎉🎉🎉
更多推荐
所有评论(0)