实现流式输出依旧选用SSE的@microsoft/fetch-event-source
一、useSSE.js的流式请求实现分析:
1. 核心流式机制:
◦ 基于@microsoft/fetch-event-source库实现SSE流式通信
◦ 使用EventSource协议保持长连接,服务器可以持续推送数据
2. 数据流处理流程:
◦ 服务器发送的数据通过onmessage回调实时处理
◦ 每次收到消息都会触发onmessage(e)回调
◦ e.data包含服务器推送的最新数据块
◦ 数据立即更新到data.value响应式变量
◦ 同时调用外部传入的onMessage回调处理数据
3. 关键实现细节:
fetchEventSource(url, {
//...其他配置
onmessage: (e) => {
data.value = e.data; // 更新响应式数据
currentOptions.value.onMessage?.(e.data); // 触发外部回调
}
})
4. 流式特性:
◦ 实时性:数据到达立即处理,无需等待完整响应
◦ 连续性:保持连接接收多个数据块
◦ 增量更新:每次只处理最新到达的数据部分
5. 与普通请求的区别:
◦ 传统请求:等待完整响应后一次性处理
◦ SSE流式:分块实时处理,适合聊天、实时监控等场景
6. 性能优化:
◦ 使用AbortController管理连接生命周期
◦ 响应式数据更新确保UI及时渲染
◦ 错误自动重连机制(需服务器支持)
二、连接的终止条件判断如下:
1. 显式终止条件(主动调用):
- 直接调用closeSSE()函数
- 组件卸载时调用closeSSE()清理资源
- 调用updateOptions()重新初始化时会先终止现有连接
2. 自动终止条件(被动触发):
- 网络错误:onerror回调触发时自动调用closeSSE()
- 服务器关闭连接:EventSource收到关闭事件
- 认证失败:401/403等HTTP错误状态码
3. 错误处理流程:
- onerror回调接收错误事件
- 设置error.value保存错误信息
- 调用options.onError回调(如果提供)
- 最后调用closeSSE()终止连接
4. 重连机制:
- 当前实现中没有自动重连逻辑
- 需要外部通过initSSE()手动重新连接
- 每次initSSE()都会先终止现有连接
三、连接终止实现
- 核心终止方法:
◦ closeSSE()函数调用controller.value?.abort()来终止当前SSE连接
◦ 然后将controller.value设置为null - 终止触发点:
◦ 显式调用:外部可以直接调用closeSSE()来终止连接
◦ 自动触发:在onerror回调中会自动调用closeSSE()
◦ 重新初始化:调用initSSE()时会先调用closeSSE()终止之前的连接 - 实现细节:
◦ 使用Vue的ref来管理controller状态
◦ 将AbortController的signal传递给fetchEventSource
◦ 终止后清理controller引用
这种设计确保了SSE连接可以被可靠地终止,并防止了连接泄漏。
四、代码实现
1. 新建useSSE.js:
/**
* SSE (Server-Sent Events) 流式请求Hook
* 基于@microsoft/fetch-event-source实现,提供SSE连接管理功能
*/
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ref } from 'vue'
/**
* 创建SSE连接Hook
* @param {string} url - SSE服务端URL
* @param {Object} options - 配置选项
* @param {string} [options.method='POST'] - 请求方法
* @param {Object} [options.headers] - 请求头
* @param {Object} [options.body] - 请求体
* @param {Function} [options.onMessage] - 消息回调
* @param {Function} [options.onError] - 错误回调
* @returns {Object} SSE连接管理对象
*/
export function useSSE(url, options) {
// 控制器实例(用于终止连接)
const controller = ref(null)
// 错误信息
const error = ref(null)
// 接收到的数据
const data = ref('')
// 当前配置选项
const currentOptions = ref(options)
/**
* 初始化SSE连接
* 1. 关闭现有连接
* 2. 创建新的AbortController
* 3. 建立SSE连接
*/
const initSSE = () => {
closeSSE() // 确保先关闭现有连接
controller.value = new AbortController()
fetchEventSource(url, {
method: currentOptions.value.method || 'POST', // 默认POST方法
headers: {
'Content-Type': 'application/json',
...currentOptions.value.headers // 合并自定义headers
},
body: JSON.stringify(currentOptions.value.body || {}), // 序列化请求体
signal: controller.value.signal, // 绑定终止信号
onmessage: (e) => {
// 处理服务器推送的消息
data.value = e.data // 更新响应式数据
currentOptions.value.onMessage?.(e.data) // 触发外部消息回调
},
onerror: (e) => {
// 处理连接错误
error.value = e // 保存错误信息
currentOptions.value.onError?.(e) // 触发外部错误回调
closeSSE() // 错误时自动关闭连接
}
})
}
/**
* 更新SSE连接配置
* @param {Object} newOptions - 新配置选项
*/
const updateOptions = (newOptions) => {
currentOptions.value = {
...currentOptions.value, // 保留原有配置
...newOptions // 合并新配置
}
initSSE() // 使用新配置重新初始化连接
}
/**
* 关闭SSE连接
* 1. 终止当前连接
* 2. 重置控制器
*/
const closeSSE = () => {
controller.value?.abort() // 终止连接
controller.value = null // 清理控制器引用
}
// 返回SSE连接管理API
return {
controller, // AbortController实例
data, // 响应式数据
error, // 错误信息
initSSE, // 初始化方法
closeSSE, // 关闭方法
updateOptions // 配置更新方法
}
}
页面调用:
<template>
<view class="chat-container">
<!--使用renderjs 解决事件流 请求在app端不能运行 start-->
<view :prop="renderData" :change:prop="renderjs.updateData" ref="renderjsDom"></view>
<!--使用renderjs 解决事件流 请求在app端不能运行 end-->
<!-- 消息区域 -->
<scroll-view class="message-list" scroll-y="true" :scroll-with-animation="true" :scroll-top="scrollTop">
<view v-for="(msg, index) in messages" :key="index" :class="['message-item', msg.isMe ? 'me' : 'other']">
<image v-if="!msg.isMe" class="avatar" :src="msg.avatar || defaultAvatar" />
<view class="message-content">
<text class="text">{{ msg.content }}</text>
<text class="time">{{ msg.time }}</text>
</view>
<image v-if="msg.isMe" class="avatar" :src="msg.avatar || defaultAvatar" />
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<input v-model="inputMsg" placeholder="输入消息..." class="input-box" @confirm="sendMsg" />
<button @click="sendMsg" class="send-btn">发送</button>
</view>
</view>
</template>
<script lang="ts">
/**
* 聊天页面组件 - 优化版
*
* 主要功能:
* 1. 实现用户与AI的实时聊天交互
* 2. 使用SSE(Server-Sent Events)接收AI流式响应
* 3. 本地存储聊天记录,支持历史消息恢复
* 4. 自动滚动到底部保持最新消息可见
* 5. 支持清空聊天记录功能
*
* 技术要点:
* - 使用renderjs模块处理APP端SSE事件流
* - 采用uni-app存储API持久化聊天记录
* - 响应式滚动位置管理
* - 防抖处理消息发送
*/
interface Message {
groupid: string
content: string
isMe: boolean
role: string
time: string
avatar: string
}
interface RenderData {
id: string
code: 'init' | 'send' | 'close'
value: {
ChatDetail: Array<{ content: string }>
}
}
export default {
data() {
return {
/** 用户输入的消息内容 */
inputMsg: '',
/**
* 消息列表数组
* @type {Array<{
* groupid: string, // 消息组ID,用于关联请求和响应
* content: string, // 消息文本内容
* isMe: boolean, // 是否为用户发送的消息
* role: string, // 角色标识(user/robot)
* time: string, // 格式化后的时间字符串
* avatar: string // 头像URL
* }>}
*/
messages: [
{
groupid: '',
content: '你好,我是AI助手',
isMe: false,
role: '',
time: this.formatTime(),
avatar: '/static/images/chat/robot.gif'
}
],
/** 当前聊天会话唯一ID,用于关联消息组 */
chatUnique: '',
/** 滚动条位置,用于自动滚动到底部 */
scrollTop: 0,
/** 默认头像URL,当消息没有指定头像时使用 */
defaultAvatar: '/static/images/chat/robot.gif',
/**
* renderjs模块通信数据
* @property {string} id - 会话ID
* @property {string} code - 操作类型(init/send/close)
* @property {object} value - 传输的数据内容
*/
renderData: {
id: "",
code: "init",
value: {
ChatDetail: [{
content: ""
}]
}
}
}
},
onNavigationBarButtonTap(e) {
console.log(e)
uni.showModal({
title: '提示',
content: '确定要清空聊天记录吗?',
success: (res) => {
if (res.confirm) {
// 移除聊天记录
uni.removeStorageSync('chatLogList')
this.messages = [{
groupid: '',
content: '你好,我是AI助手',
isMe: false,
role: '',
time: this.formatTime(),
avatar: '/static/images/chat/robot.gif'
}]
}
}
})
},
mounted() {
// const systemInfo = uni.getSystemInfoSync()
// console.log(systemInfo)
//console.log('mounted')
if (uni.getStorageSync('chatLogList')) {
this.messages = JSON.parse(uni.getStorageSync('chatLogList') || '')
setTimeout(() => {
this.scrollToBottom()
}, 500)
}
},
methods: {
handleClear() { //清空记录
uni.showModal({
title: '提示',
content: '确定要清空聊天记录吗?',
success: (res) => {
if (res.confirm) {
//移除聊天记录
uni.removeStorageSync('chatLogList')
this.messages = [{
groupid: '',
content: '你好,我是AI助手',
isMe: false,
role: '',
time: this.formatTime(),
avatar: '/static/images/chat/robot.gif'
}]
}
}
})
},
isValidJSON(val) {
try {
JSON.parse(val);
return true;
} catch (e) {
return false;
}
},
/**
* 处理从renderjs模块接收的SSE数据块
* @param {Object} event - 包含SSE数据的对象
* @param {string} event.data - 原始SSE数据字符串
*/
handleChunkFromRenderjs(event) {
const _this = this
console.log('[SSE] Received data chunk:', event.data)
// 验证数据是否为有效JSON
if (!_this.isValidJSON(event.data)) {
console.warn('[SSE] Invalid JSON data received')
return
}
try {
const jsonData = JSON.parse(event.data)
// 查找当前会话的AI回复消息
const aiMessages = _this.messages.filter(
msg => msg.groupid === _this.chatUnique && !msg.isMe
)
// 清空已有的AI回复内容(流式响应)
aiMessages.forEach(msg => {
if (msg.role === 'robot') {
msg.content = ''
}
})
if (aiMessages.length > 0) {
let combinedContent = ''
// 处理SSE流中的每个choice
jsonData.choices.forEach(choice => {
console.log('[SSE] Processing choice:', choice)
// 更新角色信息
if (choice.delta.role) {
aiMessages[0].role = choice.delta.role
}
// 检查是否结束
if (choice.finish_reason === "stop") {
_this.renderData = {
id: _this.chatUnique,
code: "close",
value: { ChatDetail: [{ content: "" }] }
}
return
}
// 拼接内容
if (choice.delta.content) {
combinedContent += choice.delta.content
}
})
// 更新AI消息内容
aiMessages[0].content += combinedContent
}
// 滚动到底部并保存聊天记录
_this.scrollToBottom()
uni.setStorageSync('chatLogList', JSON.stringify(_this.messages))
} catch (error) {
console.error('[SSE] Error processing data:', error)
}
},
generateGUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
},
/**
* 计算滚动位置,使最新消息可见
*/
calculateScrollTop() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query.selectAll('.message-item')
.boundingClientRect((res: UniApp.NodeInfo[] | UniApp.NodeInfo) => {
if (Array.isArray(res) && res.length > 0) {
const totalHeight = res.reduce((sum, cur) => sum + cur.height!, 0)
this.scrollTop = totalHeight + 999999
}
})
.exec()
})
},
scrollToBottom() {
this.calculateScrollTop()
},
formatTime() {
const date = new Date()
console.log(date.getDate(), "date")
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
},
handleBack() {
//uni.navigateBack()
uni.switchTab({
url: '/pages/homePage/index'
})
},
/**
* 发送消息处理函数
* 1. 验证输入内容
* 2. 生成唯一会话ID
* 3. 添加用户消息到列表
* 4. 添加AI"思考中"占位消息
* 5. 通过renderjs触发SSE请求
*/
sendMsg() {
const _this = this
// 验证输入内容
const trimmedMsg = _this.inputMsg.trim()
if (!trimmedMsg) {
uni.showToast({
title: '消息不能为空',
icon: 'none'
})
return
}
// 生成唯一会话ID
_this.chatUnique = _this.generateGUID()
// 添加用户消息
_this.messages.push({
groupid: _this.chatUnique,
content: trimmedMsg,
isMe: true,
role: 'user',
time: _this.formatTime(),
avatar: '/static/images/chat/logo.png'
})
_this.scrollToBottom()
// 添加AI回复占位消息(500ms延迟避免闪烁)
setTimeout(() => {
_this.messages.push({
groupid: _this.chatUnique,
content: '正在思考中...',
role: 'robot',
isMe: false,
time: _this.formatTime(),
avatar: '/static/images/chat/robot.gif'
})
_this.scrollToBottom()
}, 500)
// 通过renderjs触发SSE请求
_this.renderData = {
id: _this.chatUnique,
code: "send",
value: {
ChatDetail: [{
content: trimmedMsg
}]
}
}
// 清空输入框
_this.inputMsg = ''
}
}
}
</script>
<script module="renderjs" lang="renderjs">
import { useSSE } from '../../service/api/useSSE';
export default {
methods: {
/**
* 更新SSE连接数据
* @param {Object} newVal - 新的SSE配置数据
* @param {string} newVal.code - 操作类型: "send"发送消息 | "close"关闭连接
* @param {Object} newVal.value - 当code为"send"时包含的消息内容
*
* 功能说明:
* 1. 根据操作类型初始化或更新SSE连接
* 2. 处理从服务器接收的流式响应
* 3. 将接收到的数据转发给主线程处理
*
* 技术要点:
* - 使用useSSE hook管理SSE连接
* - 500ms延迟转发数据以避免UI阻塞
* - 支持动态更新请求体内容
*/
updateData(newVal) {
console.log('[SSE] Operation code:', newVal.code)
const { data, initSSE, closeSSE, updateOptions } = useSSE('https://www.lbfq.cn/api/SSE/events', {
method: 'POST',
body: { },
onMessage: (data) => {
// 延迟500ms转发数据以避免UI阻塞
setTimeout(() => {
this.$ownerInstance.callMethod('handleChunkFromRenderjs', {data: data})
}, 500)
}
})
// 发送消息操作
if (newVal.code == "send") {
updateOptions({
body: newVal.value
})
}
// 关闭连接操作
if (newVal.code == "close") {
closeSSE()
}
}
}
}
</script>
<style lang="scss" scoped>
.main-title-color {
color: #d14328;
}
.uni-app--showleftwindow+.uni-tabbar-bottom {
display: none;
}
::v-deep .uni-page-head {
background-image: url("@/static/images/notebackground.png") !important;
}
.chat-container {
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.message-list {
// margin-top: 64px;
flex: 1;
padding: 20rpx;
overflow-y: auto;
}
.message-item {
display: flex;
margin-bottom: 30rpx;
&.me {
justify-content: flex-end;
.message-content {
background: #95ec69;
}
}
&.other {
justify-content: flex-start;
.message-content {
background: white;
}
}
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
}
.message-content {
max-width: 70%;
padding: 20rpx;
border-radius: 10rpx;
margin: 0 20rpx;
.text {
font-size: 32rpx;
word-break: break-word;
}
.time {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
}
.input-area {
display: flex;
padding: 20rpx;
background: white;
border-top: 1px solid #eee;
.input-box {
flex: 1;
height: 80rpx;
padding: 0 20rpx;
background: #f5f5f5;
border-radius: 40rpx;
}
.send-btn {
width: 160rpx;
height: 80rpx;
line-height: 80rpx;
margin-left: 20rpx;
background: #07c160;
color: white;
border-radius: 40rpx;
}
}
/* 全局样式或在页面/组件样式中 */
/* 修改导航栏背景为透明 */
:deep() {
.transparent-navbar {
background-image: url("@/static/images/notebackground.png");
--wd-navbar-background: rgba(0, 123, 255, 1) !important;
background-color: rgba(0, 123, 255, 1) !important;
// position: fixed !important;
// height: 44px;
z-index: 999999;
width: 100vw;
position: fixed !important;
}
// .transparent-navbar .wd-icon-arrow-left{
// z-index: 9999999;
// }
/* 修改左箭头为白色 */
.transparent-navbar .wd-icon-arrow-left::before {
color: #ffffff !important;
fill: #ffffff !important;
/* 如果是 SVG 图标可能需要这个 */
}
/* 移除导航栏底部边框(如果存在) */
.transparent-navbar::after {
display: none !important;
}
/* 确保文字颜色为白色 */
.transparent-navbar .wd-navbar__text,
.transparent-navbar .wd-navbar__title {
color: #ffffff !important;
}
}
/* 禁用状态样式 */
.disabled-btn {
opacity: 0.6;
background-color: #eee !important;
color: #999 !important;
}
</style>