UniApp + Dify 实战:详解 SSE 流式响应的解析与前端渲染

1. 理解核心机制:拼接而非替换

Dify 的 streaming 模式下,服务器会不断推送形如 data: {"event": "message", "answer": "字"} 的数据包。
核心逻辑是: 收到一个包,解析出 answer 字段,将其**追加(Append)**到当前正在显示的对话变量后,而不是直接替换。

2. 关键数据解析逻辑

Dify 返回的数据流格式如下:

text 复制代码
data: {"event": "message", "answer": "我", ...}\n\n
data: {"event": "message", "answer": "是", ...}\n\n
data: {"event": "message_end", ...}\n\n

处理难点:

  1. 前缀处理 :每行数据都以 data: 开头,解析 JSON 前必须去掉。
  2. 粘包处理 :有时候一次网络请求回调会收到多条 data,需要用 \n\n 分割。
  3. 事件区分 :必须判断 event 字段。
    • message: 文本块,核心展示内容
    • message_replace: 敏感词替换,需要替换整段文本。
    • message_end: 结束标志。
    • ping: 心跳,忽略即可。

3. UniApp 代码实现方案

在 UniApp 中(特别是微信小程序端),不能直接使用浏览器原生的 EventSource。推荐使用 uni.requestenableChunked: true 参数。

以下是一个完整的处理示例代码:

javascript 复制代码
// 假设这是发送消息的方法
sendMessage(userQuery) {
    const that = this;
    
    // 1. 在界面先创建一个空的回答占位(为了立刻显示 loading 或光标)
    this.messageList.push({
        role: 'user',
        content: userQuery
    });
    this.messageList.push({
        role: 'assistant',
        content: '' // 初始为空,稍后拼接
    });
    
    // 获取当前正在更新的这条消息在数组中的索引
    const currentMsgIndex = this.messageList.length - 1;

    // 2. 发起请求
    const requestTask = uni.request({
        url: 'http://47.243.127.167:4010/v1/chat-messages',
        method: 'POST',
        header: {
            'Authorization': 'Bearer {API_KEY}', // 替换为真实 Key
            'Content-Type': 'application/json'
        },
        data: {
            inputs: {},
            query: userQuery,
            response_mode: "streaming", // 必须是 streaming
            user: "uni-user-123",
            conversation_id: that.conversationId || "" // 如果是连续对话,需传入
        },
        enableChunked: true, // 【关键】开启流式传输支持
        success: (res) => {
            // 这里是请求完成后的回调,流式通常不在这里处理数据
        }
    });

    // 3. 监听流式数据头(可选)
    requestTask.onHeadersReceived((headers) => {
        // console.log('Header received', headers);
    });

    // 4. 【核心】监听分片数据
    requestTask.onChunkReceived((res) => {
        // res.data 是 ArrayBuffer,需要转换
        const arrayBuffer = res.data;
        // 小程序/App端需要 TextDecoder,或者使用第三方库转换
        // 如果环境不支持 TextDecoder,需使用类似 text-encoding 的 polyfill
        const uint8Array = new Uint8Array(arrayBuffer);
        let text = "";
        
        // 简易转换 (注意:中文可能乱码,生产环境建议用专业库如 fast-text-encoding)
        // 微信小程序基础库高版本已支持 TextDecoder
        try {
            const decoder = new TextDecoder('utf-8');
            text = decoder.decode(uint8Array, { stream: true });
        } catch (e) {
            // 兼容写法,逐字节处理(此处仅为示意,建议引入库)
            text = String.fromCharCode.apply(null, uint8Array); 
            // 实际开发请务必处理 UTF-8 多字节中文乱码问题
            text = decodeURIComponent(escape(text)); 
        }

        // 5. 处理 Dify 返回的原始数据字符串
        that.processDifyStream(text, currentMsgIndex);
    });
},

// 处理 Dify 数据流的专用函数
processDifyStream(chunkText, msgIndex) {
    // Dify 的数据块以 \n\n 分隔
    const lines = chunkText.split('\n\n');

    lines.forEach(line => {
        // 去掉 data: 前缀
        if (line.startsWith('data: ')) {
            const jsonStr = line.replace('data: ', '');
            try {
                const data = JSON.parse(jsonStr);
                
                // 根据 Dify 文档判断 event 类型
                if (data.event === 'message') {
                    // 【关键步骤】拼接 answer 字段到当前消息
                    this.messageList[msgIndex].content += data.answer;
                    
                    // 保存 conversation_id 以便下一轮对话
                    if (!this.conversationId && data.conversation_id) {
                        this.conversationId = data.conversation_id;
                    }
                } 
                else if (data.event === 'message_replace') {
                    // 内容审查替换,直接覆盖
                    this.messageList[msgIndex].content = data.answer;
                }
                else if (data.event === 'message_end') {
                    console.log('生成结束', data);
                    // 可以在这里处理 metadata,比如 token 消耗
                }
                else if (data.event === 'error') {
                    console.error('Dify 报错:', data);
                    this.messageList[msgIndex].content += "\n[出错: " + data.message + "]";
                }
                
                // 【重要】强制触发 Vue 视图更新(如果在某些层级深的结构中)
                // 这一步在 Vue2 中可能不需要,但在某些 UniApp 场景下需要
                // this.$forceUpdate(); 
                
            } catch (e) {
                // JSON 解析失败通常是因为数据包不完整(被截断),
                // 生产环境需要做一个 buffer 缓存上一块未解析完的字符串
                // 暂时忽略或存入 buffer
                console.log('JSON parse error (ignore partial chunk):', e);
            }
        }
    });
}

4. 常见坑排查清单

如果还是展示不出来,请按以下顺序检查:

  1. ArrayBuffer 解码乱码

    • UniApp 的 onChunkReceived 返回的是 ArrayBuffer。如果不进行 UTF-8 解码直接转字符串,中文会显示乱码或空白。
    • 解决 :确保使用了 TextDecoder 或者 decodeURIComponent(escape(String.fromCharCode(...))) 这种方式正确解码。
  2. Vue 响应式失效

    • 如果在 onChunkReceived 这种异步回调中,this 指向可能丢失。
    • 解决 :确保在外部定义了 const that = this;,或者使用箭头函数。
    • 解决 :如果是 Vue 2,修改数组索引可能不会触发视图更新。使用 this.$set(this.messageList, index, newValue) 或者直接修改对象属性 this.messageList[index].content += '...' 通常是有效的,但要确保 messageList 是在 data 中定义的。
  3. Markdown 渲染

    • Dify 输出的是 Markdown 格式(包含 **加粗**Code Block 等)。
    • 如果直接用 <text>{``{ content }}</text>,只能显示纯文本。
    • 建议 :在 UniApp 中引入 mp-htmltowxml 等组件来渲染 Markdown,这样能正确展示代码块和格式。
  4. JSON 解析报错

    • 流式传输网络抖动时,JSON 可能会被截断(比如 {"answer": "你好 后面断了)。
    • 解决 :需要实现一个 buffer 变量,如果 JSON.parse 失败,将当前字符串存起来,等下一个 chunk 来了拼接到头部再解析。
相关推荐
lxh01137 小时前
复原IP地址
前端·数据结构·算法
2501_915918417 小时前
iOS 项目中证书管理常见的协作问题
android·ios·小程序·https·uni-app·iphone·webview
Miketutu7 小时前
[特殊字符] uni-app App 端实现文件上传功能(基于 xe-upload 插件)
前端·vue.js·uni-app
焚 城7 小时前
uniapp 各种文件预览实现
vue.js·uni-app·html
weixin79893765432...7 小时前
uni-app 全面深入的解读
uni-app
2501_915918417 小时前
提升 iOS 应用安全审核通过率的一种思路,把容易被拒的点先处理
android·安全·ios·小程序·uni-app·iphone·webview
San30.7 小时前
现代前端工程化实战:从 Vite 到 Vue Router 的构建之旅
前端·javascript·vue.js
sg_knight7 小时前
模块热替换 (HMR):前端开发的“魔法”与提速秘籍
前端·javascript·vue·浏览器·web·模块化·hmr
A24207349307 小时前
js常用事件
开发语言·前端·javascript