🎉🎉🎉🎉🎉🎉
欢迎访问个人博客: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 拉你入群~
🎉🎉🎉🎉🎉🎉

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐