山东大学软件学院项目实训——创新实训——角色疆界(4)

前言

前端界面的对话功能具体由另一位同学实现,现在的目标是对其进一步优化,将后端回应的内容逐字地、像打印机一样展示出来,使界面更加美观。
效果类似于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。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值