Accueil / Articles

Concevoir une interface utilisateur AG : flux d’événements, registre de widgets et LLM locaux

Cessez de demander aux modèles du JSX. Transmettez des événements de texte, d’outil ou de widget en temps réel, validez les propriétés, enregistrez les widgets React, et maintenez le pipeline de rendu fermé pour permettre des modifications.

Éditeur : REACTAPP.TOP10 min de lecture2410 mots

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

Les chatbots de démonstration qui ne gèrent que le Markdown semblent sophistiqués tant qu’on ne demande pas de tableau, de graphique, de calendrier ou de formulaire. À ce moment-là, le produit cesse d’être un simple wrapper de chat pour devenir une application — et le Markdown s’avère alors être un format d’application insuffisant.

Une approche plus efficace est apparue après plusieurs impasses : il faut cesser de demander au modèle d’inventer l’interface utilisateur. Il suffit de lui demander de choisir des actions. Le frontend sait déjà comment afficher les éléments ; le backend connaît déjà l’endroit où se trouvent les données. C’est au modèle de décider de la suite des opérations.

Cette séparation a permis de créer un assistant capable d’afficher seize widgets React prêts à l’emploi, de prendre en charge une interface utilisateur gérée par le backend, de fonctionner avec un LLM local, sans ajouter de nouvelles dépendances frontend, et d’être étendu enregistrant un seul composant.

En bref : refusez les marquages générés par le modèle. Laissez le backend émettre une liste séquentielle d’événements structurés — texte, état des outils et intentions des widgets — tandis que le client gère un registre et affiche ces événements dans l’ordre. La seule tâche du modèle est de choisir quelle capacité du backend doit répondre. Cela permet une sortie plus stable, des composants réutilisables, ainsi qu’un processus de rendu qui ne change pas à mesure que le catalogue s’agrandit.

Le problème des interfaces utilisateur générées par l’IA

De nombreuses applications basées sur l’IA suivent une approche étroite :

User
  ↓
LLM
  ↓
Markdown
  ↓
React renders Markdown

C’est simple, mais limité. Les utilisateurs demandent finalement à voir les membres actifs, à comparer les revenus mensuels, à lister des rendez-vous ou à consulter un profil. Ces réponses prennent la forme d’interfaces, et non de paragraphes.

Demander au modèle d’émettre du HTML ou du JSX échoue lorsqu’il invente des composants manquants, omet des propriétés nécessaires ou qualifie d’erreur un widget qui n’existe pas. Le frontend tombe alors en panne à cause de ce markup imaginaire. Le markup généré ne peut pas non plus être tapé, testé unitairement, réutilisé en dehors du chat ou examiné dans une demande de fusion. Les garanties offertes par la bibliothèque de composants disparaissent ; c’est plutôt le déterminisme qui devient l’objectif.

L’idée fondamentale : le modèle choisit les actions, pas les composants

Le backend ne renvoie jamais de HTML, de JSX ni de noms de composants React. Chaque réponse est un flux d’événements ordonnés :

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

Le backend communique l’intention ; le frontend décide de la présentation. Les responsabilités restent claires : l’IA décide du contenu, React décide de la manière de le présenter, et les widgets déterminent l’apparence. La chaîne de caractères "table" est une clé sémantique, pas un nom de classe — l’interface desktop peut virtualiser une grille tandis que l’interface mobile empile des cartes, sans que le backend s’en soucie.

Le protocole d’événements

Le contrat reste suffisamment petit pour tenir en mémoire de travail. Les événements saisis le maintiennent sécurisé :

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;

Trois types d’événements couvrent les réponses de l’assistant. L’ordre est important : une puce d’outil peut apparaître avant le widget qu’elle a généré, et une phrase de résumé peut suivre. La transcription ressemble à un flux de travail narré.

Un pipeline de rendu, pas d’instructions switch

Chaque message emprunte un seul chemin :

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

Aucun mécanisme de branchement ad hoc ne perturbe la structure. Un seul rendeur gère chaque type d’événement : le texte devient du contenu de chat, l’outil devient un indicateur de progression, et le widget devient un composant React. Ajouter de nouvelles fonctionnalités ne nécessite pas de réécrire le pipeline — il suffit d’enregistrer un nouveau widget. Le rendeur qui gère le widget un continue de gérer le widget seize.

Ouvert à l’extension, fermé à la modification

De nouveaux widgets s’inscrivent ; le pipeline reste fermé. C’est là l’application du principe ouvert/fermé à l’interface de chat. La croissance du produit devient alors un problème de catalogue, et non un problème de réécriture.

N’ayez jamais confiance dans le modèle : validez les props à la frontière

La sortie du modèle constitue une entrée non fiable. Avant que le widget ne soit chargé, les props passent par une validation selon un schéma (Zod dans l’exemple). Les en-têtes invalides génèrent une interface d’erreur sécurisée plutôt que des exceptions en temps de exécution au sein de l’arborescence.

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 validation à la frontière empêche les pannes dans les composants de base et permet aux opérateurs de comprendre clairement en cas où le modèle déforme les données.

Création d’une véritable bibliothèque de widgets

Les widgets sont stockés dans un registre identifié par des noms sémantiques :

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 est obtenu à partir du registre, ce qui fait que TypeScript rejette les clés inconnues. Les tableaux, profils, graphiques et formulaires deviennent des composants ordinaires que le reste de l’application peut réutiliser en dehors du chat.

Pourquoi j’ai ajouté zéro dépendance

Le shell de chat a réutilisé le système de conception existant ainsi que la pile React. Aucun nouveau kit d’interface n’a été ajouté dans package.json. Cette contrainte a obligé l’assistant à utiliser les mêmes boutons, tableaux et éléments que ceux déjà intégrés au produit — ce qui permet aux écrans de chat et non de chat de rester visuellement cohérents.

Widgets interactifs sans appels de fonction React

Les widgets ne doivent pas importer le shell de chat. Ils émettent des DOM CustomEvents pour des actions telles que la mise à jour ou l’ouverture d’un profil :

// 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

Le dialogue écoute une seule fois, associe les événements aux appels d’outils et permet aux widgets de rester portables. La même table peut être affichée sur une page des membres ainsi que dans un chat, sans avoir besoin de propriétés spécifiques au chat.

L’IA ne décide pas de l’interface utilisateur

Le modèle ne choisit jamais les couleurs, les layouts ou les arbres de composants. Il sélectionne simplement des outils. L’orchestration backend transforme les résultats des outils en événements de widgets dotés de propriétés validées. La conception reste sous le contrôle des ingénieurs frontend.

Remplacer les points de terminaison codés en dur par un catalogue d’outils

Les prototypes initiaux utilisaient des URL codées en dur. La version finale propose un catalogue d’outils que l’hôte peut découvrir :

{
  "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" }
  ]
}

Chaque outil indique un point de terminaison et un schéma. Le modèle choisit un outil ; le backend l’exécute ; les événements de widgets transmettent le résultat. Changer d’infrastructure se résume alors à une modification du catalogue, et non à une réécriture de la requête.

Streaming sans enfreindre le contrat

Le streaming émet toujours les mêmes types d’événements. L’état partiel de l’outil peut passer de running à completed au fur et à mesure que les tokens de texte arrivent. Le moteur d’affichage ajoute ou met à jour des événements sans avoir recours à un second protocole. Les utilisateurs voient des indicateurs de progression avant que les widgets finaux ne soient affichés.

Créer une expérience de chat moderne

La page hôte reste une simple liste. L’assistant se cache derrière un chat contrôlé par l’IA et répond en utilisant les mêmes données et composants que ceux déjà présents sur la page. La continuité est plus importante qu’une interface distincte dédiée au produit IA.

Les flags fonctionnelles sont essentielles

Une seule flag permet de contrôler l’expérience :

VITE_ENABLE_AI_CHAT

Ces flags permettent aux équipes de lancer le dialogue en mode sombre, de le désactiver en cas de forte charge, ou de ne l’exposer qu’aux employés, sans avoir à rédéployer le code des widgets.

Tester un framework de widgets

Les tests ciblent trois niveaux : la validation du schéma rejette les propriétés incorrectes, la recherche dans le registre résout les widgets connus, et l’ordre des événements affiche tool → widget → texte comme prévu. Les tests unitaires des widgets s’exécutent en dehors du chat. Les tests bout-en-bout ouvrent le dialogue, demandent les membres et vérifient que le tableau s’affiche.

Lorsque cette architecture est excessive

Les bots FAQ purs qui n’ont besoin que de paragraphes peuvent rester en Markdown. Le protocole d’événements s’avère utile lorsque les réponses sont des interfaces, lorsque les widgets doivent être réutilisés en dehors du chat, et lorsque l’équipe refuse de laisser le modèle inventer des arbres de composants.

Principes de conception qui ont guidé chaque décision

  • C’est le modèle qui choisit les actions, pas le marquage.
  • Un seul pipeline de rendu pour chaque message.
  • Les widgets s’inscrivent dans le registre ; le pipeline ne se divise pas.
  • Vérifier les propriétés avant l’affichage.
  • Les widgets communiquent via des CustomEvents, et non par des importations depuis le chat.
  • Les outils sont stockés dans un catalogue, et non via des URL codées en dur.
  • Diffusez les mêmes types d’événements que ceux que vous stockez.
  • Utilisez des flags de fonctionnalité pour contrôler l’accès.
  • Préférez zéro nouvelle dépendance frontend lorsque un système de conception existe déjà.
  • Le résultat

    L’assistant diffuse les événements saisis, affiche seize widgets via un même pipeline, valide chaque propriété et s’étend par inscription. Les backends LLM locaux restent interchangeables car le contrat repose sur des événements, et non sur des SDK de fournisseurs dans l’interface utilisateur.

    Pensées finales

    Apprendre aux modèles à créer des interfaces revient à mener la mauvaise bataille. Apprendre plutôt à sélectionner des fonctionnalités — et laisser un frontend bien structuré les afficher — permet de préserver les systèmes de conception, la testabilité et le développement. La messagerie devient alors une autre interface pour les mêmes composants en which le produit a déjà confiance.

    L’écran des membres dans l’exemple reste une page de produit ordinaire. L’assistant n’est pas un second produit ; il se trouve derrière une interface de chat contrôlée par l’IA et répond en utilisant le même plan de données ainsi que les mêmes composants que la page elle-même. Cette continuité est intentionnelle. Lorsque le chat invente un langage visuel parallèle, les utilisateurs cessent de lui faire confiance. Lorsque le chat et la page partagent des widgets, toute amélioration apportée au tableau s’applique aux deux endroits.

    L’ordre des éléments dans un tableau d’événements fait partie du langage propre au produit. Un événement tool avec le statut running peut apparaître en premier afin que l’utilisateur voie le travail commencer. Un événement widget peut suivre avec des lignes de données. Un événement text peut conclure par un résumé en une seule ligne. Réorganiser ces événements modifie la perception de la conversation sans modifier aucun composant React. Considérez ce tableau comme un storyboard que les développeurs backend écrivent et que les développeurs frontend mettent en scène.

    La validation des schémas n’est pas une formalité optionnelle. Les modèles omettent des champs, convertissent les nombres en chaînes de caractères et renomment parfois des clés. Zod (ou un équivalent) placé à la frontière du registre transforme ces erreurs en éléments d’erreur contrôlés plutôt qu’en écran blanc. En enregistrant l’échec de validation à côté du payload brut, les corrections rapides via des outils sont possibles plus rapidement qu’en se fiant à un rapport utilisateur.

    CustomEvents permettent de clarifier les responsabilités. Un widget qui importe des hooks de chat ne peut pas être réutilisé sur une page statique. Un widget qui n’envoie que member:open ou table:refresh peut être utilisé n’importe où. Le dialogue devient alors un adaptateur qui traduit ces événements en appels aux outils. Cet adaptateur est le seul à connaître l’état de la session de l’assistant.

    Le streaming suit le même contrat. Au fur et à mesure que les jetons et les statuts des outils arrivent, l’application client ajoute ou met à jour les événements en place. Il n’existe pas de « format streaming » distinct. Si un outil échoue en cours d’exécution, le statut failed remplace running et un événement texte optionnel explique l’échec. Les utilisateurs voient donc une continuité plutôt qu’un indicateur en rotation qui s’arrête silencieusement.

    Les flags de fonctionnalité assurent une sécurité opérationnelle. En déployant seize widgets sous VITE_ENABLE_AI_CHAT, les équipes peuvent désactiver le dialogue en cas de charge élevée sans devoir modifier le code. Associez ce flag à une autorisation par outil afin qu’un catalogue en lecture seule reste actif tandis que les outils destructeurs restent désactivés.

    La stratégie de test reflète l’architecture. Les tests unitaires couvrent les schémas ainsi que le rendu pur des widgets. Les tests d’intégration fournissent un tableau d’événements fixe au rendeur et en capturent la structure. Les tests bout-en-bout demandent des informations aux services backend réels ou simulés pour obtenir les membres, et vérifient que le widget de tableau s’affiche avec les colonnes attendues. Comme les widgets sont des composants ordinaires, les suites existantes de détection des régressions visuelles peuvent les inclure sans recourir à un outil d’intelligence artificielle spécial.

    Des cas où cette approche est excessive existent néanmoins. Si le produit ne répond qu’à des questions définitives par de courts paragraphes, Markdown reste plus économique. L’architecture basée sur les événements s’avère rentable lorsque les réponses prennent la forme d’interfaces, lorsque les mêmes widgets apparaissent en dehors du chat, et lorsque l’équipe refuse de laisser le modèle créer des arbres de composants. Ces trois conditions se produisent généralement en même temps une fois qu’un produit d’outils internes est mature.

    Zéro nouvelle dépendance frontend était une contrainte délibérée, et non un concours de pureté. Ajouter une autre bibliothèque de graphiques rien que pour les chats entraînerait une scission du système de conception. Reutiliser les packages existants pour les graphiques et les tableaux obligeait l’assistant à adopter le langage visuel du produit. Le coût résidait dans des codes d’adaptation occasionnels ; l’avantage était un style visuel et une norme d’accessibilité unifiés.

    Le support des LLM locaux est important pour les déploiements sensibles à la vie privée. Comme l’interface ne comprend que des événements, remplacer un modèle cloud par un environnement d’exécution local relève de la couche backend. Le registre, les validateurs et le bus CustomEvent restent identiques. C’est cette séparation qui permet d’utiliser la même démo frontend avec Ollama en développement et un modèle hébergé en production, sans avoir à modifier l’interface de chat.

    Étendre le système est délibérément ennuyeux. Il suffit d’ajouter un fichier de widget, de le enregistrer avec un schéma, de mapper les résultats d’une outil en propriétés, et d’écrire un test. Aucun changement dans le moteur de rendu. Aucun nouveau type d’événement, sauf si le produit a réellement besoin d’un quatrième type — or trois types se sont avérés suffisants jusqu’à présent. Cet ennui est en fait un atout : il permet aux juniors de contribuer avec des widgets sans avoir à apprendre un framework UI d’IA sur mesure.

    L’état final ressemble moins à un chatbot collé sur un produit qu’à un routeur de conversation vers les capacités existantes du produit. C’est là la victoire architecturale : le modèle devient un dispatcheur, le système de conception reste la source unique de vérité pour la présentation, et le backend reste la source unique de vérité pour les données et les permissions.

    D’un point de vue opérationnel, tenez un journal des modifications pour les enregistrements des widgets ainsi que pour les changements dans le catalogue d’outils. Lorsque la structure des réponses d’un outil change, le schéma correspondant du widget doit également être mis à jour dans la même demande de pull. Cette association empêche l’échec classique où l’agent continue de répondre correctement en texte tandis que le widget affiche silencieusement des colonnes vides. Les examinateurs devraient rejeter les demandes de pull d’outils qui oublient ce schéma associé.

    L’accessibilité découle de la réutilisation des widgets en production. Si le tableau des membres prend déjà en charge la navigation par clavier et les étiquettes pour lecteurs d’écran, le tableau intégré dans la chat fonctionne également avec ces fonctionnalités. Générer du HTML ad hoc à partir d’un modèle annulerait cet investissement. L’architecture basée sur les événements est donc non seulement un choix en faveur de la fiabilité, mais aussi une stratégie pour préserver l’accessibilité.

    Points clés

    • Préférez les flux d’événements (texte, outil, widget) aux fichiers HTML/JSX générés par des modèles.
  • Conserver un seul rendeur et développer un registre de widgets.
  • Vérifier les propriétés des widgets à la frontière d’interface.
  • Découpler les widgets du chat au moyen de CustomEvents.
  • Gérer le comportement à partir d’un catalogue d’outils et de flags fonctionnels.
  • Réutiliser le système de conception existant plutôt que d’ajouter une pile UI parallèle.
  • Collectivement, le flux d’événements, le registre, les validateurs et le bus CustomEvent forment un cadre petit mais complet pour une interface utilisateur de produit assistée par l’IA, sans céder le contrôle du design au modèle. Continuez à itérer sur les widgets plutôt que sur des demandes de mise en forme ponctuelles, afin que l’assistant reste maintenable à mesure que le produit évolue.