Construyendo Interfaces Conversacionales. Parte 3

El productor del stream: Creando el endpoint de la API
Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/chatbot-part3
Toda interfaz de chat necesita un backend que genere las respuestas. Siguiendo nuestro análisis teórico, crearemos un Route Handler en Next.js que actúa como el "productor" del stream de datos que useChat
consumirá.
Paso 1: Crear el archivo de la API
Dentro del directorio src/app/
, crea una nueva estructura de carpetas api/chat/
y, dentro de ella, un archivo route.ts
.
/
|- app/
| |- api/
| | |- chat/
| | | |- route.ts <-- Nuestro nuevo endpoint
| |- page.tsx
...
Paso 2: Implementar la lógica del route handler
Abre src/app/api/chat/route.ts
y añade el siguiente código. Este código es el corazón de nuestro backend conversacional.
import { google } from '@ai-sdk/google';
import { streamText, convertToModelMessages, UIMessage } from 'ai';
import { z } from 'zod';
// Permite que las respuestas en streaming se ejecuten por hasta 30 segundos
export const maxDuration = 30;
// Definimos un schema de Zod para validar el cuerpo de la petición
const PostBodySchema = z.object({
messages: z.array(z.any()), // Por ahora, aceptamos cualquier objeto de mensaje
});
export async function POST(req: Request) {
try {
// Extraer y validar el cuerpo de la petición
const body = await req.json();
const validation = PostBodySchema.safeParse(body);
if (!validation.success) {
return new Response(JSON.stringify(validation.error.flatten()), { status: 400 });
}
const { messages }: { messages: UIMessage[] } = validation.data;
// Instanciar el modelo de IA
const model = google('gemini-2.0-flash-001');
// Preparar el prompt con un mensaje de sistema para guiar al LLM
const systemPrompt = `Eres un asistente de código experto llamado "CodeGenius".
Tu especialidad es analizar fragmentos de código y explicar su funcionamiento de forma clara, concisa y pedagógica.
Cuando un usuario te envíe un snippet, tu tarea es:
1. Identificar el lenguaje de programación.
2. Explicar el propósito general del código.
3. Describir la función de cada línea o bloque de código importante.
4. Mantén tus explicaciones orientadas a un desarrollador que busca entender rápidamente el código.
5. No generes código nuevo a menos que se te pida explícitamente. Céntrate en la explicación.`;
// Llamar a streamText del Vercel AI SDK
const result = await streamText({
model: model,
system: systemPrompt,
messages: convertToModelMessages(messages),
});
// Devolver la respuesta como un stream que el hook `useChat` puede consumir
return result.toUIMessageStreamResponse()
} catch (error) {
// Manejo de errores en el servidor
console.error("Error en la API de chat:", error);
const errorMessage = error instanceof Error ? error.message : "Un error inesperado ha ocurrido.";
return new Response(errorMessage, { status: 500 });
}
}
Análisis del código:
- Validación de Entrada: Aunque
useChat
envía un cuerpo predecible, un endpoint de API público nunca debe confiar ciegamente en la entrada. Usamos Zod para una validación mínima, que podríamos hacer más estricta si fuera necesario. -
convertToModelMessages
: Esta función es un helper crucial. El formato de mensaje queuseChat
utiliza en el frontend (UIMessage
) contiene metadatos adicionales para la UI. Esta función lo traduce al formato limpio que el modelo de IA (streamText
) espera, asegurando una correcta comunicación. -
system
Prompt: Aquí es donde inyectamos la "personalidad" y las directrices a nuestro agente. Este es un ejemplo de prompt engineering fundamental para guiar el comportamiento del LLM. -
result.toUIMessageStreamResponse()
: Como discutimos en la teoría, esta es la función que serializa el stream de salida del AI SDK en una respuesta HTTP que el frontend puede interpretar correctamente.
Instalando y creando componentes de chatbot
Con el backend listo, ahora nos centramos en el frontend. Usaremos shadcn/ui
para acelerar la creación de una interfaz pulida. Es un conjunto de componentes reutilizables que se instalan directamente en tu proyecto, dándote control total sobre su código.
Paso 1: Inicializar shadcn/ui
Si aún no lo has hecho en tu proyecto, ejecuta el siguiente comando en la raíz y sigue las instrucciones. Acepta los valores por defecto, que son una excelente base.
npx shadcn@latest init
Paso 2: Instalar los componentes de UI necesarios
Instalaremos los bloques de construcción para nuestra interfaz de chat:
npx shadcn@latest add card input button avatar scroll-area
Esto añadirá los componentes a tu directorio src/components/ui/
, listos para ser utilizados.
$ npx shadcn@latest add card input button avatar scroll-area
√ You need to create a components.json file to add components. Proceed? ... yes
√ Which color would you like to use as the base color? » Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Installing dependencies.
✔ Created 5 files:
- src\components\ui\card.tsx
- src\components\ui\input.tsx
- src\components\ui\button.tsx
- src\components\ui\avatar.tsx
- src\components\ui\scroll-area.tsx
Paso 3: Crear el componente principal del chatbot
Con el backend listo para servir respuestas, nos enfocamos en el frontend. En lugar de construir un único componente monolítico, adoptaremos un enfoque más modular y escalable. Dividiremos la interfaz de chat en componentes más pequeños y especializados, cada uno con una única responsabilidad. Esta práctica no solo hace que el código sea más fácil de leer y mantener, sino que también fomenta la reutilización.
Crearemos un componente Chatbot.tsx
que encapsulará toda la lógica y la vista de nuestra conversación. Para esto necesitamos instalar @ai-sdk/react
, que nos ofrecerá una capa de abstracción con nuestro frontend.
pnpm add @ai-sdk/react
La nueva estructura de nuestros componentes de chat será la siguiente:
/
|- components/
| |- ui/
| | |- Chatbot/
| | | |- Chatbot.tsx <-- Componente Orquestador Principal
| | | |- ChatbotHeader.tsx <-- Cabecera del Chat
| | | |- ChatbotMessages.tsx <-- Contenedor de la lista de mensajes
| | | |- ChatbotMessage.tsx <-- Componente para un mensaje individual
| | | |- ChatbotInput.tsx <-- Formulario de entrada de texto
| | | |- ChatbotLoading.tsx <-- Indicador de "escribiendo..."
|- hooks/
| |- useCustomChat.ts <-- Hook personalizado para la lógica del chat
Comenzaremos por la lógica, encapsulándola en un hook personalizado, para luego construir los componentes de UI que la consumirán.
A. Encapsulando la lógica: El hook useCustomChat
Para mantener nuestros componentes de UI limpios y centrados exclusivamente en la presentación, extraemos toda la lógica de interacción con el Vercel AI SDK a un hook personalizado.
Crea el archivo src/hooks/useCustomChat.ts
:
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, UIMessage } from "ai";
import {
useState,
ChangeEvent,
FormEvent,
useCallback,
useMemo,
} from "react";
interface UseCustomChatProps {
api: string;
}
interface UseCustomChatResult {
input: string;
setInput: React.Dispatch<React.SetStateAction<string>>;
messages: UIMessage[];
sendMessage: (message: UIMessage) => void;
status: string;
handleInputChange: (e: ChangeEvent<HTMLTextAreaElement>) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
}
export const useCustomChat = ({ api }: UseCustomChatProps): UseCustomChatResult => {
const [input, setInput] = useState('');
const chat = useChat({
transport: useMemo(() => new DefaultChatTransport({ api }), [api]),
});
const handleInputChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
},
[setInput]
);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedInput = input.trim();
if (trimmedInput) {
chat.sendMessage({ text: trimmedInput });
setInput("");
}
},
[chat.sendMessage, input, setInput]
);
return {
input,
setInput,
messages: chat.messages,
sendMessage: chat.sendMessage,
status: chat.status,
handleInputChange,
handleSubmit,
};
};
Análisis del hook:
- Abstracción: Este hook actúa como una fachada. Internamente utiliza
useChat
del Vercel AI SDK, pero expone una interfaz simplificada y adaptada a nuestras necesidades. - Gestión de estado centralizada: Maneja el estado del
input
y se encarga de la lógica dehandleInputChange
yhandleSubmit
. - Rendimiento: El uso de
useCallback
para las funcioneshandleInputChange
yhandleSubmit
yuseMemo
para eltransport
previene que se recreen innecesariamente en cada renderizado, optimizando el rendimiento del componente que lo use. - Interfaz clara: Devuelve un objeto con todo lo que nuestros componentes de UI necesitarán: el estado actual (
input
,messages
,status
) y las funciones para interactuar con él (handleInputChange
,handleSubmit
).
B. Creando los componentes de la interfaz
Ahora, creamos los componentes visuales, que serán "tontos" en el sentido de que solo recibirán props y renderizarán la UI, sin contener lógica de negocio.
1. El mensaje individual (ChatbotMessage.tsx
y ChatbotLoading.tsx
)
Primero, el componente para mostrar un mensaje, distinguiendo entre el usuario y el asistente.
import { cn } from "@/lib/utils"
import { UIMessage } from "@ai-sdk/react"
interface ChatMessageProps {
message: UIMessage
}
export function ChatBotMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user"
return (
<div className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
<span className="text-slate-900 font-bold text-xs">AI</span>
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2 break-words",
isUser ? "bg-yellow-500 text-slate-900" : "bg-slate-700 text-white",
)}
>
<p className="whitespace-pre-wrap leading-relaxed">
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</p>
</div>
{isUser && (
<div className="w-8 h-8 bg-slate-500 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
<span className="text-white font-bold text-xs">TÚ</span>
</div>
)}
</div>
)
}
Y un componente simple para mostrar una animación de carga mientras el asistente responde.
export function ChatbotLoading() {
return (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
<span className="text-slate-900 font-bold text-xs">AI</span>
</div>
<div className="bg-slate-700 rounded-lg px-4 py-2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0.1s" }}></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0.2s" }}></div>
</div>
</div>
</div>
)
}
2. El contenedor de mensajes (ChatbotMessages.tsx
)
Este componente se encarga de renderizar la lista de mensajes, el estado de carga y un mensaje de bienvenida si la conversación aún no ha comenzado. También gestiona el autoscroll.
"use client"
import { ChatBotMessage } from "./ChatbotMessage"
import { ChatbotLoading } from "./ChatbotLoading"
import { useEffect, useRef } from "react"
import { UIMessage } from "ai"
interface ChatMessagesProps {
messages: UIMessage[]
isLoading: boolean
}
export function ChatbotMessages({ messages, isLoading }: ChatMessagesProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
return (
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-yellow-500 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-slate-900 font-bold text-xl">AI</span>
</div>
<h2 className="text-white text-xl font-semibold mb-2">¡Hola! Soy tu asistente AI</h2>
<p className="text-slate-300">Pregúntame lo que quieras y te ayudaré</p>
</div>
</div>
)}
{messages.map((message) => (
<ChatBotMessage key={message.id} message={message} />
))}
{isLoading && <ChatbotLoading />}
<div ref={messagesEndRef} />
</div>
)
}
3. La cabecera y la entrada de texto (ChatbotHeader.tsx
, ChatbotInput.tsx
)
Creamos componentes dedicados para la cabecera y el área de entrada. ChatbotInput
es especialmente interesante, ya que maneja su propia lógica de UI, como el auto-ajuste de la altura del textarea
y el envío con la tecla "Enter".
export function ChatbotHeader() {
return (
<div className="bg-slate-700 border-b border-slate-600 p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
<span className="text-slate-900 font-bold text-sm">AI</span>
</div>
<div>
<h1 className="text-white font-semibold">Asistente AI</h1>
<p className="text-slate-300 text-sm">Siempre listo para ayudarte</p>
</div>
</div>
</div>
)
}
"use client"
import type React from "react"
import { type FormEvent, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Send } from "lucide-react"
interface ChatInputProps {
input: string
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
handleSubmit: (e: FormEvent<HTMLFormElement>) => void
isLoading: boolean
}
export function ChatbotInput({ input, handleInputChange, handleSubmit, isLoading }: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}
}, [input])
useEffect(() => {
if (!isLoading) {
textareaRef.current?.focus()
}
}, [isLoading])
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (input.trim() && !isLoading) {
handleSubmit(e)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
if (input.trim() && !isLoading) {
e.currentTarget.form?.requestSubmit()
}
}
}
return (
<div className="border-t border-slate-600 bg-slate-700 p-4">
<form onSubmit={onSubmit} className="flex gap-2 items-center">
<div className="flex-1">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Escribe tu mensaje aquí..."
className="w-full bg-slate-600 text-white placeholder-slate-400 border border-slate-500 rounded-lg px-4 py-3 resize-none focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent min-h-[48px] max-h-[120px] no-scrollbar"
disabled={isLoading}
rows={1}
/>
</div>
<Button
type="submit"
disabled={!input.trim() || isLoading}
className="bg-yellow-500 hover:bg-yellow-600 text-slate-900 font-semibold px-4 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</Button>
</form>
</div>
)
}
C. Ensamblando el componente principal: Chatbot.tsx
Finalmente, creamos el componente Chatbot.tsx
, que actúa como el orquestador. Su trabajo es:
- Llamar a nuestro hook
useCustomChat
para obtener el estado y los manejadores de eventos. - Pasar esos datos como props a los componentes hijos (
ChatbotMessages
yChatbotInput
). - Estructurar el layout general de la interfaz de chat.
Crea el archivo src/components/ui/Chatbot/Chatbot.tsx
:
"use client"
import { ChatbotMessages } from "./ChatbotMessages"
import { ChatbotInput } from "./ChatbotInput"
import { ChatbotHeader } from "./ChatbotHeader"
import { useCustomChat } from "@/hooks/useCustomChat";
export function Chatbot() {
const {
input,
messages,
status,
handleInputChange,
handleSubmit
} = useCustomChat({ api: `/api/chat` });
return (
<div className="flex flex-col h-full bg-slate-600">
<ChatbotHeader />
<div className="flex-1 flex flex-col min-h-0">
<ChatbotMessages messages={messages} isLoading={status !== 'ready'} />
<ChatbotInput
input={input}
handleInputChange={handleInputChange}
handleSubmit={handleSubmit}
isLoading={status !== 'ready'}
/>
</div>
</div>
)
}
Análisis del componente principal:
- Orquestación pura: Observa qué tan limpio y declarativo es este componente. No contiene lógica compleja. Su única misión es conectar la lógica del hook con los componentes de presentación.
- Flujo de datos unidireccional: Los datos (
messages
,input
,status
) fluyen desde el hook hacia abajo, a los componentes hijos. Las acciones del usuario (handleSubmit
,handleInputChange
) se propagan hacia arriba, al hook, que actualiza el estado, provocando un nuevo renderizado. Este es un patrón central en React que facilita el razonamiento sobre la aplicación. - Claridad estructural: Al ver este archivo, entendemos de inmediato la estructura de la interfaz: una cabecera, un área de mensajes y un área de entrada. La complejidad de cada parte está encapsulada en su propio módulo.
- Mapeo de
status
: El estadostatus
que nos dauseChat
(y que nuestro hook expone) se traduce a un booleanoisLoading
. Esto es una pequeña adaptación para que los componentes hijos solo necesiten saber si algo está cargando o no, simplificando sus props.
El ensamblaje final: Integrando el chat en la página
El último paso es colocar nuestro nuevo componente Chatbot
en la página principal.
Modifica app/page.tsx
para que se vea así:
import { Chat } from "@/components/Chat";
export default function HomePage() {
return (
<div className="bg-gray-100 min-h-screen flex items-center justify-center">
<Chat />
</div>
);
}
Conclusión de la práctica
Hemos construido una aplicación de chat completa, en tiempo real y robusta. Al separar el backend (el productor del stream) del frontend (el consumidor), hemos creado un sistema desacoplado y mantenible.
En la sección final, pondremos a prueba el ejemplo completo, analizaremos los resultados y solidificaremos nuestra comprensión del flujo de datos en una aplicación de chat con IA.
Con el endpoint de la API y el componente de la interfaz de usuario construidos, ahora tenemos un sistema completo y funcional. Esta sección se centra en ejecutar la aplicación, probar su funcionalidad y analizar en detalle el ciclo de vida de una interacción para consolidar los conceptos teóricos y prácticos que hemos abordado.
Ejecutar la aplicación
Abre tu terminal en la raíz del proyecto y ejecuta el comando de desarrollo:
pnpm dev
Navega en tu navegador a http://localhost:3000
. Deberías ver la interfaz de nuestro "AI Code Assistant".
Prueba de escenario: Analizando un snippet de código
Vamos a realizar una prueba de extremo a extremo para observar el sistema en acción.
Interacción:
- Copia el siguiente snippet de código TypeScript:
const memoize = <T extends (...args: any[]) => any>(fn: T): T => {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
};
2. Pega el código en el campo de entrada del chat.
3. Haz clic en el botón de enviar.
Observaciones en tiempo real:
- Instantáneamente, el snippet que pegaste aparecerá en la ventana del chat, alineado a la derecha, con tu avatar. Esto es la UI optimista de
useChat
en acción. El hook no espera la confirmación del servidor para actualizar la conversación local. - El campo de entrada y el botón de enviar se deshabilitarán inmediatamente. El botón mostrará la animación de pulso que definimos. Esto corresponde al estado
status: 'awaiting_response'
. - Después de un breve momento (el *Time To First Token* de Gemini), el avatar de la IA aparecerá a la izquierda, seguido del texto de la respuesta. El texto no aparecerá de golpe. Verás cómo se escribe en la pantalla, token por token. Este es el streaming en acción. El estado ha transicionado a
status: 'streaming'
. - Mientras el texto se está streameando, la ventana de chat se desplazará automáticamente hacia abajo para mantener visible el final de la respuesta.
- Una vez que Gemini haya completado su explicación, el stream se cerrará. El campo de entrada y el botón se volverán a habilitar. El estado ha vuelto a
status: 'ready'
.
Análisis del tráfico de red (para el experto curioso):
Si abres las Herramientas de Desarrollador de tu navegador y vas a la pestaña de Red (Network), verás la petición POST
a /api/chat
.
- Pestaña "Headers": Verás que es una petición
POST
estándar. En la sección "Request Payload", verás el array demessages
queuseChat
ha enviado, incluyendo tu snippet de código. - Pestaña "Response": Esta es la parte interesante. En lugar de ver un JSON completo, verás una respuesta que se va actualizando con el tiempo. Los navegadores modernos tienen herramientas para inspeccionar estos streams (a veces bajo pestañas como "EventStream" o "Streaming"). Aquí es donde puedes ver los fragmentos (
chunks
) del protocolo del Vercel AI SDK llegando uno por uno.
Esta inspección confirma nuestro modelo mental: useChat
maneja la orquestación en el cliente, y nuestro backend se dedica exclusivamente a producir un stream de datos que el cliente consume y renderiza de forma incremental.
Desafío de experto: Manejo de estado y contexto
Ahora que el sistema funciona, considera estas preguntas, que son típicas de un desarrollador senior llevando esto a producción:
- ¿Qué sucede si el usuario envía un segundo snippet de código?
Pruébalo. Verás que la conversación continúa. useChat
automáticamente incluye el historial completo (tu primer snippet, la explicación de la IA, tu segundo snippet) en la siguiente petición. Gemini usará este contexto. Podrías preguntarle: "¿Puedes comparar la complejidad de este segundo snippet con el primero?". El LLM tendrá el contexto para responder.
2. ¿Cuál es el límite de este contexto?
Los modelos tienen un límite de tokens de contexto. Si la conversación se alarga demasiado, la petición a la API fallará o el modelo empezará a "olvidar" el principio de la conversación. En un capítulo posterior sobre optimización, abordaremos estrategias para manejar historiales largos, como el resumen de conversaciones o el uso de ventanas deslizantes (sliding window
).
3. ¿Cómo podríamos persistir la conversación?
Actualmente, si refrescas la página, la conversación se pierde. La persistencia implicaría almacenar el array de messages
(p. ej., en localStorage
para una solución simple, o en una base de datos para una solución robusta) y pasarlo como initialMessages
al hook useChat
al cargar la página. Esto también lo exploraremos en futuros capítulos.
Conclusión del capítulo 2
En este capítulo, hemos dado un salto cualitativo desde una interacción de "pregunta-respuesta" a un diálogo dinámico y en tiempo real. Hemos construido una aplicación de chat que no solo funciona, sino que está basada en una arquitectura moderna y eficiente, ideal para las demandas de las aplicaciones de IA.
Hemos dominado los siguientes conceptos clave:
- Arquitectura cliente-servidor para streaming: Entendemos el rol de cada parte: el frontend como consumidor y renderizador de un stream, y el backend como el productor agnóstico al proveedor.
- Gestión de estado complejo con
useChat
: Hemos aprovechado una abstracción de alto nivel para manejar la UI optimista, los estados de carga/error y la actualización incremental de la interfaz, simplificando enormemente nuestro código de cliente. - Prompt engineering básico: Hemos visto cómo un "system prompt" bien definido puede guiar eficazmente el comportamiento de un LLM para una tarea específica, estableciendo una
persona
y unas reglas claras.
La interfaz conversacional es el lienzo sobre el cual pintaremos funcionalidades de IA cada vez más sofisticadas. Con esta base sólida, estamos preparados para el siguiente gran desafío: dotar a nuestro agente de conocimiento externo. En el Capítulo 3, nos sumergiremos en el mundo del Retrieval-Augmented Generation (RAG), enseñando a nuestro chatbot a responder preguntas basándose en una base de conocimiento privada.
Puedes encontrar el código de esta sección en: https://github.com/aperezl/ai-fullstack-serie/tree/chatbot-part3