Actions utilisateur
Sur cette page
Leçon réécrite le 3 novembre 2026. La version précédente gérait les clics dans des composants React maison et des « actions de suggestion » inventées. Dans ChatKit, une action est un objet attaché à un composant de widget (bouton, ligne de liste, formulaire), traité par défaut sur votre serveur, dans la méthode
actiondeChatKitServer, ou dans le navigateur si vous le demandez. La leçon suit le guide Actions in ChatKit et le SDKopenai-chatkit1.6.5 ; le serveur, les gabarits et le formulaire ont été exécutés en local, le code React vérifié par compilation. Sources : Actions in ChatKit · Build interactive responses with widgets · notes de version 1.6.2 du SDK Python.
Ce qu’est une action
Un widget qui ne fait qu’afficher reste une jolie réponse. Les actions le rendent utile : un clic sur « Annuler la commande », le choix d’une ligne dans une liste, l’envoi d’un formulaire déclenchent un traitement sans que l’utilisateur ait à écrire un message. ChatKit transmet l’action à votre serveur, qui peut agir sur vos systèmes, mettre le widget à jour, ajouter une réponse au fil ou relancer l’agent.
Une action se déclare dans le gabarit du widget, sur un composant qui en accepte une (onClickAction d’un Button ou d’un ListViewItem, onChangeAction d’un Select, onSubmitAction d’un Form, confirm et cancel d’une Card). C’est un petit objet JSON :
| Champ | Rôle |
|---|---|
type | Le nom de l’action, que votre code teste : "commande.annuler" |
payload | Les données utiles : {"numero": "C-42"} |
handler | "server" (par défaut) ou "client" pour la traiter dans le navigateur |
loadingBehavior | L’état de chargement affiché pendant le traitement (voir plus bas) |
Traiter une action sur le serveur
Prenons une carte de commande qui propose d’annuler. Le gabarit déclare l’action sur les boutons de confirmation et d’annulation de la Card :
GABARIT_ANNULATION = """
{
"type": "Card",
"children": [
{"type": "Title", "value": {{ ("Commande " ~ numero) | tojson }}, "size": "sm"},
{"type": "Text", "value": "Voulez-vous vraiment annuler cette commande ?"}
],
"confirm": {"label": "Annuler la commande",
"action": {"type": "commande.annuler", "payload": {"numero": {{ numero | tojson }}}}},
"cancel": {"label": "Garder la commande",
"action": {"type": "commande.garder", "payload": {"numero": {{ numero | tojson }}}}}
}
"""
Côté serveur, ajoutez à votre ChatKitServer une méthode action. Elle reçoit le fil, l’action, le widget qui l’a émise (sender) et votre contexte, et renvoie des événements comme respond :
from datetime import datetime
from typing import Any, AsyncIterator
from chatkit.actions import Action
from chatkit.server import ChatKitServer
from chatkit.types import (
AssistantMessageContent,
AssistantMessageItem,
HiddenContextItem,
ThreadItemDoneEvent,
ThreadMetadata,
ThreadStreamEvent,
WidgetItem,
)
class MonServeurChatKit(ChatKitServer[dict[str, Any]]):
# respond(...) : comme à la leçon 17
def message(self, thread: ThreadMetadata, context: dict[str, Any], texte: str) -> ThreadItemDoneEvent:
return ThreadItemDoneEvent(
item=AssistantMessageItem(
thread_id=thread.id,
id=self.store.generate_item_id("message", thread, context),
created_at=datetime.now(),
content=[AssistantMessageContent(text=texte)],
)
)
async def action(
self,
thread: ThreadMetadata,
action: Action[str, Any],
sender: WidgetItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
if action.type == "commande.annuler":
numero = str((action.payload or {}).get("numero", ""))
# La charge utile vient du navigateur : revérifiez les droits
if not await commande_de_l_utilisateur(numero, context.get("user_id")):
yield self.message(thread, context, "Cette commande n'est pas la vôtre.")
return
await annuler_commande(numero)
# Une trace pour le modèle, jamais affichée à l'utilisateur
await self.store.add_thread_item(
thread.id,
HiddenContextItem(
id=self.store.generate_item_id("message", thread, context),
thread_id=thread.id,
created_at=datetime.now(),
content=f"<USER_ACTION>L'utilisateur a annulé la commande {numero}.</USER_ACTION>",
),
context,
)
yield self.message(thread, context, f"C'est fait : la commande {numero} est annulée.")
elif action.type == "commande.garder":
yield self.message(thread, context, "Très bien, la commande est conservée.")
Trois règles se lisent dans ce code :
- La charge utile n’est pas fiable. Elle arrive du navigateur, où n’importe qui peut la modifier : le guide d’OpenAI demande de la traiter comme une donnée non vérifiée. Avant d’annuler, le serveur contrôle que la commande appartient bien à l’utilisateur authentifié. Le
user_iddu contexte est celui que votre point d’accès authentifié y place (leçon 21). - Le modèle doit savoir ce qui s’est passé. Un clic n’est pas un message : sans trace, l’agent ignorerait au tour suivant que la commande est annulée. Un
HiddenContextItemajoute cette information au fil sans l’afficher. Le SDK n’enregistre automatiquement que les éléments diffusés, et un élément caché ne doit jamais l’être : il faut l’ajouter vous-même au store. - La réponse est un flux d’événements. Ici un simple message ; ce pourrait être un widget mis à jour, diffusé avec
stream_widgetcomme à la leçon précédente.
Pour que l’agent commente lui-même ce qui vient de se passer, relancez-le après l’action : async for event in self.respond(thread, None, context): yield event. Votre méthode respond relit le fil, y trouve l’élément caché et répond en conséquence.
Les formulaires
Quand des champs de saisie (Input, Textarea, Select, Checkbox, DatePicker…) sont placés dans un Form, leurs valeurs sont ajoutées à la charge utile de toute action émise depuis ce formulaire, sous le nom de chaque champ :
GABARIT_SIGNALEMENT = """
{
"type": "Card",
"children": [
{"type": "Form", "direction": "col", "gap": 3,
"onSubmitAction": {"type": "ticket.creer", "payload": {"source": "chat"}},
"children": [
{"type": "Title", "value": "Signaler un problème", "size": "sm"},
{"type": "Label", "value": "Numéro de commande", "fieldName": "commande"},
{"type": "Input", "name": "commande", "required": true, "pattern": "C-[0-9]+", "placeholder": "C-42"},
{"type": "Label", "value": "Catégorie", "fieldName": "categorie"},
{"type": "Select", "name": "categorie", "placeholder": "Choisir",
"options": [{"label": "Colis abîmé", "value": "abime"},
{"label": "Retard", "value": "retard"},
{"label": "Autre", "value": "autre"}]},
{"type": "Textarea", "name": "detail", "rows": 3, "placeholder": "Décrivez le problème"},
{"type": "Button", "label": "Envoyer", "submit": true}
]}
]
}
"""
À l’envoi, action.payload contient source, commande, categorie et detail. Un nom à point, "client.nom", produit une valeur imbriquée : payload["client"]["nom"]. Si le nom d’un champ entre en collision avec une clé déjà présente dans la charge utile, la valeur du champ est ignorée et ChatKit émet un événement d’erreur : choisissez des noms distincts.
La validation du formulaire est celle du navigateur : required et pattern bloquent l’envoi tant qu’un champ est invalide. C’est un confort pour l’utilisateur, pas une protection : revalidez côté serveur, par exemple avec un modèle Pydantic.
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class Signalement(BaseModel):
commande: str = Field(pattern=r"^C-[0-9]+$")
categorie: Literal["abime", "retard", "autre"]
detail: str = Field(default="", max_length=2000)
# Dans la méthode action
if action.type == "ticket.creer":
try:
signalement = Signalement.model_validate(action.payload or {})
except ValidationError:
yield self.message(thread, context, "Le formulaire est incomplet ou invalide.")
return
numero_ticket = await creer_ticket(signalement, context.get("user_id"))
yield self.message(thread, context, f"Ticket {numero_ticket} créé, nous revenons vers vous.")
Pour une validation plus riche, avec des messages d’erreur sur mesure, la documentation conseille de sortir du widget : une action traitée côté client ouvre une fenêtre de votre application, et le résultat revient à ChatKit par sendCustomAction. Notez aussi qu’une Card déclarée avec "asForm": true se comporte comme un formulaire : ses champs sont validés et transmis à son action confirm.
Confirmer avant d’agir
La carte d’annulation du début illustre une règle à généraliser : une action irréversible ou sensible se confirme par un clic explicite. Annuler une commande, envoyer un e-mail, rembourser, supprimer : l’agent propose, l’utilisateur valide sur une carte qui dit clairement ce qui va se passer, et c’est votre serveur, pas le modèle, qui exécute après avoir revérifié les droits. Les boutons confirm et cancel d’une Card sont faits pour cela. Leur état de chargement par défaut grise toute la carte pendant le traitement, ce qui évite les doubles clics.
Traiter une action dans le navigateur
Certaines actions n’ont rien à faire sur le serveur : ouvrir une page de votre application, copier un lien, afficher une fenêtre. Déclarez-les avec "handler": "client" dans le gabarit, par exemple {"type": "commande.suivre", "handler": "client", "payload": {"numero": "C-42"}}. Elles arrivent alors dans l’option widgets.onAction de l’interface :
import { ChatKit, useChatKit } from '@openai/chatkit-react';
export function AssistantCommandes() {
const chatkit = useChatKit({
api: { url: '/chatkit', domainKey: 'local-dev' },
widgets: {
onAction: async (action, widgetItem) => {
if (action.type === 'commande.suivre') {
window.open(`/commandes/${String(action.payload?.numero ?? '')}`, '_blank');
// Prévenir le serveur, qui peut mettre le widget à jour
await chatkit.sendCustomAction(
{ type: 'commande.suivie', payload: action.payload },
widgetItem.id,
);
}
},
},
});
return <ChatKit control={chatkit.control} className="h-[600px] w-[360px]" />;
}
sendCustomAction envoie une action à votre serveur comme si elle venait d’un widget. Elle sert aussi en dehors de ChatKit : un bouton de votre page peut appeler chatkit.sendCustomAction({ type: 'panier.vide' }) pour que l’assistant réagisse à ce qui se passe ailleurs dans l’application.
Les états de chargement
loadingBehavior règle ce que l’utilisateur voit pendant le traitement d’une action :
| Valeur | Effet |
|---|---|
auto (par défaut) | S’adapte au composant : le bouton seul pour un Button, rien pour un Select, toute la carte pour le confirm d’une Card |
self | État de chargement sur le seul composant qui porte l’action |
container | Tout le widget s’estompe et devient inactif |
none | Aucun état de chargement |
La documentation recommande de garder auto, sauf besoin précis. Par défaut aussi, les actions d’un widget sont bloquées tant qu’une réponse est en cours de diffusion, pour éviter les conflits. Depuis la version 1.6.2 du SDK, une action déclarée avec "streaming": false échappe à ce blocage : elle est traitée par la méthode sync_action du serveur, réservée aux actions qui ne font que mettre leur propre widget à jour ou agir hors du fil.
Points clés à retenir
- Une action est un objet
{type, payload, handler, loadingBehavior}attaché à un composant de widget ; elle déclenche un traitement sans message de l’utilisateur - Côté serveur, la méthode
actiondeChatKitServerla reçoit et renvoie des événements : message, widget mis à jour, ou nouvelle réponse de l’agent viarespond - La charge utile vient du navigateur : revérifiez droits et données côté serveur (Pydantic), même quand le formulaire valide déjà
requiredetpattern - Un
HiddenContextItem, ajouté à la main au store, informe le modèle de ce que l’utilisateur a fait - Les actions sensibles passent par une carte de confirmation ; les actions purement locales se traitent dans le navigateur (
handler: "client",widgets.onAction,sendCustomAction)