前端接入chatgpt,实现流式文字的显示
前端接入,实现流式文字的显示 业务需求:
项目需要接入提供的api,后端返回流式的字符,前端接收并实时显示。
相关技术原理: 1. JS中的流:
在中,使用流通常指的是处理数据流的一种方式,特别是在Node.js环境下。可以是可读的、可写的、或者既可读又可写的。它们允许数据被处理成块,而不是一次性处理整个数据集,这对于处理大量数据或者来自网络请求的数据非常有用。
但曾经这些对于 是不可用的。以前,如果我们想要处理某种资源(如视频、文本文件等),我们必须下载完整的文件,等待它反序列化成适当的格式,然后在完整地接收到所有的内容后再进行处理。
随着流在 中的使用,一切发生了改变——只要原始数据在客户端可用,你就可以使用 按位处理它,而不再需要缓冲区、字符串或 blob。
2. API
以下是封装的用来调用的 API的核心代码,为了方便调用封装成了Hook组件。有以下组成部分:
Hook: 接受一个URL和一个参数对象。这个对象可以包含几个回调函数(, , , )和一个对象,用于自定义fetch请求。 函数: 被内部调用,用于实际发起fetch请求,并使用的来逐块读取数据。它处理流数据的读取,并根据提供的回调函数处理数据块、错误和流结束。
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} params.onNext
* @param {React.MutableRefObject} params.onError
* @param {React.MutableRefObject} 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中的TML
TML是React中的一个属性,允许你直接在组件内部插入HTML代码字符串。由于直接使用HTML字符串可能会导致跨站脚本(XSS)攻击,因此React将其命名为TML,以此提醒开发者注意使用时的潜在风险。
使用TML时,需要传递一个对象,该对象有一个键,对应的值就是你想要插入的HTML字符串。
例如:
<div dangerouslySetInnerHTML={{ __html: "这是HTML内容" }}>
在上述代码中,
标签内将显示 这是HTML内容,而不是将其作为字符串显示出来。
使用TML时应该非常小心,确保传入的HTML内容是安全的,避免XSS攻击。在可能的情况下,尽量使用React的组件和属性来动态生成内容,而不是直接使用TML。
业务实现
当理清上述的技术点后,剩下的业务逻辑实现就不算困难了。但是本人项目里面夹杂了太多了的业务性质的代码,所以这里只展示主要逻辑了。因为流式传来的是一个个字符,所以前期需要收集并拼接传来的字符,等待如[DONE]这类明确状态的字符传来后,再通过更新DOM.
导入依赖:引入了React库的、、钩子,antd-库的组件,样式文件,一个图片资源,以及自定义的钩子。组件定义:是一个函数式组件,接收props作为参数。状态和引用: 处理流数据: 使用自定义钩子:通过钩子与后端建立流连接,传入、、函数和参数。渲染UI:组件返回的JSX中,如果.有内容,则显示聊天记录。使用组件显示机器人头像,TML属性将聊天内容作为HTML插入到页面中,以保留格式(如换行)。样式和布局:通过内联样式和引用外部.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('
')) {
data = data.replace(/
/g, 'rn');
}
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 && (
)
}
>
)
}
export default ChatGptStream;
</code>
这里展示在外部的引用:
...
// 如果消息超出了屏幕,自动滚动到最底部
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 &&
}
...
)
以上,便是实现业务需求的总体逻辑了。