下面是近期用React写的一个处理流式数据实例:
import { useState, useRef } from 'react'
import { Button, Form, Input, Modal, Spin, message } from 'antd'
import { ArrowUpOutlined } from '@ant-design/icons'
import AIAskAndAnswer, { type AIAskAndAnswerProps } from './AIAskAndAnswer'
function AIAsk() {
// 打开发起AI聊天的弹窗
const [isOpen, setIsOpen] = useState(false)
// 提问id
const [askId, setAskId] = useState('')
// 提问子组件Ref
const askAnswerRef = useRef<AIAskAndAnswerProps>({
// 暴露的完成方法
onFinish() {},
// 重置内容
resetContent() {}
})
// 提问弹窗中的表单Ref
const [askDialogForm] = Form.useForm()
// 问题内容
const question = Form.useWatch('question', askDialogForm)
// 提问处理事件
const onHandleAsk = (id: string) => {
setIsOpen(true)
setAskId(id)
}
// 回车处理事件
const handlePressEnter = () => {
askRef.current.onFinish(question)
}
// 关闭
const handleClose = () => {
askRef.current.resetContent()
askDialogForm.resetFields()
setIsOpen(false)
}
return (<>
<Button
type="link"
size="small"
onClick={() => onHandleAsk(id)}
>
发起提问
</Button>
<Modal
title={'标题…'}
width={1000}
open={isOpen}
maskClosable={false}
onCancel={handleClose}
footer={() => <></>}
>
<Spin spinning={false}>
<Form
className="my-[24px] px-[12px] pb-[2px]"
form={askDialogForm}
layout="vertical"
>
<Form.Item
label={''}
name="answer"
rules={[{ required: false }]}
>
<AIAskAndAnswer
childRef={askAnswerRef}
askId={askId}
/>
</Form.Item>
<Form.Item
label={''}
name="question"
rules={[{ required: false, message: '' }]}
>
<Input
showCount
size="large"
maxLength={50}
placeholder={'请输入要咨询的问题'}
suffix={
<ArrowUpOutlined
style={{ color: 'rgba(0,0,0,.45)' }}
onClick={handlePressEnter}
/>
}
onPressEnter={handlePressEnter}
/>
</Form.Item>
</Form>
</Spin>
</Modal>
</>)
}
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Card, Spin, message } from 'antd'
import { v4 as uuidCreate } from 'uuid'
import { useAskApi } from '@/apis/hooks'
// 暴露的api方法
export interface AIAskAndAnswerProps {
onFinish: (text: string) => void
resetContent: () => void
}
function AIAskAndAnswer(props: {
childRef: React.MutableRefObject<AIAskAndAnswerProps>
askId: string
}) {
const { childRef, askId } = props
// 内容区Ref
const contentRef = useRef<HTMLDivElement>(null)
// 聊天随机 sessionId Ref
const sessionIdRef = useRef<string>(uuidCreate())
const [loading, setLoading] = useState(false)
// 历史对话
const [history, setHistory] = useState<
{ role: 'user' | 'ai'; text: string }[]
>([])
// 请求接口
const { askApi } = useAskApi()
// 自动滚动到底部
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [history])
// 插入历史聊天文字
const handleText = (text: string) => {
setHistory((h) => {
const newH = [...h]
const lastIdx = newH.length - 1
if (lastIdx >= 0 && newH[lastIdx].role === 'ai') {
newH[lastIdx] = {
...newH[lastIdx],
text
}
}
return newH
})
}
// 接收流式内容并实时展示
const doFetchAIContent = async (text: string) => {
setLoading(true)
try {
const params = {
sessionId: sessionIdRef.current,
content: text,
askId
}
// 先插入一条空的ai消息
setHistory((h) => [...h, { role: 'ai' as const, text: '' }])
const res = await askApi(params, {})
if (!res?.body) throw new Error('暂时无答案')
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
let resText = ''
while (!done) {
const { value, done: isReading } = await reader.read()
done = isReading
if (value) {
// decode 时需要加上 { stream: true } 参数,因为流式传输时,一个汉字符号可能会被拆成多个码点,可能正好不在同一块数据中,{ stream: true } 可以在下次解码时自动将缓存字节与新数据块开头拼接,保证正确解码
const chunk = decoder.decode(value, { stream: true })
// 处理流式返回的多行 data: 内容
const lines = chunk.split(/\r?\n/)
for (const line of lines) {
// data: 开头,正常内容
if (line.startsWith('data:')) {
// 有数据返回就开始展示
setLoading(false)
// 提取内容
const str = line.replace('data:', '').trim()
if (str) {
try {
const content = JSON.parse(str)?.data?.content || ''
// 过滤掉 '[DONE]' 内容
if (content && content !== '[DONE]') {
resText += content
handleText(resText)
}
} catch (err) {
console.log(err)
}
}
} else if (line.startsWith('{"code":"')) {
// 直接以 {"code":" 开头,请求为 非 200 状态,需要特殊处理
const pieces = line.split(',')
// 提取出第一段中的 数字,即是状态码信息
const code = pieces[0].match(/\d+/)
// 非200
if (code && code[0] !== '200') {
// 状态消息文字匹配
const pies = line.split('message":')
const message = pies[1].match(/\"(.*?)\"/)
if (message) {
resText += message[1] ?? ''
} else {
resText += '查询有误~~'
}
handleText(resText)
}
}
}
}
}
} catch (e) {
message.error('查询有误')
handleText('查询有误')
} finally {
setLoading(false)
}
}
// 发送输入区内容,流式生成
const handleSend = async (text: string) => {
if (!text?.trim()) return
setHistory((h) => [...h, { role: 'user', text }])
await doFetchAIContent(text)
}
useImperativeHandle(childRef, () => ({
onFinish: (text: string) => handleSend(text),
resetContent: () => setHistory([])
}))
return (
<Card className="w-full h-[300px]">
{/* 问答内容区 */}
<div
className="h-[260px] text-[18px] overflow-y-auto"
ref={contentRef}
>
{history.map((item, idx) => (
<div
key={idx}
className={`flex ${item.role === 'user' ? 'justify-end' : 'justify-start'} mb-3`}
>
<div
className={
'max-w-[80%] p-3 relative break-all text-[#222] leading-[1.8]',
'rounded-xl border border-solid border-[#D8E0F0] whitespace-pre-wrap',
` ${item.role === 'user' ? 'bg-[#E8F3FF] shadow-[0_2px_8px_0_#E8F3FF]' : 'bg-[#fff] shadow-[0_2px_8px_0_#F2F6FA]'} `
}
>
{item.role === 'ai' && idx === history.length - 1 &&
(loading) ? (
<span className="flex items-center gap-2">
<Spin size="small" />
<span className="text-12px text-[#888]">
回答中...
</span>
</span>
) : (
item.text
)}
</div>
</div>
))}
</div>
</Card>
)
}
export default AIAskAndAnswer