logo

Crea una interfaz de usuario en React para un chatbot con IA

Una interfaz interactiva en React para chatbots con streaming en tiempo real, respuestas en markdown y scroll automático

13 de septiembre de 2024

En el último artículo, "Crea un chatbot con IA alimentado por tus datos", exploramos cómo implementar Retrieval-Augmented Generation (RAG) para crear un chatbot con IA listo para producción que pudiera responder preguntas sobre tendencias tecnológicas, a partir de los últimos informes de instituciones como McKinsey, Deloitte, el Banco Mundial, el Foro Económico Mundial y la OCDE.

Sin embargo, quedaba pendiente una parte fundamental para completar esta aplicación full stack: la interfaz de usuario. En este artículo, vamos a crear un frontend en React para el chatbot de tendencias tecnológicas.

Este frontend está diseñado para ser flexible y fácil de adaptar a cualquier otro chatbot con IA, como por ejemplo un bot de atención al cliente o un asistente de programación. Solo necesitas conectarlo a una API que proporcione dos endpoints específicos para el chat (o adaptarlo a tus propios endpoints).

El frontend será una aplicación React sencilla, sin frameworks adicionales, utilizando Tailwind CSS para los estilos y Vite como herramienta de compilación (build tool). Al construir el frontend desde cero, verás que crear una interfaz de chatbot funcional requiere poco código y, además, es bastante sencillo

La implementación incluirá características como streaming de respuestas con Server-Sent Events, respuestas en formato markdown, diseño responsive optimizado para móviles y gestión de errores durante las interacciones. Y si quieres ampliarlo, puedes añadir funcionalidades más avanzadas como autenticación, historial de chats e incluso compartir conversaciones, para construir tu propia aplicación al estilo ChatGPT, totalmente adaptada a tus necesidades.

Al terminar este artículo, tendrás una interfaz de chatbot con IA totalmente funcional y personalizable con este aspecto:

Interfaz de usuario del chatbot

Puedes acceder a una demo del chatbot aquí. Y el código completo de este proyecto (frontend y backend) está disponible en este repositorio de GitHub.

#Estructura del frontend

Esta es la estructura principal del frontend:

frontend/
├── public/ # Archivos estáticos públicos
├── src/
│ ├── assets/ # Imágenes e iconos
│ ├── components/
│ │ ├── Chatbot.jsx # Componente del chatbot
│ │ ├── ChatInput.jsx # Componente de input del usuario
│ │ ├── ChatMessages.jsx # Componente de mensajes del chat
│ │ └── Spinner.jsx # Componente del spinner de carga
│ ├── hooks/ # Hooks personalizados de React
│ │ ├── useAutoScroll.js
│ │ └── useAutosize.js
│ ├── api.js # Funciones para la comunicación con el backend
│ ├── App.jsx # Componente principal de la app
│ ├── index.css # Estilos CSS globales
│ ├── main.jsx # Punto de entrada de la app
│ └── utils.js # Funciones varias
├── index.html # Plantilla HTML
├── tailwind.config.js # Configuración de Tailwind CSS
└── vite.config.js # Configuración de Vite

#Componente principal del chatbot

El componente principal de la aplicación frontend es el componente Chatbot. Contiene el estado central de la aplicación y renderiza los subcomponentes necesarios. Esta es una versión simplificada del código del componente:

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() {
// Implemented in the next section
}

return (
<div>
{messages.length === 0 && (
<div>{/* Chatbot welcome message */}</div>
)}
<ChatMessages
messages={messages}
isLoading={isLoading}
/>
<ChatInput
newMessage={newMessage}
isLoading={isLoading}
setNewMessage={setNewMessage}
submitNewMessage={submitNewMessage}
/>
</div>
);
}

export default Chatbot;

⚠️En general, nos vamos a centrar en la estructura y la lógica principal de los componentes. Para mantener los fragmentos de código más concisos y legibles, omitiremos las clases de Tailwind CSS, ya que no son cruciales para entender la funcionalidad del chatbot. Pero puedes consultar el código completo en el repositorio de GitHub.

Como puedes ver al inicio del componente, la aplicación tiene tres variables de estado principales:

Observa también cómo estamos usando tanto useState como useImmer para la gestión del estado. Si tienes experiencia con React, sabrás que el estado nunca debe actualizarse directamente y todas las actualizaciones de estado deben realizarse de forma inmutable (creando un nuevo objeto o modificando una copia profunda del objeto). Esto puede hacer que el código sea más verboso y propenso a errores, sobre todo en estructuras de datos anidadas como el array messages.

Immer es una librería ligera y muy práctica que simplifica las actualizaciones de estado mediante el hook useImmer. Immer simplifica el código enormemente permitiéndote actualizar directamente un objeto temporal, y se ocupa de crear el siguiente estado de forma inmutable. Por ejemplo, puedes actualizar el último objeto en el array messages directamente así:

setMessages(draft => {
draft[draft.length - 1].loading = false;
});

La estructura JSX del componente Chatbot es sencilla. Renderiza tres elementos:

#Envío de nuevos mensajes y procesamiento de la respuesta

La funcionalidad principal del chatbot está en la función 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;
});
}
}

Veamos qué es lo que hace:

  1. Comprobamos que el mensaje no esté vacío y que no se esté procesando ya otra respuesta antes de continuar.
  2. Añadimos el mensaje del usuario al chat y creamos un mensaje provisional del asistente con la propiedad loading con valor true (útil para mostrar un spinner mientras carga).
  3. Si no existe una sesión de chat, creamos una nueva usando la función api.createChat.
  4. Luego usamos api.sendChatMessage para enviar el mensaje del usuario al backend, que devuelve un stream como respuesta.
  5. Usamos la función parseSSEStream para convertir el stream de Server-Sent Events (SSE) en un iterador asíncrono de fragmentos de texto. Por cada nuevo fragmento de texto recibido, actualizamos el mensaje del asistente, creando el efecto de streaming en tiempo real.
  6. Una vez que termina el streaming de la respuesta, actualizamos la propiedad loading del mensaje del asistente con valor false.
  7. Si hay algún error en el proceso, actualizamos la propiedad error del mensaje del asistente con valor true para mostrar un mensaje de error en la interfaz del chat.

El archivo api.js contiene las dos funciones (createChat y sendChatMessage) que interactúan con los endpoints de la API del backend:

const BASE_URL = import.meta.env.VITE_API_URL;

async function createChat() {
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 sendChatMessage(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;
}

Como puedes ver en el código, ambas usan la API Fetch nativa del navegador. La función createChat devuelve una respuesta JSON con el nuevo ID de chat, mientras que sendChatMessage devuelve el body directamente ya que es una respuesta en streaming que necesitamos procesar de forma diferente.

Finalmente, echemos un vistazo a la función que usamos para procesar el stream SSE, aprovechando la librería eventsource-parser para simplificar la extracción de datos de los eventos:

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;
}
}
}

La función aplica dos transformaciones al stream de entrada: TextDecoderStream() convierte los bytes entrantes en texto, y EventSourceParserStream() procesa los eventos SSE. Luego itera sobre los eventos y emite (con yield) los datos de cada evento (cada uno contiene un fragmento de texto de la respuesta del asistente).

Observa que la función es un generador asíncrono, por eso podemos iterar sobre los fragmentos de texto con un simple bucle for await...of en la función submitNewMessage.

#Visualización de los mensajes del chat

El componente ChatMessages se encarga de renderizar el historial de mensajes. Echemos un vistazo a una versión simplificada del código (sin las clases de 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='user icon' />
)}
<div>
<div>
{(loading && !content) ? <Spinner />
: (role === 'assistant')
? <Markdown>{content}</Markdown>
: <div>{content}</div>
}
</div>
{error && (
<div>
<img src={errorIcon} alt='error icon' />
<span>Error generating the response</span>
</div>
)}
</div>
</div>
))}
</div>
);
}

export default ChatMessages;

Este componente implementa diferentes visualizaciones según el tipo de mensaje:

Para mejorar la experiencia de usuario, también implementamos scroll automático cuando se envían nuevos mensajes del asistente mediante un hook personalizado useAutoScroll. Si tienes curiosidad por los detalles, puedes ver el código completo aquí. De forma resumida, así es cómo funciona el hook de auto-scrolling:

#Interfaz para escribir mensajes

La pieza final del frontend de chatbot es el componente ChatInput, que permite a los usuarios escribir y enviar sus mensajes. Esta es una versión simplificada del código:

import useAutosize from '@/hooks/useAutosize';
import sendIcon from '@/assets/images/send.svg';

function ChatInput({ newMessage, isLoading, setNewMessage, submitNewMessage }) {
const textareaRef = useAutosize(newMessage);

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 incluye un elemento textarea para escribir los mensajes y un botón de envío. Este elemento textarea incluye también una funcionalidad de redimensionado automático mediante el hook useAutosize, que ajusta el tamaño del elemento de texto según su contenido. Puedes ver el código del hook aquí.

El componente también incluye la función handleKeyDown, que permite enviar mensajes con la tecla Enter (sin Shift). Al mismo tiempo, preserva el comportamiento nativo del textarea para añadir saltos de línea con Shift+Enter, permitiendo al usuario formatear adecuadamente mensajes más largos.


Con esto, damos por finalizada la implementación del chatbot con IA full stack. Puedes usar este proyecto como base y personalizarlo según tus necesidades.

Todas las técnicas que hemos cubierto en este artículo y el anterior - Retrieval-Augmented Generation, bases de datos vectoriales, búsqueda semántica, programación asíncrona, respuestas estructuradas, streaming en tiempo real con SSE, renderizado de markdown, scroll automático - son las piezas fundamentales para que puedas crear tus propios chatbots con IA.

¡Espero que te haya resultado útil!