最初发布于https://codeawake.com .
在我们之前的帖子《构建由您的数据驱动的AI聊天机器人》中,我们探讨了如何通过实现检索增强生成(RAG)来创建一个能够回答有关新兴技术趋势问题的准备就绪的AI聊天机器人,这些信息基于来自世界银行、世界经济论坛、麦肯锡、德勤和经合组织等顶级机构的最新报告。
然而,有一个非常重要的部分缺失了,那就是用户界面。在这次分享里,我们将为我们的技术趋势AI聊天助手构建一个“用React开发的前端”。
这个前端设计得非常灵活,可以轻松适应或调整为任何其他AI聊天机器人应用程序,比如客服机器人或编程助手工具。你需要做的就是将其连接到提供两个特定聊天端点的API接口(或者根据你自己的API进行调整)。
前端将使用简单的React构建,不使用额外的框架,使用Tailwind CSS进行样式设计,并使用Vite作为构建工具和打包工具。从头开始构建前端,你会发现创建这样一个功能性的聊天机器人界面并不需要编写太多代码,而且代码非常简洁。
我们的前端实现将包括诸如使用服务器发送事件(SSE)的流式响应、支持 Markdown 的聊天响应、移动友好的响应式界面以及以处理聊天互动中的错误等功能。您还可以通过添加更多功能,如身份验证、聊天历史记录以及聊天共享等功能,来自定义构建您自己的类似 ChatGPT 的应用程序,以满足您的特定需求。
到本文结束为止,你将拥有一个功能齐全且可自定义的AI聊天机器人前端,像下面这样:
你可以在这里访问聊天机器人的实时版本的应用。该项目的完整源代码(包括前端和后端)可在此GitHub仓库获取。
前端项目的结构这是我们React应用的核心项目结构,如下所示:
    前端/  
    ├── public/                   公共静态文件  
    ├── src/  
    │   ├── assets/               图片和图标  
    │   ├── components/  
    │   │   ├── Chatbot.jsx       主聊天机器人组件  
    │   │   ├── ChatInput.jsx     用户输入组件  
    │   │   ├── ChatMessages.jsx  聊天消息组件  
    │   │   └── Spinner.jsx       加载图标组件  
    │   ├── hooks/                自定义 React 钩子  
    │   │   ├── useAutoScroll.js  
    │   │   └── useAutosize.js  
    │   ├── api.js                后端 API 通信函数  
    │   ├── App.jsx               根组件  
    │   ├── index.css             全局样式  
    │   ├── main.jsx              应用入口点  
    │   └── utils.js              工具函数(包括 parseSSEStream)  
    ├── index.html                HTML 模板  
    ├── tailwind.config.js        Tailwind 配置  
    └── vite.config.js            Vite 配置聊天机器人的前端应用的核心组件是 Chatbot 组件。它包含主要应用状态并渲染所需子组件。我们来看这个组件代码的一个稍微简化了的版本:
    import { useState } from 'react';  
    import { useImmer } from 'use-immer';  
    import ChatMessages from '@/components/ChatMessages';  
    import ChatInput from '@/components/ChatInput';  
    function Chatbot() {  
      const [chatId, setChatId] = useState(null);  
      const [messages, setMessages] = useImmer([]);  
      const [newMessage, setNewMessage] = useState('');  
      const isLoading = messages.length && messages[messages.length - 1].loading;  
      async function submitNewMessage() {  
        /* Not implemented */  
      }  
      return (  
        <div>  
          {messages.length === 0 && (  
            <div>{/* 欢迎消息 */}</div>  
          )}  
          <ChatMessages  
            messages={messages}  
            isLoading={isLoading}  
          />  
          <ChatInput  
            newMessage={newMessage}  
            isLoading={isLoading}  
            setNewMessage={setNewMessage}  
            submitNewMessage={submitNewMessage}  
          />  
        </div>  
      );  
    }  
    export default Chatbot;⚠️ 通常,我们将专注于前端组件的结构和核心逻辑部分。为了使代码片段更简洁易读,将省略 Tailwind CSS 样式类,因为这些样式类对于理解聊天机器人的功能并不重要。但是,你可以在 GitHub 仓库中查看完整项目代码: GitHub 仓库 .
如您所见,在组件顶部,我们的应用有三个主要状态变量:
- chatId: 保存当前聊天会话的唯一标识符(ID)。
- messages: 存储当前聊天中的所有消息。每条消息包含- role(用户或助手)、- content、- loading和- error字段。
- newMessage: 保存当前输入框中的文本(在提交前)。
另外值得注意的是,我们是如何同时使用 useState 和 useImmer 来管理状态的。如果你熟悉React的话,你就会知道状态绝不能直接修改,所有state更新都必须以不可变的方式进行(创建一个新的对象或深拷贝对象)。这可能导致冗长且容易出错的代码,特别是在像这样的数据结构中,例如messages数组。
Immer 是一个小巧且非常方便的库,用于简化状态管理,并提供了 useImmer 钩子来实现这一目的。Immer 允许你通过将所有更新应用到一个临时对象上来编写更简洁和直观的代码,并且会为你创建下一个不可变状态。例如,你可以这样方便地更新 messages 数组中的最后一个元素:
    setMessages(draft => {  
      draft[draft.length - 1].loading = false;  
    });聊天机器人的 JSX 结构非常简单。它会渲染三个元素,
- 如果没有消息,则显示一个初始欢迎消息。
- ChatMessages组件用于显示聊天内容。
- ChatInput组件用于用户输入内容。
我们的聊天机器人程序的主要功能是 submitNewMessage 函数。
    async function submitNewMessage() {  
      const trimmedMessage = newMessage.trim();  // 去除首尾空格后的消息
      if (!trimmedMessage || isLoading) return;  
      setMessages(draft => [...draft,  
        { role: 'user', content: trimmedMessage },  
        { role: 'assistant', content: '', sources: [], loading: true }  
      ]);  
      setNewMessage('');  
      let chatIdOrNew = chatId;  
      try {  
        if (!chatId) {  
          const { id } = await api.createChat();  
          setChatId(id);  
          chatIdOrNew = id;  
        }  
        const stream = await api.sendChatMessage(chatIdOrNew, trimmedMessage);  
        for await (const textChunk of parseSSEStream(stream)) {  // 解析服务器发送的事件流
          setMessages(draft => {  
            draft[draft.length - 1].content += textChunk;  
          });  
        }  
        setMessages(draft => {  
          draft[draft.length - 1].loading = false;  // 设置最后一条消息为加载完成
        });  
      } catch (err) {  
        console.log(err);  
        setMessages(draft => {  
          draft[draft.length - 1].loading = false;  // 设置最后一条消息为加载完成
          draft[draft.length - 1].error = true;  // 标记最后一条消息为错误
        });  
      }  
    }我们来看看正在发生的事情是怎么回事:
- 我们确保输入的消息不为空且没有正在加载的回复后再继续。
- 我们将用户的消息添加到聊天中,并创建一个带有 loading属性设为true的助手消息(这有助于在加载过程中显示加载图标)。
- 如果没有现有的聊天会话,我们调用 api.createChat创建一个新的聊天会话。
- 然后我们使用 api.sendChatMessage将用户的消息发送到后端,后端会返回一个流作为响应。
- 我们使用 parseSSEStream工具函数将 SSE 流转换为异步的文本块迭代器。每次接收到新文本块时,更新助手消息,以实现实时流的效果。
- 一旦响应流结束,将助手消息的 loading属性设为false。
- 如果过程中出现任何错误,将助手消息的 error属性设为true,从而在聊天界面显示错误消息。
在 api.js 文件中有两个函数 (createChat 和 sendChatMessage), 它们用于与后端 API 交互:
    const BASE_URL = import.meta.env.VITE_API_URL;  
    async function 创建聊天室() {  
      const res = await fetch(BASE_URL + '/chats', {  
        method: 'POST',  
        headers: { 'Content-Type': 'application/json' }  
      });  
      const data = await res.json();  
      if (!res.ok) {  
        return Promise.reject({ status: res.status, data });  
      }  
      return data;  
    }  
    async function 发送聊天消息(chatId, message) {  
      const res = await fetch(BASE_URL + `/chats/${chatId}`, {  
        method: 'POST',  
        headers: { 'Content-Type': 'application/json' },  
        body: JSON.stringify({ message })  
      });  
      if (!res.ok) {  
        return Promise.reject({ status: res.status, data: await res.json() });  
      }  
      return res.body;  
    }如代码所示,它们都使用了原生 Fetch API。createChat 函数返回一个包含新聊天 ID 的 JSON 响应,而 sendChatMessage 则直接返回响应体内容,因为它是一个需要特别处理的流式响应。
最后,让我们来看看解析SSE流的函数,利用eventsource-parser这个库来简化SSE数据提取:
    import { EventSourceParserStream } from 'eventsource-parser/stream';  
    export async function* parseSSEStream(stream) {  
      const sseStream = stream  
        .pipeThrough(new TextDecoderStream())  
        .pipeThrough(new EventSourceParserStream())  
      for await (const chunk of sseStream) {  
        if (chunk.type === 'event') {  
          yield chunk.data;  
        }   
      }  
    }该函数对输入流应用了两个转换:TextDecoderStream() 将传入的字节转换为文本形式,而 EventSourceParserStream() 则解析从服务器发送的每个事件。接着,它会遍历每个事件,并为每个事件yields出数据(每个事件的数据包含了助手响应的文本片段)。
可以看到,该函数是一个异步生成器,因此我们可以在submitNewMessage函数中使用简单的for await...of循环逐个处理文本块。
ChatMessages 组件负责展示消息历史。让我们来看一下简化后的代码部分(不包括 CSS 样式代码)。
    import Markdown from 'react-markdown';  
    import useAutoScroll from '@/hooks/useAutoScroll';  
    import Spinner from '@/components/Spinner';  
    import userIcon from '@/assets/images/user.svg';  
    import errorIcon from '@/assets/images/error.svg';  
    function ChatMessages({ messages, isLoading }) {  
      const scrollContentRef = useAutoScroll(isLoading);  
      return (  
        <div ref={scrollContentRef}>  
          {messages.map(({ role, content, loading, error }, idx) => (  
            <div key={idx}>  
              {role === 'user' && (  
                <img src={userIcon} alt='用户标志' />  
              )}  
              <div>  
                <div>  
                  {(loading && !content) ? <Spinner />  
                    : (role === 'assistant')  
                      ? <Markdown>{content}</Markdown>  
                      : <div>{content}</div>  
                  }  
                </div>  
                {error && (  
                  <div>  
                    <img src={errorIcon} alt='错误标志' />  
                    <span>生成回应时出错了</span>  
                  </div>  
                )}  
              </div>  
            </div>  
          ))}  
        </div>  
      );  
    }  
    export default ChatMessages;该组件处理不同类型的消息显示:
- 用户的消息会带有用户图标来显示。
- 助手的消息使用来自 react-markdown 库的 Markdown组件进行渲染。这非常有用,因为 LLM 的回复通常以 Markdown 格式包含丰富的文本格式、段落、列表等元素。
- 当助手的消息正在加载且没有内容时,会显示一个 Spinner组件来表示加载状态。
- 如果在处理助手回复时出现任何错误,则会在下方显示错误图标和消息。
为了提升使用聊天机器人的体验,我们还实现了一个自定义的 useAutoScroll 钩子,在新助理消息出现时自动滚动。如果你对这个功能的实现细节感兴趣,可以在这里查看完整代码。这里有一个关于这个钩子如何实现自动滚动的简要说明:
- 它定义并返回一个 scrollContentRef,我们将它添加到聊天消息容器元素中。通过使用这个 ref 和 Resize Observer Web API,我们现在可以监控聊天消息容器的尺寸变化(当有新内容添加时),并在滚动条未在底部时自动滚动到底部。
- 它还包含一个智能禁用功能,这是许多 AI 聊天应用程序中的常见功能:如果用户在助手消息流式传输时手动向上滚动,它会暂时停用自动滚动。这允许用户阅读对话历史的任何部分而不被自动滚动中断。
- 自动滚动会在当用户滚动回到底部时或新的助手消息开始流式传输时重新启用。
- 需要注意的一个重要细节是,这个钩子假定整个文档(HTML 元素)是可滚动的容器,因此使用 document.documentElement进行滚动测量和滚动操作。如果你的应用程序使用的是不同的可滚动容器(例如具有overflow: scroll的 div),你需要修改钩子以使用该特定容器的引用。
我们聊天机器人的前端的最后一部分是用户输入界面。这是由 ChatInput 组件实现的,它允许用户输入并提交消息。这是代码的一个简化版本:
    import useAutosize from '@/hooks/useAutosize';
    import sendIcon from '@/assets/images/send.svg';
    // 创建一个聊天输入组件,用于处理新消息的输入和发送
    function ChatInput({ newMessage, isLoading, setNewMessage, submitNewMessage }) {
      const textareaRef = useAutosize(newMessage);
      // 如果按下回车键且不是Shift键,同时没有正在加载,则阻止默认行为并提交消息
      function handleKeyDown(e) {
        if(e.keyCode === 13 && !e.shiftKey && !isLoading) {
          e.preventDefault();
          submitNewMessage();
        }
      }
      return(
        <div>
          <textarea
            ref={textareaRef}
            rows='1'
            value={newMessage}
            onChange={e => setNewMessage(e.target.value)}
            onKeyDown={handleKeyDown}
          />
          <button onClick={submitNewMessage}>
            <img src={sendIcon} alt='send' />
          </button>
        </div>
      );
    }
    export default ChatInput;ChatInput 组件包含一个用于输入消息的文本框和一个提交消息的发送按钮。文本框还包含一个自适应大小的功能特性,使用了自定义的 useAutosize 钩子。这个钩子会根据文本框内容动态调整高度,会随着用户输入自动调整高度,删除内容后也会相应缩小。更多详情,请点击这里查看代码。
该组件还包括一个 handleKeyDown 函数,允许用户只需按下回车键(不带 Shift)即可发送消息。同时,它保留了 textarea 的原有功能,支持使用 Shift+Enter 添加换行符,为用户提供格式化较长的消息或添加换行符以提高清晰度的灵活性。
我们现在已经完成了一个全栈AI聊天机器人项目的实现。你可以把这个项目(this project)作为起点,将其扩展和定制以满足你的具体需求。
在这两篇文章中我们所介绍的所有技术——检索增强生成技术、向量数据库、语义搜索、异步编程、结构化输出、实时 SSE 流、Markdown 渲染、自动滚动功能——提供了一个实用的框架,可以帮助你创建自己的 AI 聊天机器人。
希望这对你有帮助,期待看到你用它造出的下一次的作品!
共同学习,写下你的评论
评论加载中...
作者其他优质文章
 
                 
            

 
			 
					 
					