Aller au contenu principal
Leçon 19 sur 25 · Construire des Agents OpenAI

Widgets interactifs

Leçon 19 sur 25 Mis à jour le 3 novembre 2026 Développeur
Sur cette page
  1. Ce qu’est un widget ChatKit
  2. Conteneurs et composants
  3. Concevoir le widget dans le Widget Builder
  4. Ce que contient un gabarit
  5. Afficher le widget depuis respond
  6. Laisser l’agent décider
  7. Une liste cliquable
  8. Mettre à jour un widget pendant l’affichage
  9. Et les classes Python ?
  10. Quand utiliser un widget
  11. Points clés à retenir

Leçon réécrite le 3 novembre 2026. La version précédente présentait les widgets comme des composants React, écrits par le développeur et enregistrés dans l’interface, avec un module @openai/chatkit/react qui n’existe pas. Dans ChatKit, un widget est une description déclarative que votre serveur construit et que ChatKit affiche lui-même. La leçon suit le SDK Python openai-chatkit 1.6.5, qui fait construire les widgets à partir de gabarits .widget : ses classes Card, Text, Badge… sont dépréciées. Les exemples ont été exécutés sur un serveur ChatKit local. Sources : ChatKit widgets · Widgets (SDK Python) · Build interactive responses with widgets · Widget Builder.

Ce qu’est un widget ChatKit

Une réponse en texte suffit pour expliquer. Pour présenter une commande, comparer trois offres ou faire confirmer un rendez-vous, elle devient vite un mur de lignes que l’utilisateur doit relire. Les widgets remplacent ce texte par une interface : une carte, une liste, un formulaire, un graphique, affichés au fil de la conversation.

Le principe est déclaratif. Votre serveur ne livre ni HTML ni composant React : il décrit le widget en JSON, avec un ensemble fermé de composants et de propriétés, et l’envoie dans le fil de discussion comme un élément à part entière (un WidgetItem). ChatKit le dessine avec ses propres composants, aux couleurs du thème vu à la leçon précédente. Deux conséquences :

  • Vous ne pouvez utiliser que les composants et les propriétés que ChatKit connaît. En échange, le rendu reste cohérent avec le reste de l’interface, et aucun HTML ni JavaScript arbitraire venu du serveur n’arrive dans la page.
  • Le widget fait partie de l’historique : aux tours suivants, il est converti en entrée pour le modèle, qui « voit » donc ce qu’il a montré à l’utilisateur.

Conteneurs et composants

Tout widget a une racine, choisie parmi trois conteneurs :

ConteneurUsage
CardUn contenu autonome : un récapitulatif, une confirmation, un formulaire. Accepte un statut et des boutons de confirmation et d’annulation sous le contenu
ListViewUn ensemble d’options : restaurants, fichiers, commandes. Ses enfants sont des ListViewItem, avec un « voir plus » intégré (limit)
BasicRéservé aux aperçus d’entités affichés au survol d’une mention

À l’intérieur, vous assemblez des composants : du texte (Title, Text, Caption, Markdown), des étiquettes (Badge), de la mise en page (Row, Col, Box, Spacer, Divider), des médias (Image, Icon), des commandes (Button, Select, DatePicker, Checkbox, RadioGroup, Input, Textarea, qu’un Form peut regrouper), des graphiques (Chart) et des animations (Transition). La page Components du Widget Builder liste les propriétés de chacun, avec un éditeur pour les essayer.

Concevoir le widget dans le Widget Builder

Le chemin prévu commence dans le Widget Builder de ChatKit Studio. Vous y dessinez la mise en page, vous remplissez le panneau des données avec des valeurs d’exemple pour juger du rendu, puis vous exportez un fichier .widget. C’est un fichier JSON qui réunit le gabarit, le schéma des données attendues (jsonSchema) et les données d’exemple. Versionnez-le avec le code de votre serveur, dans un dossier widgets/ par exemple.

Côté serveur, WidgetTemplate charge le fichier et build le remplit avec les données réelles :

from chatkit.widgets import WidgetTemplate

gabarit_commande = WidgetTemplate.from_file("widgets/commande.widget")

carte = gabarit_commande.build({
    "numero": "C-42",
    "statut": "Expédiée",
    "couleur": "success",
    "articles": [{"nom": "Clavier", "prix": "49,90 €"}, {"nom": "Souris", "prix": "10,00 €"}],
    "total": "59,90 €",
})

Un chemin relatif est résolu depuis le fichier Python qui appelle from_file, et non depuis le dossier de lancement. build accepte un dictionnaire ou un modèle Pydantic.

Ce que contient un gabarit

Le gabarit est un modèle Jinja qui produit le JSON du widget. Celui de la carte de commande ressemble à ceci :

GABARIT_COMMANDE = """
{
  "type": "Card",
  "children": [
    {"type": "Row", "justify": "between", "align": "center", "children": [
      {"type": "Title", "value": {{ ("Commande " ~ numero) | tojson }}, "size": "sm"},
      {"type": "Badge", "label": {{ statut | tojson }}, "color": {{ couleur | tojson }}}
    ]},
    {"type": "Divider"},
    {"type": "Col", "gap": 2, "children": [
      {%- for article in articles %}
      {"type": "Row", "justify": "between", "children": [
        {"type": "Text", "value": {{ article.nom | tojson }}},
        {"type": "Text", "value": {{ article.prix | tojson }}}
      ]}{% if not loop.last %},{% endif %}
      {%- endfor %}
    ]},
    {"type": "Divider"},
    {"type": "Row", "justify": "between", "children": [
      {"type": "Text", "value": "Total", "weight": "semibold"},
      {"type": "Text", "value": {{ total | tojson }}, "weight": "semibold"}
    ]}
  ]
}
"""

gabarit_commande = WidgetTemplate({"version": "1.0", "name": "commande", "template": GABARIT_COMMANDE})

Vous écrirez rarement un gabarit à la main, puisque le Widget Builder le génère. Mais le lire vous fera gagner du temps au premier problème. Trois remarques :

  • Le filtre tojson convertit chaque valeur en JSON valide (une chaîne entre guillemets, un booléen en true ou false) et échappe les caractères spéciaux : une apostrophe ou un guillemet dans un nom de produit ne cassera pas le JSON.
  • Jinja tourne en mode strict : une donnée manquante fait échouer build, au lieu de produire un widget à moitié vide.
  • build ne vérifie pas les données contre le jsonSchema. Ce schéma les documente ; si elles viennent d’un outil ou d’une base, validez-les avant, par exemple avec un modèle Pydantic.

Le constructeur WidgetTemplate(...) prend le même contenu qu’un fichier .widget déjà lu : c’est pratique dans les tests, ou pour un gabarit généré par votre propre code.

Afficher le widget depuis respond

Pour envoyer le widget dans le fil, stream_widget produit les événements ChatKit correspondants. Vous les relayez depuis votre méthode respond :

from chatkit.server import stream_widget

# Dans votre classe ChatKitServer
async def respond(self, thread, input_user_message, context):
    carte = gabarit_commande.build(await donnees_commande("C-42"))  # vos données
    async for event in stream_widget(
        thread,
        carte,
        copy_text="Commande C-42 : expédiée, 59,90 €",
        generate_id=lambda item_type: self.store.generate_item_id(item_type, thread, context),
    ):
        yield event

copy_text est le texte copié dans le presse-papiers quand l’utilisateur clique sur le bouton de copie sous la réponse. Le serveur enregistre lui-même le widget dans le store, comme tout élément diffusé.

Laisser l’agent décider

Le plus souvent, ce n’est pas votre code qui décide d’afficher une carte, c’est l’agent, quand la conversation s’y prête. Donnez-lui un outil qui diffuse le widget avec AgentContext.stream_widget : stream_agent_response, vu à la leçon 17, le transmet à l’interface au milieu de la réponse.

from agents import RunContextWrapper, function_tool
from chatkit.agents import AgentContext


@function_tool(description_override="Affiche le détail d'une commande sous forme de carte.")
async def afficher_commande(ctx: RunContextWrapper[AgentContext], numero: str) -> str:
    donnees = await donnees_commande(numero)  # votre accès aux données
    await ctx.context.stream_widget(gabarit_commande.build(donnees))
    return f"La carte de la commande {numero} est affichée."

Ajoutez l’outil à la liste tools de l’agent. La chaîne renvoyée par l’outil sert au modèle, qui sait ainsi que la carte est à l’écran et peut la commenter au lieu de la répéter.

Une liste cliquable

Pour proposer un choix, ListView présente des lignes cliquables. Chaque ListViewItem peut porter une action, déclenchée au clic sans que l’utilisateur ait à taper quoi que ce soit ; la leçon suivante montre comment la traiter côté serveur.

GABARIT_LISTE = """
{
  "type": "ListView",
  "limit": 5,
  "children": [
    {%- for c in commandes %}
    {
      "type": "ListViewItem",
      "key": {{ c.numero | tojson }},
      "onClickAction": {"type": "commande.ouvrir", "payload": {"numero": {{ c.numero | tojson }}}},
      "children": [
        {"type": "Col", "children": [
          {"type": "Text", "value": {{ ("Commande " ~ c.numero) | tojson }}},
          {"type": "Caption", "value": {{ c.date | tojson }}}
        ]}
      ]
    }{% if not loop.last %},{% endif %}
    {%- endfor %}
  ]
}
"""

gabarit_liste = WidgetTemplate({"version": "1.0", "name": "liste_commandes", "template": GABARIT_LISTE})
liste = gabarit_liste.build({"commandes": [{"numero": "C-42", "date": "2 novembre"}, {"numero": "C-41", "date": "28 octobre"}]})

Au-delà de limit, ChatKit affiche un lien « voir plus ».

Mettre à jour un widget pendant l’affichage

Un widget peut évoluer sous les yeux de l’utilisateur, par exemple pour afficher un brouillon que le modèle rédige. Donnez à stream_widget un générateur asynchrone qui produit les états successifs du widget : ChatKit calcule les différences et n’envoie que ce qui change.

from collections.abc import AsyncGenerator

from chatkit.widgets import DynamicWidgetRoot, WidgetTemplate

gabarit_brouillon = WidgetTemplate({
    "version": "1.0",
    "name": "brouillon",
    "template": """{"type": "Card", "children": [
        {"type": "Title", "value": "Brouillon"},
        {"type": "Text", "id": "corps", "value": {{ texte | tojson }}, "streaming": {{ en_cours | tojson }}}
    ]}""",
})


async def brouillon_en_cours(morceaux: AsyncGenerator[str, None]) -> AsyncGenerator[DynamicWidgetRoot, None]:
    texte = ""
    async for morceau in morceaux:
        texte += morceau
        yield gabarit_brouillon.build({"texte": texte, "en_cours": True})
    # État final, une fois le texte complet
    yield gabarit_brouillon.build({"texte": texte, "en_cours": False})

Seuls les composants Text et Markdown dotés d’un id affichent leurs mises à jour en flux, mot à mot ; pour les autres, ChatKit remplace la partie modifiée du widget.

Et les classes Python ?

Vous croiserez, jusque dans les guides d’OpenAI, des widgets construits avec des classes : Card(children=[Text(value="…")]). Elles fonctionnent encore, mais le SDK openai-chatkit 1.6.5 les déclare dépréciées au profit de WidgetTemplate : leur utilisation émet un avertissement qui renvoie vers les fichiers .widget. Pour un nouveau projet, partez des gabarits. Pour un projet existant, migrez au rythme de vos évolutions : l’avertissement ne bloque rien, mais une classe dépréciée peut disparaître dans une version future.

Quand utiliser un widget

La documentation en cite les usages types :

  • recueillir une saisie structurée avec un formulaire, ou présenter un résultat riche (tableau, carte, graphique) que le texte rend mal ;
  • proposer un choix entre plusieurs options ;
  • combiner le widget avec des actions, pour valider une sélection, confirmer une étape ou lancer un traitement côté serveur ;
  • accompagner un court texte d’explication d’une commande interactive.

À l’inverse, n’en mettez pas partout. Un widget qui reprend mot pour mot ce que dit le texte n’apporte rien, et une conversation saturée de cartes devient aussi difficile à lire qu’un mur de texte.

Points clés à retenir

  • Un widget ChatKit est une description déclarative (du JSON) construite par votre serveur, et non un composant React : ChatKit le dessine lui-même
  • Trois conteneurs : Card (contenu autonome), ListView (liste d’options), Basic (aperçus d’entités)
  • Concevez dans le Widget Builder, exportez un fichier .widget, chargez-le avec WidgetTemplate.from_file(...) et remplissez-le avec build(données) ; les classes Card, Text… sont dépréciées
  • Diffusez avec stream_widget depuis respond, ou depuis un outil de l’agent avec ctx.context.stream_widget
  • Un générateur asynchrone met le widget à jour en direct ; seuls Text et Markdown avec un id défilent mot à mot