
vue 仿deepseek前端开发一个对话界面;自行封装EventSource对象,实现打字效果的对话流
·
后端:调用deepseek的api,所以返回数据格式和deepseek相同
{"model": "DeepSeek-R1-Distill-Qwen-1.5B", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ",有什么", "tool_calls": null}, "finish_reason": null, "logprobs": null}], "usage": {"prompt_tokens": 5, "completion_tokens": 11, "total_tokens": 16}, "id": "chatcmpl-203a3024a36e4c02b02200ca47d8901e", "object": "chat.completion.chunk", "created": 1741766893}
前端开发的几个注意点:
- 将后端返回的文本转换为html展示在页面上,注意调整样式
- 模拟打字形式,页面随之滚动
- 撑开的输入框和对话内容部分样式调整
- 测试多种文本输入,例如含有html标签的
- 记录思考时间,思考内容模仿deepseek做收起
- 修改nginx配置,禁用缓冲和启用分块传输,确保数据实时传输
前提:
- 项目中安装依赖highlight.js、markdown-it
- 修改nginx.conf
location 你的对话请求接口 {
proxy_pass https://127.0.0.1:10443; // 按实际的写
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; //禁用代理缓冲,使响应数据立即发送给客户端而不是先缓冲
chunked_transfer_encoding on; //启用分块传输编码,适用于流式传输
add_header Cache-Control no-cache; //添加响应头,告诉客户端不要缓存响应
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $http_host;
proxy_set_header Remote_addr $remote_addr;
}
这只是初步的项目,仅支持文本输入
懒得分步写了,直接贴完整代码吧
补充:最开始发对话使用的是get请求,已修改为post,见最下
4.9优化了回答的打字效果,见最下
有些地方可能写得比较繁琐
比如判断思考内容那段,只能通过think标签判断吗?
请各位多多指点,欢迎交流!!
<template>
<div class="talk-window">
<div class="talk-title">
<p>{{ answerTitle }}</p>
<el-input v-model="choicedTasks.name" placeholder="请选择任务" class="sangedianBtn" readonly>
<template #append>
<el-button class="ec-font icon-sangedian" @click="isShowTask = true" />
</template>
</el-input>
</div>
<div class="talk-container">
<div class="talk-welcome" v-if="contentList.length == 0">
<h1>{{ welcome.title }}</h1>
<p>{{ welcome.desc }}</p>
</div>
<div class="talk-box" v-else :style="{ height: answerContHeight }">
<div ref="logContainer" class="talk-content">
<el-row v-for="(item, i) in contentList" :key="i" class="chat-assistant">
<transition name="fade">
<div :class="['answer-cont', item.type === 'send' ? 'end' : 'start']">
<img v-if="item.type == 'answer'" :src="welcome.icon" />
<div :class="item.type === 'send' ? 'send-item' : 'answer-item'">
<div v-if="item.type == 'answer'" class="hashrate-markdown" v-html="item.message" />
<div v-else>{{ item.message }}</div>
</div>
<!-- 增加复制 -->
<!-- <div v-if="item.type == 'answer' && !isTalking">
<el-tooltip centent="复制">
<i class="ec-font icon-ticket" @click="copyMsg(item.message)" />
</el-tooltip>
</div> -->
</div>
</transition>
</el-row>
</div>
<div style="text-align: center; margin-top: 10px">
<el-button class="chat-add" @click="newChat">
<i class="ec-font icon-tianjia1" />
新建对话
</el-button>
</div>
</div>
<div class="talk-send">
<textarea
@keydown.enter="enterMessage"
ref="input"
v-model="inputMessage"
@input="adjustInputHeight"
placeholder="输入消息..."
:rows="2" />
<!-- <el-input
v-model="inputMessage"
:autosize="{ minRows: 2, maxRows: 5 }"
type="textarea"
@keyup.enter="enterMessage"
placeholder="Please input" /> -->
<div class="talk-btn-cont" style="text-align: right">
<img @click="sendMessage" :src="iconImg" />
</div>
</div>
</div>
<taskDialog v-if="isShowTask" :choicedTasks="choicedTasks" v-model:isShow="isShowTask" @confirm="confirmTask" />
</div>
</template>
<script>
import taskDialog from '@/views/chat/task/taskDialog.vue'
import hljs from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import { VSConfig } from '/config/envConfig'
window.hiddenThink = function (index) {
// 隐藏思考内容
if (document.getElementById(`think_content_${index}`).style.display == 'none') {
document.getElementById(`think_content_${index}`).style.display = 'block'
document.getElementById(`think_icon_${index}`).classList.replace('icon-a-xiangshang3', 'icon-a-xiangxia3')
} else {
document.getElementById(`think_content_${index}`).style.display = 'none'
document.getElementById(`think_icon_${index}`).classList.replace('icon-a-xiangxia3', 'icon-a-xiangshang3')
}
}
export default {
props: {
isCollapsed: {
type: Boolean,
default: false
},
activeChat: String,
activeTitle: String,
chat_style_setting: {
type: Object,
default: function () {
return {}
}
},
systemNameOption: {
type: Object,
default: function () {
return {}
}
}
},
components: { taskDialog },
data() {
return {
inputMessage: '',
messages: [],
choicedTasks: {
uuid: '',
name: ''
},
isShowTask: false,
contentList: [],
eventSourceChat: null,
markdownIt: {},
startAnwer: false,
startTime: null,
endTime: null,
thinkTime: null,
answerTitle: '',
talkUUID: '',
refreshHistoryFlag: false, // 是否已经生成了对话uuid,生成了的话就刷新历史列表
msgHight: null,
isTalking: false, //是否在对话中,处于对话中则展示休止按钮
welcome: {
title: '很高兴见到你!',
desc: '我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~',
icon: require('@/assets/image/AI.png')
},
store: {}
}
},
watch: {
activeTitle(val) {
// 重命名了对话
this.answerTitle = val
},
activeChat(val) {
this.answerTitle = ''
this.talkUUID = val || ''
this.inputMessage = ''
this.refreshHistoryFlag = false
this.isTalking = false
if (val) {
// 滚动回到顶部
const logContainer = this.$refs.logContainer
if (logContainer) {
logContainer.scrollTop = 0
}
this.getTalkDetail()
} else {
this.contentList = []
}
this.eventSourceChat && this.eventSourceChat.close()
},
chat_style_setting(val) {
this.welcome.title = val.welcome_speech_style || this.welcome.title
this.welcome.desc = val.description_style || this.welcome.desc
this.welcome.icon = val.icon_image || this.welcome.icon
}
},
computed: {
iconImg() {
// 对话中可能有输入
if (this.isTalking) {
return require('/src/assets/image/chat/stop.png')
} else {
if (
!this.inputMessage ||
this.inputMessage.trim() === '' ||
this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
) {
return require('/src/assets/image/chat/unsend.png')
} else {
return require('/src/assets/image/chat/send.png')
}
}
},
answerContHeight() {
// 回到初始值
return this.msgHight == '56px' ? 'calc(100% - 140px)' : `calc(100% - 140px - ${this.msgHight} + 56px)`
}
},
mounted() {
this.store = mainStore()
this.markdownIt = MarkdownIt({
html: true,
linkify: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {}
}
return '' // use external default escaping
}
})
},
methods: {
getTalkDetail() {
chat
.historyDetail({
uuid: this.activeChat
})
.then((res) => {
this.contentList = []
if (res.code == 0) {
res.info.history_meta &&
res.info.history_meta.forEach((item, index) => {
if (item.conversation_type == 'Answer') {
// 增加一个历史思考记录收起吧
item.context = item.context
.replace(/<think>\n\n<\/think>/g, '')
.replaceAll(
'<think>',
`<div class="think-time">历史思考<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div><section id="think_content_${index}">`
)
.replaceAll('</think>', '</section>')
item.context = this.markdownIt.render(item.context)
}
this.contentList.push({
type: item.conversation_type == 'Question' ? 'send' : 'answer',
message: item.context
})
})
this.answerTitle = res.info.name.substring(0, 20)
}
})
},
enterMessage(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
this.sendMessage()
}
},
sendMessage() {
// 终止当前对话
if (this.isTalking) {
this.eventSourceChat && this.eventSourceChat.close()
// 关闭后,处理正在对话的"思考中"
let curAnswer = this.contentList[this.contentList.length - 1].message
curAnswer = curAnswer.replaceAll('<div class="think-time">思考中……</div>', '对话中止')
this.contentList[this.contentList.length - 1].message = curAnswer
this.isTalking = false
// 再获取一下历史记录
// 暂时不获取历史记录,因为中止对话时,历史UUID可能还没返回
// this.$emit('getHistoryList')
return
}
if (
!this.inputMessage ||
this.inputMessage.trim() === '' ||
this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
) {
this.inputMessage = ''
return false
}
if (!this.choicedTasks.name) {
this.$message({ message: '请选择推理任务', type: 'warning' })
return false
}
// 回到初始高度
const textarea = this.$refs.input
textarea.style.height = '56px'
this.msgHight = '56px'
this.eventSourceChat && this.eventSourceChat.close()
// let markedText = this.markdownIt.render(this.inputMessage)
this.contentList.push({ type: 'send', message: this.inputMessage })
this.contentList.push({ type: 'answer', message: `<div class="think-time">思考中……</div>` })
this.answerTitle = this.answerTitle || this.contentList[0].message.substring(0, 20)
this.scrollToBottom()
this.initSSEChat()
},
initSSEChat() {
const url = `${VSConfig.isHttps ? 'https' : 'http'}://${
this.store.leader
}/v1/intelligent_computing/task/chat/stream?uuid=${this.choicedTasks.uuid}&message=${encodeURIComponent(
this.inputMessage
)}&token=${this.store.token}&conversation_uuid=${this.talkUUID}`
this.inputMessage = ''
this.eventSourceChat = new EventSource(url)
let buffer = ''
this.startTime = null
this.endTime = null
this.thinkTime = null
let len = this.contentList.length
let index = len % 2 === 0 ? len - 1 : len
this.isTalking = true
this.eventSourceChat.onmessage = async (event) => {
await this.sleep(10)
if (event.data == '[DONE]') { // 最后一条数据
return false
}
// 接收 Delta 数据
// 我项目的会返回对话UUID,第二次发对话的时候要传参
try {
var { choices, created } = JSON.parse(event.data)
} catch (e) {
// 新对话在历史列表补充数据
this.talkUUID = event.data
if (!this.refreshHistoryFlag) {
this.refreshHistoryFlag = event.data
this.$emit('refreshHistory', this.refreshHistoryFlag)
}
}
// const { choices, created } = JSON.parse(event.data)
if (choices && choices[0].delta?.content) {
buffer += choices[0].delta.content
// think标签内是思考内容,单独记录思考时间
if (choices[0].delta.content.includes('<think>')) {
choices[0].delta.content = `<div class="think-time">思考中……</div><section id="think_content_${index}">`
buffer = buffer.replaceAll('<think>', choices[0].delta.content)
this.startTime = Math.floor(new Date().getTime() / 1000)
}
if (choices[0].delta.content.includes('</think>')) {
// console.log("结束时间赋值的判断")
choices[0].delta.content = `</section>`
this.endTime = Math.floor(new Date().getTime() / 1000)
// 获取到结束时间后,直接展示收起按钮
this.thinkTime = this.endTime - this.startTime
buffer = buffer
.replaceAll(
'<div class="think-time">思考中……</div>',
`<div class="think-time">已深度思考(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div>`
)
.replaceAll('</think>', choices[0].delta.content)
.replaceAll(`<section id="think_content_${index}"></section>`, '')
}
let markedText = this.markdownIt.render(buffer)
this.contentList[index] = { type: 'answer', message: markedText }
this.scrollToBottomIfAtBottom()
}
}
this.eventSourceChat.onerror = (event) => {
console.log('错误触发===》', event)
this.contentList[index] = { type: 'answer', message: `<div class="think-time">对话服务连接失败</div>` }
this.eventSourceChat.close()
this.isTalking = false
}
this.eventSourceChat.onclose = (event) => {
// 关闭事件
console.log('关闭事件--->')
this.isTalking = false
}
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
},
scrollToBottomIfAtBottom() {
this.$nextTick(() => {
const logContainer = this.$refs.logContainer
if (logContainer) {
const threshold = 100
const distanceToBottom = logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight
if (distanceToBottom <= threshold) logContainer.scrollTop = logContainer.scrollHeight
}
})
},
scrollToBottom() {
this.$nextTick(() => {
const logContainer = this.$refs.logContainer
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight
}
})
},
confirmTask(val) {
this.choicedTasks = val
},
clearWindow() {
this.eventSourceChat && this.eventSourceChat.close()
this.contentList = []
this.answerTitle = ''
this.talkUUID = ''
this.inputMessage = ''
this.refreshHistoryFlag = false
this.isTalking = false
const textarea = this.$refs.input
textarea.style.height = '56px'
this.msgHight = '56px'
},
newChat() {
this.clearWindow()
this.$emit('clearChat')
},
adjustInputHeight(event) {
// enter键盘按下的换行赋值为空
if (event.key === 'Enter' && !event.shiftKey) {
this.inputMessage = ''
event.preventDefault()
return
}
this.$nextTick(() => {
const textarea = this.$refs.input
textarea.style.height = 'auto'
// 最高200px
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
this.msgHight = textarea.style.height
})
},
copyMsg(txt) {
// 复制功能
// 创建一个临时的 textarea 元素
const textarea = document.createElement('textarea')
textarea.value = txt.replace(/<[^>]+>/g, '') // 去掉html标签
textarea.style.position = 'fixed'
document.body.appendChild(textarea)
textarea.select() // 选中文本
try {
document.execCommand('copy') // 执行复制
ElMessage({
message: '复制成功',
type: 'success'
})
} catch (err) {
ElMessage({
message: '复制失败',
type: 'error'
})
} finally {
document.body.removeChild(textarea) // 移除临时元素
}
}
},
beforeDestroy() {
this.eventSourceChat && this.eventSourceChat.close()
}
}
</script>
<style scoped lang="scss">
.talk-window {
height: 100%;
transition: margin 0.2s ease;
position: relative;
}
.talk-container {
height: calc(100% - 58px);
position: relative;
}
.talk-welcome {
text-align: center;
// margin-bottom: 25px;
padding: 10% 20% 25px;
box-sizing: border-box;
h1 {
margin-bottom: 30px;
font-size: 21px;
}
p {
color: #8f9aad;
}
}
.messages {
padding: 20px;
overflow-y: auto;
}
.message {
display: flex;
margin: 12px 0;
}
.message.user {
justify-content: flex-end;
}
.bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message.user .bubble {
background: #e8f4ff;
}
.time {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.talk-send {
background: #f1f2f7;
border-radius: 10px;
border: 1px solid #e9e9eb;
padding: 5px 10px;
margin: 0px 20%;
img {
cursor: pointer;
}
textarea {
width: 100%;
padding: 10px;
resize: none;
overflow: auto;
// min-height: 48px;
max-height: 200px;
line-height: 1.5;
box-sizing: border-box;
font-family: inherit;
border: 0px;
background: #f1f2f7;
}
textarea:focus {
outline: none !important;
}
}
input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
}
.talk-title {
height: 56px;
line-height: 56px;
p {
color: #000000;
font-size: 15px;
font-weight: 550;
text-align: center;
}
.sangedianBtn {
width: 225px;
height: 32px;
position: absolute;
top: 15px;
right: 30px;
}
}
.send-item {
max-width: 60%;
word-break: break-all;
padding: 10px;
background: #eef6ff;
border-radius: 10px;
color: #000000;
white-space: pre-wrap;
font-size: 13px;
}
.msg-row {
margin-bottom: 10px;
}
.talk-box {
height: calc(100% - 140px);
.talk-content {
background-color: #fff;
color: #324659;
overflow-y: auto;
height: calc(100% - 50px);
box-sizing: border-box;
padding: 0px 20%;
// &:hover {
// overflow-y: auto;
// }
.chat-assistant {
display: flex;
margin-bottom: 10px;
.answer-item {
line-height: 30px;
color: #324659;
}
}
.answer-cont {
position: relative;
display: flex;
width: 100%;
> img {
width: 30px;
height: 30px;
margin-right: 10px;
}
&.end {
justify-content: flex-end;
}
&.start {
justify-content: flex-start;
}
}
}
.chat-sse {
min-height: 100px;
max-height: 460px;
}
.chat-message {
height: calc(100vh - 276px);
}
.thinking-bubble {
height: calc(100vh - 296px);
}
}
.chat-add {
width: 111px;
height: 33px;
background: #dbeafe;
border-radius: 6px !important;
font-size: 14px !important;
border: 0px;
color: #516ffe !important;
&:hover {
background: #ebf0f7;
}
.icon-tianjia1 {
margin-right: 10px;
font-size: 14px;
}
}
.talk-btn-cont {
text-align: right;
height: 30px;
margin-top: 5px;
}
</style>
<style lang="scss">
@use './markdown.scss';
</style>
markdown.scss
.hashrate-markdown {
font-size: 14px;
}
.hashrate-markdown ol,
.hashrate-markdown ul {
padding-left: 2em;
}
.hashrate-markdown pre {
border-radius: 6px;
line-height: 1.45;
overflow: auto;
display: block;
overflow-x: auto;
background: #2c2c36;
color: rgb(248, 248, 242);
padding: 16px 8px;
}
.hashrate-markdown h1,
.hashrate-markdown h2,
.hashrate-markdown h3 {
// font-size: 1em;
}
.hashrate-markdown h4,
.hashrate-markdown h5,
.hashrate-markdown h6 {
font-weight: 600;
line-height: 1.7777;
margin: 0.57142857em 0;
}
.hashrate-markdown li {
margin: 0.5em 0;
}
.hashrate-markdown strong {
font-weight: 600;
}
.hashrate-markdown p {
white-space: pre-wrap;
word-break: break-word;
line-height: 24px;
color: #324659;
font-size: 14px;
}
.hashrate-markdown hr {
background-color: #e8eaf2;
border: 0;
box-sizing: content-box;
height: 1px;
margin: 12px 0;
min-width: 10px;
overflow: hidden;
padding: 0;
}
.hashrate-markdown table {
border-collapse: collapse;
border-spacing: 0;
display: block;
max-width: 100%;
overflow: auto;
width: max-content;
}
.hashrate-markdown table tr {
border-top: 1px solid #e8eaf2;
}
.hashrate-markdown table td,
.hashrate-markdown table th {
border: 1px solid #e8eaf2;
padding: 6px 13px;
}
.hashrate-markdown table th {
background-color: #f3f2ff;
font-weight: 600;
}
.hashrate-markdown section {
margin-inline-start: 0px;
border-left: 2px solid #e5e5e5;
padding-left: 10px;
color: #718096;
margin-bottom: 5px;
font-size: 12px;
p {
color: #718096;
font-size: 12px;
margin: 8px 0;
}
}
.think-time {
height: 36px;
background: #f1f2f7;
border-radius: 10px;
line-height: 36px;
font-size: 12px;
display: inline-flex;
padding: 0px 15px;
margin-bottom: 20px;
color: #1e1e1e;
}
最终页面:
3.26更新:接口换为post请求
网上常用的是@microsoft/fetch-event-source这个库
试了下有出错后陷入循环的问题
而我项目接口报错时会出现非对话流的返回,对话流错误时会返回非message类型推流,所以自行封装了EventSource发送post请求
继续优化:优化回答的显示形式,加强打字效果模拟
之前用的是
let markedText = this.markdownIt.render(buffer)
this.contentList[index] = { type: 'answer', message: markedText }
上面这段代码的逻辑是:
每次接收到新的 SSE 片段,就把所有内容存到 buffer,把整个 buffer 渲染成 HTML,替换掉原先的 message,整个 HTML 一次性展示。
这就导致就算设置了sleep,最终页面都会一次性更新,没有“打字”那种一字一顿的逐步更新感
增加定时器,控制字符更新节奏
优化代码改到下面了
sse.js:
// 从git上找了一个项目 在此基础上另外修改封装
/**
* sse.js - A flexible EventSource polyfill/replacement.
* https://github.com/mpetazzoni/sse.js
*
*/
/**
* @type SSE
* @param {string} url
* @param {SSEOptions} options
* @return {SSE}
*/
var SSE = function (url, options) {
if (!(this instanceof SSE)) {
return new SSE(url, options);
}
/** @type {string} */
this.url = url;
options = options || {};
this.headers = options.headers || {};
this.payload = options.payload !== undefined ? options.payload : '';
this.method = options.method || (this.payload && 'POST' || 'GET');
this.withCredentials = !!options.withCredentials;
this.debug = !!options.debug;
/** @type {string} */
this.FIELD_SEPARATOR = ':';
/** @type { {[key: string]: [EventListener]} } */
this.listeners = {};
/** @type {XMLHttpRequest} */
this.xhr = null;
/** @type {number} */
this.readyState = SSE.INITIALIZING;
/** @type {number} */
this.progress = 0;
/** @type {string} */
this.chunk = '';
/** @type {string} */
this.lastEventId = '';
/**
* @type AddEventListener
*/
this.addEventListener = function (type, listener) {
if (this.listeners[type] === undefined) {
this.listeners[type] = [];
}
if (this.listeners[type].indexOf(listener) === -1) {
this.listeners[type].push(listener);
}
};
/**
* @type RemoveEventListener
*/
this.removeEventListener = function (type, listener) {
if (this.listeners[type] === undefined) {
return;
}
const filtered = [];
this.listeners[type].forEach(function (element) {
if (element !== listener) {
filtered.push(element);
}
});
if (filtered.length === 0) {
delete this.listeners[type];
} else {
this.listeners[type] = filtered;
}
};
/**
* @type DispatchEvent
*/
this.dispatchEvent = function (e) {
if (!e) {
return true;
}
if (this.debug) {
console.debug(e);
}
e.source = this;
const onHandler = 'on' + e.type;
if (this.hasOwnProperty(onHandler)) {
this[onHandler].call(this, e);
if (e.defaultPrevented) {
return false;
}
}
if (this.listeners[e.type]) {
return this.listeners[e.type].every(function (callback) {
callback(e);
return !e.defaultPrevented;
});
}
return true;
};
/** @private */
this._markClosed = function () {
this.xhr = null;
this.progress = 0;
this.chunk = '';
this._setReadyState(SSE.CLOSED);
};
/** @private */
this._setReadyState = function (state) {
const event = new CustomEvent('readystatechange');
event.readyState = state;
this.readyState = state;
this.dispatchEvent(event);
};
this._onStreamFailure = function (e) {
const event = new CustomEvent('error');
event.responseCode = e.currentTarget.status;
event.data = e.currentTarget.response;
this.dispatchEvent(event);
this._markClosed();
}
this._onStreamAbort = function () {
this.dispatchEvent(new CustomEvent('abort'));
this._markClosed();
}
/** @private */
this._onStreamProgress = function (e) {
if (!this.xhr) {
return;
}
if (this.xhr.status < 200 || this.xhr.status >= 300) {
this._onStreamFailure(e);
return;
}
const data = this.xhr.responseText.substring(this.progress);
this.progress += data.length;
const parts = (this.chunk + data).split(/(\r\n\r\n|\r\r|\n\n)/g);
/*
* We assume that the last chunk can be incomplete because of buffering or other network effects,
* so we always save the last part to merge it with the next incoming packet
*/
const lastPart = parts.pop();
parts.forEach(function (part) {
if (part.trim().length > 0) {
this.dispatchEvent(this._parseEventChunk(part));
}
}.bind(this));
this.chunk = lastPart;
};
/** @private */
this._onStreamLoaded = function (e) {
this._onStreamProgress(e);
// Parse the last chunk.
this.dispatchEvent(this._parseEventChunk(this.chunk));
this.chunk = '';
this._markClosed();
// 手动触发 close 事件
if (typeof this.onclose === 'function') {
this.onclose(e);
}
};
/**
* Parse a received SSE event chunk into a constructed event object.
*
* Reference: https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage
*/
this._parseEventChunk = function (chunk) {
if (!chunk || chunk.length === 0) {
return null;
}
if (this.debug) {
console.debug(chunk);
}
const e = { 'id': null, 'retry': null, 'data': null, 'event': null };
chunk.split(/\n|\r\n|\r/).forEach(function (line) {
const index = line.indexOf(this.FIELD_SEPARATOR);
let field, value;
if (index > 0) {
// only first whitespace should be trimmed
const skip = (line[index + 1] === ' ') ? 2 : 1;
field = line.substring(0, index);
value = line.substring(index + skip);
} else if (index < 0) {
// Interpret the entire line as the field name, and use the empty string as the field value
field = line;
value = '';
} else {
// A colon is the first character. This is a comment; ignore it.
return;
}
if (!(field in e)) {
return;
}
// consecutive 'data' is concatenated with newlines
if (field === 'data' && e[field] !== null) {
e['data'] += "\n" + value;
} else {
e[field] = value;
}
}.bind(this));
if (e.id !== null) {
this.lastEventId = e.id;
}
if (e.event) {
const event = new CustomEvent(e.event || 'message');
event.id = e.id;
event.data = e.data || '';
event.lastEventId = this.lastEventId;
return event;
} else {
// 考虑一种情况,单纯后端报错;
// 要与正常对话结束返回的JSON区分,且要与alert类型(报错类型)后的返回区分
try {
const { data } = JSON.parse(chunk)
if (data && data.status.code) {
// 后端报错
const errorEvent = new CustomEvent('alert');
errorEvent.id = e.id;
errorEvent.data = JSON.stringify(data.status);
errorEvent.lastEventId = this.lastEventId;
return errorEvent
}
} catch (e) {
}
}
};
this._onReadyStateChange = function () {
if (!this.xhr) {
return;
}
if (this.xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
const headers = {};
const headerPairs = this.xhr.getAllResponseHeaders().trim().split('\r\n');
for (const headerPair of headerPairs) {
const [key, ...valueParts] = headerPair.split(':');
const value = valueParts.join(':').trim();
// Ensure the header value is always an array
headers[key.trim().toLowerCase()] = headers[key.trim().toLowerCase()] || [];
headers[key.trim().toLowerCase()].push(value);
}
const event = new CustomEvent('open');
event.responseCode = this.xhr.status;
event.headers = headers;
this.dispatchEvent(event);
this._setReadyState(SSE.OPEN);
}
};
//这里绑定方法,确保 `removeEventListener` 能正确移除事件
this._onStreamProgress = this._onStreamProgress.bind(this);
this._onStreamLoaded = this._onStreamLoaded.bind(this);
this._onReadyStateChange = this._onReadyStateChange.bind(this);
this._onStreamFailure = this._onStreamFailure.bind(this);
this._onStreamAbort = this._onStreamAbort.bind(this);
/**
* starts the streaming
* @type Stream
* @return {void}
*/
this.stream = function () {
if (this.xhr) {
// Already connected.
return;
}
this._setReadyState(SSE.CONNECTING);
this.xhr = new XMLHttpRequest();
this.xhr.addEventListener('progress', this._onStreamProgress);
this.xhr.addEventListener('load', this._onStreamLoaded);
this.xhr.addEventListener('readystatechange', this._onReadyStateChange);
this.xhr.addEventListener('error', this._onStreamFailure);
this.xhr.addEventListener('abort', this._onStreamAbort);
this.xhr.open(this.method, this.url);
for (let header in this.headers) {
this.xhr.setRequestHeader(header, this.headers[header]);
}
if (this.lastEventId.length > 0) {
this.xhr.setRequestHeader("Last-Event-ID", this.lastEventId);
}
this.xhr.withCredentials = this.withCredentials;
this.xhr.send(this.payload);
};
/**
* closes the stream
* @type Close
* @return {void}
*/
this.close = function () {
if (this.readyState === SSE.CLOSED) {
return;
}
// 移除事件监听器
this.xhr.removeEventListener('progress', this._onStreamProgress);
this.xhr.removeEventListener('load', this._onStreamLoaded);
this.xhr.removeEventListener('readystatechange', this._onReadyStateChange);
this.xhr.removeEventListener('error', this._onStreamFailure);
this.xhr.removeEventListener('abort', this._onStreamAbort);
// 终止请求
try {
this.xhr.abort();
} catch (e) {
console.warn('XHR abort error:', e);
}
this.xhr = null;
// 标记关闭状态
this._markClosed();
};
// 补充
this.destroy = function () {
this.close();
this.listeners = {}; // 彻底销毁事件监听器
this._onStreamProgress = null;
this._onStreamLoaded = null;
this._onReadyStateChange = null;
this._onStreamFailure = null;
this._onStreamAbort = null;
};
if (options.start === undefined || options.start) {
this.stream();
}
};
/** @type {number} */
SSE.INITIALIZING = -1;
/** @type {number} */
SSE.CONNECTING = 0;
/** @type {number} */
SSE.OPEN = 1;
/** @type {number} */
SSE.CLOSED = 2;
// Export our SSE module for npm.js
if (typeof exports !== 'undefined') {
exports.SSE = SSE;
}
// Export as an ECMAScript module
export { SSE };
使用:
postSSEChat() {
// 修改为post类型
this.eventSourceChat = new SSE(
`${VSConfig.isHttps ? 'https' : 'http'}://${this.store.leader}/v1/intelligent_computing/task/chat/stream`,
{
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
Authorization: `Bearer ${this.store.token}`
},
payload: JSON.stringify({
message: this.inputMessage,
token: this.store.token,
conversation_uuid: this.talkUUID,
uuid: this.choicedTasks.uuid
})
}
)
this.inputMessage = ''
let buffer = ''
let displayBuffer = ''
this.startTime = null
this.endTime = null
this.thinkTime = null
let len = this.contentList.length
let index = len % 2 === 0 ? len - 1 : len
this.isTalking = true
this.eventSourceChat.onmessage = async (event) => {
if (event.data == '[DONE]') {
this.clearTypingInterval()
return false
}
await this.sleep(10)
// 接收 Delta 数据
// 最后一条是UUID,第二次发对话的时候要传参
try {
var { choices, created } = JSON.parse(event.data)
} catch (e) {
// 新对话在历史列表补充数据
this.talkUUID = event.data
if (!this.refreshHistoryFlag) {
this.refreshHistoryFlag = event.data
this.$emit('refreshHistory', this.refreshHistoryFlag)
}
}
if (choices && choices[0].delta?.content) {
let answerCont = choices[0].delta.content
buffer += answerCont
// 单独记录时间
if (answerCont.includes('<think>') || answerCont.includes('</think>')) {
// 思考时间这段不需要有打字效果
// 执行替换逻辑
if (answerCont.includes('<think>')) {
answerCont = `<div class="think-time">思考中……</div><section id="think_content_${index}">`
buffer = buffer.replaceAll('<think>', answerCont)
this.startTime = Math.floor(new Date().getTime() / 1000)
}
if (answerCont.includes('</think>')) {
answerCont = `</section>`
this.endTime = Math.floor(new Date().getTime() / 1000)
// 获取到结束直接后,直接展示收起按钮
this.thinkTime = this.endTime - this.startTime
buffer = buffer
.replaceAll(
'<div class="think-time">思考中……</div>',
`<div class="think-time">已深度思考(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div>`
)
.replaceAll('</think>', answerCont)
.replaceAll(`<section id="think_content_${index}"></section>`, '')
}
// 避免闪动 直接修改数据,这里不需要打字效果
displayBuffer = buffer // 同步displayBuffer避免断层
this.contentList[index] = { type: 'answer', message: this.markdownIt.render(buffer) }
this.scrollToBottomIfAtBottom()
} else {
// 逐字效果
if (!this.typingInterval) {
this.typingInterval = setInterval(() => {
if (displayBuffer.length < buffer.length) {
const remaining = buffer.length - displayBuffer.length
// 暂定一次性加3个字符
const addChars = buffer.substr(displayBuffer.length, Math.min(3, remaining))
displayBuffer += addChars
// displayBuffer += buffer[displayBuffer.length]
let markedText = this.markdownIt.render(displayBuffer)
this.contentList[index] = { type: 'answer', message: markedText }
this.scrollToBottomIfAtBottom()
} else {
clearInterval(this.typingInterval)
this.typingInterval = null
}
}, 40)
}
}
}
}
// 如果是报错,后端会返回alert
this.eventSourceChat.onalert = (e) => {
this.eventSourceChat.close()
this.errorFunc(index, e)
}
this.eventSourceChat.onerror = (e) => {
this.eventSourceChat.close()
this.isTalking = false
}
this.eventSourceChat.onabort = (e) => {
console.log('AAAAAAAAA---------')
this.clearTypingInterval()
this.isTalking = false
}
this.eventSourceChat.onclose = (e) => {
console.log('onclose---------')
this.clearTypingInterval()
this.isTalking = false
}
},
// 记得项目多处都要清掉计时器
clearTypingInterval() {
clearInterval(this.typingInterval)
this.typingInterval = null
},
更多推荐
所有评论(0)