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

news2024/12/23 9:07:36

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

业务需求:

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

相关技术原理:

1. JS中的Stream流:

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

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

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

img

2. Stream API

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

  1. useStream Hook: 接受一个URL和一个参数对象。这个对象可以包含几个回调函数(onFirst, onNext, onError, onDone)和一个fetchParams对象,用于自定义fetch请求。
  2. startStream 函数: 被useStream内部调用,用于实际发起fetch请求,并使用ReadableStream的reader来逐块读取数据。它处理流数据的读取,并根据提供的回调函数处理数据块、错误和流结束。
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字符串。

例如:

<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文件中定义的样式,设置聊天记录的布局和外观。
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在外部的引用:

...
  // 如果消息超出了屏幕,自动滚动到最底部
  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}
      />
    }
...
)

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1887815.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

react native中使用@react-navigation/native进行自定义头部

react native中使用react-navigation/native进行自定义头部 效果示例图实例代码 效果示例图 实例代码 /* eslint-disable react-native/no-inline-styles */ /* eslint-disable react/no-unstable-nested-components */ import React, { useLayoutEffect } from react; import…

ripro子主题eeesucai-child集成后台美化包(适用于设计素材站+资源下载站等)

模板介绍 最新RiPro子主题模板&#xff0c;Eeesucai-child模板后台美化包&#xff0c;使用该子主题前需要安装WordPress程序和RiPro模板。 安装教程 第一种&#xff0c;在wordpress后台上传主题&#xff0c;上传之后点启动 第二种&#xff0c;上传到wordpress主题目录/wp-con…

MatLab 二维图像绘制基础

MatLab 二维图像绘制基础 plot 描点绘图 %% % 二维绘图 &#xff0c;plot进行描点&#xff0c;步长越小&#xff0c;越平滑 x [1:9]; y [0.1:0.2:1.7]; X x y*i; % 复数 plot(X)plot绘制矩阵 %% % 当X Y 为矩阵时&#xff0c;对应矩阵中的元素依次绘制 t 0:0.01:2*pi; …

将多个Excel工作表合并成一个工作表,1分钟轻松搞定!

1. 案例展示 2. 视频详解 多个工作表合并成一个工作表 3. 图文详解 第一步&#xff1a;相同格式&#xff08;表头&#xff09;的表格&#xff0c;并将所有表格都放在一个文件夹内“将多个工作表合并成一个工作表”&#xff08;自己定义文件名&#xff09; 第二步&#xff1a;新…

Linux 【线程池】【单例模式】【读者写者问题】

&#x1f493;博主CSDN主页:麻辣韭菜&#x1f493;   ⏩专栏分类&#xff1a;Linux初窥门径⏪   &#x1f69a;代码仓库:Linux代码练习&#x1f69a;   &#x1f339;关注我&#x1faf5;带你学习更多Linux知识   &#x1f51d; 目录 &#x1f3f3;️‍&#x1f308;前言 …

VSCode打开其它IDE项目注释显示乱码的解决方法

问题描述&#xff1a;VSCode打开Visual Studio&#xff08;或其它IDE&#xff09;工程&#xff0c;注释乱码&#xff0c;如下图所示&#xff1a; 解决方法&#xff1a;点击VSCode右下角的UTF-8&#xff0c;根据提示点击“通过编码重新打开”&#xff0c;再选择GB2312&#xff0…

JDBC链接kerberos认证的impala数据库报错问题解决

先上代码 public static Connection connectToImpala() {try {log.info("ketTabPath:" ketTabPath);log.info("krb5Path:" krb5Path);System.setProperty("java.security.krb5.conf", krb5Path);System.setProperty("sun.security.krb5.…

python如何输出list

直接输出list_a中的元素三种方法&#xff1a; list_a [1,2,3,313,1] 第一种 for i in range(len(list_a)):print(list_a[i]) 1 2 3 313 1 第二种 for i in list_a:print(i) 1 2 3 313 1 第三种&#xff0c;使用enumerate输出list_a方法&#xff1a; for i&#xff0c;j in enum…

线程池666666

1. 作用 线程池内部维护了多个工作线程&#xff0c;每个工作线程都会去任务队列中拿取任务并执行&#xff0c;当执行完一个任务后不是马上销毁&#xff0c;而是继续保留执行其它任务。显然&#xff0c;线程池提高了多线程的复用率&#xff0c;减少了创建和销毁线程的时间。 2…

【FFmpeg】avformat_find_stream_info函数

【FFmpeg】avformat_find_stream_info 1.avformat_find_stream_info1.1 初始化解析器&#xff08;av_parser_init&#xff09;1.2 查找探测解码器&#xff08;find_probe_decoder&#xff09;1.3 尝试打开解码器&#xff08;avcodec_open2&#xff09;1.4 读取帧&#xff08;re…

Redis的使用(二)redis的命令总结

1.概述 这一小节&#xff0c;我们主要来研究一下redis的五大类型的基本使用&#xff0c;数据类型如下&#xff1a; redis我们接下来看一看这八种类型的基本使用。我们可以在redis的官网查询这些命令:Commands | Docs,同时我们也可以用help 数据类型查看命令的帮助文档。 2. 常…

新鲜出炉!恭喜这 5 位同学中选 NebulaGraph 社区 2024 开源之夏项目!

开源之夏是中国科学院软件研究所发起的“开源软件供应链点亮计划”系列暑期活动&#xff0c;旨在鼓励高校学生积极参与开源软件的开发维护&#xff0c;促进优秀开源软件社区的蓬勃发展。活动联合各大开源社区&#xff0c;针对重要开源软件的开发与维护提供项目开发任务&#xf…

【JPCS出版,PSESG 2024,8月16-18】2024年电力系统工程与智能电网国际学术会议

2024年电力系统工程与智能电网国际学术会议(PSESG 2024)于2024年8月16-18日在中国北京隆重召开。 会议旨在为从事“电力系统工程”、“智能电网”、“储能技术”等领域的专家学者、工程技术人员、研发人员提供一个共享科研成果和前沿技术&#xff0c;了解学术发展趋势&#xf…

linux的Top学习

学习文档 https://www.cnblogs.com/liulianzhen99/articles/17638178.html TOP 问题 1&#xff1a;top 输出的利用率信息是如何计算出来的&#xff0c;它精确吗&#xff1f; top 命令访问 /proc/stat 获取各项 cpu 利用率使用值内核调用 stat_open 函数来处理对 /proc/sta…

PMP通过率为什么高?

很多人在初步了解PMP的时候&#xff0c;都会考虑到PMP考试的难度以及通过率&#xff0c;继而在网上查询到很多资料后&#xff0c;都会发现&#xff0c;其实PMP的国内通过率一直都是很高的。 通过率高≠含金量低 看到PMP的通过率这么高&#xff0c;很多人觉得证书的水分很大&a…

鼠标连点器:解放双手的自动化效率神器,鼠标自动快速连点!

日常使用电脑整理工作时&#xff0c;总会做一些重复的工作&#xff0c;比如&#xff1a;刷题、做任务、浏览多张图片、浏览多个文件等。这些操作的工作量在于鼠标左键&#xff0c;需要一直重复的点&#xff0c;略微有些枯燥了。 面对重复且枯燥的工作&#xff0c;我们可以借助第…

Windows系统安装NVM,实现Node.js多版本管理

目录 一、前言 二、NVM简介 三、准备工作 1、卸载Node 2、创建文件夹 四、下载NVM 五、安装NVM 六、使用NVM 1、NVM常用操作命令 2、查看NVM版本信息 3、查看Node.js版本列表&#xff1b; 4、下载指定版本Node.js 5、使用指定版本Node.js 6、查看已安装Node.js列…

快速入门FreeRTOS心得(正点原子学习版)

对于FreeROTS&#xff0c;我第一反应想到的就是通信里的TDM&#xff08;时分多址&#xff09;。不同任务给予分配不同的时间间隔&#xff0c;也就是任务之间在每个timeslot都在来回切换。 这里有重要的一点&#xff0c;就是中断要短小&#xff0c;优先级是自高到底进行打断。 …

如何避免删库跑路?

如何避免删库跑路&#xff0c;这几乎是一个老生常谈的话题&#xff0c;也是大部分上了规模的企业都很关心的话题&#xff0c;京东到家、微盟、链家、思科... 在这些大企业上发生过的删库事件仍然历历在目&#xff0c;无论是否当事人有意为之还是系统 BUG 导致&#xff0c;造成的…

vue-advanced-chat 聊天控件的使用

测试代码&#xff1a;https://github.com/robinfoxnan/vue-advanced-chat-test0 控件源码&#xff1a;https://github.com/advanced-chat/vue-advanced-chat 先上个效果图&#xff1a; 这个控件就是专门为聊天而设计的&#xff0c;但是也有一些不足&#xff1a; 1&#xf…