Widgets interactifs
Sur cette page
- Ce qu’est un widget ChatKit
- Conteneurs et composants
- Concevoir le widget dans le Widget Builder
- Ce que contient un gabarit
- Afficher le widget depuis respond
- Laisser l’agent décider
- Une liste cliquable
- Mettre à jour un widget pendant l’affichage
- Et les classes Python ?
- Quand utiliser un widget
- 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/reactqui 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 Pythonopenai-chatkit1.6.5, qui fait construire les widgets à partir de gabarits.widget: ses classesCard,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 :
| Conteneur | Usage |
|---|---|
Card | Un contenu autonome : un récapitulatif, une confirmation, un formulaire. Accepte un statut et des boutons de confirmation et d’annulation sous le contenu |
ListView | Un ensemble d’options : restaurants, fichiers, commandes. Ses enfants sont des ListViewItem, avec un « voir plus » intégré (limit) |
Basic | Ré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
tojsonconvertit chaque valeur en JSON valide (une chaîne entre guillemets, un booléen entrueoufalse) 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. buildne vérifie pas les données contre lejsonSchema. 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 avecWidgetTemplate.from_file(...)et remplissez-le avecbuild(données); les classesCard,Text… sont dépréciées - Diffusez avec
stream_widgetdepuisrespond, ou depuis un outil de l’agent avecctx.context.stream_widget - Un générateur asynchrone met le widget à jour en direct ; seuls
TextetMarkdownavec uniddéfilent mot à mot