Skip to content

Lección 04

UI tipo ChatGPT con streaming de tokens

Tu bot ya no espera 8 segundos en silencio: las palabras van apareciendo conforme se generan. Server-sent events + React.


Por qué importa el streaming

Una respuesta de Claude de 800 tokens tarda ~6 segundos. Si esperas a tener la respuesta completa, el usuario ve un spinner ese tiempo entero. Si streamingeas, ve la primera palabra en ~300ms.

La UX cambia totalmente. Vamos.

El backend con streaming

Cambia tu endpoint a un ReadableStream que reenvíe lo que llega del SDK:

// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

export async function POST(req: Request) {
  const { messages } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      const ms = await client.messages.stream({
        model: "claude-haiku-4-5",
        max_tokens: 1024,
        system: "Eres un asistente útil. Responde en español.",
        messages,
      });

      for await (const event of ms) {
        if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
          controller.enqueue(encoder.encode(event.delta.text));
        }
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "X-Accel-Buffering": "no",
    },
  });
}

El frontend que lee el stream

async function send() {
  if (!input.trim()) return;
  const next: Msg[] = [...messages, { role: "user", content: input }];
  setMessages(next);
  setInput("");

  setMessages([...next, { role: "assistant", content: "" }]);

  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages: next }),
  });

  if (!res.body) return;
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let acc = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    acc += decoder.decode(value, { stream: true });

    setMessages((prev) => {
      const copy = [...prev];
      copy[copy.length - 1] = { role: "assistant", content: acc };
      return copy;
    });
  }
}

Cada chunk de texto que llega del backend se concatena en acc y actualiza el último mensaje. Visualmente, ves cómo se escribe.

Detalles que separan amateur de pro

1. Autoscroll inteligente

Si el usuario está leyendo arriba y haces autoscroll, lo interrumpes. Sólo scrollea si ya estaba al fondo.

useEffect(() => {
  const el = containerRef.current;
  if (!el) return;
  const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
  if (wasAtBottom) {
    el.scrollTop = el.scrollHeight;
  }
}, [messages]);

2. Botón para parar la generación

const abortRef = useRef<AbortController | null>(null);

async function send() {
  abortRef.current?.abort();
  const ctrl = new AbortController();
  abortRef.current = ctrl;

  await fetch("/api/chat", {
    method: "POST",
    signal: ctrl.signal,
    // ...
  });
}

function stop() {
  abortRef.current?.abort();
}

3. Markdown en las respuestas

Claude responde a menudo con markdown. Usa react-markdown:

npm install react-markdown remark-gfm
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

<ReactMarkdown remarkPlugins={[remarkGfm]}>
  {m.content}
</ReactMarkdown>

4. Code blocks con syntax highlighting

npm install rehype-highlight highlight.js
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";

<ReactMarkdown rehypePlugins={[rehypeHighlight]}>...</ReactMarkdown>

Y ya tienes una UI respetable. En la última lección la desplegamos en Vercel.


NIVEL 2

Reto Pro

Reto Pro de esta lección

Misma idea, sin pistas, evaluada por tests automáticos.

Desbloquear Modo Pro · 29 € Pago único · acceso de por vida · sin suscripción
NIVEL 3

Hard Mode

Hard Mode

Variante extrema del reto. Tiempo límite y restricciones adicionales.

Desbloquear Modo Pro · 29 € Pago único · acceso de por vida · sin suscripción