前端接入chatgpt,实现流式文字的显示

前端接入chatgpt,实现流式文字的显示

业务需求:

项目需要接入chatgpt提供的api,后端返回流式的字符,前端接收并实时显示。

相关技术原理:

1. JS中的Stream流:

在JavaScript中,使用Stream流通常指的是处理数据流的一种方式,特别是在Node.js环境下。Stream可以是可读的、可写的、或者既可读又可写的。它们允许数据被处理成块,而不是一次性处理整个数据集,这对于处理大量数据或者来自网络请求的数据非常有用。

但曾经这些对于 JavaScript 是不可用的。以前,如果我们想要处理某种资源(如视频、文本文件等),我们必须下载完整的文件,等待它反序列化成适当的格式,然后在完整地接收到所有的内容后再进行处理。

随着流在 JavaScript 中的使用,一切发生了改变------只要原始数据在客户端可用,你就可以使用 JavaScript 按位处理它,而不再需要缓冲区、字符串或 blob。

2. Stream API

以下是封装的用来调用的Stream API的核心代码,为了方便调用封装成了Hook组件。有以下组成部分:

  1. useStream Hook: 接受一个URL和一个参数对象。这个对象可以包含几个回调函数(onFirst, onNext, onError, onDone)和一个fetchParams对象,用于自定义fetch请求。
  2. startStream 函数: 被useStream内部调用,用于实际发起fetch请求,并使用ReadableStream的reader来逐块读取数据。它处理流数据的读取,并根据提供的回调函数处理数据块、错误和流结束。
jsx 复制代码
import React, { useCallback, useState, useRef, useEffect } from 'react';
import 'abortcontroller-polyfill';
import { getLoginToken } from '../../utils/localStorage.js';
import {getRoleFromLocation} from '../commonUtils.js';

/**
 * React hook for the [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API).
 * Use this hook to stream data from a URL.
 * @param {string} url
 * @param {object} [params]
 * @param {function(Response)} [params.onNext]
 * @param {function(Error)} [params.onError]
 * @param {function()} [params.onDone]
 * @param {RequestInit} [params.fetchParams]
 *
 * @returns {StreamHook}
 */

function useStream(url, params) {
  if (typeof params !== 'object' || params === null) {
    params = {};
  }

  const streamRef = useRef();
  const onFirst = useRef(params.onFirst);
  const onNext = useRef(params.onNext);
  const onError = useRef(params.onError);
  const onDone = useRef(params.onDone);
  const close = useCallback(() => {
    if (streamRef.current) {
      streamRef.current.abort();
    }
  }, []);
  useEffect(() => {
    if (streamRef.current) {
      streamRef.current.abort();
    }

    streamRef.current = new AbortController();
    if (params.fetchParams) {
      startStream(url, {
        onFirst: onFirst,
        onNext: onNext,
        onError: onError,
        onDone: onDone,
        fetchParams: {
          ...params.fetchParams,
          signal: streamRef.current.signal
        }
      });
    }
  }, [url, params.fetchParams]);

  useEffect(() => {
    onFirst.current = params.onFirst;
  }, [params.onFirst]);
  useEffect(() => {
    onNext.current = params.onNext;
  }, [params.onNext]);
  useEffect(() => {
    onError.current = params.onError;
  }, [params.onError]);
  useEffect(() => {
    onDone.current = params.onDone;
  }, [params.onDone]);
  return {
    close
  };
}
/**
 * Use this function to start streaming data from an URL
 * @param {string} url
 * @param {object} params
 * @param {React.MutableRefObject<function(Response)>} params.onNext
 * @param {React.MutableRefObject<function(Error)>} params.onError
 * @param {React.MutableRefObject<function()>} params.onDone
 * @param {RequestInit} params.fetchParams
 */

async function startStream(url, {
  onFirst,
  onNext,
  onError,
  onDone,
  fetchParams
}) {
  const errCb = err => {
    if (typeof onError.current === 'function') {
      onError.current(err);
    }
  };

  try {
    // 获取role
    const locationType = getRoleFromLocation();
    // add header
    const reqHeaders = { Authorization: getLoginToken(locationType), 'Content-Type': "application/json"}
    const res = await fetch(url, { method: 'GET', ...fetchParams, headers: reqHeaders });
    const reader = res.body.getReader();
    const headers = res.headers;
    if (typeof onFirst.current === 'function') {
      onFirst.current(headers);
    }

    if (fetchParams.signal instanceof AbortSignal) {
      fetchParams.signal.addEventListener('abort', evt => reader.cancel(evt), {
        once: true,
        passive: true
      });
    } // eslint-disable-next-line no-constant-condition

    while (true) {
      try {
        const {
          done,
          value
        } = await reader.read();
        if (done) {
          if (typeof onDone.current === 'function') {
            onDone.current();
          }
          return;
        }
        if (typeof onNext.current === 'function') {
          const data = new TextDecoder('utf-8').decode(value);
          onNext.current(data);
        }
      } catch (e) {
        errCb(e);
        return;
      }
    }
  } catch (e) {
    errCb(e);
  }
}

export default useStream;

3. React中的dangerouslySetInnerHTML

dangerouslySetInnerHTML是React中的一个属性,允许你直接在组件内部插入HTML代码字符串。由于直接使用HTML字符串可能会导致跨站脚本(XSS)攻击,因此React将其命名为dangerouslySetInnerHTML,以此提醒开发者注意使用时的潜在风险。

使用dangerouslySetInnerHTML时,需要传递一个对象,该对象有一个__html键,对应的值就是你想要插入的HTML字符串。

例如:

jsx 复制代码
<div dangerouslySetInnerHTML={{ __html: "<span>这是HTML内容</span>" }}></div>

在上述代码中,
标签内将显示 这是HTML内容,而不是将其作为字符串显示出来。

使用dangerouslySetInnerHTML时应该非常小心,确保传入的HTML内容是安全的,避免XSS攻击。在可能的情况下,尽量使用React的组件和属性来动态生成内容,而不是直接使用dangerouslySetInnerHTML。

业务实现

当理清上述的技术点后,剩下的业务逻辑实现就不算困难了。但是本人项目里面夹杂了太多了的业务性质的代码,所以这里只展示主要逻辑了。因为流式传来的是一个个字符,所以前期需要收集并拼接传来的字符,等待如[DONE]这类明确状态的字符传来后,再通过setState更新DOM.

  1. 导入依赖:引入了React库的useCallback、useState、useRef钩子,antd-mobile库的Avatar组件,样式文件,一个图片资源,以及自定义的useStream钩子。
  2. 组件定义:ChatGptStream是一个函数式组件,接收props作为参数。
  3. 状态和引用
  • 使用useState钩子定义了chatgptAnswer状态,用于存储聊天回答的内容。
  • 使用useRef钩子创建了answerDataRef引用,用于累积接收到的流数据。
  1. 处理流数据
  • getChatGptStream函数处理从流中接收到的每一条消息。如果消息包含特定的结束标记(如[DONE]、[FAILED]、[OVER]),则调用handleCommend函数处理并结束处理流程。如果消息包含
    ,则将其替换为换行符,并累积到answerDataRef中。
  • 更新chatgptAnswer状态以显示累积的聊天内容,并调用scrollMessageListToEnd函数滚动到消息列表的底部。
  1. 使用自定义钩子:通过useStream钩子与后端建立流连接,传入requestUrl、onFirst、getChatGptStream函数和chatgptParams参数。
  2. 渲染UI:组件返回的JSX中,如果chatgptAnswer.title_zh有内容,则显示聊天记录。使用Avatar组件显示机器人头像,dangerouslySetInnerHTML属性将聊天内容作为HTML插入到页面中,以保留格式(如换行)。
  3. 样式和布局:通过内联样式和className引用外部.less文件中定义的样式,设置聊天记录的布局和外观。
jsx 复制代码
import React, { useCallback, useState, useRef } from 'react';
import { Avatar } from 'antd-mobile';

import './index.less';
import siuvoRobot from '@/assets/images/avatar_robot.png';
import useStream from '@/utils/hooks/useStreamV2';

const ChatGptStream = (props) => {
  const {
    chatgptParamsObj,
    scrollMessageListToEnd,
  } = props;
  const [chatgptAnswer, setChatgptAnswer] = useState({
    title_zh: '',
  });
  const answerDataRef = useRef('');
// 由外部传来的请求地址和入参
  const { requestUrl, chatgptParams } = chatgptParamsObj;

  const handleCommend = data => {
    // 处理data逻辑
  }

  const getChatGptStream = async res => {
    let data = res;
    // 根据后端返回字符,做相应的处理
    if (data.includes('[DONE]') || data.includes('[FAILED]') || data.includes('[OVER]')) {
      handleCommend(data);
      return;
    }
    // 换行
    if (data.includes('<br/>')) {
      data = data.replace(/<br\/>/g, '\r\n');
    }
    answerDataRef.current += data;
    // 显示聊天内容
    setChatgptAnswer({ title_zh: answerDataRef.current, });
    scrollMessageListToEnd();
  };

  const onFirst = useCallback(async res => {
    // 处理首次返回的数据
  }, []);

  useStream(requestUrl, { onFirst, onNext: getChatGptStream, fetchParams: chatgptParams });

  return (
    <>
      {
        chatgptAnswer?.title_zh && (
          <div className="chatting-records-content"
            style={{
              padding: '0 0.5rem',
              marginTop: '-1rem',
            }}
          >
            <div className="dialogue-block flex-start">
              <div className="head">
                <Avatar src={siuvoRobot} style={{ '--size': '32px' }} />
              </div>
              <div className="dialogue left-message-text" style={{ background: 'lavender' }}>
                <div dangerouslySetInnerHTML={{ __html: chatgptAnswer?.title_zh }}>
                </div>
              </div>
            </div>
          </div>
        )
      }
    </>
  )
}

export default ChatGptStream;

这里展示ChatGptStream在外部的引用:

jsx 复制代码
...
  // 如果消息超出了屏幕,自动滚动到最底部
  const scrollMessageListToEnd = useCallback(() => {
    // ...根据实际样式,获取元素
    // 元素当前的滚动位置 = 这是元素内容的总高度 - 元素可见部分的高度
    messagesShowContent.scrollTop = messagesShowContent.scrollHeight - messagesShowContent.clientHeight;
    // ...
  }, [])

  // chatgptParamsObj对象值发生更变,触发更新
  setChatgptParamsObj({
    ...chatgptParamsObj,
    chatgptParams: {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    },
    requestUrl: `${BASE_URL}ai/suggest/v2?sessionId=${sessionIdRef.current}`
  });

...
return (
  ...
    {
      chatgptParamsObj.chatgptParams &&
      <ChatGptStream
        chatgptParamsObj={chatgptParamsObj}
        scrollMessageListToEnd={scrollMessageListToEnd}
      />
    }
...
)

以上,便是实现业务需求的总体逻辑了。

相关推荐
梦境之冢31 分钟前
axios 常见的content-type、responseType有哪些?
前端·javascript·http
racerun34 分钟前
vue VueResource & axios
前端·javascript·vue.js
m0_548514771 小时前
前端Pako.js 压缩解压库 与 Java 的 zlib 压缩与解压 的互通实现
java·前端·javascript
AndrewPerfect1 小时前
xss csrf怎么预防?
前端·xss·csrf
Calm5501 小时前
Vue3:uv-upload图片上传
前端·vue.js
浮游本尊1 小时前
Nginx配置:如何在一个域名下运行两个网站
前端·javascript
m0_748239831 小时前
前端bug调试
前端·bug
m0_748232921 小时前
[项目][boost搜索引擎#4] cpp-httplib使用 log.hpp 前端 测试及总结
前端·搜索引擎
新中地GIS开发老师1 小时前
《Vue进阶教程》(12)ref的实现详细教程
前端·javascript·vue.js·arcgis·前端框架·地理信息科学·地信
m0_748249541 小时前
前端:base64的作用
前端