UNIAPP接收SSE数据

176 阅读3分钟

function EVENT_SOURCE(url, params = {}) {
  const Token = uni.getStorageSync("token");
  const queryParams = queryString.stringify({
    ...params,
    access_token: Token,
    store_id: getStoreId(),
  });

  const fullUrl = API_URL(url) + '&' + queryParams;
  console.log('SSE 连接 URL:', fullUrl);

  return new Promise((resolve, reject) => {
    // H5 环境
    // #ifdef H5
    if (typeof EventSource !== 'undefined') {
      try {
        const eventSource = new EventSource(fullUrl);
        let connected = false;

        eventSource.onopen = () => {
          console.log('H5 SSE 连接成功');
          connected = true;
          resolve(eventSource);
        };

        eventSource.onerror = (error) => {
          console.error('H5 SSE 错误:', error);
          eventSource.close();
          if (!connected) {
            reject(error);
          }
        };
      } catch (error) {
        console.error('H5 SSE 创建失败:', error);
        reject(error);
      }
    } else {
      reject(new Error('当前浏览器不支持 EventSource'));
    }
    // #endif

    // 非 H5 环境
    // #ifndef H5
    let buffer = '';
    const customEventSource = {
      onmessage: null,
      onerror: null,
      close: null,
      readyState: 0,
      isCompleted: false
    };

    // APP 环境
    // #ifdef APP-PLUS
    const requestTask = uni.request({
      url: fullUrl,
      method: 'GET',
      header: {
        'Accept': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
      },
      responseType: 'text',
      success: (res) => {
        try {
          const lines = res.data.split('\n');
          let index = 0;

          function processLine() {
            if (index >= lines.length) return;

            const line = lines[index];
            if (line.startsWith('data: ')) {
              const eventData = line.slice(6).trim();
              if (eventData === '[DONE]') {
                customEventSource.isCompleted = true;
                if (customEventSource.onmessage) {
                  customEventSource.onmessage({ data: '[DONE]' });
                }
              } else {
                try {
                  const parsedData = JSON.parse(eventData);
                  if (customEventSource.onmessage) {
                    customEventSource.onmessage({ data: JSON.stringify(parsedData) });
                  }
                } catch (error) {
                  if (customEventSource.onmessage) {
                    customEventSource.onmessage({ data: eventData });
                  }
                }
              }
            }
            index++;
            setTimeout(processLine, 50);
          }

          processLine();
        } catch (error) {
          if (!customEventSource.isCompleted && customEventSource.onerror) {
            customEventSource.onerror(error);
          }
        }
      },
      fail: (err) => {
        if (!customEventSource.isCompleted) {
          customEventSource.readyState = 2;
          if (customEventSource.onerror) {
            customEventSource.onerror(err);
          }
          reject(err);
        }
      }
    });
    // #endif

    // 小程序环境  小程序可以使用  text-encoding 代替 二进制字符串处理方式 
    // #ifdef MP
    const requestTask = uni.request({
      url: fullUrl,
      method: 'GET',
      header: {
        'Accept': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
      },
      enableChunked: true,
      success: () => {
        customEventSource.readyState = 1;
      },
      fail: (err) => {
        if (!customEventSource.isCompleted) {
          customEventSource.readyState = 2;
          if (customEventSource.onerror) {
            customEventSource.onerror(err);
          }
          reject(err);
        }
      }
    });

    requestTask.onChunkReceived((res) => {
      try {
        let chunk = '';
        const data = res.data;

        // 处理二进制数据,确保UTF-8编码正确解析
        if (data instanceof ArrayBuffer) {
          // 使用更安全的方式处理UTF-8字符
          let str = '';
          const bytes = new Uint8Array(data);
          const len = bytes.byteLength;

          // 手动处理UTF-8编码
          for (let i = 0; i < len;) {
            if (bytes[i] < 128) {
              // ASCII字符
              str += String.fromCharCode(bytes[i++]);
            } else if (bytes[i] >= 192 && bytes[i] < 224) {
              // 2字节UTF-8
              if (i + 1 < len) {
                str += String.fromCharCode(((bytes[i] & 31) << 6) | (bytes[i+1] & 63));
              }
              i += 2;
            } else if (bytes[i] >= 224 && bytes[i] < 240) {
              // 3字节UTF-8
              if (i + 2 < len) {
                str += String.fromCharCode(
                    ((bytes[i] & 15) << 12) |
                    ((bytes[i+1] & 63) << 6) |
                    (bytes[i+2] & 63)
                );
              }
              i += 3;
            } else if (bytes[i] >= 240 && bytes[i] < 248) {
              // 4字节UTF-8 (转为UTF-16代理对)
              if (i + 3 < len) {
                const codePoint =
                    ((bytes[i] & 7) << 18) |
                    ((bytes[i+1] & 63) << 12) |
                    ((bytes[i+2] & 63) << 6) |
                    (bytes[i+3] & 63);

                if (codePoint >= 0x10000) {
                  const highSurrogate = Math.floor((codePoint - 0x10000) / 0x400) + 0xD800;
                  const lowSurrogate = ((codePoint - 0x10000) % 0x400) + 0xDC00;
                  str += String.fromCharCode(highSurrogate, lowSurrogate);
                } else {
                  str += String.fromCharCode(codePoint);
                }
              }
              i += 4;
            } else {
              // 未知字节,跳过
              i++;
            }
          }
          chunk = str;
        } else {
          chunk = data;
        }

        buffer += chunk;
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        lines.forEach(line => {
          if (line.startsWith('data: ')) {
            const eventData = line.slice(6).trim();
            if (eventData === '[DONE]') {
              customEventSource.isCompleted = true;
              if (customEventSource.onmessage) {
                customEventSource.onmessage({ data: '[DONE]' });
              }
            } else {
              try {
                const parsedData = JSON.parse(eventData);
                if (customEventSource.onmessage) {
                  customEventSource.onmessage({ data: JSON.stringify(parsedData) });
                }
              } catch (error) {
                if (customEventSource.onmessage) {
                  customEventSource.onmessage({ data: eventData });
                }
              }
            }
          }
        });
      } catch (error) {
        console.error('数据处理错误:', error);
        if (!customEventSource.isCompleted && customEventSource.onerror) {
          customEventSource.onerror(error);
        }
      }
    });
    // #endif

    customEventSource.close = () => {
      if (customEventSource.readyState !== 2) {
        console.log('关闭 SSE 连接');
        customEventSource.readyState = 2;
        customEventSource.isCompleted = true;
        requestTask && requestTask.abort();
      }
    };

    resolve(customEventSource);
    // #endif
  });
}

页面上使用

async chatStream() {
  // 定义一个安全的 marked 包装函数
  const safeMarked = (text) => {
    try {
      // #ifdef APP-PLUS
      // APP端使用简单的换行处理替代marked
      return text.replace(/\n/g, '<br>');
      // #endif

      // #ifndef APP-PLUS
      return marked(text);
      // #endif
    } catch (error) {
      console.error('marked解析错误:', error);
      return text;
    }
  };


  if (!this.value.trim()) {
    this.$utils.toast('请输入问题');
    return;
  }
  uni.showLoading({
    title: '正在思考中...',
    mask: true,
  });
  try {
    const value = JSON.parse(JSON.stringify(this.value));
    this.value = this.$options.data().value;

    // 添加用户消息
    this.list.push({
      text: value,
      type: 'user',
      time: this.$utils.formatDate(new Date()),
      nickname: this.userInfo.nickname,
      avatar_url: this.userInfo.avatar_url,
    });

    // 准备AI消息,但不立即添加到列表
    let aiText = '';
    const aiMessage = {
      type: 'ai',
      text: '',
      time: this.$utils.formatDate(new Date()),
      nickname: this.stores.ai_help_name,
      avatar_url: this.stores.logo
    };

    const eventSource = await this.$allrequest.chart.questionTemplateStream({
      question: value
    });

    let isFirstContent = true;

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        // console.log('收到SSE数据:', data);

        const code = data.code;
        if (code === 1) {
          eventSource.close();
          uni.hideLoading();
          this.$u.toast(data.msg);
          return;
        }

        // 检查是否是最后一条消息
        if (data.choices?.[0]?.finish_reason === 'stop') {
          // console.log('SSE结束');
          eventSource.close();
          return;
        }

        // 获取content内容
        const content = data.choices?.[0]?.delta?.content || '';

        if (content) {
          // 第一次收到内容时,添加AI消息到列表
          if (isFirstContent) {
            uni.hideLoading();
            isFirstContent = false;
            aiMessage.text = content;
            aiMessage.html = safeMarked(content);
            this.list.push(aiMessage);
          } else {
            aiMessage.text += content;
            aiMessage.html = safeMarked(aiMessage.text);
          }

          this.$nextTick(() => {
            uni.pageScrollTo({
              scrollTop: 1000 * this.list.length,
              duration: 300
            });
          });
        }
      } catch (error) {
        // console.error('解析消息失败:', error);
        uni.hideLoading();
      }
    };

    eventSource.onerror = (error) => {
      console.error('SSE连接失败:', error);
      eventSource.close();
      uni.hideLoading();
      aiMessage.text = '出错啦,请稍后再试';
      aiMessage.html = safeMarked(aiMessage.text);
      this.list.push(aiMessage);
      // this.$u.toast('连接失败,请重试');
    };

  } catch (error) {
    console.error('SSE请求失败:', error);
    uni.hideLoading();
    this.$u.toast('发送失败,请重试');
  }
}