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

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对比

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

7. 适用场景

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

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

总的来说,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("模型调用错误,请稍后重试!")
            }
        });
}
相关推荐
会飞的鱼先生2 分钟前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克16 分钟前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia1 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话1 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby1 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云1 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo1 小时前
前端获取环境变量方式区分(Vite)
前端·vite
土豆骑士1 小时前
monorepo 实战练习
前端
土豆骑士1 小时前
monorepo最佳实践
前端
见青..1 小时前
【学习笔记】文件包含漏洞--本地远程包含、伪协议、加密编码
前端·笔记·学习·web安全·文件包含