美文网首页
跟上时代?AI对话页面搭建心得

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

作者: 人猿Jim | 来源:发表于2024-06-02 13:46 被阅读0次

今天牢大又来活了,他指着一个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)

相关文章

  • 金融壹账通前端H5技术周报(第十六期)

    本期导读:本期我为大家带来原创文章:AI生成H5页面pix2code论文启发与思考,跟上时代科技的潮流,与张艳的J...

  • 【AI数据建设2】数据存储

    AI时代,数据为王。那么AI数据是如何建设的?本文将以人脸和声纹数据为例,从AI数据的存储说起。 2.1搭建数据库...

  • 跟上时代

    前几天学君搞了一个《简书》,经请教,试了几次没成功。今又琢磨了一番,再次尝试,终于成功了。这样以后再写点较长的文章...

  • 《跟上时代》

    上午我上了一会班,然后和小同事打了招呼,就提前走了。 我去自来水公司用微信扫码绑了手机缴水费,上个月小区电网改造,...

  • 跟上时代

    今天上午我去我们当地的二手市场,因为我想买一把二手的牛角椅,这款椅子我非常的中意,在网上也看了价格。网络上的价格是...

  • IOT设备AI搭建1:Linux上构建Tensorflow Li

    系列目录: IOT设备AI搭建1:Linux上构建Tensorflow LiteIOT设备AI搭建2:树莓派部署T...

  • IOT设备AI搭建3:TF Lite构建过程解析

    系列目录: IOT设备AI搭建1:Linux上构建Tensorflow LiteIOT设备AI搭建2:树莓派部署T...

  • IOT设备AI搭建2:树莓派部署TF Lite(图片分类实例)

    系列目录: IOT设备AI搭建1:Linux上构建Tensorflow LiteIOT设备AI搭建2:树莓派部署T...

  • 跟上这个时代,跟上这群人

    今年罗胖的跨年演讲中讲了这件一件事: “在哈佛大学,每逢冬季期末考试的前夜,学生们都会举行一项奇怪的活动:集体裸奔...

  • 跟上这个时代,跟上这群人

    罗振宇(罗胖)在“时间的朋友”2019-2020跨年演讲会上,讲了一个裸奔的故事。 对,没错,裸奔,而且是集体裸奔...

网友评论

      本文标题:跟上时代?AI对话页面搭建心得

      本文链接:https://www.haomeiwen.com/subject/epxsqjtx.html