SSE 的 AI 问答多数据流在 Vue3 中的实现
1.SSE 简介
SSE(Server-Sent Events)即服务器推送事件,是一种允许服务器向客户端实时推送更新的技术。它基于 HTTP 连接,是单向通信,数据只能从服务器流向客户端,在单向推送场景下(如各大 AI 网站)得到很好的应用。一个 EventSource 实例会对 HTTP 服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。
2. SSE 用于 AI 问答多数据流的优势
- 流式数据推送:适用于 AI 聊天等场景,数据一旦生成,立即传输给客户端,可实现 AI 逐字输出回答,提升用户体验。
- 轻量级实现:基于 HTTP 传输,无需 WebSocket 复杂握手过程,浏览器原生支持,兼容性强。
- 减少服务器负担:相比轮询请求,SSE 仅建立一个持久连接,降低系统开销。
- 高并发与非阻塞:基于 Reactor 响应式编程模型,适合处理高并发流式数据。
3.涉及库
解码数据流
TextDecoder
const decoder = new TextDecoder('utf-8');
TextDecoder.decode
TextDecoder
实例的decode()
方法可以将Uint8Array
中的字节序列解码为字符串。这个方法可以处理多块数据,即使这些数据不是完整的UTF-8字符序列。
getReader() 读取流数据
每次调用读取器的read()
方法,它都会返回一个包含done
和value
属性的对象。
done
是一个布尔值,表示流是否已经结束;
value
是一个Uint8Array
,包含了流中的数据片段。
document.querySelector('#thinkContentBox')
:这行代码会返回文档中 ID 为 thinkContentBox
的第一个元素\
处理数据
buffer += chunk;
//将新接收到的数据块(chunk
)追加到现有的缓冲区字符串(buffer
)中
let lines = buffer.split('data:');
// 这行代码将buffer
中的数据按字符串'data:'
进行分割,生成一个字符串数组lines
。每个数组元素都是一个以'data:'
开头的字符串,代表一个完整的数据条目。这通常用于处理服务器发送的、以特定分隔符分隔的数据流。
buffer = lines.pop();
//这行代码从lines
数组中移除最后一个元素,并将其赋值回buffer
。这个操作是为了保存可能不完整的数据条目(如果有的话),以便在下一次接收数据时可以继续处理。如果最后一个元素是一个完整的数据条目,那么buffer
将被清空,准备接收新的数据块。
接收数据流
序列化和反序列化
SSE(Server-Sent Events)协议
4. SSE协议具体介绍
SSE(Server-Sent Events)协议,全称Server-Sent Events,是一种用于服务器主动向客户端推送数据的技术,也被称为事件流(Event Stream),以下将从其主要信息、特点、使用步骤、字段含义、与其他协议对比、适用场景等方面展开介绍:
主要信息
SSE协议基于HTTP协议,通过长连接的方式实现服务器向客户端的实时数据推送。
特点
- 基于HTTP协议:利用HTTP协议的长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。
- 单向通信:仅支持服务器向客户端的单向通信,客户端无法直接通过SSE连接向服务器发送数据。
- 轻量级:在数据传输上相对轻量级,主要发送文本格式的数据,不需要进行复杂的编码或解码操作。
- 自动重连:自带自动重连功能,如果连接断开,浏览器会尝试重新建立连接,确保客户端能够持续接收服务器发送的事件。
- 良好的兼容性:在现代浏览器中都有良好的兼容性,可以广泛应用于Web应用程序和移动端应用。不过,IE和早期版本的Edge不支持该协议。
- 协议限制:不支持二进制传输,需要使用方把数据转换成二进制格式;需要开启长连接和禁止缓存内容,对服务器资源有一定要求。
5. 使用步骤
- 客户端发起请求:客户端通过EventSource对象向服务器发起一个HTTP GET请求,请求特定的SSE资源。
- 服务器响应:服务器接收到请求后,不会立即关闭连接,而是保持连接开启状态,并通过这条连接不断向客户端发送数据。服务器发送的数据被封装成事件流的形式,每个事件包含一定的数据内容。
- 客户端接收数据:客户端通过监听EventSource对象上的事件(如onmessage、onerror、onopen等),来接收服务器发送的数据,并根据需要进行处理。
字段含义
SSE协议中约定的字段主要包括以下几种:
字段 | 含义 |
---|---|
id | 事件的唯一标识符,用于表示事件的序号。客户端可以通过这个标识符实现断线重连功能。需要重连的时候,客户端在HTTP的header里加一个Last - Event - ID字段,把最后接收到的id传给服务端,服务端实现了重连功能,就能继续传Last - Event - ID之后的消息给客户端 |
data | 返回的业务数据。如果数据很长,可以分成多行返回 |
retry | 重连的间隔时间(以毫秒为单位),用于指定如果连接断开后,客户端应该多久后尝试重新连接 |
event | 用来标识事件的类型,例如当服务端数据推送完成后,通常会发送一个特殊的event事件表示数据全部发送完,之后断开连接 |
6. 与其他协议对比
与WebSocket对比
对比项 | SSE | WebSocket |
---|---|---|
通信方向 | 只支持服务器向客户端推送数据 | 支持双向通信 |
协议 | 基于HTTP协议 | 有自己的协议 |
浏览器兼容性 | 在现代浏览器中得到良好支持,但不支持IE | 得到了更广泛的浏览器支持 |
性能 | 对于大规模的实时数据推送性能可能不如WebSocket | 全双工,对于大规模实时数据推送可能提供更好的性能 |
用途 | 适合轻量级的推送任务 | 适合需要复杂交互的应用 |
数据类型 | 主要用于文本数据 | 可以更有效地处理二进制数据 |
资源损耗 | 消耗更少的资源,因为只需要服务器向客户端推送数据 | 消耗更多资源,需要处理双向通信,涉及更多的数据传输和状态管理 |
7. 适用场景
SSE协议特别适用于需要服务器主动向客户端推送数据,但客户端不需要频繁向服务器发送请求的场景,例如:
- 实时新闻更新:服务器可以实时将最新的新闻推送给客户端。
- 股票行情推送:让客户端实时获取股票价格的变化。
- 在线聊天室的消息推送:虽然WebSocket更适用于双向通信,但在某些场景下,SSE可以用于实现简单的聊天应用。
- 服务器监控:实时获取服务器运行状态、日志等信息。
总的来说,SSE协议是一种简单、轻量级且兼容性良好的实时通信技术,适用于多种Web应用程序和移动端应用的实时数据推送需求。
8. 在 Vue3 中实现 SSE 的 AI 问答多数据流的步骤
安装依赖
首先,需要安装
@microsoft/fetch-event-source
库,它可以帮助我们在 Vue3 中更方便地处理 SSE 连接。
sql
npm install @microsoft/fetch-event-source
前端实现(Vue3 + SSE)
以下是一个完整的 Vue3 组件示例,使用 Composition API 实现 AI 对话功能:
xml
<template>
<div>
<div v-for="(message, index) in messages" :key="index">
<span>{{ message.role === 'user' ? '你: ' : 'AI: ' }}</span>
<span>{{ message.content }}</span>
</div>
<input v-model="inputText" @keyup.enter="sendMessage" placeholder="请输入问题">
<button @click="sendMessage">发送</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { fetchEventSource } from '@microsoft/fetch-event-source';
// 使用 ref 存储对话历史,包括用户和 AI 的消息
const messages = ref([]);
// 使用 ref 绑定用户输入框的值
const inputText = ref('');
const sendMessage = () => {
if (inputText.value.trim() === '') return;
// 将用户输入添加到 messages 中
messages.value.push({ role: 'user', content: inputText.value });
inputText.value = '';
// 调用 fetchAIResponse 发送到 AI API
fetchAIResponse();
};
const fetchAIResponse = () => {
const apiUrl = 'YOUR_AI_API_URL'; // 替换为实际的 AI API 地址
const lastUserMessage = messages.value[messages.value.length - 1].content;
fetchEventSource(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ question: lastUserMessage }),
onopen: (response) => {
if (response.ok) {
console.log('SSE 连接已建立');
} else {
console.error('SSE 连接失败', response.status);
}
},
onmessage: (event) => {
const data = JSON.parse(event.data);
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage.role === 'user') {
messages.value.push({ role: 'assistant', content: data.text });
} else {
lastMessage.content += data.text;
}
},
onclose: () => {
console.log('SSE 连接已关闭');
},
onerror: (error) => {
console.error('SSE 发生错误', error);
}
});
};
</script>
后端实现(以 Spring Boot + WebFlux 为例)
在后端,我们需要设置一个支持 SSE 的端点来处理 AI 问答请求,并将 AI 的回答以流式数据的形式返回给客户端。以下是一个简单的示例:
kotlin
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.stream.Stream;
@RestController
public class AiController {
@PostMapping(path = "/ai-answer", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> getAiAnswer(@RequestBody String question) {
// 模拟 AI 逐字输出回答
String answer = "这是一个模拟的 AI 回答";
Stream<String> charStream = answer.chars()
.mapToObj(c -> String.valueOf((char) c));
return Flux.fromStream(charStream)
.delayElements(Duration.ofMillis(100))
.map(data -> ServerSentEvent.<String>builder()
.data(data)
.build());
}
}
注意事项
- 连接数限制:受 HTTP/1.1 限制,部分浏览器对单域名的 SSE 连接数有限制。
- 学习成本:与传统 Spring MVC 不同,使用 WebFlux 需要掌握响应式编程范式。
- 安全性考虑:在实际应用中,需要确保数据传输的安全性,例如使用 HTTPS 协议等。
通过以上步骤,你可以在 Vue3 中实现基于 SSE 的 AI 问答多数据流功能,为用户提供更流畅的交互体验。
具体如下
ini
export const getData = (params) => {
reverbParams.value=params
console.log(params)
writingModel.setLoading(true)
postWriting1(params).then(response => {
console.log(params)
writingModel.setIsLike('')
writingModel.setFinalText('')
writingModel.setMarkdownText('')
setThinkBox()
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let html = ''
// html_title = `思考中...<img id="thinkArrow" src="./img/up.png" width="15px" height="15px" show="show">`
let renderObj = {
content: '',
thinkContent: '',
startTime: null,
endTime: null,
// 判断思考是否完成
isThink: null,
// 判断是否是深度思考
// isThinking: params?.modelParam?.modelId!=="1901558589563211776",
// isThinking: true,
isThinking: secretaryModelStore().getThink,
cmId:''
}
let content = ''
const thinkLabel = document.querySelector('#thinkLabel');
const thinkArrow = thinkLabel.querySelector('#thinkArrow');
const thinkContent = document.querySelector('#thinkContentBox');
function processStreamResult(result2) {
const chunk = decoder.decode(result2.value, {
stream: !result2.done
});
if (response.status == 200) {
buffer += chunk;
//逐条解析后端返回数据
let lines = buffer.split('data:')
buffer = lines.pop();
if(buffer.indexOf(': ping')<0){
if(JSON.parse(buffer)?.code ==500){
message.destroy()
message.error(JSON.parse(buffer)?.message||"模型调用错误,请稍后重试!")
writingModel.setLoading(false);
return
}
}
lines.forEach(line => {
if (line.trim().length > 0) {
console.log(line)
let res
if(line.split(': ping')[0]){
res = JSON.parse(line.split(': ping')[0])
}
if(res?.data?.cmId){
renderObj.cmId=res?.data?.cmId
writingModel.setCmId(res?.data?.cmId)
writingModel.setId(res?.data?.cmId)
}
if (res?.data.content) {
content += res.data.content
}
if (res?.data.content ) {
if (res.data.content.indexOf('<think>') > -1 || renderObj.isThink == null) {
renderObj.isThink = true
renderObj.thinkContent = res.data.content
renderObj.startTime = (new Date()).getTime()
thinkArrow.src = './img/up.png'
thinkArrow.setAttribute('show', 'show')
thinkContent.style.display = 'block'
thinkContent.style.opacity = '1'
thinkLabel.querySelector('#thinkIcon').src = './img/think-start.png'
thinkLabel.querySelector('p').innerHTML = '思考中...'
} else if (res.data.content.indexOf('</think>') > -1&&renderObj.isThinking) {
renderObj.isThink = true
renderObj.endTime = (new Date()).getTime()
renderObj.thinkContent = renderObj.thinkContent + res.data.content
thinkLabel.querySelector('#thinkIcon').src = './img/think-finish.png'
thinkLabel.querySelector('p').innerHTML = `已深度思考(用时${(renderObj.endTime - renderObj.startTime) / 1000}s)`
thinkArrow.src = './img/down.png'
thinkArrow.setAttribute('show', 'hide')
thinkContent.style.display = 'none';
} else if (renderObj.thinkContent.indexOf('</think>') < 0&&renderObj.isThink&&renderObj.isThinking) {
renderObj.thinkContent = renderObj.thinkContent + res.data.content
} else {
let titleMarkdown = params?.title?`\n\n# ${params?.title}\n\n## `:''
if(renderObj.content.indexOf(titleMarkdown)<0){
// 添加标题
renderObj.content = titleMarkdown+renderObj.content + res.data.content
}else{
renderObj.content = renderObj.content + res.data.content
}
}
}
}
});
if (content && writingModel.getLoading) {
console.log(renderObj.content,content,html)
//写入存储中全局文档对象,更新返回的内容
writingModel.setMarkdownText(renderObj.content)
setThinkBox(secretaryModelStore().getThink?_markdown2html.parse(renderObj.thinkContent):"")
// setThinkBox(html)
writingModel.setFinalText(_markdown2html.parse(renderObj.isThinking?renderObj.content:content))
}
if (!result2.done) {
return reader.read().then(processStreamResult);
} else {
const iframeFooter = document.querySelector('#iframeFooter');
iframeFooter.querySelector('span').innerHTML = '以上内容由AI生成。'
iframeFooter.style.display = 'flex';
let historyParams={
isLike: '0',
commentValue: '',
otherComment: '',
cmId:renderObj.cmId,
answer:writingModel.getMarkdownText,
finalText: encodeURIComponent(writingModel.getFinalText)
}
postUpdateMessage(historyParams).then(data => {
if (data.basicParam.code == 'AS0000') {
writingModel.setWritingDoc({
...writingModel.getWritingDoc,
id: data.basicParam.id,
// title:params.write_info.title,
// ...obj
})
console.info(writingModel.getWritingDoc)
}
})
.catch(error => {
console.error('Error:', error);
})
writingModel.setLoading(false)
}
} else if (response.status == 429) {
message.destroy();
message.warning(chunk)
writingModel.setLoading(false)
}
}
return reader.read().then(processStreamResult);
})
.catch(error => {
console.error('Error:', error.message);
writingModel.setLoading(false)
console.info(error)
if (error.message && error.message.indexOf('aborted') < 0) {
message.destroy();
message.error("模型调用错误,请稍后重试!")
}
});
}