Inicio / Artículos

Diseño de una interfaz frontend AG-UI: flujos de eventos, registro de widgets y LLMs locales

Deje de solicitar JSX a los modelos. Transmita eventos de texto, herramientas o widgets tipados, valide las propiedades, registre los widgets de React y mantenga el pipeline de renderizado cerrado para realizar modificaciones.

Editor: REACTAPP.TOP10 min de lectura2410 palabras

Fuentes: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama

Los chatbots de demostración que solo utilizan Markdown parecen pulidos hasta que alguien solicita una tabla, un gráfico, un calendario o un formulario. En ese momento, el producto deja de ser un simple envoltorio para chat y se convierte en una aplicación, y Markdown resulta ser un formato de aplicación deficiente.

Después de varios callejones sin salida, surgió un enfoque mejor: dejar de pedirle al modelo que invente la interfaz de usuario. En lugar de eso, hay que pedirle que elija las acciones a realizar. El frontend ya sabe cómo renderizar; el backend ya sabe dónde se encuentran los datos. El modelo debe decidir qué sucede a continuación.

Esa separación permitió crear un asistente que renderiza dieciséis widgets de React listos para uso, admite interfaces de usuario controladas por el backend, funciona con un LLM local, no añade nuevas dependencias al frontend y se extiende registrando un único componente.

En resumen: rechace el marcado generado por el modelo. Deje que el backend emita una lista secuenciada de eventos tipificados —prosa, estado de las herramientas e intenciones de los widgets— mientras el cliente mantiene un registro y muestra esos eventos en orden. La única tarea del modelo es seleccionar qué capacidad del backend debe responder. Esto genera una salida más estable, componentes reutilizables y un proceso de renderizado que no se ve afectado a medida que crece el catálogo.

El problema con las interfaces de usuario generadas por IA

Muchas aplicaciones basadas en IA siguen un camino estrecho:

User
  ↓
LLM
  ↓
Markdown
  ↓
React renders Markdown

Es simple, pero limitado. Con el tiempo, los usuarios solicitan ver miembros activos, comparar ingresos mensuales, listar citas o abrir un perfil. Esas respuestas son interfaces, no párrafos.

Al solicitar al modelo que genere HTML o JSX, se produce un error cuando este inventa componentes faltantes, omite propiedades necesarias o etiqueta como existente un widget que en realidad no existe. El frontend entonces falla al intentar procesar ese marcado imaginario. El marcado generado tampoco puede ser escrito a mano, probado con pruebas unitarias, reutilizado fuera del chat ni revisado en una solicitud de integración. Las garantías ofrecidas por la biblioteca de componentes desaparecen; en su lugar, el objetivo es el determinismo.

La idea central: el modelo elige las acciones, no los componentes

El backend nunca devuelve HTML, JSX ni nombres de componentes de React. Cada respuesta es un flujo ordenado de eventos:

{
  "events": [
    { "type": "tool",   "tool": "list_members", "status": "completed" },
    { "type": "widget", "widget": "table", "props": { "rows": [] } },
    { "type": "text",   "text": "I found 24 active members." }
  ]
}

El backend comunica la intención; el frontend decide cómo se presenta. Las responsabilidades quedan claras: la IA decide qué, React decide cómo y los widgets deciden su apariencia. La cadena "table" es una clave semántica, no un nombre de clase; la versión para escritorio puede virtualizar una cuadrícula mientras que la versión móvil apila tarjetas, sin que al backend le importe.

El protocolo de eventos

El contrato se mantiene lo suficientemente pequeño como para caber en la memoria de trabajo. Los eventos escritos a mano lo mantienen seguro:

type TextEvent = {
  type: 'text';
  text: string;
};
type ToolEvent = {
  type: 'tool';
  tool: string;
  status: 'running' | 'completed' | 'failed';
  error?: string;
};type WidgetEvent = {
  type: 'widget';
  widget: WidgetName;              // union of registered keys
  props: Record<string, unknown>;  // validated at the boundary
};export type AssistantEvent = TextEvent | ToolEvent | WidgetEvent;

Tres tipos de eventos cubren las respuestas del asistente. El orden es importante: un chip de herramienta puede aparecer antes que el widget que generó, y una oración de resumen puede seguir. La transcripción se lee como un flujo de trabajo narrado.

Un único pipeline de renderizado, sin instrucciones switch

Cada mensaje sigue un único camino:

AiDialog
   ↓
ChatMessages
   ↓
ChatMessage
   ↓
MessagePart
   ↓
WidgetRenderer
   ↓
widgetRegistry[widget]

No hay instrucciones switch ad hoc dispersas en el árbol. Un único renderizador se encarga de cada tipo de evento: el texto se convierte en el contenido del chat, la herramienta en el indicador de progreso y el widget en un componente React. Agregar nuevas capacidades no implica reescribir el pipeline; simplemente se registra otro widget. El renderizador que manejó al widget uno sigue manejando al widget dieciséis.

Abierto para extensiones, cerrado para modificaciones

Se registran nuevos widgets; la cadena de procesamiento permanece cerrada. Ese es el principio de abierto/cerrado aplicado a la interfaz de chat. El crecimiento del producto se convierte en un problema de catálogo, no en uno de reescritura.

Nunca confíes en el modelo: Valida las propiedades en los límites

La salida del modelo es una entrada en la que no se puede confiar. Antes de que un widget se monte, las propiedades pasan por una validación de esquema (Zod en el ejemplo). Los datos inválidos generan una interfaz de error segura en lugar de excepciones en tiempo de ejecución en lo más profundo de la estructura.

const registry = {
  table: {
    schema: z.object({
      columns: z.array(z.object({ key: z.string(), label: z.string() })),
      rows: z.array(z.record(z.unknown())).default([]),
    }),
    component: TableWidget,
  },
  // ...
};
function WidgetRenderer({ widget, props }: WidgetEvent) {
  const entry = registry[widget];
  if (!entry) return <UnknownWidget name={widget} />;  const parsed = entry.schema.safeParse(props);
  if (!parsed.success) return <WidgetError name={widget} issues={parsed.error.issues} />;  const Widget = entry.component;
  return <Widget {...parsed.data} />;
}

La validación en los límites evita que se produzcan fallos en los componentes más básicos y proporciona a los operadores un modo de fallo claro cuando el modelo distorsiona los datos.

Creando una verdadera biblioteca de widgets

Los widgets se almacenan en un registro indexado por nombres semánticos:

export const widgetRegistry = {
  table,
  profile,
  chart,
  timeline,
  calendar,
  markdown,
  json,
  stats,
  progress,
  alert,
  fileList,
} satisfies Record<string, ComponentType<any>>;
export type WidgetName = keyof typeof widgetRegistry;

WidgetName se obtiene del registro, por lo que TypeScript rechaza las claves desconocidas. Las tablas, perfiles, gráficos y formularios se convierten en componentes normales que el resto de la aplicación puede reutilizar fuera del chat.

Por qué añadí cero dependencias

La estructura del chat reutilizó el sistema de diseño existente y la pila de React. Ningún nuevo kit de interfaz se incluyó en package.json. Esa restricción obligó al asistente a utilizar los mismos botones, tablas y tokens que ya incluye el producto, de modo que las pantallas de chat y no relacionadas con el chat mantengan una apariencia uniforme.

Widgets interactivos sin callbacks de React

Los widgets no deben importar la estructura del chat. Emiten eventos personalizados de DOM para acciones como actualizar o abrir perfil:

// Inside a widget — it knows nothing about the app
element.dispatchEvent(
  new CustomEvent('ai:action', {
    bubbles: true,
    detail: { action: 'open_member', payload: { id: member.id } },
  })
);
Widget
  ↓
CustomEvent
  ↓
AiDialog
  ↓
AI Agent

El diálogo escucha una vez, asocia los eventos a llamadas de herramientas y mantiene los widgets portátiles. La misma tabla puede aparecer en una página de miembros y dentro del chat sin necesidad de propiedades específicas para el chat.

La IA no decide la interfaz de usuario

El modelo nunca elige colores, diseños o estructuras de componentes. Solo selecciona herramientas. La orquestación en el backend convierte los resultados de las herramientas en eventos de widgets con propiedades validadas. El diseño sigue siendo responsabilidad de los ingenieros frontend.

Sustituir puntos finales codificados por un catálogo de herramientas

Los prototipos iniciales utilizaban URLs codificadas. La versión final incluye un catálogo de herramientas que el host puede descubrir:

{
  "message": "Show me all active members",
  "tools": [
    { "name": "list_members", "endpoint": "/api/v1/members",        "method": "GET" },
    { "name": "get_member",   "endpoint": "/api/v1/members/{id}",   "method": "GET" },
    { "name": "member_stats", "endpoint": "/api/v1/members/stats",  "method": "GET" }
  ]
}

Cada herramienta indica un punto final y su esquema. El modelo elige una herramienta; el backend la ejecuta; los eventos de widgets transportan el resultado. Cambiar la infraestructura se convierte en una edición del catálogo, no en una reescritura de la instrucción.

Transmisión en tiempo real sin romper el contrato

La transmisión en streaming sigue emitiendo los mismos tipos de eventos. El estado parcial de la herramienta puede cambiar de running a completed mientras llegan los tokens de texto. El renderizador agrega o actualiza eventos sin necesidad de crear un segundo protocolo. Los usuarios ven indicadores de progreso antes de que aparezcan los widgets finales.

Crear una experiencia de chat moderna

La página principal sigue siendo un listado ordinario. El asistente se oculta detrás de una interfaz de Chat con control por IA y responde utilizando los mismos datos y componentes que ya utiliza la página. La continuidad es más importante que un aspecto visual distinto para un “producto de IA” separado.

Las banderas de funcionalidad son importantes

Una sola bandera controla la experiencia:

VITE_ENABLE_AI_CHAT

Las banderas permiten a los equipos lanzar la interfaz de diálogo en modo oscuro, desactivarla cuando hay mucho tráfico o exponerla únicamente al personal, sin necesidad de volver a implementar el código del widget.

Pruebas de un framework de widgets

Las pruebas se centran en tres capas: la validación del esquema rechaza las propiedades inválidas, la búsqueda en el registro resuelve los widgets conocidos, y el orden de los eventos muestra herramienta → widget → texto tal como se espera. Las pruebas unitarias de los widgets se ejecutan fuera del chat. Las pruebas end-to-end abren el diálogo, solicitan los miembros y verifican que se muestre una tabla.

Cuando esta arquitectura es excesiva

Los bots de preguntas frecuentes puros que solo necesitan párrafos pueden seguir utilizando Markdown. El protocolo de eventos resulta útil cuando las respuestas son interfaces, cuando los widgets deben reutilizarse fuera del chat y cuando el equipo se niega a permitir que el modelo cree árboles de componentes.

Principios de diseño que guiaron cada decisión

  • El modelo elige las acciones, no el marcado.
  • Un único pipeline de renderizado para cada mensaje.
  • Los widgets se registran; el pipeline no se divide.
  • Validar las propiedades antes del montaje.
  • Los widgets se comunican a través de CustomEvents, no mediante importaciones desde el chat.
  • Las herramientas se encuentran en un catálogo, no en URLs codificadas de forma fija.
  • Transmite los mismos tipos de eventos que almacenas.
  • Controla el acceso mediante flags de funcionalidad.
  • Preferir cero nuevas dependencias frontales cuando ya existe un sistema de diseño.
  • El resultado

    El asistente transmite eventos escritos, renderiza dieciséis widgets a través de un único flujo de trabajo, valida cada prop y se expande mediante registro. Los backends locales de LLM siguen siendo intercambiables porque el contrato se basa en eventos, no en SDKs de proveedores dentro de la interfaz.

    Pensamientos finales

    Enseñar a los modelos a diseñar interfaces es luchar la batalla equivocada. Enseñarles a seleccionar capacidades y permitir que un front-end disciplinado renderice esas capacidades preserva los sistemas de diseño, la posibilidad de pruebas y el crecimiento. El chat se convierte en otra superficie para los mismos componentes en los que ya confía el producto.

    La pantalla de los miembros en la muestra sigue siendo una página de producto ordinaria. El asistente no es un segundo producto; se encuentra detrás de un control de Chat con IA y responde utilizando el mismo plano de datos y los mismos componentes que ya utiliza esa página. Esa continuidad es intencionada. Cuando el chat crea un lenguaje visual paralelo, los operadores dejan de confiar en él. Cuando el chat y la página comparten widgets, cada mejora en la tabla se refleja en ambos lugares.

    Ordenar los elementos dentro de un array de eventos forma parte del lenguaje del producto. Un evento tool con estado running puede aparecer primero para que el usuario vea cómo comienza el trabajo. Un evento widget puede seguir con las filas correspondientes. Un evento text puede cerrar con un resumen de una sola línea. Reordenar esos eventos modifica la sensación que se tiene durante la conversación sin cambiar ningún componente de React. Considere el array como un guion gráfico que los desarrolladores del backend crean y los equipos frontend representan.

    La validación de esquemas no es un truco opcional. Los modelos omiten campos, convierten números en cadenas de texto y, ocasionalmente, renombran las claves. Zod (o un equivalente) en el límite del registro convierte esos errores en un widget de error controlado en lugar de una pantalla en blanco. Registrar el fallo de validación junto con la carga bruta permite realizar correcciones rápidas con herramientas, en lugar de tener que adivinar basándose en un informe del usuario.

    CustomEvents mantienen clara la propiedad de los elementos. Un widget que importa ganchos de chat no puede reutilizarse en una página estática. Un widget que solo envía member:open o table:refresh puede utilizarse en cualquier lugar. El diálogo funciona como un adaptador que traduce esos eventos a llamadas a herramientas. Ese adaptador es el único lugar que conoce la sesión del asistente.

    La transmisión en streaming sigue el mismo contrato. A medida que llegan los tokens y los estados de las herramientas, la aplicación del cliente agrega o modifica eventos en su lugar. No existe un segundo “formato de streaming”. Si una herramienta falla durante su ejecución, el estado failed reemplaza a running y un evento de texto opcional explica la causa del fallo. Los usuarios ven continuidad en lugar de un indicador que se detiene silenciosamente.

    Las banderas de funcionalidad brindan seguridad operativa. Al incluir dieciséis widgets bajo VITE_ENABLE_AI_CHAT, los equipos pueden desactivar el diálogo durante picos de carga sin tener que modificar el código. Combine esta bandera con autorizaciones por herramienta para que un catálogo de solo lectura siga funcionando mientras las herramientas destructivas permanezcan desactivadas.

    La estrategia de pruebas refleja la arquitectura. Las pruebas unitarias cubren los esquemas y el renderizado puro de los widgets. Las pruebas de integración introducen un array fijo de eventos en el renderizador y toman una captura de la estructura resultante. Las pruebas end-to-end solicitan información a un backend real o simulado sobre los miembros y verifican que el widget de tabla se muestre con las columnas esperadas. Dado que los widgets son componentes ordinarios, los conjuntos existentes de pruebas de regresión visual pueden incluirlos sin necesidad de herramientas especializadas basadas en IA.

    Aún existen casos en los que sería excesivo utilizar esta solución. Si el producto solo responde a preguntas definitorias con párrafos cortos, Markdown sigue siendo la opción más económica. La arquitectura basada en eventos se justifica cuando las respuestas son interfaces, cuando los mismos widgets aparecen fuera del chat y cuando el equipo se niega a permitir que el modelo cree estructuras de componentes. Estas tres condiciones suelen darse juntas una vez que un producto de herramientas internas madura.

    El hecho de no tener ninguna nueva dependencia en el frontend fue una restricción deliberada, no un concurso de pureza. Añadir otra biblioteca de gráficos solo para las conversaciones dividiría el sistema de diseño. Reutilizar los paquetes existentes para gráficos y tablas obligó al asistente a seguir el estilo visual del producto. El costo fue código de adaptación ocasional; la ventaja fue un aspecto visual y una base común de accesibilidad.

    El soporte para LLM locales es importante en implementaciones sensibles a la privacidad. Dado que la interfaz solo entiende eventos, cambiar un modelo en la nube por uno local es una cuestión del backend. El registro, los validadores y el bus CustomEvent permanecen idénticos. Esa separación permite utilizar la misma demostración frontend con Ollama en desarrollo y con un modelo alojado en producción, sin tener que modificar la estructura de la interfaz de chat.

    Ampliar el sistema es intencionadamente aburrido. Se agrega un archivo de widget, se registra con un esquema, se mapean los resultados de la herramienta a propiedades y se escribe una prueba. No hay cambios en el renderizador. No se añaden nuevos tipos de eventos a menos que el producto realmente necesite un cuarto tipo; hasta ahora tres han sido suficientes. El aburrimiento aquí es una ventaja: significa que los empleados junior pueden contribuir con widgets sin tener que aprender un framework de interfaz de usuario de IA personalizado.

    El estado final se siente menos como un chatbot pegado a un producto y más bien como un enrutador de conversaciones hacia las capacidades existentes del producto. Esa es la ventaja arquitectónica. El modelo se convierte en un distribuidor de tareas. El sistema de diseño sigue siendo la fuente de verdad para la presentación. El backend sigue siendo la fuente de verdad para los datos y los permisos.

    Desde el punto de vista operativo, mantenga un registro de cambios en las registraciones de widgets junto con los cambios en el catálogo de herramientas. Cuando la estructura de respuesta de una herramienta cambia, el esquema del widget correspondiente debe modificarse en la misma solicitud de integración. Esa combinación evita el fallo típico en el que el agente sigue respondiendo correctamente en formato de texto mientras el widget muestra silenciosamente columnas vacías. Los revisores deben rechazar las solicitudes de integración de herramientas que olviden incluir el esquema correspondiente.

    La accesibilidad se logra mediante la reutilización de widgets listos para uso. Si la tabla de miembros ya admite navegación por teclado y etiquetas para lectores de pantalla, la tabla integrada en el chat hereda esa funcionalidad. Generar HTML ad hoc a partir de un modelo desperdiciaría todo ese esfuerzo. Por lo tanto, la arquitectura de eventos no solo es una opción para garantizar fiabilidad; también constituye una estrategia para preservar la accesibilidad.

    Puntos clave

    • Preferir flujos de eventos (texto, herramienta, widget) en lugar de HTML/JSX generado por modelos.
  • Mantener un único renderizador y ampliar el registro de widgets.
  • Validar las propiedades de los widgets en los puntos de entrada/salida.
  • Desacoplar los widgets del chat mediante CustomEvents.
  • Gestionar el comportamiento a partir de un catálogo de herramientas y flags de funcionalidades.
  • Reutilizar el sistema de diseño existente en lugar de añadir una capa de interfaz gráfica paralela.
  • Juntos, el flujo de eventos, el registro, los validadores y el bus CustomEvent forman un marco pequeño pero completo para interfaces de producto asistidas por IA, sin ceder el control del diseño al modelo. Siga iterando en los widgets y no en solicitudes puntuales de marcado, para que el asistente siga siendo mantenible a medida que el producto crece.

    Lecturas relacionadas