Bootstrap

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


引言

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

前端Gitee代码地址:https://gitee.com/buxingzhe/deepseek-chat.git
后端Gitee代码地址:https://gitee.com/buxingzhe/deep-seek-project.git
DeepSeek本地部署教程

前端部分

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

安装依赖

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">
        <div
            v-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>
          <!-- 编辑和删除操作菜单 -->
          <div
              v-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">
            <textarea
                v-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 {

    @Resource
    private ApplicationContext applicationContext;

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addAdditionalTomcatConnectors(createWebSocketConnector());
        return tomcat;
    }

    /**
     * 设置最大消息大小
     */
    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        // 在此处设置bufferSize
        container.setMaxTextMessageBufferSize(512000);
        container.setMaxBinaryMessageBufferSize(512000);
        container.setMaxSessionIdleTimeout(15 * 60000L);
        return container;
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(deepSeekWebSocketHandler(), "/websocket").setAllowedOrigins("*");
    }

    public DeepSeekWebSocketHandler deepSeekWebSocketHandler() {
        // 从spring容器中获取bean,如过直接new的话就不是spring管理的bean了,DeepSeekWebSocketHandler 内部使用依赖注入其他的类则会为null
        return 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();

    @Resource
    private ITalkInfoService italkInfoService;

    @Value("${deepseek.tool.type:local}")
    private String deepSeekToolType;

    @Override
    public 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消息
     */
    @Override
    protected 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, "消息处理发生异常");
        }
    }

    @Override
    public 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_increment
        primary 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_increment
        primary 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应用的整个过程了,笔者的技术能力有限,尤其是前端部分,很多样式的调整,其实是借助了大模型帮我调整的,不足之处请大家多多指教!

;