跟上時代?AI對話頁面搭建心得

今天牢大又來活了,他指著一個AI-Chat的頁面跟我說,你來搭個這個吧。
我尋思牢大你是潮哥嗎,什么新你來什么。但沒有辦法,只能接活,我尋思前端頁面也不會太難,底層模型就交給后臺大哥了!


poe.com

EventStream

后端接口使用事件流返回,此時在瀏覽器打開F12,可以看到請求中會有EventStream流返回,前端要做的就是處理這個EventStream流。
1)服務(wù)端返回的 Stream,瀏覽器會識別為 ReadableStream 類型數(shù)據(jù),執(zhí)行 getReader() 方法創(chuàng)建一個讀取流隊列,可以讀取 ReadableStream 上的每一個分塊數(shù)據(jù);
2)通過循環(huán)調(diào)用 reader 的 read() 方法來讀取每一個分塊數(shù)據(jù),它會返回一個 Promise 對象,在 Promise 中返回一個包含 value 參數(shù)和 done 參數(shù)的對象;
3)done 負責(zé)表明這個流是否已經(jīng)讀取完畢,若值為 true 時表明流已經(jīng)關(guān)閉,不會再有新的數(shù)據(jù),此時 result.value 的值為 undefined;
4)value 是一個 Uint8Array 字節(jié)類型,可以通過 TextDecoder 轉(zhuǎn)換為文本字符串進行使用。

EventStream

處理Markdown語法

message中會返回Markdown語法的句子,這個時候需要用到Markdown的插件,我選了react-markdown這個庫來進行編譯,展示code模塊時,添加代碼復(fù)制的功能(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('復(fù)制成功')
    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="復(fù)制"
            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
      // 向接口發(fā)送請求獲取機器人響應(yīng)

      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: '很抱歉,我目前無法回復(fù)。', 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: '知識庫匹配結(jié)果', 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="請輸入對話內(nèi)容,換行請使用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 流式處理網(wǎng)絡(luò)請求 - 掘金 (juejin.cn)
Axios 流式(stream)請求怎么實現(xiàn)?2種方法助你輕松獲取持續(xù)響應(yīng) (apifox.com)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容