SSE AI问答实现打字机效果实践-vue3版

1,349 阅读9分钟

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()方法,它都会返回一个包含donevalue属性的对象。
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协议,通过长连接的方式实现服务器向客户端的实时数据推送。

特点

  1. 基于HTTP协议:利用HTTP协议的长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。
  2. 单向通信:仅支持服务器向客户端的单向通信,客户端无法直接通过SSE连接向服务器发送数据。
  3. 轻量级:在数据传输上相对轻量级,主要发送文本格式的数据,不需要进行复杂的编码或解码操作。
  4. 自动重连:自带自动重连功能,如果连接断开,浏览器会尝试重新建立连接,确保客户端能够持续接收服务器发送的事件。
  5. 良好的兼容性:在现代浏览器中都有良好的兼容性,可以广泛应用于Web应用程序和移动端应用。不过,IE和早期版本的Edge不支持该协议。
  6. 协议限制:不支持二进制传输,需要使用方把数据转换成二进制格式;需要开启长连接和禁止缓存内容,对服务器资源有一定要求。

5. 使用步骤

  1. 客户端发起请求:客户端通过EventSource对象向服务器发起一个HTTP GET请求,请求特定的SSE资源。
  2. 服务器响应:服务器接收到请求后,不会立即关闭连接,而是保持连接开启状态,并通过这条连接不断向客户端发送数据。服务器发送的数据被封装成事件流的形式,每个事件包含一定的数据内容。
  3. 客户端接收数据:客户端通过监听EventSource对象上的事件(如onmessage、onerror、onopen等),来接收服务器发送的数据,并根据需要进行处理。

字段含义

SSE协议中约定的字段主要包括以下几种:

字段含义
id事件的唯一标识符,用于表示事件的序号。客户端可以通过这个标识符实现断线重连功能。需要重连的时候,客户端在HTTP的header里加一个Last - Event - ID字段,把最后接收到的id传给服务端,服务端实现了重连功能,就能继续传Last - Event - ID之后的消息给客户端
data返回的业务数据。如果数据很长,可以分成多行返回
retry重连的间隔时间(以毫秒为单位),用于指定如果连接断开后,客户端应该多久后尝试重新连接
event用来标识事件的类型,例如当服务端数据推送完成后,通常会发送一个特殊的event事件表示数据全部发送完,之后断开连接

6. 与其他协议对比

与WebSocket对比

对比项SSEWebSocket
通信方向只支持服务器向客户端推送数据支持双向通信
协议基于HTTP协议有自己的协议
浏览器兼容性在现代浏览器中得到良好支持,但不支持IE得到了更广泛的浏览器支持
性能对于大规模的实时数据推送性能可能不如WebSocket全双工,对于大规模实时数据推送可能提供更好的性能
用途适合轻量级的推送任务适合需要复杂交互的应用
数据类型主要用于文本数据可以更有效地处理二进制数据
资源损耗消耗更少的资源,因为只需要服务器向客户端推送数据消耗更多资源,需要处理双向通信,涉及更多的数据传输和状态管理

7. 适用场景

SSE协议特别适用于需要服务器主动向客户端推送数据,但客户端不需要频繁向服务器发送请求的场景,例如:

  1. 实时新闻更新:服务器可以实时将最新的新闻推送给客户端。
  2. 股票行情推送:让客户端实时获取股票价格的变化。
  3. 在线聊天室的消息推送:虽然WebSocket更适用于双向通信,但在某些场景下,SSE可以用于实现简单的聊天应用。
  4. 服务器监控:实时获取服务器运行状态、日志等信息。

总的来说,SSE协议是一种简单、轻量级且兼容性良好的实时通信技术,适用于多种Web应用程序和移动端应用的实时数据推送需求。

8. 在 Vue3 中实现 SSE 的 AI 问答多数据流的步骤

安装依赖

首先,需要安装

@microsoft/fetch-event-source

库,它可以帮助我们在 Vue3 中更方便地处理 SSE 连接。

npm install @microsoft/fetch-event-source 

前端实现(Vue3 + SSE)

以下是一个完整的 Vue3 组件示例,使用 Composition API 实现 AI 对话功能:

<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 的回答以流式数据的形式返回给客户端。以下是一个简单的示例:

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 问答多数据流功能,为用户提供更流畅的交互体验。

具体如下

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("模型调用错误,请稍后重试!")
            }
        });
}