前端接入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}
      />
    }
...
)

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

相关推荐
Larcher1 天前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐1 天前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭1 天前
如何理解HTML语义化
前端·html
jump6801 天前
url输入到网页展示会发生什么?
前端
诸葛韩信1 天前
我们需要了解的Web Workers
前端
brzhang1 天前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu1 天前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花1 天前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋1 天前
场景模拟:基础路由配置
前端
六月的可乐1 天前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程