uniapp实现deepSeek问答流式输出

实现流式输出依旧选用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()都会先终止现有连接
三、连接终止实现
  1. 核心终止方法:
    ◦ closeSSE()函数调用controller.value?.abort()来终止当前SSE连接
    ◦ 然后将controller.value设置为null
  2. 终止触发点:
    ◦ 显式调用:外部可以直接调用closeSSE()来终止连接
    ◦ 自动触发:在onerror回调中会自动调用closeSSE()
    ◦ 重新初始化:调用initSSE()时会先调用closeSSE()终止之前的连接
  3. 实现细节:
    ◦ 使用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>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容