跟上时代?AI对话页面搭建心得

今天牢大又来活了,他指着一个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 转换为文本字符串进行使用。

EventStream

处理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)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容