需求:实现在APP上AI问答功能

思路:使用RenderJS + XHR来实现,RenderJS 能监听到readyState==3的实时输出

踩雷:使用plus.net.XMLHttpRequest的话也能实现流式输出,但不是实时输出的,而是AI数据处理完后一次性返回n条readyState==3

RenderJS 教程:renderjs | uni-app官网

XHR教程:XMLHttpRequest 详解 _xmlhtttprequest-CSDN博客

实现步骤:

1、首先在template中添加两个view标签用于绑定RenderJS元素

        sseConfig:开启SSE流式请求

        sseStop:停止SSE流式请求

        :change:sseConfig:uniapp的特殊语法,能够监听到sseConfig的数据发生改变时,自动调用RenderJS的startSSE函数(下面讲解作用)

<template>
	<view>
        <view :sseConfig="sseConfig" :change:sseConfig="renderScript.startSSE" style="display: none;"></view>
        <view :sseStop="sseStop" :change:sseStop="renderScript.stopSSE" style="display: none;"></view>
    </view>
</template>

2、创建一个RenderJS模块

        注意:script 标签上增加module="renderScript" lang="renderjs"属性

        startSSE函数:就是上面讲的监听到sseConfig数据变化是执行的函数,函数中的代码是一些基本的SSE流式请求处理

        sseStop函数:也是和上面的对照,是停止SSE流式请求,简称中断

<script module="renderScript" lang="renderjs">
export default {
  methods: {
    startSSE(newVal, _oldVal, ownerInstance) {
      if (this._xhr) {
        try {
          this._xhr.abort()
        } catch (e) {}
        this._xhr = null
      }
      if (!newVal || !newVal.url) return

      const xhr = new XMLHttpRequest()
      this._xhr = xhr
      this._lastProcessedLen = 0
      xhr.open(newVal.method || 'POST', newVal.url, true)

      const header = newVal.header || {}
      Object.keys(header).forEach((key) => {
        try {
          xhr.setRequestHeader(key, header[key])
        } catch (e) {}
      })

      xhr.onreadystatechange = () => {
        if (xhr.readyState !== 3 && xhr.readyState !== 4) return

        const currentText = xhr.responseText || ''
        const chunk = currentText.substring(this._lastProcessedLen || 0)
        this._lastProcessedLen = currentText.length

        if (chunk) {
          ownerInstance.callMethod('handleSSERawChunkFromRenderJS', chunk)
        }

        if (xhr.readyState === 4) {
          const ok = xhr.status >= 200 && xhr.status < 300
          ownerInstance.callMethod('handleSSEStatusFromRenderJS', {
            status: ok ? 'done' : 'error',
            code: xhr.status,
          })
          this._xhr = null
        }
      }

      xhr.onerror = () => {
        ownerInstance.callMethod('handleSSEStatusFromRenderJS', {
          status: 'error',
          code: 500,
        })
        this._xhr = null
      }

      xhr.send(newVal.body || null)
    },
    stopSSE(_newVal, _oldVal, ownerInstance) {
      if (this._xhr) {
        try {
          this._xhr.abort()
        } catch (e) {}
        this._xhr = null
      }
      ownerInstance.callMethod('handleSSEStatusFromRenderJS', {
        status: 'stop',
        code: 0,
      })
    },
  },
}
</script>

3、Vue中使用(部分参数说明代码中有注释)

        handleSSERawChunkFromRenderJS :主要处理SSE每条流式返回的数据提取需要的文字内容。

        handleSSEStatusFromRenderJS :接收SSE流式请求的结束状态停止、失败、完成

        handleSSERawChunkFromRenderJS 下currentEvent === 'conversation.message.delta' :每一条的实时数据都会在这个里面执行,提取出文字后自己根据需求自行处理

        handleSSERawChunkFromRenderJS 下 currentEvent === 'conversation.message.completed' :ai问答输出完毕后,会一次性返回以上每条数据整合到一块的内容)

<script setup lang="ts">
    // SSE 配置
    const sseConfig = ref<any>(null)
    // 停止SSE
    const sseStop = ref(0)
    // 渲染SSE 缓冲区
    let renderSseBuffer = ''
    // 渲染会话ID
    let renderConversationId = ''
    // 渲染完整内容
    let renderFullContent = ''
    // 是否完成
    let renderIsCompleted = false
    // 渲染AI消息索引
    let renderAiIndex = -1

    // 初始化SSE
    if (instance && instance.proxy) {
        ;(instance.proxy as any).handleSSERawChunkFromRenderJS = handleSSERawChunkFromRenderJS
        ;(instance.proxy as any).handleSSEStatusFromRenderJS = handleSSEStatusFromRenderJS
    }

    // SSE 原始块事件
    const handleSSERawChunkFromRenderJS = (chunk: string) => {
        if (renderIsCompleted) return
        renderSseBuffer += String(chunk || '')
        const parts = renderSseBuffer.split(/\r?\n\r?\n/)
        renderSseBuffer = parts.pop() || ''

        parts
          .map(v => v.trim())
          .filter(Boolean)
          .forEach((block) => {
            const lines = block.split(/\r?\n/).map(v => v.trim())
            let currentEvent = ''
            let currentData = ''
            lines.forEach((line) => {
              if (line.startsWith('event:')) {
                currentEvent = line.replace(/^event:\s?/, '').trim()
                return
              }
              if (line.startsWith('data:')) {
                currentData = line.replace(/^data:\s?/, '').trim()
              }
            })

            if (!currentEvent) return
            if (currentEvent === 'ping') return

            if (currentEvent === 'done') {
              renderIsCompleted = true
              chatLoading.value = false
              return
            }

            if (currentEvent === 'conversation.chat.completed') {
              renderIsCompleted = true
              chatLoading.value = false
              return
            }

            if (!currentData) return

            try {
              const parsed = JSON.parse(currentData)
              if (parsed?.conversation_id) {
                renderConversationId = String(parsed.conversation_id)
              }

              if (currentEvent === 'conversation.message.delta') {
                const content = parsed?.content
                if (content !== undefined && content !== null) {
                  renderFullContent += String(content).replace(/\\n/g, '\n')
                  updateAiMessage(renderFullContent)
                }
                return
              }

              if (currentEvent === 'conversation.message.completed') {
                const content = parsed?.content
                if (content !== undefined && content !== null) {
                  renderFullContent = String(content).replace(/\\n/g, '\n')
                  updateAiMessage(renderFullContent)
                }
                renderIsCompleted = true
                chatLoading.value = false
              }
            } catch (e) {}
    })
  }

    // SSE 状态事件
    const handleSSEStatusFromRenderJS = (statusData: any) => {
        if (!statusData) return

        if (statusData.status === 'error') {
          chatLoading.value = false
          updateAiMessage(renderFullContent || '请求出错,请稍后重试')
          return
        }

        if (statusData.status === 'stop') {
          chatLoading.value = false
          return
        }

        if (statusData.status === 'done') {
          chatLoading.value = false
        }
    }

    // 发送消息
    const sendMsg = async () => {

        renderSseBuffer = ''
        renderConversationId = conversation_id
        renderFullContent = ''
        renderIsCompleted = false
        renderAiIndex = chatList.value.length - 1

        try {
          // 根据后端接口获取调取ai的token
          const res: any = await AiApi.getAiToken({
            mini_user_id,
          })
          const token = res?.data?.token || ''
          // 调取ai传递的内容,根据自己的ai文档自行修改
          const body = JSON.stringify({
            query: '你好',
            userId: mini_user_id,
          })

          // 关键
          sseConfig.value = {
            // ai接口地址
            url: 'https://xxx/getAnswerStream/sse',
            method: 'POST',
            header: {
              Accept: 'text/event-stream',
              'Cache-Control': 'no-cache',
              // 上面获取到的token
              Token: token,
              'Content-Type': 'application/json',
            },
            // 提供给ai接口的内容
            body,
            // 当前时间,确保每次都能触发 change
            timestamp: Date.now(),
          }
        } catch (e) {
            //请求出错,请稍后重试
        }
    }


    // 停止SSE
    const stop = () => {
        renderIsCompleted = true
        chatLoading.value = false
        sseStop.value = Date.now()
    }
</script>

4、AI返回的content中有些特殊符号会显示不出来,因为是markdown加密了,默认返回的是*,需要使用markdown再解析一下。下面图片能够看出明显的区别

直接使用插件市场中的:zero-markdown-view(markdown解析) - DCloud 插件市场

导入插件,即可直接使用,非常奈斯,点赞👍

特别感谢大佬的教程:一端运行,多端踩坑:uni-app APP 端 SSE 流式输出只能靠 RenderJS_uniapp sse-CSDN博客

更多推荐