TechLead
Lección 8 de 8
8 min de lectura
AI SDK

Crear aplicaciones de chat con IA

Construye aplicaciones de chat con IA listas para producción con mejores prácticas

Construir aplicaciones de chat con IA en producción

Esta guía cubre mejores prácticas y patrones para construir aplicaciones de chat con IA listas para producción usando Vercel AI SDK. Veremos autenticación, persistencia, manejo de errores, rate limiting y patrones de UX.

Checklist de producción

  • Autenticación: Protege tus rutas API
  • Persistencia: Almacena el historial de conversación
  • Rate limiting: Evita abuso y controla costos
  • Manejo de errores: Degradación elegante
  • Estados de carga: Mejor experiencia de usuario
  • UX de streaming: Feedback en tiempo real

Estructura completa de la app de chat

Una aplicación de chat bien organizada separa la lógica API, los componentes de UI, las utilidades de base de datos y los tipos en directorios claros dentro del App Router de Next.js.

app/
├── api/
│   └── chat/
│       └── route.ts      # Chat API endpoint
├── chat/
│   ├── page.tsx          # Chat page
│   └── [id]/
│       └── page.tsx      # Conversation page
├── components/
│   ├── ChatMessages.tsx  # Message list component
│   ├── ChatInput.tsx     # Input component
│   └── ChatSidebar.tsx   # Conversation list
├── lib/
│   ├── db.ts             # Database utilities
│   └── rate-limit.ts     # Rate limiting
└── types/
    └── chat.ts           # Type definitions

Ruta API con autenticación

En producción, la ruta API debe verificar la identidad del usuario, aplicar límites de uso y guardar los mensajes en base de datos tras completar la respuesta.

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { saveMessages, getConversation } from '@/lib/db';

export const maxDuration = 30;

export async function POST(req: Request) {
  // Authentication
  const session = await auth();
  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Rate limiting
  const rateLimitResult = await rateLimit(session.user.id);
  if (!rateLimitResult.success) {
    return new Response('Too many requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': rateLimitResult.limit.toString(),
        'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
        'X-RateLimit-Reset': rateLimitResult.reset.toString(),
      },
    });
  }

  const { messages, conversationId } = await req.json();

  // Validate input
  if (!messages || !Array.isArray(messages)) {
    return new Response('Invalid messages', { status: 400 });
  }

  const result = streamText({
    model: openai('gpt-4-turbo'),
    system: `You are a helpful AI assistant.
      User: ${session.user.name}
      Be concise and helpful.`,
    messages,
    onFinish: async ({ text }) => {
      // Save to database after completion
      await saveMessages(conversationId, session.user.id, [
        messages[messages.length - 1],
        { role: 'assistant', content: text },
      ]);
    },
  });

  return result.toDataStreamResponse();
}

Implementación de rate limiting

El rate limiting protege tu API contra abuso y controla costos. Puedes usar servicios como Upstash Redis para límites distribuidos o un enfoque en memoria para el desarrollo local.

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL!,
  token: process.env.UPSTASH_REDIS_TOKEN!,
});

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 requests per minute
  analytics: true,
});

export async function rateLimit(userId: string) {
  const { success, limit, remaining, reset } = await ratelimit.limit(userId);
  return { success, limit, remaining, reset };
}

// Simple in-memory rate limiter (for development)
const requests = new Map<string, number[]>();

export function simpleeRateLimit(userId: string, limit = 20, window = 60000) {
  const now = Date.now();
  const userRequests = requests.get(userId) || [];

  // Filter requests within the window
  const recentRequests = userRequests.filter(time => now - time < window);

  if (recentRequests.length >= limit) {
    return { success: false, remaining: 0 };
  }

  recentRequests.push(now);
  requests.set(userId, recentRequests);

  return { success: true, remaining: limit - recentRequests.length };
}

Persistencia de conversaciones

Almacenar las conversaciones en una base de datos permite a los usuarios retomar chats previos. Con Prisma u otro ORM, puedes crear, recuperar y actualizar conversaciones vinculadas a cada usuario.

// lib/db.ts (using Prisma example)
import { prisma } from './prisma';

export async function createConversation(userId: string, title?: string) {
  return prisma.conversation.create({
    data: {
      userId,
      title: title || 'New Chat',
    },
  });
}

export async function getConversation(id: string, userId: string) {
  return prisma.conversation.findFirst({
    where: { id, userId },
    include: {
      messages: {
        orderBy: { createdAt: 'asc' },
      },
    },
  });
}

export async function saveMessages(
  conversationId: string,
  userId: string,
  messages: { role: string; content: string }[]
) {
  // Verify ownership
  const conversation = await prisma.conversation.findFirst({
    where: { id: conversationId, userId },
  });

  if (!conversation) {
    throw new Error('Conversation not found');
  }

  return prisma.message.createMany({
    data: messages.map((msg) => ({
      conversationId,
      role: msg.role,
      content: msg.content,
    })),
  });
}

export async function getUserConversations(userId: string) {
  return prisma.conversation.findMany({
    where: { userId },
    orderBy: { updatedAt: 'desc' },
    take: 50,
  });
}

Componente de UI de chat

El componente principal del chat conecta el hook useChat con los subcomponentes de mensajes e input, gestionando auto-scroll, errores y estados de carga.

// app/chat/page.tsx
'use client';

import { useChat } from 'ai/react';
import { useRef, useEffect } from 'react';
import { ChatMessages } from '@/components/ChatMessages';
import { ChatInput } from '@/components/ChatInput';

interface ChatPageProps {
  conversationId?: string;
  initialMessages?: any[];
}

export default function ChatPage({ conversationId, initialMessages }: ChatPageProps) {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    isLoading,
    error,
    reload,
    stop,
  } = useChat({
    id: conversationId,
    initialMessages,
    body: { conversationId },
    onError: (error) => {
      console.error('Chat error:', error);
    },
  });

  // Auto-scroll to bottom
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex flex-col h-screen">
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4">
        <ChatMessages messages={messages} isLoading={isLoading} />
        <div ref={messagesEndRef} />
      </div>

      {/* Error display */}
      {error && (
        <div className="px-4 py-2 bg-red-50 text-red-600 text-sm">
          Something went wrong. <button onClick={() => reload()}>Try again</button>
        </div>
      )}

      {/* Input */}
      <ChatInput
        input={input}
        handleInputChange={handleInputChange}
        handleSubmit={handleSubmit}
        isLoading={isLoading}
        onStop={stop}
      />
    </div>
  );
}

Componente de lista de mensajes

Este componente renderiza la lista de mensajes con estilos diferenciados para el usuario y la IA. Soporta Markdown y resaltado de código para respuestas ricas.

// components/ChatMessages.tsx
import { Message } from 'ai';
import { cn } from '@/lib/utils';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';

interface ChatMessagesProps {
  messages: Message[];
  isLoading: boolean;
}

export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
  return (
    <div className="space-y-4">
      {messages.map((message) => (
        <div
          key={message.id}
          className={cn(
            'flex',
            message.role === 'user' ? 'justify-end' : 'justify-start'
          )}
        >
          <div
            className={cn(
              'max-w-[80%] rounded-2xl px-4 py-2',
              message.role === 'user'
                ? 'bg-blue-600 text-white'
                : 'bg-gray-100 text-gray-900'
            )}
          >
            <ReactMarkdown
              components={{
                code({ node, inline, className, children, ...props }) {
                  const match = /language-(\w+)/.exec(className || '');
                  return !inline && match ? (
                    <SyntaxHighlighter
                      language={match[1]}
                      PreTag="div"
                      {...props}
                    >
                      {String(children).replace(/\n$/, '')}
                    </SyntaxHighlighter>
                  ) : (
                    <code className={className} {...props}>
                      {children}
                    </code>
                  );
                },
              }}
            >
              {message.content}
            </ReactMarkdown>
          </div>
        </div>
      ))}

      {isLoading && (
        <div className="flex justify-start">
          <div className="bg-gray-100 rounded-2xl px-4 py-2">
            <div className="flex space-x-2">
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
              <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Componente de entrada de chat

El componente de entrada maneja el envío de mensajes por botón o con la tecla Enter, e incluye un botón para detener la generación en curso.

// components/ChatInput.tsx
import { FormEvent, KeyboardEvent } from 'react';

interface ChatInputProps {
  input: string;
  handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
  handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
  isLoading: boolean;
  onStop: () => void;
}

export function ChatInput({
  input,
  handleInputChange,
  handleSubmit,
  isLoading,
  onStop,
}: ChatInputProps) {
  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      const form = e.currentTarget.form;
      if (form) form.requestSubmit();
    }
  };

  return (
    <form onSubmit={handleSubmit} className="border-t p-4">
      <div className="flex items-end gap-2">
        <textarea
          value={input}
          onChange={handleInputChange}
          onKeyDown={handleKeyDown}
          placeholder="Type a message..."
          className="flex-1 resize-none border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
          rows={1}
          disabled={isLoading}
        />
        {isLoading ? (
          <button
            type="button"
            onClick={onStop}
            className="px-4 py-3 bg-red-500 text-white rounded-lg"
          >
            Stop
          </button>
        ) : (
          <button
            type="submit"
            disabled={!input.trim()}
            className="px-4 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
          >
            Send
          </button>
        )}
      </div>
      <p className="text-xs text-gray-500 mt-2">
        Press Enter to send, Shift+Enter for new line
      </p>
    </form>
  );
}

Error Boundaries

// components/ChatErrorBoundary.tsx
'use client';

import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ChatErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 bg-red-50 rounded-lg">
          <h2 className="text-red-800 font-semibold">Something went wrong</h2>
          <p className="text-red-600 text-sm">{this.state.error?.message}</p>
          <button
            onClick={() => this.setState({ hasError: false })}
            className="mt-2 px-3 py-1 bg-red-600 text-white rounded text-sm"
          >
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Actualizaciones optimistas

// Using useChat with optimistic updates
const { messages, append, setMessages } = useChat();

// Optimistically add user message before API call
const sendMessage = async (content: string) => {
  const userMessage = {
    id: crypto.randomUUID(),
    role: 'user' as const,
    content,
  };

  // Add user message immediately
  setMessages([...messages, userMessage]);

  // This will trigger the API call and handle the response
  await append(userMessage);
};

Consejos de producción

  • • Usa streaming para mejor rendimiento percibido
  • • Implementa virtualización de mensajes para conversaciones largas
  • • Añade lógica de reintento para fallos transitorios
  • • Considera caché para consultas repetidas
  • • Monitorea uso de tokens y costos
  • • Añade moderación de contenido si hay contenido generado por usuarios

💡 Puntos clave

  • • Autentica siempre las rutas API en producción
  • • Implementa rate limiting para controlar costos y abuso
  • • Persiste conversaciones para continuidad de experiencia
  • • Maneja errores con opciones de reintento
  • • Usa streaming y estados de carga para mejor UX
  • • Considera accesibilidad en la interfaz de chat

📚 Más recursos

Continuar aprendiendo