如何使用react实现流式调用gpt接口,并打字机输出的效果呢?
经过一周的调研和实现,最后通过结合fetch来调用接口实现。
通过fetch,如果要获取流式数据可以如下处理:
async function getRes(content) {
const res = await fetch(url, {...});
const reader = res.body.getReader();
// 读取数据流的第一块数据,done表示数据流是否完成,value表示当前的数
const {done, value} = await reader.read();
// 上面读取到的是数据的字节码,还需要处理字节码为文本
const decoder = new TextDecoder();
const text = decoder.decode(value);
// 打印第一块的文本内容
console.log(text, done);
}
以上代码指执行了一块数据,还要通过循环获取剩下流式内容:
async function getRes(content) {
const res = await fetch(url, {...});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let content = '';
let isThinking = true
while(isThinking) {
// 读取数据流的第一块数据,done表示数据流是否完成,value表示当前的数
const {done, value} = await reader.read();
if (done) {
isThinking = false
break;
}
const text = decoder.decode(value);
// 拼接文本内容
content = content + text
console.log(content, done);
}
}
以上可以实现基本的流式输出
react结合fetch整体可以这么实现
const [history, setHistory] = useState<{ speaker: string; text: string }[]>([
{ speaker: 'bot', text: '我是你的AI助手,有什么问题都可以问我' },
]);
const [isThinking, setIsThinking] = useState(false);
const [inputText, setInputText] = useState('');
const [abortController, setAbortController] = useState<AbortController | null>(null);
const handleSubmit = async (question?: string) => {
if(inputText==='' && question===undefined){
return
}
const controller = new AbortController();
setAbortController(controller);
const newHistory = [
...history,
{ speaker: 'user', text: question ? question : inputText },
{ speaker: 'bot', text: '' },
];
setHistory(newHistory);
setIsThinking(true);
let toBody = {
'model': 'gpt-3.5-turbo',
temperature: 0.1,
stream: true,
messages: [
{
role: 'user',
content: question ? question : inputText ,
},
],
};
setInputText('');
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
},
body: JSON.stringify(toBody),
signal: controller.signal,
})
.then(async (response) => {
if (response.body!.locked) {
console.log('流已经被一个读取器锁定。');
return;
}
if (!response.body) throw Error('No response from server');
const reader = response.body!.getReader();
const textDecoder = new TextDecoder();
let result = true;
while (result) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream ended');
result = false;
setIsThinking(false);
break;
}
const chunkText = textDecoder.decode(value);
let list = chunkText.match(/data: (.+)/g);
console.log('--list--', list);
if (list && list.length > 0) {
list.forEach((element) => {
let data = element.substring(6);
if (
data === '[DONE]' ||
typeof JSON.parse(data) !== 'object' ||
(typeof JSON.parse(data) === 'object' &&
(JSON.parse(data).choices.length === 0 ||
JSON.parse(data).choices[0].delta === undefined))
) {
if (data === '[DONE]') {
setIsThinking(false);
}
return;
}
let content = JSON.parse(data).choices[0].delta.content;
if (content) {
setHistory((history) => {
let newHistory;
// 如果聊天记录最后一条不是机器人,则拼接一条机器人回答对象
if (history[history.length - 1].speaker !== 'bot') {
newHistory = [...history, { speaker: 'bot', text: content }];
} else {
// 聊天记录最后一条是机器人,则直接在机器人回答的内容后面拼接新回答
history[history.length - 1].text = history[history.length - 1].text + content;
// 不能直接history赋值,要加上[... ]生成新对象,否则setState会认为引用地址没变,不执行页面刷新
newHistory = [...history];
}
return newHistory;
});
}
});
}
console.log('Received chunk:', chunkText);
}
})
.catch((error) => {
setIsThinking(false);
console.error('Error:', error);
if (error instanceof DOMException && error.name === 'AbortError') {
console.error('AbortError:', error);
}
});
//这里可以更新state,比如更新history
};
// 输入问题回车调用gpt
const handleKeyPress = (event: any) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
};
// 停止调用
const handleStop = () => {
setIsThinking(false);
if (abortController) {
try {
abortController.abort({
type: 'USER_ABORT_ACTION',
msg: '用户终止了操作',
});
} catch (e) {
console.log(e);
}
}
};
<div className={styles.chatBox}>
{history.map((item, index) => (
<div
key={index}
className={item.speaker === 'user' ? styles.chatRight : styles.chatLeft}
>
{item.speaker === 'bot' && (
<div style={{ marginBottom: '10px' }}>
<img
src={require('@/assets/images/ai.png')}
className={styles.chatLogo}
/>
AI助手
</div>
)}
{item.text || !isThinking || index !== history.length - 1 ? (
<span style={{ display: 'block' }}>{item.text}</span>
) : (
<Spin />
)}
</div>
))}
</div>
{showPrompts && (
<div className={styles.promptsWrapper}>
{prompts.map((prompt, index) => (
<div
key={index}
className={styles.promptBox}
onClick={() => handleSubmit(prompt.question)}
>
<div>{prompt.title}</div>
<span>{prompt.subtitle}</span>
</div>
))}
</div>
)}
{isThinking ? (
<div className={styles.stopWrapper} onClick={handleStop}>
<img src={require('@/assets/images/stop.png')} />
<span style={{ marginLeft: '10px' }}>
停止调用
</span>
</div>
) : (
<div style={{width: '80%', bottom: '10px', position: 'fixed' }}>
<div style={{ display: 'flex' }}>
<TextArea
rows={1}
value={inputText}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
/>
<img
src={require('@/assets/images/send.png')}
className={styles.chatSend}
onClick={() => handleSubmit()}
/>
</div>
</div>
)}
聊天框样式实现
.chatBox {
flex: 1;
height: 100%;
.chatRight {
display: flex;
justify-content: right;
margin: 10px 0;
margin-left: 20%;
> span {
padding: 15px;
line-height: 20px;
background-color: #4eccc0;
border-radius: 10px;
}
}
.chatLeft {
display: flex;
flex-flow: column;
align-items: flex-start;
margin-right: 20%;
> span {
padding: 15px;
background-color: #41454F;
border-radius: 10px;
line-height: 20px;
}
}
}
.chatWrapper {
display: flex;
flex-flow: column;
height: 100%;
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: rgba(136, 136, 136, 0.3) transparent;
color: #fff;
margin-bottom: 30px;
}
.chatSend {
align-self: center;
height: 20px;
padding-left: 5px;
cursor: pointer;
}
.chatLogo {
height: 20px;
padding-right: 5px;
}
.promptsWrapper {
display: flex;
flex-flow: column;
align-items: flex-start;
.promptBox{
background: #000000;
padding: 10px;
border-radius: 10px;
margin-bottom: 5px;
div{
margin-bottom: 5px;
}
span {
color: #8a93a3;
font-size: 13px;
}
}
}
.stopWrapper {
text-align: center;
img {
width: 20px;
}
bottom: 10px;
position: fixed;
}