uniapp开发的APP实现AI流式输出
需求:实现在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博客
更多推荐


所有评论(0)