React 实现 DeepSeek 流式聊天(前端)
本文介绍了基于React技术栈的AI聊天组件实现,通过流式技术处理AI回复内容。主要功能包括:使用Antd、TailwindCSS等前端工具构建界面;采用fetch请求和EventStream处理后端数据流;实现内容自动滚动、快捷复制、重新询问等交互功能;处理输入框换行发送逻辑;优化流式输出时的用户体验。
·
❤ 感谢点赞👍收藏⭐评论✍
准备:
- 前端技术栈:React
- 前端工具:Antd、TailwindCSS、ahooks
- 相关依赖:react-markdown、remark-gfm、clipboard-polyfill
- Http请求:fetch
- 后端数据结构:EventStream 流式结构
实现功能
- AI 回复内容流式输出
- 问题回答自动滚动底部
- 快捷复制
- 快捷重新询问
- 输入框 Enter 发送、Ctrl/Shift + Enter 换行、光标位置换行
- 内容生成过程中,复制、刷新、发送、输入等功能禁用问题
废话不多说,直接上干货!
代码实现
import { FC, useState, useEffect, useRef } from 'react';
import { Input, Button, message } from 'antd';
import { ArrowUpOutlined, LoadingOutlined, CopyOutlined, RedoOutlined, TwitchOutlined } from '@ant-design/icons';
import { useRequest } from 'ahooks';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as clipboard from 'clipboard-polyfill';
import './index.less';
/* API */
export const customerServiceOttaiSeek = {
// AI聊天流式对话
chatStream: (params: { question: string }) => {
return fetch(`/xxx?question=${params.question}`);
},
};
/* 组件 */
const AIChat: FC = () => {
const scrollRef = useRef<any>();
const textareaRef = useRef(null);
const [question, setQuestion] = useState('');
const [historyList, setHistoryList] = useState <any[]> ([]);
const [loading, setLoading] = useState(false);
// 滚动到最底部
useEffect(() => {
const scrollToBottom = () => {
const { current }: any = scrollRef;
if (current) {
current.scrollTop = current.scrollHeight;
}
};
scrollToBottom();
}, [historyList]);
const { run: handleFetch } = useRequest(async (reqQuestion: string) => {
setLoading(true);
const loadingItem = {
type: 1,
content: <LoadingOutlined />,
timeStamp: new Date().getTime(),
contentType: 'loading',
};
setHistoryList((list) => [...list, loadingItem]);
const data: any = await customerServiceOttaiSeek.chatStream({
question: reqQuestion,
})
const reader = data.body.getReader();
const decoder = new TextDecoder();
if (!reader) return;
let partialData = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
partialData += chunk; // 累积流数据
// 处理 SSE 格式,每行 `data: {...}`
const lines = partialData
.split('\n')
.filter((line) => line.startsWith('data:'));
for (const line of lines) {
try {
const json = JSON.parse(line.replace(/^data:/, '').trim());
await new Promise((resolve) => setTimeout(resolve, 50)); // 模拟打字延迟
// 更新聊天列表
setHistoryList((prevHistory: any[]) => {
const lastMessage = prevHistory[prevHistory.length - 1];
if (lastMessage && lastMessage.isStreaming) {
// 如果最后一条消息是流式消息,则追加内容
return [
...prevHistory.slice(0, -1),
{
...lastMessage,
type: 1,
content: lastMessage.content + json.answer,
},
];
} else {
// 否则,添加一条新的流式消息
return [
...prevHistory.slice(0, -1),
{
content: json.answer,
isStreaming: true,
type: 1,
timeStamp: new Date().getTime(),
},
];
}
});
} catch (e) {
console.error('解析 JSON 失败:', e);
}
}
/**
* 前面 partialData.split('\n').filter((line) => line.startsWith('data:')) 方法已经将部分解析完整的字段循环遍历展示;
*
* 这里通过 lastIndexOf('\n') + 1 将剩余未完整解析的 JSON 继续拼接处理,例如:
* a. partialData 为 "\n\ndata:" 时,继续拼接 'data:'
* b. partialData 为 "data:{...}\n\ndata\n\n" 时, 也会继续拼接 ''
*
* 如果上次的 partialData 刚好为 "data:{...}\n\ndata:{}",则 lastIndexOf('\n') + 1 结果为完整的 JSON => data:{},
* 但是通过 split('\n') 时已经展示过一次 data:{},所以这里继续拼接到 partialData 中,会导致该 JSON 进入下次循环重复展示!
*/
const lastData = partialData.slice(partialData.lastIndexOf('\n') + 1);
if (!(lastData.startsWith('data:{') && lastData.endsWith('}'))) {
partialData = lastData;
} else {
partialData = '';
}
}
setLoading(false);
}, { manual: true });
const handleCopy = async (text: string) => {
if (loading) return;
await clipboard.writeText(text);
message.success('复制成功');
}
const handleRefresh = (index: number) => {
if (loading) return;
const questionInfo = historyList[index - 1];
if (questionInfo.type === 0 && questionInfo.content) {
const searchItem = {
type: 0,
content: questionInfo.content,
timeStamp: new Date().getTime(),
};
setHistoryList([...historyList, searchItem]);
handleFetch(questionInfo.content);
}
}
const handleSearch = () => {
if (/^\s*$/.test(question)) return;
const searchItem = {
type: 0,
content: question,
timeStamp: new Date().getTime(),
};
setHistoryList([...historyList, searchItem]);
handleFetch(question);
setQuestion('');
}
const handleKeyDown = (e: any) => {
// Enter键提交(不配合其他键)
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault(); // 阻止默认换行行为
handleSearch();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
/**
* 解决 Ctrl + Enter 换行不生效问题
* Ctrl+Enter 或 Cmd+Enter(Mac)允许换行(不处理,允许默认行为)
*/
e.preventDefault();
insertNewline();
}
}
// 在光标位置插入换行符
const insertNewline = () => {
if (!textareaRef.current) return;
const textarea = (textareaRef.current as any).resizableTextArea.textArea;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
// 在光标位置插入换行符
const newQuestion = question.substring(0, start) + '\n' + question.substring(end);
setQuestion(newQuestion);
// 更新光标位置
setTimeout(() => {
textarea.selectionStart = start + 1;
textarea.selectionEnd = start + 1;
textarea.focus();
}, 0);
};
return (
<div ref={scrollRef} className={`ottai-seek ${historyList.length ? '' : 'justify-center'}`}>
<div className='w-[80%] pt-[20px]'>
{
historyList.length ? (
historyList.map((item, index) => {
if (item.type === 0) {
return (
<div className='flex justify-end' key={index}>
<p className='bg-[#3B78FF] text-white p-[8px] rounded-[10px]'>
{item.content}
</p>
</div>
);
} else {
return (
<div className='flex mt-[12px]' key={index}>
<TwitchOutlined className='text-[30px]' />
<div className='p-[8px]'>
{
item.contentType === 'loading' ? (
item.content
) : (
<div>
<Markdown remarkPlugins = { [remarkGfm] }>
{item.content}
</Markdown>
</div>
)
}
<div className='flex gap-[8px] mt-[8px]'>
<div
className={`${loading ? 'answer-btn-item-disabled' : 'answer-btn-item'}`}
onClick={() => handleCopy(item.content)}
>
<CopyOutlined />
</div>
<div
className={`${loading ? 'answer-btn-item-disabled ' : 'answer-btn-item'}`}
onClick={() => handleRefresh(index)}
>
<RedoOutlined />
</div>
</div>
</div>
</div>
)
}
})
) : (
<div className='text-center pb-[20px]'>
<TwitchOutlined className='text-[30px]' />
<span className='text-[20px] font-bold mt-[32px] ml-[12px]'>AI 助手</span>
</div>
)
}
</div>
{
!!historyList.length && (
<div className='mt-auto py-[20px] text-center'>
<Button
type='primary'
ghost
onClick={() => {
setHistoryList([]);
setQuestion('');
}}
>
开启新对话
</Button>
</div>
)
}
<div className='sticky bottom-0 w-[80%] pb-[20px] bg-white rounded-t-[10px]'>
<div className='text-area-box'>
<Input.TextArea
ref={textareaRef}
className='pr-0'
placeholder='请输入查询内容'
variant='borderless'
autoSize={{ minRows: 4 }}
disabled={loading}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div
className={`btn ${(!question || loading) ? 'search-disabled-btn' : 'search-btn'}`}
onClick={handleSearch}
>
{loading ? <LoadingOutlined /> : <ArrowUpOutlined />}
</div>
</div>
</div>
</div>
)
};
export default AIChat;
// index.less
.ottai-seek {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: scroll;
background-color: white;
.text-area-box {
display: flex;
padding: 8px 0;
background-color: #F8F8F8;
border-radius: 10px;
}
.btn {
margin-top: auto;
margin-right: 8px;
width: 28px;
height: 28px;
font-size: 16px;
color: white;
border-radius: 14px;
text-align: center;
}
.search-disabled-btn {
cursor: not-allowed;
background-color: #BFBFBF;
}
.search-btn {
cursor: pointer;
background-color: #4d6bfe;
}
.search-btn:hover {
background-color: #2563eb;
}
.answer-btn-item, .answer-btn-item-disabled {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
background-color: #EFEFEF;
border-radius: 12px;
}
.answer-btn-item {
cursor: pointer;
}
.answer-btn-item:hover {
background-color: #D9D9D9;
}
.answer-btn-item-disabled {
color: rgba(0, 0, 0, 0.3);
cursor: not-allowed;
}
}
后端流式数据结构:
页面demo预览
1.无会话样式
2.会话样式
更多推荐
所有评论(0)