前端接收流式数据demo,并用markdown解析数据,包括EventSource和fetch两种方式
·
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"
]
}
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)