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.
Reto Pro
Reto Pro de esta lección
Misma idea, sin pistas, evaluada por tests automáticos.
Hard Mode
Hard Mode
Variante extrema del reto. Tiempo límite y restricciones adicionales.