AI模型对话,前端接受流式数据效果demo

可以直接复制代码

如上视频,要实现类似deepseek和chatgpt的效果,用传统的方式接受数据根本行不通,必须使用SSE, Server-Sent Events(SSE)功能,允许服务端推送数据到客户端,通常叫数据推送。当数据源有新数据,它马上发送到客户端,不需要再等待客户端请求。
这里提供了完整的前端代码和用node.js写的后端模拟数据,分别包括了get和post方式,因为EventSource只支持get方式,后用fetch支持了post方式,两种效果都是一样的。

前端用的vue2,node版本v16.20.2,介绍一篇nvm管理node版本的文章(https://blog.csdn.net/qq_41822157/article/details/141259132)

DeepSeekChatGet.vue

<template>
  <div class="deepseek-chat">
    <div class="chat-container" ref="chatContainer">
      <div class="messages">
        <div
          v-for="(message1, index) in messages"
          :key="index"
          :class="['message', message1.role]"
        >
          <div class="avatar">
            <span v-if="message1.role === 'user'">你</span>
            <span v-else>DS</span>
          </div>

          <div class="message-content">
            <div
              class="message-body"
              v-for="(message, index1) in message1.arr"
              :key="index1"
            >
              <!-- 文本内容 -->
              <div v-if="message1.role === 'user'" class="text-content">
                <p>
                  {{ message.content }}
                </p>
              </div>
              <div v-else class="text-content">
                <p v-if="index === 0">
                  {{ message.content }}
                </p>
                <div v-else>
                  <div>
                    <p
                      v-html="md.render(message.content)"
                      class="chatDatas-box"
                    ></p>
                  </div>
                </div>
              </div>
              <!-- 加载状态 -->
              <div
                v-if="
                  index1 === messages[messages.length - 1].arr.length - 1 &&
                  isStreaming
                "
                class="typing-indicator"
              >
                <span></span>
                <span></span>
                <span></span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="input-area">
      <div class="input-container">
        <textarea
          v-model="userInput"
          placeholder="输入消息..."
          @keydown.enter.exact.prevent="sendMessage3"
          @keydown.shift.enter="newLine"
          rows="2"
          ref="textarea"
        ></textarea>
        <button @click="sendMessage3" :disabled="isStreaming">
          <span v-if="!isStreaming">发送</span>
          <span v-else>生成中...</span>
        </button>
      </div>
    </div>
  </div>
</template>

<script>
class DeepSeekStream {
  constructor() {
    this.buffer = "";
    this.eventSource = null;
  }

  connect(url, onMessage, onComplete, onError, onStart) {
    this.eventSource = new EventSource(url);

    this.eventSource.addEventListener("message", (event) => {
      try {
        // 确保处理完整的消息
        if (event.data.trim()) {
          const message = JSON.parse(event.data);
          onMessage(message);
        }
      } catch (error) {
        onError(`消息解析错误: ${error.message}`);
      }
    });

    this.eventSource.addEventListener("end", () => {
      onComplete();
      this.close();
    });

    this.eventSource.addEventListener("error", (error) => {
      onError(`流错误: ${error.message}`);
      this.close();
    });
    this.eventSource.addEventListener("open", () => {
      //监听连接成功的时候
      onStart();
    });
  }

  close() {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }
  }
}

// 导入解析markdown语法的第三方库markdown-it
import MarkdownIt from "markdown-it";

// 引入代码高亮
import hljs from "highlight.js";
// 你用到了什么语言就要引入什么语言,目前我还不知道怎么样导入所有语言
import javascript from "highlight.js/lib/languages/javascript";
//  这个是高亮的样式,有很多,我选了这个
import "highlight.js/styles/ir-black.css";

hljs.registerLanguage("javascript", javascript);
import "highlight.js/styles/github-dark.css";
export default {
  name: "DeepSeekChat",
  data() {
    return {
      userInput: "js中 array.sort的用法",
      messages: [
        {
          role: "assistant",
          model: "deepseek-chat",
          arr: [
            {
              content: "您好!请输入您的问题",
              type: "text",
            },
          ],
        },
      ],
      isStreaming: false,
      streamHandler: null,
      selectedModel: "deepseek-chat",
      chatDatas: [],
      md: new MarkdownIt({
        html: true, // 允许HTML标签
        linkify: true, // 自动转换URL为链接
        typographer: true, // 优化排版
        breaks: true, // 将换行符转换为<br>
        highlight: function (str, lang) {
          // 代码高亮处理
          if (lang && hljs.getLanguage(lang)) {
            try {
              return (
                '<pre class="hljs"><code>' +
                hljs.highlight(lang, str, true).value +
                "</code></pre>"
              );
            } catch (error) {
              console.error(error);
            }
          }

          return (
            '<pre class="hljs"><code>' +
            this.md.utils.escapeHtml(str) +
            "</code></pre>"
          );
        },
      }),
    };
  },
  computed: {},
  methods: {
    sendMessage3() {
      if (!this.userInput.trim() || this.isStreaming) return;
      // 添加用户消息
      this.messages.push({
        role: "user",
        model: this.selectedModel,
        arr: [
          {
            content: this.userInput,
          },
        ],
      });
      const prompt = this.userInput;
      // const prompt = "111";
      this.userInput = "";
      // 添加AI消息占位
      this.messages.push({
        role: "assistant",
        model: this.selectedModel,
        arr: [],
      });
      let arr1 = { content: "思考中,请稍候..." };

      this.messages[this.messages.length - 1].arr.push(arr1);
      // 创建流处理器
      this.streamHandler = new DeepSeekStream();
      const url = `http://localhost:3000/deepseek-stream?prompt=${encodeURIComponent(
        prompt
      )}&model=${this.selectedModel}`;
      console.log("this.messages", this.messages);
      this.streamHandler.connect(
        url,
        (data) => {
          // console.log("data--", data.content);

          arr1.content += data.content;
          // console.log("this.chatDatas---", this.chatDatas);

          // 滚动到底部
          this.scrollToBottom();
        },
        () => {
          console.log("结束了");
          this.isStreaming = false;
          this.scrollToBottom();
        },
        (error) => {
          console.error("流错误:", error);
        },
        () => {
          // console.log("开始---111");
          this.messages[this.messages.length - 1].arr[0].content = "";
        }
      );

      this.scrollToBottom();
    },

    newLine() {
      this.userInput += "\n";
      this.$nextTick(() => {
        this.$refs.textarea.scrollTop = this.$refs.textarea.scrollHeight;
      });
    },

    copyCode(code) {
      navigator.clipboard.writeText(code).then(() => {
        // this.$toast.success("代码已复制到剪贴板");
      });
    },

    scrollToBottom() {
      this.$nextTick(() => {
        const container = this.$refs.chatContainer;
        container.scrollTop = container.scrollHeight;
      });
    },
  },
  mounted() {
    this.scrollToBottom();
  },
  beforeDestroy() {
    if (this.streamHandler) {
      this.streamHandler.close();
    }
  },
};
</script>

<style lang="less" scoped>
::v-deep .chatDatas-box {
  ul,
  ol {
    margin-left: 20px;
  }
  pre {
    padding: 10px;
    margin: 10px 0;
  }
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    margin: 10px 0;
  }
}

::v-deep .markdown-it-preview * {
  word-break: break-all;
  overflow-wrap: break-word;
  white-space: normal;
}

.deepseek-chat {
  display: flex;
  flex-direction: column;
  height: 100vh;
  /* background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);
    background-size: 400% 400%;
    animation: gradientBG 15s ease infinite;
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; */
}

@keyframes gradientBG {
  0% {
    background-position: 0% 50%;
  }

  50% {
    background-position: 100% 50%;
  }

  100% {
    background-position: 0% 50%;
  }
}

.chat-header {
  padding: 15px 20px;
  background: linear-gradient(135deg, #2c3e50, #4a6491);
  color: white;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}

.logo {
  display: flex;
  align-items: center;
  gap: 10px;
}

.logo h1 {
  font-size: 1.5rem;
  font-weight: 600;
}

.logo-icon {
  font-size: 1.8rem;
  color: #64ffda;
}

.model-selector {
  display: flex;
  align-items: center;
  gap: 10px;
}

.model-selector label {
  font-weight: 500;
  font-size: 0.9rem;
}

.model-selector select {
  padding: 6px 10px;
  border-radius: 6px;
  border: none;
  background: rgba(255, 255, 255, 0.2);
  color: white;
  font-weight: 500;
  cursor: pointer;
}

.chat-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: rgba(255, 255, 255, 0.95);
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.message {
  display: flex;
  max-width: 85%;
}

.message.user {
  align-self: flex-end;
}

.message.assistant {
  align-self: flex-start;
}

.message-content {
  padding: 12px;
  border-radius: 12px;
  box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
  position: relative;
}

.user .message-content {
  background: linear-gradient(135deg, #2193b0, #6dd5ed);
  color: white;
  border-bottom-right-radius: 0;
}

.assistant .message-content {
  background: white;
  border-bottom-left-radius: 0;
}

.message-header {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
  font-size: 0.8rem;
}

.user .message-header {
  color: rgba(255, 255, 255, 0.9);
}

.assistant .message-header {
  color: #666;
}

.role-badge {
  font-weight: 600;
  margin-right: 8px;
}

.avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 8px;
  font-weight: bold;
  flex-shrink: 0;
}

.user .avatar {
  background: #2196f3;
  color: white;
}

.assistant .avatar {
  background: #673ab7;
  color: white;
}

.text-content p {
  margin-bottom: 8px;
  line-height: 1.5;
}

.text-content p:last-child {
  margin-bottom: 0;
}

.code-block {
  position: relative;
  background: #2d2d2d;
  color: #f8f8f2;
  border-radius: 6px;
  overflow: hidden;
  margin: 8px 0;
  font-family: "Fira Code", "Consolas", monospace;
}

.code-block pre {
  padding: 12px;
  overflow-x: auto;
  font-size: 0.85rem;
  margin: 0;
  line-height: 1.4;
}

.copy-btn {
  position: absolute;
  top: 6px;
  right: 6px;
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border: none;
  padding: 4px 8px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.75rem;
  transition: background 0.2s;
}

.copy-btn:hover {
  background: rgba(255, 255, 255, 0.2);
}

pre {
  margin: 0;
  padding: 16px;
  overflow-x: auto;
  max-height: 400px;
}

code {
  font-family: "Fira Code", "Consolas", "Monaco", monospace;
  font-size: 14px;
}

.markdown-content {
  background-color: #f5f5f5;
  border-radius: 18px;
  padding: 12px 16px;
  display: inline-block;
  max-width: 90%;
}

.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
  margin-top: 16px;
  margin-bottom: 8px;
  color: #333;
}

.markdown-content p {
  margin-bottom: 12px;
}

.markdown-content ul,
.markdown-content ol {
  margin-left: 24px;
  margin-bottom: 12px;
}

.markdown-content code {
  background-color: rgba(0, 0, 0, 0.05);
  padding: 2px 4px;
  border-radius: 3px;
  font-size: 0.9em;
}

.markdown-content pre {
  background-color: #282c34;
  color: #abb2bf;
  padding: 12px;
  border-radius: 6px;
  overflow-x: auto;
}

.markdown-content blockquote {
  border-left: 4px solid #ddd;
  padding-left: 12px;
  margin-left: 0;
  color: #666;
}

.markdown-content a {
  color: #165dff;
  text-decoration: none;
}

.markdown-content a:hover {
  text-decoration: underline;
}

.image-container {
  margin: 8px 0;
  text-align: center;
}

.image-container img {
  max-width: 100%;
  border-radius: 6px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}

.input-area {
  padding: 15px;
  background: rgba(255, 255, 255, 0.95);
  border-top: 1px solid #e0e0e0;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}

.input-container {
  display: flex;
  gap: 8px;
}

.input-container textarea {
  flex: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 10px;
  resize: none;
  font-size: 0.95rem;
  min-height: 50px;
  max-height: 120px;
  transition: border-color 0.2s;
}

.input-container textarea:focus {
  outline: none;
  border-color: #6e8efb;
  box-shadow: 0 0 0 2px rgba(110, 142, 251, 0.2);
}

.input-area button {
  margin-left: 12px;
  padding: 0 20px;
  background: linear-gradient(135deg, #6e8efb, #a777e3);
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 500;
  transition: transform 0.2s, box-shadow 0.2s;
}

.input-area button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.input-area button:not(:disabled):hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(110, 142, 251, 0.3);
}

/* 打字动画 */
.typing-indicator {
  display: inline-flex;
  padding: 8px 0;
}

.typing-indicator span {
  display: inline-block;
  width: 8px;
  height: 8px;
  background-color: #666;
  border-radius: 50%;
  margin: 0 4px;
  animation: typing 1.2s infinite ease-in-out;
}

@keyframes typing {
  0%,
  100% {
    transform: translateY(0);
  }

  50% {
    transform: translateY(-5px);
  }
}

.typing-indicator span:nth-child(1) {
  animation-delay: 0s;
}

.typing-indicator span:nth-child(2) {
  animation-delay: 0.2s;
}

.typing-indicator span:nth-child(3) {
  animation-delay: 0.4s;
}

@media (max-width: 768px) {
  .chat-header {
    flex-direction: column;
    gap: 10px;
  }

  .message {
    max-width: 90%;
  }

  .input-container {
    flex-direction: column;
  }

  .send-btn {
    padding: 10px;
  }
}
</style>

DeepSeekChatPost.vue

<template>
  <div class="deepseek-chat">
    <div class="chat-container" ref="chatContainer">
      <div class="messages">
        <div
          v-for="(message1, index) in messages"
          :key="index"
          :class="['message', message1.role]"
        >
          <div class="avatar">
            <span v-if="message1.role === 'user'">你</span>
            <span v-else>DS</span>
          </div>

          <div class="message-content">
            <div
              class="message-body"
              v-for="(message, index1) in message1.arr"
              :key="index1"
            >
              <!-- 文本内容 -->
              <div v-if="message1.role === 'user'" class="text-content">
                <p>
                  {{ message.content }}
                </p>
              </div>
              <div v-else class="text-content">
                <p v-if="index === 0">
                  {{ message.content }}
                </p>
                <div v-else>
                  <div>
                    <p
                      v-html="md.render(message.content)"
                      class="chatDatas-box"
                    ></p>
                  </div>
                </div>
              </div>
              <!-- 加载状态 -->
              <div
                v-if="
                  index1 === messages[messages.length - 1].arr.length - 1 &&
                  isStreaming
                "
                class="typing-indicator"
              >
                <span></span>
                <span></span>
                <span></span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="input-area">
      <div class="input-container">
        <textarea
          v-model="userInput"
          placeholder="输入消息..."
          @keydown.enter.exact.prevent="sendMessage3"
          @keydown.shift.enter="newLine"
          rows="2"
          ref="textarea"
        ></textarea>
        <button @click="sendMessage3" :disabled="isStreaming">
          <span v-if="!isStreaming">发送</span>
          <span v-else>生成中...</span>
        </button>
      </div>
    </div>
  </div>
</template>

<script>
class DeepSeekStream {
  constructor() {
    this.buffer = "";
    this.reader = null;
    this.controller = null;
    this.abortController = null; // 用于取消请求
  }

  connect(onMessage, onComplete, onError, onStart) {
    // 固定请求地址
    const url = "http://localhost:3000/deepseek-stream";

    // 创建终止控制器,用于取消请求
    this.abortController = new AbortController();
    // 发起 POST 请求
    fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        prompt: this.prompt, // 使用实例的 prompt 属性
        model: "deepseek-chat", // 固定 model 参数
      }),
      signal: this.abortController.signal, // 关联终止信号
    })
      .then((response) => {
        if (response.ok) {
          onStart();
        }
        if (!response.ok) {
          throw new Error(
            `请求失败: ${response.status} ${response.statusText}`
          );
        }

        // 检查是否为流式响应
        if (!response.body) {
          throw new Error("响应不支持流式处理");
        }

        // 获取读取器和解码器
        this.reader = response.body.getReader();
        const decoder = new TextDecoder();

        // 递归读取流数据
        const readChunk = () => {
          this.reader
            .read()
            .then(({ done, value }) => {
              if (done) {
                // 流结束,触发完成回调
                onComplete();
                this.close();
                return;
              }

              // 解码二进制数据为字符串
              const chunk = decoder.decode(value, { stream: true });
              this.buffer += chunk;

              // 处理缓冲区中的完整消息(假设以换行分隔)
              const messages = this.buffer.split("\n");
              this.buffer = messages.pop() || ""; // 保留不完整的部分
              // console.log(messages);
              // 解析并触发消息回调
              messages.forEach((msg) => {
                if (msg.trim()) {
                  try {
                    // console.log("这里---000", msg);
                    //let str = 'data: {"type":"text","content":"!"}';
                    // console.log("这里---111", msg.split(": ")[1]);
                    const data = JSON.parse(msg.split(": ")[1]);
                    // console.log("这里---222", data);
                    onMessage(data);
                  } catch (e) {
                    onError(`消息解析错误: ${e.message}`);
                  }
                }
              });

              // 继续读取下一块数据
              readChunk();
            })
            .catch((error) => {
              // 忽略主动取消的错误
              if (error.name !== "AbortError") {
                onError(`流读取错误: ${error.message}`);
              }
              this.close();
            });
        };

        // 开始读取流
        readChunk();
      })
      .catch((error) => {
        // 忽略主动取消的错误
        if (error.name !== "AbortError") {
          onError(`连接失败: ${error.message}`);
        }
        this.close();
      });
  }

  close() {
    // 取消请求
    if (this.abortController) {
      this.abortController.abort();
      this.abortController = null;
    }

    // 释放读取器
    if (this.reader) {
      this.reader.cancel().catch(() => {});
      this.reader = null;
    }

    // 清空缓冲区
    this.buffer = "";
  }
}

// 导入解析markdown语法的第三方库markdown-it
import MarkdownIt from "markdown-it";

// 引入代码高亮
import hljs from "highlight.js";
// 你用到了什么语言就要引入什么语言,目前我还不知道怎么样导入所有语言
import javascript from "highlight.js/lib/languages/javascript";
//  这个是高亮的样式,有很多,我选了这个
import "highlight.js/styles/ir-black.css";

hljs.registerLanguage("javascript", javascript);

import marked from "marked";
// import hljs from 'highlight.js';
import "highlight.js/styles/github-dark.css";
export default {
  name: "DeepSeekChat",
  data() {
    return {
      userInput: "js中 array.sort的用法",
      messages: [
        {
          role: "assistant",
          model: "deepseek-chat",
          arr: [
            {
              content: "您好!请输入您的问题",
              type: "text",
            },
          ],
        },
      ],
      isStreaming: false,
      streamHandler: null,
      selectedModel: "deepseek-chat",
      chatDatas: [],
      md: new MarkdownIt({
        html: true, // 允许HTML标签
        linkify: true, // 自动转换URL为链接
        typographer: true, // 优化排版
        breaks: true, // 将换行符转换为<br>
        highlight: function (str, lang) {
          // 代码高亮处理
          if (lang && hljs.getLanguage(lang)) {
            try {
              return (
                '<pre class="hljs"><code>' +
                hljs.highlight(lang, str, true).value +
                "</code></pre>"
              );
            } catch (error) {
              console.error(error);
            }
          }

          return (
            '<pre class="hljs"><code>' +
            this.md.utils.escapeHtml(str) +
            "</code></pre>"
          );
        },
      }),
    };
  },
  computed: {},
  methods: {
    sendMessage3() {
      if (!this.userInput.trim() || this.isStreaming) return;
      // 添加用户消息
      this.messages.push({
        role: "user",
        model: this.selectedModel,
        arr: [
          {
            content: this.userInput,
          },
        ],
      });
      const prompt = this.userInput;
      // const prompt = "111";
      this.userInput = "";
      // 添加AI消息占位
      this.messages.push({
        role: "assistant",
        model: this.selectedModel,
        arr: [],
      });
      let arr1 = { content: "思考中,请稍候..." };
      this.messages[this.messages.length - 1].arr.push(arr1);

      // 创建流处理器
      this.streamHandler = new DeepSeekStream();

      this.streamHandler.prompt = prompt;
      // 连接流并处理回调
      this.streamHandler.connect(
        (data) => {
          // console.log("data--", data.content);
          arr1.content += data.content ? data.content : "";
          this.scrollToBottom();
        },
        () => {
          console.log("流结束11");
          this.isStreaming = false;
          this.scrollToBottom();
        },
        (error) => {
          console.error("错误:", error);
        },
        () => {
          // console.log("开始---111");
          this.messages[this.messages.length - 1].arr[0].content = "";
        }
      );

      this.scrollToBottom();
    },

    newLine() {
      this.userInput += "\n";
      this.$nextTick(() => {
        this.$refs.textarea.scrollTop = this.$refs.textarea.scrollHeight;
      });
    },
    scrollToBottom() {
      this.$nextTick(() => {
        const container = this.$refs.chatContainer;
        container.scrollTop = container.scrollHeight;
      });
    },
  },
  mounted() {
    this.scrollToBottom();
  },
  beforeDestroy() {
    if (this.streamHandler) {
      this.streamHandler.close();
    }
  },
};
</script>

<style lang="less" scoped>
::v-deep .chatDatas-box {
  ul,
  ol {
    margin-left: 20px;
  }
  pre {
    padding: 10px;
    margin: 10px 0;
  }
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    margin: 10px 0;
  }
}

::v-deep .markdown-it-preview * {
  word-break: break-all;
  overflow-wrap: break-word;
  white-space: normal;
}

.deepseek-chat {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

@keyframes gradientBG {
  0% {
    background-position: 0% 50%;
  }

  50% {
    background-position: 100% 50%;
  }

  100% {
    background-position: 0% 50%;
  }
}

.chat-header {
  padding: 15px 20px;
  background: linear-gradient(135deg, #2c3e50, #4a6491);
  color: white;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}

.logo {
  display: flex;
  align-items: center;
  gap: 10px;
}

.logo h1 {
  font-size: 1.5rem;
  font-weight: 600;
}

.logo-icon {
  font-size: 1.8rem;
  color: #64ffda;
}

.model-selector {
  display: flex;
  align-items: center;
  gap: 10px;
}

.model-selector label {
  font-weight: 500;
  font-size: 0.9rem;
}

.model-selector select {
  padding: 6px 10px;
  border-radius: 6px;
  border: none;
  background: rgba(255, 255, 255, 0.2);
  color: white;
  font-weight: 500;
  cursor: pointer;
}

.chat-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: rgba(255, 255, 255, 0.95);
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.message {
  display: flex;
  max-width: 85%;
}

.message.user {
  align-self: flex-end;
}

.message.assistant {
  align-self: flex-start;
}

.message-content {
  padding: 12px;
  border-radius: 12px;
  box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
  position: relative;
}

.user .message-content {
  background: linear-gradient(135deg, #2193b0, #6dd5ed);
  color: white;
  border-bottom-right-radius: 0;
}

.assistant .message-content {
  background: white;
  border-bottom-left-radius: 0;
}

.message-header {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
  font-size: 0.8rem;
}

.user .message-header {
  color: rgba(255, 255, 255, 0.9);
}

.assistant .message-header {
  color: #666;
}

.role-badge {
  font-weight: 600;
  margin-right: 8px;
}

.avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 8px;
  font-weight: bold;
  flex-shrink: 0;
}

.user .avatar {
  background: #2196f3;
  color: white;
}

.assistant .avatar {
  background: #673ab7;
  color: white;
}

.text-content p {
  margin-bottom: 8px;
  line-height: 1.5;
}

.text-content p:last-child {
  margin-bottom: 0;
}

.code-block {
  position: relative;
  background: #2d2d2d;
  color: #f8f8f2;
  border-radius: 6px;
  overflow: hidden;
  margin: 8px 0;
  font-family: "Fira Code", "Consolas", monospace;
}

.code-block pre {
  padding: 12px;
  overflow-x: auto;
  font-size: 0.85rem;
  margin: 0;
  line-height: 1.4;
}

.copy-btn {
  position: absolute;
  top: 6px;
  right: 6px;
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border: none;
  padding: 4px 8px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.75rem;
  transition: background 0.2s;
}

.copy-btn:hover {
  background: rgba(255, 255, 255, 0.2);
}

pre {
  margin: 0;
  padding: 16px;
  overflow-x: auto;
  max-height: 400px;
}

code {
  font-family: "Fira Code", "Consolas", "Monaco", monospace;
  font-size: 14px;
}

.markdown-content {
  background-color: #f5f5f5;
  border-radius: 18px;
  padding: 12px 16px;
  display: inline-block;
  max-width: 90%;
}

.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
  margin-top: 16px;
  margin-bottom: 8px;
  color: #333;
}

.markdown-content p {
  margin-bottom: 12px;
}

.markdown-content ul,
.markdown-content ol {
  margin-left: 24px;
  margin-bottom: 12px;
}

.markdown-content code {
  background-color: rgba(0, 0, 0, 0.05);
  padding: 2px 4px;
  border-radius: 3px;
  font-size: 0.9em;
}

.markdown-content pre {
  background-color: #282c34;
  color: #abb2bf;
  padding: 12px;
  border-radius: 6px;
  overflow-x: auto;
}

.markdown-content blockquote {
  border-left: 4px solid #ddd;
  padding-left: 12px;
  margin-left: 0;
  color: #666;
}

.markdown-content a {
  color: #165dff;
  text-decoration: none;
}

.markdown-content a:hover {
  text-decoration: underline;
}

.image-container {
  margin: 8px 0;
  text-align: center;
}

.image-container img {
  max-width: 100%;
  border-radius: 6px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}

.input-area {
  padding: 15px;
  background: rgba(255, 255, 255, 0.95);
  border-top: 1px solid #e0e0e0;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}

.input-container {
  display: flex;
  gap: 8px;
}

.input-container textarea {
  flex: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 10px;
  resize: none;
  font-size: 0.95rem;
  min-height: 50px;
  max-height: 120px;
  transition: border-color 0.2s;
}

.input-container textarea:focus {
  outline: none;
  border-color: #6e8efb;
  box-shadow: 0 0 0 2px rgba(110, 142, 251, 0.2);
}

.input-area button {
  margin-left: 12px;
  padding: 0 20px;
  background: linear-gradient(135deg, #6e8efb, #a777e3);
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 500;
  transition: transform 0.2s, box-shadow 0.2s;
}

.input-area button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.input-area button:not(:disabled):hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(110, 142, 251, 0.3);
}

/* 打字动画 */
.typing-indicator {
  display: inline-flex;
  padding: 8px 0;
}

.typing-indicator span {
  display: inline-block;
  width: 8px;
  height: 8px;
  background-color: #666;
  border-radius: 50%;
  margin: 0 4px;
  animation: typing 1.2s infinite ease-in-out;
}

@keyframes typing {
  0%,
  100% {
    transform: translateY(0);
  }

  50% {
    transform: translateY(-5px);
  }
}

.typing-indicator span:nth-child(1) {
  animation-delay: 0s;
}

.typing-indicator span:nth-child(2) {
  animation-delay: 0.2s;
}

.typing-indicator span:nth-child(3) {
  animation-delay: 0.4s;
}

@media (max-width: 768px) {
  .chat-header {
    flex-direction: column;
    gap: 10px;
  }

  .message {
    max-width: 90%;
  }

  .input-container {
    flex-direction: column;
  }

  .send-btn {
    padding: 10px;
  }
}
</style>

server.js

const express = require("express");
const cors = require("cors");
const axios = require("axios");
const { PassThrough } = require("stream");

const app = express();
app.use(cors());
app.use(express.json());

const DEEPSEEK_API_KEY = ""; // 替换为你的DeepSeek API密钥
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";

// 处理流式响应 - 
// 使用GET方法--使用组件DeepSeekChatGet
// app.get("/deepseek-stream", async (req, res) => {
//   const { prompt, model = "deepseek-chat" } = req.query;

// 使用post方法--使用组件DeepSeekChatPost

app.post("/deepseek-stream", async (req, res) => {
  const { prompt, model = "deepseek-chat" } = req.body;

  // 设置SSE头
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("Access-Control-Allow-Origin", "*");

  const stream = new PassThrough();
  stream.pipe(res);

  try {
    // 调用DeepSeek API
    const response = await axios.post(
      DEEPSEEK_API_URL,
      {
        model,
        messages: [{ role: "user", content: prompt }],
        stream: true,
        temperature: 0.7,
        max_tokens: 2048,
      },
      {
        headers: {
          Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
          "Content-Type": "application/json",
          Accept: "text/event-stream",
        },
        responseType: "stream",
      }
    );

    // 转发DeepSeek的流式响应
    response.data.on("data", (chunk) => {
      const lines = chunk
        .toString()
        .split("\n")
        .filter((line) => line.trim() !== "");

      lines.forEach((line) => {
        if (line.startsWith("data:")) {
          const data = line.replace("data:", "").trim();

          if (data === "[DONE]") {
            stream.write("event: end\ndata: {}\n\n");
          } else {
            try {
              const parsed = JSON.parse(data);
              const content = parsed.choices[0]?.delta?.content || "";

              if (content) {
                // 确保发送完整的JSON格式数据
                const responseData = JSON.stringify({
                  content: content,
                });
                // 确保格式正确:data: {json}\n\n
                stream.write(`data: ${responseData}\n\n`);
              }
            } catch (e) {
              console.error("JSON解析错误:", e);
            }
          }
        }
      });
    });

    response.data.on("end", () => {
      stream.end();
    });

    response.data.on("error", (err) => {
      stream.write(
        `event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`
      );
      stream.end();
    });
  } catch (error) {
    stream.write(
      `event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`
    );
    stream.end();
  }
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () =>
  console.log(`Server running on http://localhost:${PORT}`)
);

在这里插入图片描述
注意这个地方,要去deepseek官网申请秘钥,很便宜的
后端服务采用的是 node,对应的是 server.js,启动命令 node server.js
server.js 中分别写了 get 和 post 两种请求方式,get 对应 DeepSeekChatGet 组件,post 对应 DeepSeekChatPost 组件,测试用的时候请调整
注意:前后端要同时启动,如果前端显示接口 404,注意一下后端的端口是不是被占用了,

package.json

{
  "name": "hello-world",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "@microsoft/fetch-event-source": "^2.0.1",
    "axios": "^1.10.0",
    "core-js": "^3.8.3",
    "cors": "^2.8.5",
    "dotenv": "^16.5.0",
    "event-source-polyfill": "^1.0.31",
    "eventsource": "^4.0.0",
    "express": "^5.1.0",
    "highlight.js": "^11.11.1",
    "markdown-it": "^14.1.0",
    "marked": "^15.0.12",
    "vue": "^2.6.14"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "less": "^4.3.0",
    "less-loader": "^12.3.0",
    "vue-template-compiler": "^2.6.14"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐