H5与小程序的SSE实践

场景

  • AI对话,uni-app

逻辑

  • 具体逻辑:对话模式,用户输入后,监听流。
  • 实现一个类,内部包含所有逻辑操作

h5

  • EventSource

小程序

  • 流请求时需要携带 enableChunked 参数为true
  • 获取到流信息之后得到的是 uint8Array,需要进行解码,这里依赖 iconv-lite 插件
  • 插件会返回正确的内容,但是内容是断断续续的,需要手动拼接

实现:

ts 复制代码
// dialogContainer.ts
// h5实现插件
import { EventSourcePolyfill } from 'event-source-polyfill';
// 小程序实现插件
import iconvLite from 'iconv-lite'

import * as api from '../../../api/freeDialoging'
import utils from '../../../utils'
import storage from '../../../utils/storage';
// 小程序内容组合
import { combindStr } from './wxMpStreamGenerateStr';


// 待处理的对话组
export interface IPreDialogItem {
	type : '0' | '1'
	content : string // 内容
	desc ?: string // 二级文字描述
	answer ?: (ls : IDialogItem[]) => void // 回话cb
	load ?: boolean // 是否加载中
}

// 对话组
export interface IDialogItem {
	time : string
	type : '0' | '1' // 0 --- 机器人 1---人
	content : string
	desc ?: string
	id ?: string
	load ?: boolean
}

class DialogControler {
        // 对话组列表
	list : IDialogItem[] = []
        // h5流
	sseH5Source : EventSource
        // 小程序流
	sseWxMpStream : UniApp.RequestTask
        // ai回复回调 返回最新对话列表
	sseAnswerCb : (ls : IDialogItem[]) => void
        
        // 创建新的对话
	createNewDialog({ answer: cb, ...preItem } : IPreDialogItem) : string {
		const time = utils.formatNowTime(new Date().valueOf())
		const id = (Math.random() * 100000000).toFixed().toString()
		const item : IDialogItem = {
			...preItem,
			time,
			id
		}
		this.addToList(item)
                
		if (preItem.type === '1') {
			// 开始回话
			this.sseAnswerCb = cb
			this.getAnswer(preItem.content)
		}
		return id
	}
	private addToList(item : IDialogItem) {
		this.list.push(item)
	}
	getList() : IDialogItem[] {
		return this.list
	}
	// 回话生成
	private async getAnswer(str : string) {
		const res = await api.getSseId()
		const id = res.data.sseId
		// 从这里要根据环境决定如何处理
		// #ifdef MP-WEIXIN
		this.createWxMpDialog(id, str)
		// #endif

		// #ifdef H5
		this.createH5SSE(id, str)
		// #endif
	}
	// 创建 小程序 对话
	private async createWxMpDialog(id : string, str : string) {
                // 在请求流的时候,一定要带上 enableChunked 参数 为 true 来确认是流传输,api具体内容和下面的h5请求差不多
		const reqTask = api.getWxMpStreamMessage(id)
		this.sseWxMpStream = reqTask
		const currentId = this.createNewDialog({
			content: '',
			type: '0',
			load: true
		})
		this.wxMpDialogListener(currentId)
		await api.sendMessage(id, str)
	}
	// 小程序流监听
	private wxMpDialogListener = (currentId) => {
		let str = ''
		let isDone = false
                // 流传输监听
		this.sseWxMpStream.onChunkReceived(e => {
			if (isDone) {
				return
			}
			try {
                                // 得到解析后的str,但是这时的str是断断续续的,需要进行拼接
				const strData = iconvLite.decode(e.data, 'utf-8')
				/*
					{
						paragraphIsEnd: 该段落是否结束
						isDone: 回话结束
						content: 段落内容
					}
				*/
				const tObj = combindStr(strData, str)
				if (tObj.isDone) {
					isDone = true
					this.streamPush(currentId, '', true)
				} else if (tObj.paragraphIsEnd) {
					this.streamPush(currentId, tObj.content, false)
					str = ''
				} else {
					// 未结束,先记录内容,下次带入
					str += tObj.content
				}
			} catch {
				this.streamPush(currentId, '内容生成错误,请重试!', true)
			}
		})
	}
	// 创建h5 sse
	private async createH5SSE(id : string, str : string) {
		if (!('EventSource' in window)) {
			throw Error('该浏览器不支持SSE!!')
		}
		const baseUrl = utils.returnEnvConstants('baseUrl')
		const that = this
		this.sseH5Source = new EventSourcePolyfill(`${baseUrl}sse/create/${id}`,
			{
				headers: {
					'Authorization': storage.get('token', ''),
					'Content-Type': 'text/event-stream',
					'Cache-Control': 'no-cache',
					'Connection': 'keep-alive'
				},
			}
		)
		await api.sendMessage(id, str)
		// 创建对应对话
		const currentId = this.createNewDialog({
			content: '',
			type: '0',
			load: true
		})
		this.sseH5Source.onopen = function () {
			//当连接正式建立时触发 
			// console.log('连接打开.')
		}
		this.sseH5Source.onmessage = function (msg) {
			//当连接正式建立时触发 
			// console.log('onmessage cb!!', msg, msg.data);
			const isDone = msg.lastEventId === '[DONE]'
			that.streamPush(currentId, isDone ? '' : msg.data, isDone)
		}
		this.sseH5Source.onerror = function (e) {
			//当连接发生error时触发
		}
	}
	// 持续推送内容
	streamPush(currentId : string, str : string, isDone = false) {
		const findIdx = this.list.findIndex(({ id }) => id === currentId)
		const cloneList = utils.deepClone(this.list)
		cloneList[findIdx].content += str
		cloneList[findIdx].load = !isDone
		this.list = cloneList
		this.sseAnswerCb(cloneList)
		if (isDone) {
			this.closeSSE()
		}
	}
	closeSSE() {
		// #ifdef MP-WEIXIN
		this.sseWxMpStream.abort()
		this.sseWxMpStream = null
		// #endif

		// #ifdef H5
		this.sseH5Source.close()
		this.sseH5Source = null
		// #endif
	}

}

export default DialogControler
ts 复制代码
// ./wxMpStreamGenerateStr.ts
export interface IStreamGenerateStrReturn {
	paragraphIsEnd ?: boolean
	isDone ?: boolean
	content ?: string
}
// 解析str return:[是否传输结束]
/*
	插件解析的内容是断断续续的,所以需要拼接组装。
        这里的拼接逻辑是根据数据结构决定,大家看看大概实现就好,使用需要修改
*/
export function combindStr(strData : string, latestStr : string) : IStreamGenerateStrReturn {
	const strs = strData.split('\n')
	const idItem = strs.find((item : string) => item.startsWith('id:'))
	if (idItem) {
		// 这个块存在id
		const [, id] = idItem.split('id:')
		if (id === '[DONE]') {
			return {
				isDone: true
			}
		}
	}
	// content
	const [, strStart] = strData.split('data:')
	// 有开头
	if (strStart) {
		const [strContnet, isEnd] = strStart.split('retry:')
		// 有内容
		if (strContnet) {
			return {
				// 有 retry: 说明结尾了
				paragraphIsEnd: !!isEnd,
				content: latestStr + strContnet
			}
		}
	} else if (strData.endsWith('data:')) {
		// 在结尾,但是没有实际数据
		return {
			// 还没有结束,下次继续拼接
			paragraphIsEnd: false,
			content: latestStr
		}
	} else {
		// 接着上次接受的信息进行拼接
		const [strContnet, isEnd] = strData.split('retry:')
		return {
			// 还没有结束,下次继续拼接
			paragraphIsEnd: !!isEnd,
			content: latestStr + strContnet
		}
	}
}

使用

小结

h5的话还行,主要是小程序经历了一番波折,一直纠结如何解析 uint8Array,结果最终在ai上找到答案了hh,绝了。

相关推荐
阿幸软件杂货间几秒前
谷歌浏览器(Google Chrome)136.0.7103.93便携增强版|Win中文|安装教程
前端·chrome
繁依Fanyi30 分钟前
Animaster:一次由 CodeBuddy 主导的 CSS 动画编辑器诞生记
android·前端·css·编辑器·codebuddy首席试玩官
想起你的日子38 分钟前
Android studio 实现弹出表单编辑界面
java·前端·android studio
LuckyLay2 小时前
Vue百日学习计划Day9-15天详细计划-Gemini版
前端·vue.js·学习
水银嘻嘻8 小时前
12 web 自动化之基于关键字+数据驱动-反射自动化框架搭建
运维·前端·自动化
小嘟嚷ovo8 小时前
h5,原生html,echarts关系网实现
前端·html·echarts
十一吖i9 小时前
Vue3项目使用ElDrawer后select方法不生效
前端
只可远观9 小时前
Flutter目录结构介绍、入口、Widget、Center组件、Text组件、MaterialApp组件、Scaffold组件
前端·flutter
周胡杰9 小时前
组件导航 (HMRouter)+flutter项目搭建-混合开发+分栏效果
前端·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
敲代码的小吉米9 小时前
前端上传el-upload、原生input本地文件pdf格式(纯前端预览本地文件不走后端接口)
前端·javascript·pdf·状态模式