React-native 实现流式传输AI显示打字机效果

1,223 阅读5分钟

项目介绍

因公司要求,接到了一个需求是实现AI对话形式的App,因为公司android开发只有一个,所以我就使用react-native来进行前端开发,要求根据返回流快速返回回答

前期实现

一开始为了保证开发速度,同时axios无法流式返回数据,所以我直接使用的是获取到所有的答案字符串,然后通过setInterval来实现打印效果

因为后端使用流进行返回,所以我第一时间想到了用axios来进行返回流,我第一时间想到了使用responseType为stream的形式来返回,但是在实际开发中遇到了问题,第一个问题就是他其实并没有生效,仍然是在等待一段时间才进行返回,开发代码为以下内容

    ajax({
      url,
      type: 'post',
      data: {
        inputs: {},
        query: question,
        response_mode: 'streaming',
        conversation_id: conversationIdRef.current,
        user: 'abc-123',
        files: [],
      },
      responseType: 'stream',
    }).then(response => {
      console.log(response);

    });

image.png

图中可见,他是等待了一段时间之后才一次性返回所有结果,并没有按照流的形式来进行返回的,同时debug还返回了一个警告,这个警告很明确的告诉我

image.png 意思就是stream并不是一个有效的参数,我很纳闷,然后去搜索相关材料,才发现 在 React Native 中,axios 不支持 responseType: 'stream',你可以使用 blob 或直接使用 fetch API 来处理流数据。

好,有问题就去解决,然后我就改为blob去实现,但是仍然没有出现我意料中的效果,仍然是等待一会才返回,我心想blob不能解决,我就去使用fetch解决,信以为应该解决了吧,但是意料之外,他仍然没有解决,仍然是等一会才返回结果,我就去网上搜索如何在react-native中实现流式返回

终于在一个视频中找到了这个问题的答案:

React Native 里提供了一个 Fetch API,看起来跟网页里差不多,但实际上,其内在是不同的。所以我们想在 React Native 里实现一般的流式传输是行不通的。但是为了能够同时兼容网页端和移动端,我们又不希望后端太复杂,所以使用 SSE 可能是最好的选择。

这是上述我搜索后的答案,React-native底层居然不能使用fetch进行流式传输!!!

www.bilibili.com/video/BV1DT…

地址放上面了,好,有解决办法就去做,终于在我的不懈努力下,发现react-native-sse 这个仓库是可以实现流式传输的!!

    import EventSource from 'react-native-sse';

    const es = new EventSource(url, {
      headers: header,
      body: data,
      method: 'post',
    });

    es.addEventListener('open', event => {
      console.log('Open SSE connection.');
    });

    es.addEventListener('message', event => {
      console.log(JSON.parse(event.data));
    });

    es.addEventListener('error', event => {
      if (event.type === 'error') {
        console.error('Connection error:', event.message);
      } else if (event.type === 'exception') {
        console.error('Error:', event.message, event.error);
      }
    });

    es.addEventListener('close', event => {
      console.log('Close SSE connection.');
    });

image.png

由此可见,上述返回的是一个个流形式的对象,拿到对象就简单了,首先确认event:message才是我们需要的东西

实现方案

首先捋清思路。

  1. 首先使用sse来进行发起流式传输
  2. 我们需要动态的接收流,并将其内容组装起来
  3. 需要将已经组装起来的数据,通过打印机的形式动态展示,通过锁来判断当前是否展示完成
  4. 需要将后续组装的数据保留,并形成一个队列
  5. 前面输出动画结束之后,去队列中拿到新的数据进行渲染
  6. 查看队列中有没有未渲染的数据
  7. 处理后续结尾工作

思路清楚,开始实现!

使用sse发起流式传输


    const es = new EventSource(url, {
      headers: header,
      body: data,
      method: 'post',
    });

    es.addEventListener('open', event => {
      console.log('Open SSE connection.');
    });

    es.addEventListener('message', event => {
      handleStreamData(JSON.parse(event.data), question, pushAnswer);

      if (JSON.parse(event.data).event == 'workflow_finished') {
        pushAnswer('', true);
        es.removeAllEventListeners();
        es.close();
      }
    });

    es.addEventListener('error', event => {
      if (event.type === 'error') {
        console.error('Connection error:', event.message);
      } else if (event.type === 'exception') {
        console.error('Error:', event.message, event.error);
      }
    });

    es.addEventListener('close', event => {
      console.log('Close SSE connection.');
    });

处理到接收流的数据

  const handleStreamData = (buffer, question, pushAnswer) => {
    if (buffer.event == 'message_end') {
     
    }

    if (buffer.event === 'workflow_started') {
      if (buffer.conversation_id && conversationIdRef.current == '') {
        conversationIdRef.current = buffer.conversation_id;
        // 拿到第一个问题作为对话答案
        changeChatName(question, buffer.conversation_id);
      }

      if (!messageId.current || messageId.current !== buffer.message_id) {
        messageId.current = buffer.message_id;
        if (appAuth?.suggested_questions_after_answer?.enabled) {
        //根据权限值 判断是否去拿到建议回答
          getSuggestion().then(res => {
            suggestion.current = res;
          });
        }
      }
      // 打开锁
      setIsLock(false);
    }

    if (buffer.event == 'message') {
      try {
        // 推入回答
        pushAnswer(buffer.answer);
      } catch (e) {
        console.log(e);
      }
    }
  };

通过闭包的原理,形成一个储水池,保证打印机不会中断太长时间

  const reserveAnswer = () => {
    let str = '';
    const _allList = [];

    return function (answer, isEnd = false) {
      str += `${answer ?? ''}`;

      // 蓄洪 放水
      if (str.length > 30 || isEnd) {
        _allList.push(str);
        str = '';
        chatAllList.current = _allList;
        setDisplayList([...chatAllList.current]);
      }
    };
  };

const pushAnswer = reserveAnswer()

开始编写打印机效果,我是使用setInterval来保证正常运行

  const startTotext = allStr => {
    if (!allStr) return;
    return new Promise((resolve, reject) => {
      let startIndex = 0;
      clearInterval2Ref.current = setInterval(() => {
        scrollViewRef.current.scrollToEnd({animated: true});
      }, 200);

      clearIntervalRef.current = setInterval(() => {
        if (startIndex <= allStr.length) {
          let endIndex = startIndex + 1;

          if (endIndex <= allStr.length) {
            let anotherString = allStr.substring(startIndex, endIndex);
            setDisplayText(
              prevText => (prevText ?? '') + (anotherString ?? ''),
            );
            startIndex += 1;
          } else {
            resolve();
          }
        }
      }, 20);
    });
  };

如果有锁或者队列中有数据,替换队列中的数据

  async function fetchData(arr) {
    setIsLock(true);
    if (arr.length > 0 && arr.length > 0) {
      for (let i = currentIndex.current; i < arr.length; i++) {
        const element = arr[i];

        await startTotext(element);
        clearInterval(clearIntervalRef.current);
        clearInterval(clearInterval2Ref.current);
        // 使用currentIndex来保证上次遍历的位置
        currentIndex.current = currentIndex.current + 1;
      }
    }
    setIsLock(false);
  }

  useEffect(() => {
    if (!chatAllList.current.length) return;

    if (currentIndex.current === -1) {
      chatListRef.current = [
        ...chatListRef.current,
        {
          type: 'ai',
          text: '',
        },
      ];
      setChatList(chatListRef.current);
      currentIndex.current = 0;
    }
   // 有锁或者仍有未遍历的数据,仍将新数据放到未遍历的数据
    if (!isLock && !lastChatList.length) {
      fetchData(displayList);
    } else {
      setLastChatList(displayList);
    }
  }, [displayList]);

锁一旦打开去队列中进行查询,是否有数据,如果有则渲染新的队列数据,如果没有则进行后续的清除工作

 useUpdateEffect(() => {
    if (!isLock) {
      if (lastChatList.length) {
        fetchData(lastChatList);
        setLastChatList([]);
      } else {
        if (
          displayText &&
          chatAllList.current.length === displayUseText.length
        ) {
            stopRender(displayText);
        }
      }
    }
  }, [isLock]);

因为害怕前司找茬,自己取消掉了效果图的演示,只能说确实加快了很多速度,同比没优化前,大概在前方案的一半时间,该方案就开始输出内容

因React-native的特殊性,导致在开发中遇到了很多岔路,查询了很多资料,中间也踩了很多的坑,虽然可能使用React-native开发App的并不多,但是最起码能为大家提供一个思路。