今天牢大又来活了,他指着一个AI-Chat的页面跟我说,你来搭个这个吧。
我寻思牢大你是潮哥吗,什么新你来什么。但没有办法,只能接活,我寻思前端页面也不会太难,底层模型就交给后台大哥了!
poe.com
EventStream
后端接口使用事件流返回,此时在浏览器打开F12,可以看到请求中会有EventStream流返回,前端要做的就是处理这个EventStream流。
1)服务端返回的 Stream,浏览器会识别为 ReadableStream 类型数据,执行 getReader() 方法创建一个读取流队列,可以读取 ReadableStream 上的每一个分块数据;
2)通过循环调用 reader 的 read() 方法来读取每一个分块数据,它会返回一个 Promise 对象,在 Promise 中返回一个包含 value 参数和 done 参数的对象;
3)done 负责表明这个流是否已经读取完毕,若值为 true 时表明流已经关闭,不会再有新的数据,此时 result.value 的值为 undefined;
4)value 是一个 Uint8Array 字节类型,可以通过 TextDecoder 转换为文本字符串进行使用。
处理Markdown语法
message中会返回Markdown语法的句子,这个时候需要用到Markdown的插件,我选了react-markdown这个库来进行编译,展示code模块时,添加代码复制的功能(CodeBlock)
index.tsx
import { Select,Radio, RadioChangeEvent,Input,Button,Flex,Typography,message,Collapse,notification } from "antd";
import { MessageOutlined,DatabaseOutlined,ArrowRightOutlined,OpenAIOutlined,LoadingOutlined,CloseCircleOutlined,CopyOutlined,CheckCircleFilled } from "@ant-design/icons";
import { useState,useRef } from "react";
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw';
import "./index.less";
import copy from 'copy-to-clipboard';
// 处理code部分
const CodeBlock = ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const handleCopy = () => {
message.success('复制成功')
copy(children.trim());
};
return !inline && match ? (
<pre style={{ backgroundColor: 'rgb(43, 43, 43)',color:'rgb(248, 248, 242)',padding:'10px',borderRadius:'8px', position: 'relative' }}>
<button
title="复制"
style={{
position: 'absolute',
top: '10px',
right: '10px',
padding: '5px 10px',
backgroundColor: '#333',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
onClick={handleCopy}
>
<CopyOutlined/>
</button>
<code className={className} {...props}>
{children}
</code>
</pre>
) : (
<code className={className} {...props}>
{children}
</code>
);
};
const openNotification = (chatType: number) => {
notification.open({
key:'update',
message: `切换为${chatType?'知识库对话':'LLM对话'}模式`,
placement:'bottomRight',
duration: 2,
icon: <CheckCircleFilled style={{ color: '#52c41a' }} />,
});
};
const AiPage: React.FC = () => {
const [chatType, setChatType] = useState<number>(0);
const [userInput, setUserInput] = useState<string>('')
const [messages, setMessages] = useState<any[]>([]);
const loadingRef = useRef(false)
function changeChatType(e:RadioChangeEvent) {
setChatType(e.target.value)
handleStopChat()
openNotification(e.target.value)
}
function handleKeyDown(e) {
e.preventDefault();
// 检查是否按下了 Ctrl + Enter
if (e.key === 'Enter' && e.shiftKey) {
// 阻止默认的换行行为
// 在 textarea 中插入换行符
const textarea = e.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
// textarea.value =
setUserInput(textarea.value.substring(0, start) + '\n' + textarea.value.substring(end))
// 将光标移动到换行符后面
textarea.selectionStart = textarea.selectionEnd = start + 1;
} else if(e.key === 'Enter') {
if (loadingRef.current) {
return
} else {
loadingRef.current = true
handleUserInput()
}
}
}
const handleUserInput = async () => {
if (userInput.trim() !== '') {
// 将用户输入添加到消息列表中
setMessages([...messages, { text: userInput, isUser: true },{ text: '', isUser: false, loading:true }]);
setUserInput('');
loadingRef.current = true
// 向接口发送请求获取机器人响应
try {
const req = chatType ? '/xxx/server/chat/stream/knowledge_base_chat':'/xxx/server/chat/stream/chat'
const response = await fetch(req, {
method:'POST',
body: JSON.stringify({ query:userInput }),
headers: {
'Content-Type': 'application/json',
},
// signal:controller.signal
});
if (response?.body) {
const reader = response.body.getReader();
const textDecoder = new TextDecoder();
let output = ''
let docs = null
const key = chatType?'answer':'text'
while (loadingRef.current) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream ended');
// result = false;
loadingRef.current = false
const message = {
text:output,
docs,
loading:false
}
setMessages(m=>[...m.slice(0,-1),message]);
break;
}
const chunkText = textDecoder.decode(value);
const obj = JSON.parse(chunkText.split('data: ')?.[1])
if(obj?.[key]){
output += obj?.[key]
}
const message = {
...messages[-1],
text:output,
}
if(chatType && obj.docs){
docs = obj.docs
}
setMessages(m=>[...m.slice(0,-1),message]);
// console.log('Received chunk:', chunkText,output);
}
}
} catch (error) {
console.log(error);
loadingRef.current = false
setMessages(m=>[...m.slice(0,-1), { text: '很抱歉,我目前无法回复。', isUser: false,loading:false }]);
}
}
};
function handleStopChat() {
loadingRef.current = false
if(messages.length && messages[-1]?.loading){
setMessages(m=>[...m.slice(0,-1),{...m[-1],loading:false}]);
}
}
return (
<div className="padding-24">
<Radio.Group defaultValue={0} buttonStyle="solid" value={chatType} onChange={changeChatType}>
<Radio.Button value={0}><MessageOutlined style={{marginRight:'8px'}}/>LLM对话</Radio.Button>
<Radio.Button value={1}><DatabaseOutlined style={{marginRight:'8px'}}/>知识库对话</Radio.Button>
</Radio.Group>
<section className="chat-content">
<div className="answer-content">
{
messages.map((d,index)=>(
<div
key={index}
className={`chat-message ${d.isUser ? 'user' : 'bot'}`}
>
{!d.isUser && <Flex gap="small" className="message-title"><OpenAIOutlined/><span>智能助手</span></Flex>}
<pre className="message-text">
{ d.isUser && d.text ? d.text : <ReactMarkdown components={{ code: CodeBlock }} rehypePlugins={[rehypeRaw]} children={d.text}/>}
{
d.docs && <Collapse style={{marginBottom:'10px'}}
items={[{ key: '1', label: '知识库匹配结果', children:
d.docs.map(doc=><ReactMarkdown>{doc}</ReactMarkdown>)
}]}
/>
}
{d.loading && <LoadingOutlined style={{marginLeft:'15px'}}/>}
</pre>
</div>
))
}
</div>
</section>
<section className="chat-question">
{
loadingRef.current && <Button icon={<CloseCircleOutlined />} className="stop-chat" onClick={handleStopChat}>停止</Button>
}
<Flex className="question-content" gap="small">
<Input.TextArea value={userInput} autoSize={{ minRows: 1, maxRows: 6 }} placeholder="请输入对话内容,换行请使用Shift+Enter" onChange={e=>setUserInput(e.target.value)} onPressEnter={handleKeyDown}></Input.TextArea>
<Button loading={loadingRef.current} style={{bottom:0}} type="primary" shape="circle" icon={<ArrowRightOutlined />} onClick={handleUserInput} disabled={!userInput.trim()}></Button>
</Flex>
</section>
</div>
);
};
export default AiPage;
index.less
.padding-24{
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
}
.chat-content{
flex:1;
margin-top: 20px;
overflow: auto;
.answer-content{
width: 50%;
margin: auto;
padding-bottom: 20px;
.chat-message {
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.chat-message.user {
// background-color: #e6e6e6;
text-align: right;
}
.chat-message.bot {
// background-color: #f0f0f0;
text-align: left;
.message-title{
cursor: pointer;
margin-bottom: 10px;
font-size: 15px;
}
.message-text{
background-color: #fff;
color: #000;
}
}
.message-text{
text-align: left;
font-size: 16px;
padding:.5rem .7rem;
display: inline-block;
background-color: #2989ff;
color: #fff;
overflow-x: hidden;
border-radius: 12px;
word-break: break-word;
box-sizing: border-box;
max-width:100%;
white-space: pre-wrap;
}
}
}
.chat-question{
margin-top: 20px;
position: relative;
.question-content{
display: flex;
align-items: end;
margin: auto;
width: 50%;
}
.stop-chat{
position: absolute;
left: 50%;
// tras
transform: translate(-100%);
top:-42px;
margin-bottom: 10px;
}
}
效果
fb70cb7466ddb59c23aaf41c2ab50a3.png参考:
ChatGPT Stream 流式处理网络请求 - 掘金 (juejin.cn)
Axios 流式(stream)请求怎么实现?2种方法助你轻松获取持续响应 (apifox.com)
网友评论