DeepSeek本地部署+自主开发对话Web应用

文章目录

  • 引言
  • 前端部分
    • 核心页面DeepSeek.vue
    • MyModal.vue
  • 后端部分
    • WebSocketConfig 配置类
    • AbstractDeepSeekTool
    • DeepSeekWebSocketHandler
  • 数据库设计
  • 总结


引言

最近DeepSeep横空出世,在全球内掀起一股热潮,到处都是满血大模型接入的应用,但这些有的是要钱的,有的虽然免费但是也会卡顿,作为一名软件开发人员,当然要想办法了,这不笔者就自主开发了一个web对话页面,不是简单的对话响应哦,还能支持保留历史记录的,可以新建对话框,请往下看(如果对您有帮助记得关注我,点个赞!)。

前端Gitee代码地址:https://gitee.com/buxingzhe/deepseek-chat.git
后端Gitee代码地址:https://gitee.com/buxingzhe/deep-seek-project.git

前端部分

先来个效果图
在这里插入图片描述

安装依赖

npm install

运行命令

npm run serve

打包构建

npm run build

功能特点

  1. AI流式响应,无卡顿。

  2. 支持对话消息文本的持久化,除了临时会话,其他会话框的标题均可修改,会话框支持删除。

  3. 可以随意新建对话框,支持保存历史对话消息,有一个默认临时会话框,不保存会话历史消息,刷新页面后消息丢失。

  4. 消息区分思考推理部分和正式回答部分,思考推理部分为浅灰色,正式回答为黑色文本。

  5. 使用了MarkDown渲染html,格式更加美观。

  6. 消息生成过程中可随时停止。

  7. 支持心跳检测,后端服务离线后重启时,前端自动重新建立连接webSocket会话。

核心页面DeepSeek.vue

代码稍微有点复杂了,毕竟要实现的东西也不少,什么保存消息上下文记录啊,定时心跳检测啊,文本渲染啊等等,样式部分我这省略了,具体可从gitee拉取源码。

<template><div id="app"><div class="chat-container"><!-- 会话列表 --><div class="session-list"><divv-for="(session, index) in sessions":key="index"class="session-item":class="{ active: activeSessionIndex === index }"@click="selectSession(index)"><span class="session-status" :class="{ online: session.isConnected, offline:!session.isConnected }"></span><span class="session-status-text">{{ session.isConnected ? '在线' : '离线' }}</span><span class="session-name">{{ session.title }}</span><!-- 三个小点按钮 --><div class="session-actions-trigger" @click.stop="toggleActionsMenu(index)"><span>...</span></div><!-- 编辑和删除操作菜单 --><divv-if="session.showActionsMenu"class="session-actions-menu"@click.stop><button @click="openEditModal(index)">编辑</button><button @click="openDeleteModal(index)">删除</button></div></div><button @click="createNewSession" class="new-session-button" :disabled="hasUnconnectedSession">新建会话</button></div><!-- 聊天内容 --><div class="chat-content"><div class="chatBox"><!-- 错误提示 --><div v-if="errorMessage" class="error-message">{{ errorMessage }}</div><!-- 聊天消息显示区域 --><div class="chat-messages"><div v-for="(message, index) in currentMessages" :key="index" class="message"><!-- 用户消息 --><div v-if="message.sender === 'user'" class="user-message-container"><article class="message-content user-message">{{ message.text }}</article></div><!-- 机器人消息 --><div v-else class="bot-message-container"><article class="message-content bot-message" v-html="renderMarkdown(message.text)"></article></div></div></div><!-- 输入框和发送按钮 --><div class="chat-input"><textareav-model="inputMessage"placeholder="请输入你的问题..."@keyup="handleKeyup"rows="6":disabled="!isConnected"/><button @click="handleButtonClick" :disabled="!isConnected">{{ currentSession.isGenerating ? '停止生成' : '发送' }}</button></div></div></div></div><!-- 编辑模态框 --><MyModal :visible="isEditModalVisible" title="编辑会话标题" @close="closeEditModal"><input v-model="editTitle" placeholder="请输入新的会话标题" /><template #footer><button @click="closeEditModal">取消</button><button @click="confirmEdit">确定</button></template></MyModal><!-- 删除模态框 --><MyModal :visible="isDeleteModalVisible" title="确认删除" @close="closeDeleteModal"><p>确定要删除该会话吗?</p><template #footer><button @click="closeDeleteModal">取消</button><button @click="confirmDelete">确定</button></template></MyModal></div>
</template><script setup>
import {computed, onMounted, onUnmounted, reactive, ref} from 'vue';
import MyModal from './MyModal.vue';
import {marked} from 'marked';
import DOMPurify from 'dompurify';
import axios from 'axios';// 配置 marked(保持不变)
marked.setOptions({gfm: true,breaks: true,highlight: function (code) {return code;}
});// 渲染 Markdown 内容(保持不变)
const renderMarkdown = (content) => {console.log("渲染前内容",content);content = content.replace(/<think>/g, '<div><span class="deepThink">');if (!content.includes('</think>')) {content = content.concat('</span></div>');}if (content.includes('</think>')) {content = content.replace(/<\/span><\/div>/g, '');content = content.replace(/<\/think>/g, '</span></div>');}const html = marked(content);const sanitizedHtml = DOMPurify.sanitize(html);const tempDiv = document.createElement('div');tempDiv.innerHTML = sanitizedHtml.toString();const deepThinkElements = tempDiv.querySelectorAll('.deepThink');deepThinkElements.forEach((element) => {if (element.textContent.trim() === '') {element.textContent = '暂无推理过程';}});console.log("渲染后内容",tempDiv.innerHTML);return tempDiv.innerHTML;
};// 存储所有会话(改为空数组)
const sessions = ref([]);// 当前激活的会话索引
const activeSessionIndex = ref(0);// 新增:统一错误处理函数
const handleNetworkError = (error, session) => {let errorMsg = '服务暂时不可用,请稍后重试';if (!navigator.onLine) {errorMsg = '网络连接已断开,请检查网络设置';} else if (error.message === 'Network Error') {errorMsg = '无法连接到服务器,请确认后端服务已启动';} else if (error.response?.status >= 500) {errorMsg = '服务器内部错误,请联系管理员';}session.errorMessage = errorMsg;setTimeout(() => {session.errorMessage = ''; // 5秒后自动清除错误}, 5000);
};// 保存用户消息到数据库
const saveUserMessageToDatabase = async (talkInfoId, message,messageType) => {try {await axios.post('/api/deepSeek/saveTalkInfoMessage', {talkInfoId,message,messageType});} catch (error) {console.error('保存用户消息到数据库失败:', error);const errorSession = sessions.value[activeSessionIndex.value] || {};handleNetworkError(error, errorSession);}
};// 保存机器人消息到数据库
const saveBotMessageToDatabase = async (talkInfoId, message,messageType) => {try {await axios.post('/api/deepSeek/saveTalkInfoMessage', {talkInfoId,message,messageType});} catch (error) {console.error('保存机器人消息到数据库失败:', error);const errorSession = sessions.value[activeSessionIndex.value] || {};handleNetworkError(error, errorSession);}
};// 获取单个会话框的历史消息列表
const getMessageListByTalkInfoId = async (talkInfoId) => {try {const response = await axios.get('/api/deepSeek/getMessageListByTalkInfoId', {params: { talkInfoId }});// 检查 response.data.data 是否存在且为数组if (Array.isArray(response.data?.data)) {//构建数据const result = response.data.data.map(item => {return {sender: item.messageType,text: item.message};});console.log('构建数据成功+++:', result)return result;}return [];} catch (error) {console.error('获取会话消息列表失败:', error);const errorSession = sessions.value[activeSessionIndex.value] || {};handleNetworkError(error, errorSession);return [];}
};//页面刷新时初始化会话列表
const fetchSessions = async () => {try {const response = await axios.get('/api/deepSeek/talkInfoList');const data = response.data?.data || [];// 创建临时会话const tempSession = reactive({talkInfoId: 'temp-' + Date.now(),title: '临时会话',messages: [{ sender: 'bot', text: '欢迎使用临时对话框!有什么可以帮助你的?' }],inputMessage: '',errorMessage: '',isConnected: false,isGenerating: false,socket: null,reconnectInterval: null,retryCount: 0, // 新增重试次数currentBotMessage: '' // 用于存储当前机器人流式响应消息});// 初始化会话列表,将临时会话添加到列表开头sessions.value = [tempSession, ...data.map(item =>reactive({talkInfoId: item.id,title: item.title || `会话 ${sessions.value.length + 1}`,messages: [{ sender: 'bot', text: '欢迎使用!有什么可以帮助你的?' }],inputMessage: '',errorMessage: '',isConnected: false,isGenerating: false,socket: null,reconnectInterval: null,retryCount: 0, // 新增重试次数currentBotMessage: '' // 用于存储当前机器人流式响应消息}))];// 为每个会话获取对话消息for (const session of sessions.value) {const talkInfoId = session?.talkInfoId;const result = talkInfoId.toString().startsWith('temp-')if (!result){const messages = await getMessageListByTalkInfoId(session?.talkInfoId);console.log("获取的消息为:",messages);session.messages = messages && messages.length > 0 ? messages :[{ sender: 'bot', text: '欢迎使用!有什么可以帮助你的?' }];}}// 若查询到会话框列表,开始逐一连接if (data.length > 0) {sessions.value.forEach(session => {reconnectWebSocket(session);});} else {// 查不到则直接连接临时会话框reconnectWebSocket(tempSession);}} catch (error) {console.error('获取会话列表失败:', error);// 创建临时会话const tempSession = reactive({talkInfoId: 'temp-' + Date.now(),title: '临时会话',messages: [{ sender: 'bot', text: '欢迎使用!' }],inputMessage: '',errorMessage: '',isConnected: false,isGenerating: false,socket: null,reconnectInterval: null,retryCount: 0, // 新增重试次数currentBotMessage: '' // 用于存储当前机器人流式响应消息});sessions.value = [tempSession];activeSessionIndex.value = 0;handleNetworkError(error, tempSession);// 直接连接临时会话框reconnectWebSocket(tempSession);}
};// 计算属性(保持不变)
const currentMessages = computed(() => sessions.value[activeSessionIndex.value]?.messages || []);
const inputMessage = computed({get: () => sessions.value[activeSessionIndex.value]?.inputMessage || '',set: (value) => (sessions.value[activeSessionIndex.value].inputMessage = value)
});
const errorMessage = computed({get: () => sessions.value[activeSessionIndex.value]?.errorMessage || '',set: (value) => (sessions.value[activeSessionIndex.value].errorMessage = value)
});
const isConnected = computed({get: () => sessions.value[activeSessionIndex.value]?.isConnected || false,set: (value) => (sessions.value[activeSessionIndex.value].isConnected = value)
});
const currentSession = computed(() => sessions.value[activeSessionIndex.value] || {});// 发送消息函数
const sendMessage = () => {if (!inputMessage.value) return;const messageData = JSON.stringify({talkInfoId: currentSession.value?.talkInfoId,content: inputMessage.value});currentMessages.value.push({ sender: 'user', text: inputMessage.value });currentSession.value.socket.send(messageData);// 保存用户消息到数据库if (!currentSession.value.talkInfoId.toString().startsWith('temp-')){saveUserMessageToDatabase(currentSession.value.talkInfoId, inputMessage.value,'user');}inputMessage.value = '';currentSession.value.isGenerating = true;
};// 停止生成函数
const stopGenerating = () => {const messageData = JSON.stringify({talkInfoId: currentSession.value?.talkInfoId,content: 'stopSending'});currentSession.value.socket.send(messageData);currentSession.value.isGenerating = false;
};// 发送按钮点击处理
const handleButtonClick = () => {currentSession.value.isGenerating ? stopGenerating() : sendMessage();
};// 键盘事件处理
const handleKeyup = (event) => {if (event.key === 'Enter' &&!event.shiftKey) sendMessage();
};// WebSocket 连接管理(修改)
let fetchSessionsDebounceTimer = null;
const reconnectWebSocket = (session) => {if (session.reconnectInterval) clearInterval(session.reconnectInterval);const initialDelay = 3000; // 初始延迟时间const maxDelay = 60000; // 最大延迟时间const backoffFactor = 2; // 退避因子const attemptReconnect = () => {if (!session.socket || session.socket.readyState === WebSocket.CLOSED) {session.socket = new WebSocket(`ws://localhost:8085/websocket?talkInfoId=${session.talkInfoId}`);session.socket.onopen = () => {session.errorMessage = '';session.isConnected = true;session.retryCount = 0; // 连接成功,重置重试次数clearInterval(session.reconnectInterval);};session.socket.onmessage = (event) => {const targetSession = sessions.value.find(s => s?.socket === session.socket);if (!targetSession) return;if (event.data.includes('[END_OF_MESSAGE_GENERATE]')) {targetSession.isGenerating = false;// 保存机器人消息到数据库if (!targetSession?.talkInfoId.toString().startsWith('temp-')){saveBotMessageToDatabase(targetSession?.talkInfoId, targetSession.currentBotMessage,'bot');}targetSession.currentBotMessage = '';return;}targetSession.currentBotMessage += event.data;const lastMessage = targetSession?.messages[targetSession?.messages.length - 1];lastMessage?.sender === 'bot'? lastMessage.text += event.data: targetSession?.messages.push({ sender: 'bot', text: event.data });};session.socket.onerror = (error) => {console.error('连接错误:', error);handleNetworkError(error, session);session.isConnected = false;session.retryCount++;const nextDelay = Math.min(initialDelay * Math.pow(backoffFactor, session.retryCount), maxDelay);session.reconnectInterval = setTimeout(attemptReconnect, nextDelay);};session.socket.onclose = () => {handleNetworkError(new Error('连接意外关闭'), session);session.isConnected = false;session.retryCount++;const nextDelay = Math.min(initialDelay * Math.pow(backoffFactor, session.retryCount), maxDelay);session.reconnectInterval = setTimeout(attemptReconnect, nextDelay);// 尝试刷新会话列表,添加防抖机制if (fetchSessionsDebounceTimer) {clearTimeout(fetchSessionsDebounceTimer);}fetchSessionsDebounceTimer = setTimeout(() => {fetchSessions();}, 3000); // 3 秒防抖};}};attemptReconnect();
};// 创建新会话
const createNewSession = async () => {if (hasUnconnectedSession.value) return;try {// 新建新会话的同时保存到后台const response = await axios.post('/api/deepSeek/saveTalkInfo', {title: `历史会话 ${sessions.value.length}`});const newSession = reactive({talkInfoId: response.data.data,title: `历史会话 ${sessions.value.length}`,messages: [{ sender: 'bot', text: '欢迎使用!有什么可以帮助你的?' }],inputMessage: '',errorMessage: '',isConnected: false,isGenerating: false,socket: null,reconnectInterval: null,retryCount: 0, // 新增重试次数currentBotMessage: '' // 用于存储当前机器人流式响应消息});sessions.value.push(newSession);activeSessionIndex.value = sessions.value.length - 1;reconnectWebSocket(newSession);} catch (error) {console.error('创建会话失败:', error);const errorSession = sessions.value[activeSessionIndex.value] || {};handleNetworkError(error, errorSession);// 确保至少存在一个会话if (sessions.value.length === 0) {const tempSession = reactive({talkInfoId: 'temp-' + Date.now(),title: '临时会话',messages: [{ sender: 'bot', text: '欢迎使用!' }],inputMessage: '',errorMessage: '',isConnected: false,isGenerating: false,socket: null,reconnectInterval: null,retryCount: 0, // 新增重试次数currentBotMessage: '' // 用于存储当前机器人流式响应消息});sessions.value = [tempSession];activeSessionIndex.value = 0;}}
};// 切换操作菜单显示隐藏
const toggleActionsMenu = (index) => {sessions.value.forEach((session, i) => {session.showActionsMenu = i === index && !session.showActionsMenu;});
};
//============================编辑和删除会话框相关操作逻辑=========================================================================
const isEditModalVisible = ref(false);
const isDeleteModalVisible = ref(false);
const editTitle = ref('');
const deleteTitle = ref('');
let currentEditIndex = -1;
let currentDeleteIndex = -1;const openEditModal = (index) => {currentEditIndex = index;editTitle.value = sessions.value[index].title;isEditModalVisible.value = true;
};const closeEditModal = () => {isEditModalVisible.value = false;
};const confirmEdit = async () => {const session = sessions.value[currentEditIndex];if (editTitle.value && editTitle.value!== session.title) {try {await axios.post('/api/deepSeek/updateTalkInfo', {id: session.talkInfoId,title: editTitle.value});session.title = editTitle.value;} catch (error) {console.error('编辑会话失败:', error);handleNetworkError(error, session);}}closeEditModal();
};const openDeleteModal = (index) => {currentDeleteIndex = index;deleteTitle.value = sessions.value[index].title;isDeleteModalVisible.value = true;
};const closeDeleteModal = () => {isDeleteModalVisible.value = false;
};const confirmDelete = async () => {const session = sessions.value[currentDeleteIndex];if (session.talkInfoId.toString().startsWith('temp-')) {console.log('临时会话不能删除');closeDeleteModal();return;}try {await axios.post('/api/deepSeek/deleteTalkInfo', {id: session.talkInfoId});sessions.value.splice(currentDeleteIndex, 1);if (activeSessionIndex.value === currentDeleteIndex) {activeSessionIndex.value = Math.max(0, activeSessionIndex.value - 1);}} catch (error) {console.error('删除会话失败:', error);handleNetworkError(error, session);}closeDeleteModal();
};
//======================================================================================================================
// 选择会话
const selectSession = (index) => {activeSessionIndex.value = index;isConnected.value = sessions.value[index].isConnected;
};// 计算属性:判断是否有未连接的会话
const hasUnconnectedSession = computed(() => {return sessions.value.some(session =>!session?.isConnected);
});// 生命周期
let serviceCheckInterval;
onMounted(() => {fetchSessions(); // 初始化时获取会话列表// 定期检查后端服务可用性serviceCheckInterval = setInterval(() => {axios.get('/api/deepSeek/heartBeatCheck').then(() => {// 服务可用,对未连接的会话进行重试sessions.value.forEach(session => {if (!session?.isConnected) {reconnectWebSocket(session);}});}).catch(error => {console.error('后端服务不可用:', error);sessions.value.forEach(session => {session.isConnected = false;if (session?.socket) {session.socket.close();}});});}, 10000); // 每10秒检查一次
});onUnmounted(() => {sessions.value.forEach((session) => {if (session?.socket) session.socket.close();if (session?.reconnectInterval) clearInterval(session.reconnectInterval);});clearInterval(serviceCheckInterval);
});
</script><style scoped>
...此处省略
</style>

MyModal.vue

<template><div v-if="visible" class="modal-overlay" @click.self="close"><transition name="modal-fade"><div class="modal"><div class="modal-header"><h3>{{ title }}</h3><button @click="close">&times;</button></div><div class="modal-body"><slot></slot></div><div class="modal-footer"><slot name="footer"></slot></div></div></transition></div>
</template><script setup>
import { watch } from 'vue';// eslint-disable-next-line no-undef
const props = defineProps({visible: {type: Boolean,required: true},title: {type: String,required: true}
});// eslint-disable-next-line no-undef
const emit = defineEmits(['close']);const close = () => {emit('close');
};watch(() => props.visible, (newVal) => {if (!newVal) {close();}
});
</script><style scoped>
.modal-overlay {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background-color: rgba(0, 0, 0, 0.5);display: flex;justify-content: center;align-items: center;z-index: 1000;animation: fadeIn 0.3s ease-in-out;
}.modal {background: white;border-radius: 12px;box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);width: 500px;max-width: 90%;transform-origin: center;animation: scaleUp 0.3s ease-in-out;
}.modal-header {display: flex;justify-content: space-between;align-items: center;padding: 16px 24px;border-bottom: 1px solid #e9ecef;
}.modal-header h3 {margin: 0;font-size: 1.25rem;font-weight: 600;color: #212529;
}.modal-header button {background: none;border: none;font-size: 1.5rem;line-height: 1;color: #6c757d;cursor: pointer;transition: color 0.2s;padding: 4px;
}.modal-header button:hover {color: #dc3545;
}.modal-body {padding: 20px 24px;color: #495057;line-height: 1.6;max-height: 70vh;overflow-y: auto;
}.modal-footer {display: flex;justify-content: flex-end;gap: 12px;padding: 16px 24px;border-top: 1px solid #e9ecef;background: #f8f9fa;border-radius: 0 0 12px 12px;
}@keyframes fadeIn {from { opacity: 0; }to { opacity: 1; }
}@keyframes scaleUp {from { transform: scale(0.95); }to { transform: scale(1); }
}@media (max-width: 480px) {.modal {width: 95%;margin: 10px;}.modal-header,.modal-body,.modal-footer {padding: 12px 16px;}
}
</style>

后端部分

项目结构图如下
在这里插入图片描述

WebSocketConfig 配置类

package com.deepseek.project.websocket;import jakarta.annotation.Resource;
import org.apache.catalina.connector.Connector;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;/*** @author hulei* websocket配置类*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {@Resourceprivate ApplicationContext applicationContext;@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}@Beanpublic ServletWebServerFactory servletContainer() {TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();tomcat.addAdditionalTomcatConnectors(createWebSocketConnector());return tomcat;}/*** 设置最大消息大小*/@Beanpublic ServletServerContainerFactoryBean createWebSocketContainer() {ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();// 在此处设置bufferSizecontainer.setMaxTextMessageBufferSize(512000);container.setMaxBinaryMessageBufferSize(512000);container.setMaxSessionIdleTimeout(15 * 60000L);return container;}@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(deepSeekWebSocketHandler(), "/websocket").setAllowedOrigins("*");}public DeepSeekWebSocketHandler deepSeekWebSocketHandler() {// 从spring容器中获取bean,如过直接new的话就不是spring管理的bean了,DeepSeekWebSocketHandler 内部使用依赖注入其他的类则会为nullreturn applicationContext.getBean(DeepSeekWebSocketHandler.class);}private Connector createWebSocketConnector() {Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");connector.setScheme("ws");// WebSocket 服务的端口,多通道公用connector.setPort(8085);return connector;}
}

这段代码是WebSocket配置类,主要用于配置和初始化WebSocket服务。功能包括:

  1. 启用WebSocket并注册处理器。
  2. 配置Tomcat服务器以支持WebSocket连接。
  3. 设置WebSocket的最大消息大小和会话超时时间。
  4. 创建WebSocket连接器并指定端口。

控制流程图如下:

Yes
启动
是否启用WebSocket
创建ServerEndpointExporter
配置Tomcat服务器
设置最大消息大小和超时时间
注册WebSocket处理器
创建WebSocket连接器
设置连接器端口
完成配置

AbstractDeepSeekTool

package com.deepseek.project.tool;import com.deepseek.project.constant.Constants;
import com.deepseek.project.service.ITalkInfoService;
import com.google.gson.Gson;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;import java.io.IOException;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/*** author: hulei* 抽象的DeepSeek工具类,为什么要抽象呢,因为笔者既想使用本地部署的deepSeek,又想使用线上官方提供的API,所以抽象了DeepSeek工具类,*/
@Slf4j
@Data
public abstract class AbstractDeepSeekTool {private static final int MAX_CONCURRENT = 20;private static final int BATCH_SIZE = 10;public static OkHttpClient client;private static ExecutorService httpExecutor;public static final Gson gson = new Gson();/*** deepSeek会话历史记录*/public final List<Map<String, String>> conversationHistory = Collections.synchronizedList(new ArrayList<>());/*** 是否停止发送标志,由前端传值控制*/public volatile boolean stopSending = false;/*** DeepSeek流式响应消息缓存列表,攒到一定数量时,批量保存到数据库*/private final List<Map<String, String>> deepSeekMessageCache = new ArrayList<>();/*** ITalkInfoService*/private final ITalkInfoService italkInfoService;public AbstractDeepSeekTool(ITalkInfoService italkInfoService, List<Map<String, String>> conversationHistory) {this.italkInfoService = italkInfoService;this.conversationHistory.addAll(conversationHistory);init();}public void init() {httpExecutor = Executors.newCachedThreadPool(r -> {Thread t = new Thread(r);t.setDaemon(true);return t;});client = new OkHttpClient.Builder().dispatcher(new Dispatcher(httpExecutor)).connectionPool(new ConnectionPool(50, 5, java.util.concurrent.TimeUnit.MINUTES)).connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS).readTimeout(60, java.util.concurrent.TimeUnit.SECONDS).writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS).build();}public void processMessage(String userMessage, WebSocketSession session, String talkInfoId) {synchronized (conversationHistory) {Map<String, String> userMessageMap = Map.of("role", "user", "content", userMessage);conversationHistory.add(userMessageMap);if (!talkInfoId.startsWith(Constants.TEMP_TALK_INFO_ID_PREFIX)) {addMessageToCache(userMessageMap, talkInfoId);}}requestDeepSeekMessage(session, talkInfoId);}/*** 构建请求头抽象方法,交给子类实现*/protected abstract Request buildRequest();/*** 请求DeepSeek深度搜索消息, 交给子类实现*/public abstract void requestDeepSeekMessage(WebSocketSession session, String talkInfoId);/*** 添加消息到DeepSeek流式响应消息缓存列表中*/public void addMessageToCache(Map<String, String> deepSeekMessageMap, String talkInfoId) {deepSeekMessageCache.add(deepSeekMessageMap);if (deepSeekMessageCache.size() >= BATCH_SIZE) {saveCachedDeepSeekMessages(talkInfoId);}}/*** 保存DeepSeek流式响应消息缓存列表中的消息到数据库*/public void saveCachedDeepSeekMessages(String talkInfoId) {if (!deepSeekMessageCache.isEmpty()) {try {italkInfoService.saveTalkInfoDeepSeekHistory(Integer.parseInt(talkInfoId), deepSeekMessageCache);log.info("批量消息保存到数据库成功,数量: {}", deepSeekMessageCache.size());deepSeekMessageCache.clear();} catch (Exception e) {log.error("批量消息保存到数据库失败,数量: {}", deepSeekMessageCache.size(), e);}}}/*** 发送响应结束标记信息给前端, 前端会根据这个标记来判断是否继续接收消息,改变按钮状态从停止生成改为待发送状态*/public void sendEndMarker(WebSocketSession session) {try {session.sendMessage(new TextMessage("[END_OF_MESSAGE_GENERATE]"));} catch (IOException e) {log.error("发送结束标记失败", e);}}/*** 处理异常*/public void handleError(WebSocketSession session, Exception e) {log.error("请求处理异常", e);try {session.sendMessage(new TextMessage("系统错误: " + e.getMessage()));sendEndMarker(session);} catch (IOException ex) {log.error("发送错误信息失败", ex);}}
}

这段代码定义了一个抽象类 AbstractDeepSeekTool,用于处理与 DeepSeek 的交互。主要功能包括初始化 HTTP 客户端和线程池、处理用户消息、请求 DeepSeek 消息、缓存和批量保存消息到数据库、发送结束标记给前端以及处理异常。

  1. 初始化:设置 HTTP 客户端和线程池。
  2. 处理用户消息:将用户消息添加到会话历史记录并缓存。
  3. 请求 DeepSeek 消息:由子类实现具体逻辑。
  4. 缓存和批量保存:将消息缓存并在达到批量大小时保存到数据库。
  5. 发送结束标记:通知前端消息生成结束。
  6. 处理异常:捕获并处理异常,发送错误信息给前端。

控制流图

开始
是否为临时会话?
添加消息到缓存
仅添加到会话历史
请求DeepSeek消息
请求成功?
发送响应
处理异常
发送结束标记
发送错误信息
结束

DeepSeekWebSocketHandler

package com.deepseek.project.websocket;import com.deepseek.project.constant.Constants;
import com.deepseek.project.model.TalkInfoDeepSeekHistory;
import com.deepseek.project.service.ITalkInfoService;
import com.deepseek.project.tool.AbstractDeepSeekTool;
import com.deepseek.project.tool.DeepSeekLocalTool;
import com.deepseek.project.tool.DeepSeekOnlineTool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;/*** author: 胡磊* WebSocket处理器,用于处理与客户端的WebSocket通信*/
@Slf4j
@Component
public class DeepSeekWebSocketHandler extends TextWebSocketHandler {// 使用talkInfoId作为键的会话映射private final Map<String, AbstractDeepSeekTool> talkSessionMap = new ConcurrentHashMap<>();private static final Gson gson = new Gson();private static final ObjectMapper objectMapper = new ObjectMapper();@Resourceprivate ITalkInfoService italkInfoService;@Value("${deepseek.tool.type:local}")private String deepSeekToolType;@Overridepublic void afterConnectionEstablished(@NotNull WebSocketSession session) {String talkInfoId = extractTalkInfoId(session);if (talkInfoId == null) {closeWithError(session);return;}// 创建或获取已有会话工具talkSessionMap.computeIfAbsent(talkInfoId, id -> {log.info("构建新会话工具,talkInfoId: {}", talkInfoId);List<Map<String, String>> conversationHistory = new ArrayList<>();if (!talkInfoId.startsWith(Constants.TEMP_TALK_INFO_ID_PREFIX)) {//根据会话框id查询会话框历史记录List<TalkInfoDeepSeekHistory> historyList = italkInfoService.getDeepSeekHistoryListByTalkInfoId(Integer.parseInt(talkInfoId));for (TalkInfoDeepSeekHistory history : historyList) {Type type = new TypeToken<Map<String, String>>() {}.getType();Map<String, String> messageMap = gson.fromJson(history.getContent(), type);conversationHistory.add(messageMap);}}return createDeepSeekTool(conversationHistory);});}/*** 创建AbstractDeepSeekTool具体类的实例,根据配置的toolType参数来选择创建哪种类型的工具*/private AbstractDeepSeekTool createDeepSeekTool(List<Map<String, String>> conversationHistory) {if ("online".equalsIgnoreCase(deepSeekToolType)) {return new DeepSeekOnlineTool(italkInfoService, conversationHistory);} else {return new DeepSeekLocalTool(italkInfoService, conversationHistory);}}/*** 从WebSocketSession中提取talkInfoId参数值* 此方法主要用于从WebSocket连接的URI中提取出talkInfoId参数值,该参数值用于标识对话信息* 如果URI为空,或者没有找到talkInfoId参数,则返回null** @param session WebSocketSession对象,包含客户端与服务器之间的WebSocket连接信息* @return String 返回提取出的talkInfoId参数值,如果没有找到则返回null*/private String extractTalkInfoId(WebSocketSession session) {try {URI uri = session.getUri();if (uri == null) return null;return Arrays.stream(uri.getQuery().split("&")).filter(param -> param.startsWith("talkInfoId=")).findFirst().map(param -> param.split("=")[1]).orElse(null);} catch (Exception e) {log.error("解析talkInfoId失败", e);return null;}}/*** 处理WebSocket消息*/@Overrideprotected void handleTextMessage(@NotNull WebSocketSession session, @NotNull TextMessage message) {try {log.info("收到消息:{}", message.getPayload());JsonNode json = objectMapper.readTree(message.getPayload());String talkInfoId = json.get("talkInfoId").asText();String content = json.get("content").asText();AbstractDeepSeekTool tool = talkSessionMap.get(talkInfoId);if (tool == null) {log.warn("找不到对应的会话工具,talkInfoId: {}", talkInfoId);sendErrorMessage(session, "无效 session");return;}if ("stopSending".equalsIgnoreCase(content)) {handleStopCommand(tool, session);} else {handleNormalMessage(tool, session, content, talkInfoId);}} catch (IOException e) {log.error("消息解析失败", e);sendErrorMessage(session, "Invalid message format");}}/*** 处理前端点击发送的停止生成命令*/private void handleStopCommand(AbstractDeepSeekTool tool, WebSocketSession session) {tool.setStopSending(true);try {session.sendMessage(new TextMessage("[END_OF_MESSAGE_GENERATE]"));} catch (IOException e) {log.error("停止命令响应失败", e);}}/*** 处理普通消息*/private void handleNormalMessage(AbstractDeepSeekTool tool, WebSocketSession session, String content, String talkInfoId) {tool.setStopSending(false);try {tool.processMessage(content, session, talkInfoId);} catch (Exception e) {log.error("消息处理异常", e);sendErrorMessage(session, "消息处理发生异常");}}@Overridepublic void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus status) {String talkInfoId = extractTalkInfoId(session);if (talkInfoId != null) {// 根据业务需求决定是否立即清理资源// 如果是持久化会话可以保留,临时会话则移除,因为每次刷新页面临时会话都会新建,原来的就没用了,需要移除if (talkInfoId.startsWith(Constants.TEMP_TALK_INFO_ID_PREFIX)) {talkSessionMap.remove(talkInfoId);}log.info("会话关闭,talkInfoId: {}", talkInfoId);}}/*** 发送错误消息*/private void sendErrorMessage(WebSocketSession session, String error) {try {session.sendMessage(new TextMessage(objectMapper.createObjectNode().put("type", "error").put("message", error).toString()));} catch (IOException e) {log.error("错误信息发送失败", e);}}private void closeWithError(WebSocketSession session) {try {session.close(new CloseStatus(CloseStatus.BAD_DATA.getCode(), "缺失talkInfoId会话框ID参数"));} catch (IOException e) {log.error("关闭连接失败", e);}}
}

这段代码实现了一个WebSocket处理器,用于处理与客户端的WebSocket通信。主要功能包括:

  1. 建立连接时提取talkInfoId并创建或获取会话工具。
  2. 处理接收到的消息,区分普通消息和停止命令。
  3. 关闭连接时清理资源。

控制流图

成功
失败
普通消息
停止命令
建立连接
提取 talkInfoId
创建或获取会话工具
关闭连接并返回
等待消息
收到消息
解析消息
判断消息类型
处理普通消息
处理停止命令
连接关闭
是否为临时会话
移除会话工具
保留会话工具

数据库设计

之所以要设计几张表,是因为要实现保存上下文会话历史消息记录的需要,这里笔者使用的是mysql,其实每次的问答都会生成大量的文本消息和大量的DeepSeek的历史消息JSON数据,使用MongoDB或者Elasticsearch作为持久化工具对于查询性能更好,但笔者这里没有折腾了,读者朋友可以自己决定使用什么存储工具。

一共三张表:talk_infotalk_info_deepseek_historytalk_info_messages

  1. talk_info
    这个是对话框列表,就是新建会话的会话框会在这里新增数据
create table talk_info
(id          int auto_incrementprimary key,title       varchar(32) not null comment '对话标题',create_time datetime    not null
)comment '对话框表';
  1. talk_info_deepseek_history
    这个是deepSeek记录上下文请求和响应历史消息的表
create table talk_info_deepseek_history
(id           int auto_increment comment '主键'primary key,talk_info_id int                             not null comment '对话框id',content      text collate utf8mb4_unicode_ci null,create_time  datetime                        null
)comment '对话框的deepseek历史对话记录';
  1. talk_info_messages
    这个是干嘛的呢,也是记录上下文对话文本消息的表,只不过是记录单纯的文本消息,用于前端对话框展示的,而deepSeek则要求按照一定的json格式组装,这样才能被其加载读取历史会话记录,所以分开存储了。
create table talk_info_messages
(id           int auto_incrementprimary key,talk_info_id int                             not null comment '对话框id',message_type varchar(10)                     null comment '消息类型:user,bot',message      text collate utf8mb4_unicode_ci null,create_time  datetime                        not null
)comment '对话历史消息列表';

总结

以上就是笔者开发deepSeek对话web应用的整个过程了,笔者的技术能力有限,尤其是前端部分,很多样式的调整,其实是借助了大模型帮我调整的,不足之处请大家多多指教!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/24745.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

DMA 定制固件教程:小白跟做即得单人固件,超详细纯喂饭教程,100% 成功秘籍!FPGA仿真1:1、中断逻辑和TLP核心都在。

DMA 定制固件教程 小白跟着操作做可以做出的单人固件 图文教程 链接&#xff1a;https://docs.qq.com/doc/DQ01lVGtHelROVHNv 本图文教程包含内容&#xff1a; 一、DMA仿真技术采集真实单人固件 二、网卡TLP仿真固件生成 三、DMA仿真技术io、中断逻辑&#xff0c;从零仿真 四、…

Linux | Ubuntu 与 Windows 双系统安装 / 高频故障 / UEFI 安全引导禁用

注&#xff1a;本文为 “buntu 与 Windows 双系统及高频故障解决” 相关文章合辑。 英文引文&#xff0c;机翻未校。 How to install Ubuntu 20.04 and dual boot alongside Windows 10 如何将 Ubuntu 20.04 和双启动与 Windows 10 一起安装 Dave’s RoboShack Published in…

spring中的注解介绍

本篇文章专门用来介绍spring中的各种注解。 1、RestController 1、含义 2、举例 3、使用场景 RestController 通常用于开发 RESTful API&#xff0c;适合返回 JSON 或 XML 数据的场景 4、总结 RestController 是 Spring 中用于简化 RESTful Web 服务开发的注解&#xff0c;它结…

JVM生产环境问题定位与解决实战(二):JConsole、VisualVM到MAT的高级应用

生产问题定位指南&#xff1a;几款必备的可视化工具 引言 在上一篇文章中&#xff0c;详细的介绍了JDK自带的一系列命令行工具&#xff0c;&#xff0c;如jps、jmap、jstat、jstack以及jcmd等&#xff0c;这些工具为排查和诊断Java虚拟机&#xff08;JVM&#xff09;问题提供…

网页制作09-html,css,javascript初认识のhtml如何使用表单

表单主要用来收集客户端提供的相关信息。,使网页具有交互作用。在网页制作的过程中&#xff0c;常常需要使用表单&#xff0c;如进行会员注册&#xff0c;网上调查和搜索等 访问者可以使用如文本域列表框&#xff0c;复选框以及单选按钮之类的表单对象输入信息&#xff0c;然后…

基本网络安全的实现

基本网络安全的实现 一 &#xff1a;AAA AAA 是Authentication&#xff0c;Authorization and Accounting&#xff08;认证、授权和计费&#xff09;的简 称&#xff0c;它提供了一个用来对认证、授权和计费这三种安全功能进行配置的一致性框架&#xff0c; 它是对网络安全…

Jupyter Notebook~Anaconda3安装教程

一、下载anaconda&#xff1a; https://mirrors.tuna.tsinghua.edu.cn/anaconda/archive/ 百度网盘通道&#xff0c;链接:https://pan.baidu.com/s/1gyVDG2p71neFXi8VwXgvEQ?pwdewn7提取码: ewn7 二、安装 1、右击安装软件选择【以管理员身份运行】&#xff0c;点击【Next】…

【运维】内网服务器借助通过某台可上外网的服务器实现公网访问

背景&#xff1a; 内网服务器无法连接公网,但是办公电脑可以连接内网服务器又可以连接公网。 安装软件 1、frp 2、ccproxy 配置 1、内网服务器 # 内网服务器启动frp服务配置文件参考vi frps.ini# frps.ini [common] bind_port 7000# 备注: bind_port端口可以随意配置。配置完…

php 对接mqtt 完整版本,订阅消息,发送消息

首先打开链接如何在 PHP 项目中使用 MQTT 根据文章让所用依赖安装一下&#xff1a; composer require php-mqtt/client 安装之后弄一个部署 之后在工具里边可以相应链接上 接下来是代码&#xff1a; /**** 订阅消息* return void* throws \PhpMqtt\Client\Exceptions\Confi…

(2.26 “详细分析示例“ 暴力+位运算 最长优雅子数组)leetcode 2401

a&b0说明a和b的每一位都是一个0和一个1 不存在两个均为1的位次 a|0a 0与任何数|都等于它本身 &#xff08;mask&#xff09;的作用&#xff1a; 担心两数的1在用一位导致mask覆盖了&#xff1f; 答&#xff1a;出现这种情况说明mask与nums j后就直接break 由&#xff1a;…

数据开发的简历及面试

简历 个人信息: 邮箱别写QQ邮箱, 写126邮箱/189邮箱等 学历>>本科及以上写,大专及以下不写 专业>>非计算机专业不写 政治面貌>>党员写, 群众不用写 掌握的技能: 精通 > 熟悉 > 了解 专业工具: 大数据相关的 公司: 如果没有可以写的>>金融服…

Git原理+使用(超详细)

Git初识 当我们写项目代码时&#xff0c;需要不断的更新版本&#xff0c;那么就需要一个东西去管理这些不同版本的文件—版本控制器。 目前最主流的版本控制器就是Git。它是一个可以记录工程的每一次改动和版本迭代的管理系统&#xff0c;同时方便多人协同作业。 &#xff0…

数据结构秘籍(一)线性数据结构

1.数组 数组&#xff08;Array&#xff09;是一种很常见的数据结构。它由相同类型的元素&#xff08;element&#xff09;组成&#xff0c;并且是使用一块连续的内存来存储。 我们直接可以利用元素的索引&#xff08;index&#xff09;计算出该元素对应的存储地址。 数组的特…

WiFi IEEE 802.11协议精读:IEEE 802.11-2007,6,MAC service definition MAC服务定义

继续精读IEEE 802.11-2007 6&#xff0c;MAC service definition MAC服务定义 6.1 MAC服务概述 6.1.1 数据服务 此服务为对等逻辑链路控制&#xff08;LLC&#xff09;实体提供交换MAC服务数据单元&#xff08;MSDU&#xff09;的能力。为支持此服务&#xff0c;本地媒体访…

QT基于mmap文件映射机制实现的内存池方法总结

在现代计算机系统中&#xff0c;高效的内存管理对于程序性能有着至关重要的影响。尤其是在处理大量数据或频繁分配和释放小块内存的应用场景下&#xff0c;传统的内存分配方式&#xff08;如malloc和free&#xff09;可能会导致显著的性能开销和内存碎片化问题。为了克服这些问…

车载DoIP诊断框架 --- 连接 DoIP ECU/车辆的故障排除

我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 简单,单纯,喜欢独处,独来独往,不易合同频过着接地气的生活,除了生存温饱问题之外,没有什么过多的欲望,表面看起来很高冷,内心热情,如果你身…

0—QT ui界面一览

2025.2.26&#xff0c;感谢gpt4 1.控件盒子 1. Layouts&#xff08;布局&#xff09; 布局控件用于组织界面上的控件&#xff0c;确保它们的位置和排列方式合理。 Vertical Layout&#xff08;垂直布局&#xff09; &#xff1a;将控件按垂直方向排列。 建议&#xff1a;适…

普中单片机-51TFT-LCD显示屏(1.8寸 STM32)

普中官方论坛&#xff1a; http://www.prechin.cn/gongsixinwen/208.html 普中科技-各型号开发板资料链接&#xff1a;https://www.bilibili.com/read/cv23681775/?spm_id_from333.999.0.0 27-TFTLCD显示实验_哔哩哔哩_bilibili 2.程序烧录 2.1设置彩屏驱动 3.实验效果

嵌入式开发工程师笔试面试指南-Linux系统移植

1 Linux内核启动流程 引导加载阶段 计算机通电后&#xff0c;首先由 BIOS 或 UEFI 进行初始化&#xff0c;完成硬件自检等操作。 找到可启动设备&#xff0c;读取其第一个扇区的 MBR&#xff0c;MBR 中的引导加载程序&#xff08;如 GRUB&#xff09;被加载到内存并运行。 内…

图扑数字孪生:解锁压缩空气储能管控新高度

​在能源转型的关键时期&#xff0c;压缩空气储能凭借其独特优势&#xff0c;成为解决可再生能源间歇性问题、保障可靠能源供应的重要技术。图扑软件&#xff08;Hightopo&#xff09;充分发挥其在 Web 2D&3D 可视化领域的技术专长&#xff0c;打造出先进的数字孪生压缩空气…