Cómo construir una interfaz de chat con IA con shadcn/ui y el AI SDK de Vercel que sobreviva a usuarios reales
Un chat con IA en streaming es fácil de demostrar y difícil de publicar. Los estados que un chat con shadcn/ui y el AI SDK tiene que manejar, desde detener y reintentar hasta aprobaciones de herramientas y entrada CJK.
Preet Suthar · · 6 min de lectura
- AI
- React
- shadcn/ui
- Next.js
La demo lleva una tarde. useChat del AI SDK, una lista de mensajes, un textarea, y los tokens fluyen en la pantalla. Todo el equipo queda impresionado.
Entonces lo usa una persona real. Pulsa Enter mientras compone en japonés y envía media palabra. Detiene una respuesta y la interfaz no muestra nada. Una llamada a herramienta pide aprobación en mitad del stream y no hay interfaz para eso. Hace scroll hacia arriba para releer algo y la vista la devuelve de un tirón al final con cada token.
He construido esta pantalla más veces de las que me gustaría, la última como los bloques detrás de HextaUI. Esta es la lista de lo que una interfaz de chat tiene que manejar antes de estar terminada, con los detalles de shadcn/ui y el AI SDK para cada punto.
Empieza por partes de mensaje, no por strings
El useChat del AI SDK devuelve mensajes compuestos por partes: texto, razonamiento, llamadas a herramientas con su estado, fuentes, archivos. Renderiza las partes, no un string concatenado. Todas las funciones de abajo dependen de ello.
Las partes de herramienta se tipan como tool-<name>, así que isToolUIPart es la comprobación fiable.
Mantén tus bloques libres del import del SDK. Recibe las partes como props. Así los mismos componentes funcionan con un backend de LangGraph o con tu propio servidor una vez que mapeas su salida a la misma forma.
El compositor
El textarea es donde viven la mayoría de los bugs.
Enter envía, Shift+Enter hace un salto de línea. Todo el mundo hace esto. Casi nadie gestiona la composición. Cuando alguien escribe en japonés, chino o coreano, el IME está abierto y Enter confirma el candidato, no el mensaje. Comprueba event.nativeEvent.isComposing y sal:
Crece con el texto, con límites. Una línea al principio, hasta unas ocho filas, y después scroll. Anima el cambio de altura, y nunca a través del estado de React en cada pulsación. Mide scrollHeight en un layout effect y fija la altura en el nodo del DOM. Cualquier setState en un listener nativo del input acabará perdiendo o retrasando teclas bajo carga.
Los borradores sobreviven a una recarga. Guarda el borrador en localStorage con el id del chat como clave, con debounce. La gente pierde prompts largos por recargas accidentales y nunca te lo perdona.
Pon en cola los mensajes escritos durante una respuesta. Si el modelo todavía está escribiendo y el usuario envía otro mensaje, o deshabilitas el envío o lo pones en cola y lo envías cuando termine el stream. Descartarlo en silencio es la peor opción y es la que viene por defecto.
Los adjuntos muestran progreso y reintento. Las subidas fallan. Muestra el fallo en la tarjeta del adjunto con un reintento, no como un toast que desaparece.
Streaming que se lee bien
Los modelos emiten tokens a ráfagas. Si renderizas exactamente lo que llega, el texto va a trompicones: nada, luego doce palabras, luego nada. Suavízalo. Revela los caracteres a un ritmo constante que se acelere cuando crece el búfer, para que nunca te quedes muy por detrás del stream pero tampoco des tirones.
El Markdown durante el streaming tiene su propia trampa: la sintaxis a medio escribir. El modelo ha escrito **bold y todavía no lo ha cerrado, así que tu renderizador muestra asteriscos literales durante un frame y luego texto en negrita. Retén la sintaxis en línea incompleta hasta que se cierre, o hasta que termine el stream.
Los bloques de código necesitan el mismo cuidado. Resalta de forma incremental y nunca vuelvas a resaltar el bloque entero en cada token.
Detener, reintentar, editar y versiones
Detener debería dejar la respuesta parcial en pantalla, con un marcador "Stopped" visible para que el usuario sepa que está incompleta. No la borres.
Reintentar en una respuesta fallida es un botón en el mensaje, no un gesto oculto. Conéctalo a regenerate().
Editar un mensaje anterior crea una rama. Existen tanto la respuesta antigua como la nueva. Muestra un pequeño selector 1 / 2 en el mensaje para que el usuario pueda alternar entre versiones. Aquí es donde muchas interfaces de chat caseras se rinden, y es la función que más echa de menos la gente de ChatGPT y Claude.
Las respuestas fallidas reciben un motivo y un reintento. "Something went wrong" sin ninguna acción es un callejón sin salida.
Desplazamiento
Sigue los tokens nuevos cuando el usuario está al final. En cuanto haga scroll hacia arriba, deja de seguir y muestra un botón de "ir a lo último" con un contador de lo que se ha perdido. Cuando salte, vuelve a seguir.
Cargar historial antiguo por arriba no debe mover la vista. Mide la altura de scroll antes y después de anteponer, y ajusta scrollTop con la diferencia en el mismo frame.
Si el usuario envía un mensaje, haz scroll para que su mensaje quede fijado en la parte superior del viewport y la respuesta se transmita debajo. Así se comportan ahora las dos grandes apps de chat y los usuarios lo esperan.
Llamadas a herramientas y aprobaciones
El AI SDK da a cada parte de herramienta un estado: input-streaming, input-available, output-available, output-error y, con humano en el bucle, approval-requested, approval-responded y output-denied. Cada estado necesita su propio aspecto.
Las lecturas y búsquedas pueden plegarse en un resumen de una línea. Las ediciones, los comandos y los errores deberían quedarse expandidos. Las aprobaciones bloquean el stream y necesitan un Allow y un Deny claros, más una forma de decir "haz esto en su lugar" que envíe texto de vuelta al modelo como motivo de la denegación.
Muestra el resultado cuando sea útil. Una edición de archivo se abre en un diff. Un comando se abre en un terminal con la salida. Una búsqueda se abre en resultados. Una llamada a herramienta que solo dice "Done" enseña a los usuarios a ignorar las llamadas a herramientas.
Razonamiento y fuentes
Las partes de razonamiento deberían ser plegables y estar plegadas por defecto en cuanto empieza la respuesta. Mientras el modelo todavía piensa, una etiqueta en vivo que siga cada paso ("Searching docs", "Reading three files") tranquiliza mucho más que un spinner.
Las citas van junto a las afirmaciones que respaldan, en línea, no en una lista al final que nadie lee.
Accesibilidad que no puedes saltarte
- Los mensajes nuevos se anuncian mediante una región
role="log", para que quienes usan lector de pantalla oigan las respuestas. - El botón de detener es accesible por teclado y tiene etiqueta.
- El texto en streaming no debería disparar cien anuncios de región live. Anuncia cuando el mensaje se complete, o en los límites de oración.
- Cada botón que solo tiene icono lleva
aria-label.
Juntándolo todo
Si quieres las piezas, shadcn/ui publicó AI Elements en octubre de 2026: 21 patrones que copias en tu proyecto y compones tú mismo, incluidos Chat, Prompt Input, Reasoning y Tool. La biblioteca AI Elements anterior de Vercel queda obsoleta a finales de noviembre de 2026 en favor de ellos.
Si quieres las pantallas ya montadas con los estados anteriores resueltos, eso son los bloques de HextaUI: un Chat Thread con detener, reintentar y versiones, Tool Calls con todos los estados del AI SDK y aprobaciones, Streaming y Markdown que nunca muestran sintaxis a medio escribir, y un Prompt Input que gestiona entrada CJK, subidas, colas y borradores. Prompt Input es gratuito. El resto es una compra única. Todo lee partes de mensaje del AI SDK y nada importa el SDK, así que funciona con cualquier backend que puedas mapear.
Puedes probar todo el conjunto montado como una app de chat en hextaui.com/blocks/hexta-ai antes de instalar nada.
Preguntas frecuentes
¿Necesito el AI SDK de Vercel para usar estos patrones? No. Lo útil es la forma de los mensajes en partes. Cualquier backend sirve una vez que mapeas su salida a partes.
¿Cómo gestiono Enter con un IME? Comprueba event.nativeEvent.isComposing en tu manejador de keydown y sal pronto.
¿El texto en streaming debería actualizar una región live en cada token? No. Anuncia al completarse o en los límites de oración, o los lectores de pantalla se vuelven inutilizables.
¿Qué pasa con una respuesta detenida? Conserva el texto parcial y márcalo como detenido. Nunca borres lo que el usuario ya ha leído.