写一个简单的聊天页面,使用SSE调用ChatGPT的接口,使用react-markdown显示获取到的数据。
又来水一篇文章。后端小伙伴对于ai一直很感兴趣,想在公司的综合平台里用gpt集成知识库,让同事都可以免费使用gpt4。研究了一下,了解了一点SSE的知识点。(只是想法,自己写着玩儿,上线的话还需要考虑很多东西)
ChatGPT的打字机效果是使用SSE(Server-Sent Events)获取流式数据实现的。SSE与websocket是单向的,服务器可以主动向浏览器发送数据,但是浏览器不能向服务器发。SSE基于HTTP,而websocket是有另外的协议的,所以SSE的兼容性更好一点。
原生的EventSource直接发送get请求,我们想发post请求可以用封装过的@microsoft/fetch-event-source
react-markdown是一个基于react的markdown解析器组件,可以在组件中直接渲染markdown格式的内容,然后通过其他插件可以给这个元素加各种样式。
import { Button, Col, Input, Row } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { PageContainer } from '@ant-design/pro-layout';
import ProCard from '@ant-design/pro-card';
import styles from './index.less';
import { SearchOutlined } from '@ant-design/icons';
import { useModel } from 'umi';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; // 划线、表、任务列表和直接url等的语法扩展
import rehypeRaw from 'rehype-raw'; // 解析标签,支持html语法
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; // 代码高亮
//高亮的主题,还有很多别的主题,可以自行选择
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { v4 as uuidv4 } from 'uuid';
import 'github-markdown-css';
const AIChat: React.FC = () => {
const [inputValue, setInputValue] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false); //控制是否能input
const stopReading = useRef<boolean>(false);
const [chatData, setChatData] = useState<Record<string, any>[]>([
{ id: -1, content: '你好,请输入你的问题。' },
]);
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
const count = useRef<number>(0);
const [tempContent, setTempContent] = useState<string>('');
const tempContent2 = useRef<string>('');
useEffect(() => {
if (localStorage.getItem('chatData')) {
setChatData(JSON.parse(localStorage.getItem('chatData') || ''));
}
}, []);
const ctrl = new AbortController();
const fetchEventGPT = () => {
setChatData([
...chatData,
{ id: uuidv4(), content: inputValue, roles: 'user' },
{ id: uuidv4(), content: '', roles: 'gpt' },
]);
count.current += 2;
setInputValue('');
setTempContent('');
tempContent2.current = '';
setLoading(true);
// const data = {
// channelId: '1220648311464267808',
// messages: [
// {
// content: inputValue,
// role: 'user',
// },
// ],
// model: 'GPT-3.5',
// stream: true,
// };
const data = {
content: inputValue,
};
//换成gpt的接口
fetchEventSource('http://192.168.200.207:8123/gpt/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
signal: ctrl.signal,
async onopen(response) {
if (response.ok && response.status === 200) {
console.log('open');
}
},
onmessage(msg) {
if (stopReading.current) {
console.log('stop');
return;
}
if (msg.data === '{"event":"done"}') {
console.log('done');
let temp: Record<string, any>[] = [];
setChatData((pre) => {
pre[pre.length - 1].content = tempContent2.current;
temp = pre;
return temp;
});
localStorage.setItem('chatData', JSON.stringify(temp));
return;
}
if (msg.data) {
try {
const datas = JSON.parse(msg.data);
const { message } = datas;
if (message?.type === 'answer' && message?.content) {
tempContent2.current = tempContent2.current + message?.content;
setTempContent(tempContent2.current);
}
// if (datas.event === 'error') {
// setTempContent('出错了,' + datas?.error_information?.err_msg || '请联系管理员。');
// }
} catch (error) {
console.log(error);
}
}
},
onclose() {
console.log('close');
stopReading.current = false;
setLoading(false);
ctrl.abort();
},
onerror(err) {
console.log('error', err);
stopReading.current = false;
setLoading(false);
ctrl.abort();
// source?.close();
},
});
};
const suffix = (
<SearchOutlined
style={{
fontSize: 16,
}}
onClick={fetchEventGPT}
/>
);
return (
<>
<PageContainer title={false} style={{ height: '100%' }}>
<ProCard style={{ marginBottom: 20, borderRadius: 5 }}>
<div className={styles.gptContent}>
{chatData && chatData.length > 0 && (
<>
{chatData.map((item) => {
return (
<Row key={item.id} gutter={[24, 24]} style={{ marginBottom: 18 }}>
{item.roles === 'user' ? (
<>
<Col>
<img
src={
currentUser?.headImgUrl ||
'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
}
alt=""
className={styles.imgStyle}
/>
</Col>
<Col
span={20}
style={{
border: '1px solid #cfd3d3',
borderRadius: '10px',
width: '100%',
minHeight: '30px',
backgroundColor: 'rgb(93,92,222)',
color: '#fff',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
fontSize: 16,
}}
>
<div id={item.id} style={{ padding: '5 2' }}>
{item.content}
</div>
</Col>
</>
) : (
<>
<Col>
<img
src={
'https://psc2.cf2.poecdn.net/assets/_next/static/media/assistant.b077c338.svg'
}
alt=""
className={styles.imgStyle}
/>
</Col>
<Col
span={20}
style={{
border: '1px solid #cfd3d3',
borderRadius: '10px',
width: '100%',
minHeight: '30px',
}}
>
<ReactMarkdown
className="markdown-body"
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img(props) {
return <img {...props} style={{ maxWidth: '100%' }} />;
},
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
style={tomorrow}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{item.content ? item.content : tempContent}
</ReactMarkdown>
{/* <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
style={{ marginBottom: 0 }}
>
{item.content ? item.content : tempContent}
</ReactMarkdown> */}
</Col>
</>
)}
</Row>
);
})}
</>
)}
</div>
<Button
style={{ display: loading ? '' : 'none' }}
onClick={() => {
stopReading.current = true;
ctrl.abort();
}}
>
Stop
</Button>
<Input
size={'large'}
type="textarea"
placeholder="与Assistant交谈"
className={styles.chatInput}
suffix={suffix}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onPressEnter={fetchEventGPT}
disabled={loading}
/>
</ProCard>
</PageContainer>
</>
);
};
export default AIChat;
做了上下文的缓存,样式还有没写好的地方,没有做到poe那样,有空再继续完善。
两种markdown的样式