前言
前端界面的对话功能具体由另一位同学实现,现在的目标是对其进一步优化,将后端回应的内容逐字地、像打印机一样展示出来,使界面更加美观。
效果类似于chatgpt的输出。
逐字输出具体实现
在网上搜集了几种实现打字机效果的流式实现方案,它们或是基于SSE库,或是基于axios,下面我将逐一列出,进行对比。
EvenSource实现
逻辑思路大致如下:
- 检查现有链接:if (!this.eventSource):只有当 eventSource 不存在时才创建新的 EventSource 连接。这确保了不会在已有连接的情况下重复创建。
- 发送消息并初始化接收:this.messages.push({text: this.inputText, isMine: true}):将用户输入的消息添加到 messages 数组中,并标记 isMine 为 true 表示是用户发送的消息。this.messages.push({text: “”, isMine: false}):在 messages 数组中添加一个空消息对象,标记 isMine 为 false 表示是系统接收的消息。这一空对象将用于逐字显示从服务器接收到的消息。
- 创建新的EvenSource链接:this.eventSource = new EventSource(‘http://127.0.1.1:8383/completions?messages=’+this.inputText):使用用户输入的消息 inputText 创建一个新的 EventSource 连接到指定的服务器端点。
- 设置消息接收的回调函数:this.eventSource.onmessage = (event) => { … }:为 EventSource 设置 onmessage 回调函数,以便处理从服务器接收到的消息。const data = JSON.parse(event.data):解析接收到的 JSON 格式的数据。this.messages[this.messages.length - 1].text += data.choices[0].delta.content:将接收到的消息内容逐字追加到 messages 数组中最后一个消息对象的 text 属性中。
- 处理错误事件:this.eventSource.onerror = (event) => { … }:为 EventSource 设置 onerror 回调函数,以便处理连接错误。
console.error(“EventSource failed:”, event):在控制台打印错误信息。
this.eventSource.close():关闭出错的 EventSource 连接。
this.eventSource = null:将 eventSource 变量重置为 null,以便在下次调用 sendSSEMessage 方法时能够重新创建连接。
sendSSEMessage() {
// 只有当eventSource不存在时才创建新的EventSource连接
if (!this.eventSource) {
this.messages.push({text: this.inputText, isMine: true});
this.messages.push({text: "", isMine: false});
// 创建新的EventSource连接
this.eventSource = new EventSource('http://127.0.1.1:8383/completions?messages='+this.inputText);
// 设置消息接收的回调函数
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
this.messages[this.messages.length - 1].text += data.choices[0].delta.content;
};
// 可选:监听错误事件,以便在出现问题时能够重新连接或处理错误
this.eventSource.onerror = (event) => {
console.error("EventSource failed:", event);
this.eventSource.close(); // 关闭出错的连接
this.eventSource = null; // 重置eventSource变量,允许重建连接
};
}
}
@microsoft/fetch-event-source插件库实现
逻辑思路大致如下:
- 首先从@microsoft/fetch-event-source中引入fetchEventSource
- 通过 fetchEventSource(url, {…}) 发送 SSE 请求。
- 传递给 fetchEventSource 的参数包括请求的方法、头部信息、请求体、信号等
- onopen(response):建立连接时的回调函数。
- onmessage(msg):接收到数据时的回调函数。由于 SSE 是流式传输,所以这个回调函数会被多次调用。在这个回调函数中,首先检查是否发生了用户手动取消请求的情况,然后根据消息的类型进行相应的处理
- onclose():正常结束连接时的回调函数。
- onerror(err):连接出现异常时的回调函数。
- 在 onmessage 回调函数中,根据接收到的消息进行相应的处理。如果消- 息类型为空字符串,则表示连接正常,对数据进行解析和处理;如果消息类型为 ‘close’,则表示连接错误。
import { fetchEventSource } from '@microsoft/fetch-event-source';
async sseSendStream(){
let that = this
let token = “获取token”;
let tokens = "Bearer" + ' ' + token;
let params = {
"model": that.gptmodel,
"stream": true,
"convGroupId": that.convGroupId,
"parentConvId": that.convId,
"request": that.inputText,
}
that.inputText = ""; //发送请求前将输入清空
return new Promise((resolve, reject) => {
try {
let concateContent = "";
fetchEventSource(url, {
method: 'post',
headers: {
'Content-Type': 'application/json',
"Accept": "text/event-stream",
"Authorization": tokens,
},
responseType: 'text/event-stream',
body: JSON.stringify(params),
signal: that.controller.signal,
openWhenHidden: true,
async onopen(response) {//建立连接的回调},
onmessage(msg) {//接收一次数据段时回调,因为是流式返回,所以这个回调会被调用多次
if(msg.event==''){
//进行连接正常的操作
try{
const dataObj = JSON.parse(msg.data)
// 检查数据块是否包含有效的content字段
if (dataObj) {
// 将content字段的值拼接到结果字符串中
concateContent += dataObj.choices[0].delta.content;
that.dialogueList[that.dialogueList.length - 1].text = concateContent
}
}catch (e){
}
}else if (msg.event === 'close') {
//连接错误的操作
reject('close error')
}
},
onclose() {//正常结束的回调
//在这里写一些GPT回答结束后的一些操作
},
onerror(err) {//连接出现异常回调
// 取消请求
reject(err); // 发生错误,拒绝 Promise
throw err
},
})
} catch (e) {
reject(e); // 拒绝 Promise
}
});
},
axios实现
逻辑思路大致如下:
- 异步请求导出日志数据:exportLog(getNeedDelIds).then(res => { … }).catch(err => { … }):调用 exportLog 方法,并传递参数 getNeedDelIds,这个方法返回一个 Promise 对象。当请求成功时,执行 then 回调函数;当请求失败时,执行 catch 回调函数。
- 处理响应数据:let response = res.data:将响应数据赋值给 response 变量
const dispositionHeader = res.headers[‘content-disposition’]:获取响应头中的 Content-Disposition 字段,用于提取文件名。 - 提取文件名:const matches = /filename*=UTF-8’‘([\w%±]+).xlsx/i.exec(dispositionHeader):使用正则表达式从 Content-Disposition 头中提取文件名。正则表达式匹配 filename*=UTF-8’’ 后的文件名部分。
const filename = matches ? decodeURIComponent(matches[1]) : ‘file.xlsx’:如果正则表达式匹配成功,则解码文件名,否则默认文件名为 file.xlsx。 - 创建 URL 对象:const url = window.URL.createObjectURL(new Blob([response], { type: response.type })):将响应数据转换为 Blob 对象,并创建一个 URL 对象,response.type 设置为 Blob 的类型。
- 创建下载链接:const link = document.createElement(‘a’):创建一个 a 元素,用于下载文件。
link.style.display = ‘none’:将链接隐藏在页面上。
link.href = url:设置链接的 href 属性为之前创建的 URL。
link.setAttribute(‘download’, filename):设置 download 属性为提取的文件名。 - 触发下载并清理:document.body.appendChild(link):将链接元素添加到文档的 body 中。
link.click():触发点击事件,开始下载文件。
document.body.removeChild(link):从文档中移除链接元素。 - 错误处理:catch(err => { console.log(err) }):如果请求过程中发生错误,捕获错误并打印到控制台。
exportLog(getNeedDelIds).then(res => {
let response = res.data;
// const filename = res.headers['content-disposition'].split('filename=')[1]
// 获取文件名
const dispositionHeader = res.headers['content-disposition'];
const matches = /filename\*=UTF-8''([\w%+-]+)\.xlsx/i.exec(dispositionHeader);
const filename = matches ? decodeURIComponent(matches[1]) : 'file.xlsx';
console.log('文件名:', filename);
const url = window.URL.createObjectURL(new Blob([response], { type: response.type })) // 创建URL对象
const link = document.createElement('a')
link.style.display = 'none'
link.href = url
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click() // 触发下载
document.body.removeChild(link)
}).catch(err => {
console.log(err)
})
最后实现
模板部分:
- v-for 指令:遍历 messages 数组,渲染每条消息。
- v-if 指令:在显示打字效果时,显示 typing 内容。showTyping 为 true 且当前消息为最后一条消息时,显示打字效果。
<template>
<div class="chat">
<div class="chat-bubble" v-for="(message, index) in messages" :key="index">
<div>{{ message.content }}</div>
<span v-if="showTyping && index === messages.length - 1">{{ typing }}</span>
</div>
</div>
</template>
脚本部分:
- messages:存储所有消息的数组,每条消息包含 content 属性。
- typing:显示打字动画的内。
- showTyping:控制是否显示打字动画。
- intervalId:存储定时器的 ID,用于清除定时器。
- addMessage(message):向 messages 数组添加一条消息,并逐字显示消息内容。
export default {
name: 'Chat',
data() {
return {
messages: [],
typing: '',
showTyping: false,
intervalId: null
}
},
created() {
this.addMessage({
content:
`
为什么如此?因为在青春时代,生活充满了奇特而辛酸的不可思议的事。
Why is this so? Because in youth, life is filled with strange and poignant incredible things.
`
})
},
methods: {
addMessage(message) {
this.messages.push({ ...message, content: '' })
let i = 0
const msgLength = message.content.length
const typingDelay = Math.floor(Math.random() * 100) + 50 // 随机生成打字的延迟时间
const typeNextLetter = () => {
this.messages[this.messages.length - 1].content += message.content.charAt(i)
i++
if (i <= msgLength) {
setTimeout(typeNextLetter, typingDelay)
} else {
clearTimeout(this.intervalId)
this.showTyping = false
this.intervalId = setTimeout(() => {
this.addMessage({
content: '...'
})
}, Math.floor(Math.random() * 5000) + 1000)
}
}
clearTimeout(this.intervalId)
this.showTyping = true
this.intervalId = setTimeout(typeNextLetter, 500)
}
}
}
样式部分:
- chat:设置聊天区域的样式,使其内容按列排列。
- chat-bubble:设置聊天气泡的样式,包括边距、填充、背景色等。
- chat-bubble:last-child:为最后一个聊天气泡设置额外的底部边距。
<style>
.chat {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.chat-bubble {
margin: 5px;
padding: 10px;
border-radius: 10px;
background: #eee;
color: #333;
display: inline-block;
max-width: 70%;
word-break: break-word;
}
.chat-bubble:last-child {
margin-bottom: 20px;
}
</style>
整体逻辑:
- 初始化消息内容为空:当 addMessage 方法被调用时,先将消息内容初始化为空字符串并追加到 messages 数组中。
- 生成随机打字延迟:每次输出一个字符时,延迟时间是随机的,使打字效果更真实。
- 逐字显示字符:通过 typeNextLetter 方法逐字显示消息内容,使用 setTimeout 进行定时调用。
- 控制打字效果显示:在消息逐字显示期间,设置 showTyping 为 true,在消息完全显示后设置为 false。