Parler au chatbot en visiteur — la face navigateur de l’API

Sixieme notebook de la serie « AI Engine par son API ». Les grains precedents ont parle au plugin en administrateur (l’API REST mwai/v1) et en agent (le serveur MCP). Il reste un troisieme consommateur, le plus nombreux de tous : le navigateur d’un visiteur anonyme qui ouvre une page du site et discute avec le chatbot.

Cette face a son propre namespace (mwai-ui/v1), sa propre authentification (un nonce WordPress, pas des credentials), et un mecanisme d’amorcage concu pour les pages mises en cache : la page publique ne contient aucun secret — le visiteur recupere un jeton d’interaction au moment ou il ecrit. Ce notebook demontre le cycle complet : charger la page, prouver qu’elle est vierge de jetons, amorcer la session, puis reellement discuter avec le chatbot sans aucun compte.

Les sorties de ce notebook proviennent d’une execution reelle contre l’instance locale (voir « Provenance et limites », en fin de fichier).

La serie « AI Engine par son API »

Le projet Livres Agites a mis AI Engine au coeur d’une maison d’edition : bot d’accueil, agents d’ateliers, bibliothecaire documentee par RAG, formulaires dynamiques. Cette serie presente le plugin de maniere reproductible — sans jamais exposer de donnees client :

Notebook Contenu
presenter-ai-engine-par-son-api instance, API, catalogue des chatbots, premiere completion
configurer-chatbots-par-l-api lire, dupliquer, ecrire et interroger des chatbots (document JSON global)
administrer-les-formulaires-par-l-api le formulaire comme contenu : CRUD, publication, rendu public
piloter-wordpress-par-mcp le serveur MCP : handshake, catalogue d’outils, appels reels
brancher-plusieurs-providers-par-l-api environnements, matrice d’usages, multi-provider par l’API
parler-au-chatbot-en-visiteur-par-l-api (ce notebook) la face navigateur : session, nonce, conversation anonyme
notebooks suivants …

Trois niveaux de lecture, dans chaque notebook :

  1. Decouverte — ce que fait la fonctionnalite, vue par l’API ;
  2. Branchement — comment on l’a branchee dans le projet ;
  3. Exercice — reutiliser le pattern sur un cas voisin.
# Configuration et helpers. Aucune cle ni adresse de provider n'est stockee
# dans ce fichier : tout vient de instance-jetable/.env (README, etape 5).

import base64
import html
import json
import os
import re
from pathlib import Path

import requests
from dotenv import load_dotenv

# Localisation du .env : a cote du notebook (instance-jetable/.env),
# sinon dans le repertoire courant.
charges = []
for candidat in (Path("instance-jetable/.env"), Path(".env")):
    if candidat.exists():
        load_dotenv(candidat)
        charges.append(str(candidat))
print("Fichiers .env charges :", charges or "(aucun)")

BASE_URL = os.getenv("VALMONT_BASE_URL", "http://localhost:8093").rstrip("/")
ADMIN_USER = os.getenv("VALMONT_ADMIN_USER", "")
APP_PASSWORD = os.getenv("VALMONT_APP_PASSWORD", "")
print("Base URL :", BASE_URL)

creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
ENTETES = {"Authorization": "Basic " + creds, "Content-Type": "application/json"}


def api(route, method="GET", payload=None):
    """Appel a l'API d'administration, en tant qu'admin (app password)."""
    r = requests.request(method, BASE_URL + "/wp-json" + route,
                         headers=ENTETES, json=payload, timeout=120)
    r.raise_for_status()
    return r.json()


def http_get(chemin):
    """GET d'une page publique, en simple visiteur (aucun header d'auth)."""
    r = requests.get(BASE_URL + chemin, timeout=60)
    return r.status_code, r.text


def clean_text(t, max_len=140):
    """Normalise un texte de LLM pour l'affichage (espaces, longueur)."""
    if not t:
        return "(vide)"
    t = re.sub(r"\s+", " ", t).strip()
    return t[:max_len] + ("..." if len(t) > max_len else "")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093

Trois faces, trois namespaces

L’instance expose plusieurs familles de routes REST, listees par le catalogue racine GET /wp-json/. Trois nous concernent — une par consommateur :

Namespace Consommateur Authentification Grains
mwai/v1 l’administrateur (scripts, tableaux de bord) application password 1-3, 5
mcp/v1 un agent (Claude, un IDE, un client MCP) app password ou OAuth 4
mwai-ui/v1 le navigateur du visiteur nonce WordPress ce notebook

La face UI est la plus discrete : cinq routes seulement — la conversation (chats/submit), l’editeur (editor/submit), et la gestion de fichiers. C’est pourtant celle qui porte tout le trafic public du chatbot.

# 1. Le catalogue des namespaces
catalogue = requests.get(BASE_URL + "/wp-json/", timeout=60).json()
noms = sorted(catalogue["namespaces"])
print("namespaces de l'instance :", noms)

routes_ui = sorted(r for r in catalogue["routes"] if r.startswith("/mwai-ui"))
print()
print("routes de la face visiteur (mwai-ui/v1) :")
for r in routes_ui:
    print(" ", r)
namespaces de l'instance : ['mcp/v1', 'mwai-ui/v1', 'mwai/v1', 'oembed/1.0', 'wp-block-editor/v1', 'wp-site-health/v1', 'wp/v2']

routes de la face visiteur (mwai-ui/v1) :
  /mwai-ui/v1
  /mwai-ui/v1/chats/submit
  /mwai-ui/v1/editor/submit
  /mwai-ui/v1/files/delete
  /mwai-ui/v1/files/list
  /mwai-ui/v1/files/upload

Trois faces, lues dans le catalogue

Le catalogue de /wp-json/ rend sept namespaces, et trois nous concernent directement : mwai/v1 (l’API d’administration des notebooks precedents), mwai-ui/v1 (la face visiteur, notre sujet) et mcp/v1 (le serveur MCP du quatrieme notebook). Les autres sont la plateforme WordPress elle-meme. Une face = un namespace = un contrat different : meme instance, memes donnees, mais des routes, des authentifications et des garanties distinctes.

La liste des six routes de mwai-ui/v1 se lit comme un cahier des charges du visiteur : soumettre un chat (chats/submit), un editeur (editor/submit), gerer des fichiers (upload, list, delete). Aucune route d’administration ici — ce namespace ne sait PAS configurer le moteur, seulement l’utiliser. C’est la separation que la suite du notebook va mesurer.

Ce que la page publique ne contient pas

Le chatbot s’insere dans une page par son shortcode. Le reflexe du developpeur d’API serait d’y chercher le jeton d’interaction — et il n’y est pas : pour un visiteur non connecte, le conteneur rendu embarque restNonce: null et sessionId: null, explicitement.

C’est un choix d’architecture documente dans le code du plugin : une page mise en cache (WP Rocket, Cloudflare, Varnish…) figerait dans le HTML tout ce qu’on y mettrait — un nonce expire au bout de 12 a 24 heures, et un identifiant de session fige fusionnerait les limites de tous les visiteurs en une seule. La page est donc deliberement vierge de tout jeton ; le navigateur va les chercher a la premiere interaction, par un endpoint concu pour cela.

# 2. Une page chatbot publique, et son contenu exact
TITRE = "Discussion Valmont"
SLUG = "discussion-valmont"

pages = api("/wp/v2/pages?status=publish&per_page=100")
existantes = [p for p in pages if p["slug"] == SLUG]
if existantes:
    PAGE_ID = existantes[0]["id"]
    print("page existante reutilisee :", PAGE_ID)
else:
    rep = api("/wp/v2/pages", method="POST", payload={
        "title": TITRE, "slug": SLUG, "status": "publish",
        "content": "<!-- wp:shortcode -->\n[mwai_chatbot]\n<!-- /wp:shortcode -->",
    })
    PAGE_ID = rep["id"]
    print("page creee :", PAGE_ID)

# Le visiteur charge la page : aucun header d'authentification.
statut, code_html = http_get("/" + SLUG + "/")
print("GET /" + SLUG + "/ ->", statut, "| taille du HTML :", len(code_html))

conteneur = re.search(r"<div[^>]*mwai-chatbot-container[^>]*>", code_html)
brut = re.search(r"data-system='([^']*)'", conteneur.group(0)).group(1)
systeme = json.loads(html.unescape(brut))  # entites HTML (&quot;) -> JSON
print("conteneur present      :", bool(conteneur))
print("botId embarque         :", systeme.get("botId"))
print("restNonce embarque     :", systeme.get("restNonce"))
print("sessionId embarque     :", systeme.get("sessionId"))
print("stream actif           :", systeme.get("stream"))
page creee : 23
GET /discussion-valmont/ -> 200 | taille du HTML : 50300
conteneur present      : True
botId embarque         : default
restNonce embarque     : None
sessionId embarque     : None
stream actif           : True

Lire ce null correctement

Le HTML n’est pas « sans jeton par oubli » : le conteneur porte les cles (restNonce, sessionId) avec des valeurs null — le serveur les remplit pour un visiteur connecte, et les laisse vides pour un anonyme. La page est cacheable sans risque ; le jeton, lui, a une duree de vie courte et un perimetre par visiteur. C’est le meme raisonnement qui, dans le projet client, permet de servir les pages du site depuis un cache pendant que chaque conversation reste isolee.

L’amorcage : start_session

L’endpoint d’amorcage est le seul de tout le plugin avec une permission __return_true — reellement public. Il repond a trois besoins d’un coup : un identifiant de session, un nonce frais, et le cookie mwai_session_id qui liera les requetes suivantes. Le serveur derive toujours la session du cookie — le champ du client est ignore s’il est vide.

# 3. Le bootstrap du visiteur
visiteur = requests.Session()  # porte le cookie pour la suite
rep = visiteur.post(BASE_URL + "/wp-json/mwai/v1/start_session", timeout=60).json()
NONCE = rep["restNonce"]
print("success   :", rep["success"])
print("sessionId :", rep["sessionId"][:8] + "... (jeton courte duree)")
print("nonce     :", NONCE[:6] + "...")
print("cookies   :", [c.name for c in visiteur.cookies])
success   : True
sessionId : 5024de59... (jeton courte duree)
nonce     : dd9b3f...
cookies   : ['mwai_session_id']

Ce que le visiteur porte apres le bootstrap

Le handshake mwai-ui/v1 rend quatre choses, et chacune a son role dans la conversation a venir. Le sessionId (5024de59...) est un jeton de courte duree : il identifie la conversation cote serveur. Le nonce (dd9b3f...) est la credence anti-CSRF que WordPress exigera sur chaque POST — sans elle, pas d’ecriture. Le cookie mwai_session_id pose par la session requests joue le meme role que le cookie du navigateur : il suit le visiteur d’un appel a l’autre. Et success : True dit juste que le serveur a accepte de les distribuer.

Aucune de ces trois credences n’est une authentification d’utilisateur : un visiteur anonyme les obtient toutes, c’est prevu ainsi. Ce sont des jetons de parole, pas des droits. La frontiere veritable se mesure au palier suivant — et le contraste entre les deux POST (401 sans nonce, 200 avec) montrera exactement ou elle est posee.

La conversation du visiteur

La route est POST /mwai-ui/v1/chats/submit, et l’authentification est le header X-WP-Nonce. La frontiere d’abord : sans le header, la reponse est un refus sec. Puis la conversation reelle — le meme appel que le widget du navigateur emet quand le visiteur ecrit.

# 4. La frontiere, puis la conversation
refus = visiteur.post(BASE_URL + "/wp-json/mwai-ui/v1/chats/submit",
                      json={"botId": "valmont", "newMessage": "Bonjour"}, timeout=60)
print("sans nonce ->", refus.status_code, "|", refus.json().get("code"))

reponse = visiteur.post(BASE_URL + "/wp-json/mwai-ui/v1/chats/submit",
                        headers={"X-WP-Nonce": NONCE},
                        json={"botId": "valmont",
                              "newMessage": "Bonjour. Repondez en une phrase courte."},
                        timeout=180).json()
print("avec nonce -> 200 | success :", reponse.get("success"))
print("reply      :", clean_text(reponse.get("reply")))
usage = reponse.get("usage") or {}
print("usage      : prompt", usage.get("prompt_tokens"),
      "| completion", usage.get("completion_tokens"),
      "| total", usage.get("total_tokens"))
print("champs     :", sorted(reponse.keys()))
sans nonce -> 401 | rest_forbidden
avec nonce -> 200 | success : True
reply      : Bonjour, je vous réponds avec plaisir.
usage      : prompt 77 | completion 1388 | total 1465
champs     : ['actions', 'blocks', 'images', 'reply', 'responseId', 'shortcuts', 'success', 'usage']

Nonce n’est pas authentification

La mesure ci-dessus merite d’etre lue deux fois. Le nonce a refuse l’appel sans lui — mais il l’a accepte pour un visiteur qui n’a prouve aucune identite. Un nonce WordPress n’est pas une authentification : c’est un jeton anti-CSRF, delivre a quiconque charge le site, valide 12 a 24 heures. La frontiere qu’il dessine n’est pas « qui etes-vous » mais « venez-vous bien de cette page ».

Le controle d’acces reel de la face visiteur vit ailleurs : le botId demande doit exister et etre autorise (le plugin filtre les bots selon leur perimetre), et les limites de debit s’appliquent par session — le plugin limite par defaut le nombre de requetes quotidiennes des visiteurs non connectes. C’est la difference entre les trois faces du plugin, en une ligne : l’admin prouve une identite, l’agent prouve une delegation, le visiteur ne prouve rien — et c’est borne ailleurs.

Derniere nuance : le texte de reply varie d’une execution a l’autre (c’est un vrai modele de langage) — ce notebook mesure la structure de la reponse (succes, usage, champs), jamais le contenu exact.

Ce qu’on en a fait dans le projet Livres Agites

Dans le projet d’origine, cette face est celle du chatbot public d’accueil : la premiere conversation de chaque visiteur du site passe exactement par le cycle demontre ici — page cachee sans jeton, start_session, chats/submit au nonce. Le site etant derriere un cache et un reverse proxy, le design « aucun secret dans le HTML » n’etait pas un confort : c’etait la condition pour que les pages soient servies vite pendant que les conversations restaient isolees par visiteur.

L’incident qui a fixe la lecon : les appels automatises de test ont un jour echoue en rafale — non pas parce que le nonce manquait, mais parce que la limite de requetes des visiteurs anonymes (trois par jour, par defaut) etait atteinte. Le controle d’acces de cette face n’est pas devant la porte (comme le 401 du serveur MCP, grain 4) : il est au compteur. Un test qui parle a la face visiteur doit surveiller ce compteur — ou le relever consciemment.

# 5. Nettoyage : la page repart, l'instance reste propre
api("/wp/v2/pages/%d" % PAGE_ID, method="DELETE", payload={"force": True})
statut, _ = http_get("/" + SLUG + "/")
print("apres suppression : GET /" + SLUG + "/ ->", statut)
apres suppression : GET /discussion-valmont/ -> 404

Instance rendue propre — et le parcours complet

GET /discussion-valmont/ -> 404 : la page de demonstration a disparu, l’instance redevient celle que son etat initial decrit. Reste a relire ce que le parcours a etabli, car les cinq sections forment une chaine : le catalogue (1) annoncait une face visiteur sans droits d’administration ; la page publique (2) embarquait le bot mais aucun secret (nonce et session a None dans le HTML) ; le bootstrap (3) distribuait ses jetons de parole a quiconque les demande ; la frontiere (4) refusait le POST sans nonce puis acceptait la conversation – reply reelle, usage compte (prompt 77 | completion 1388), neuf champs de reponse dont actions, blocks, images. Le visiteur peut parler au chatbot ; il ne peut pas le configurer. La face navigateur de l’API tient sa promesse de separation, et c’est mesurable.

Exercices

Trois exercices, du plus simple au plus integre. Les fonctions sont a completer ; api(), http_get() et clean_text() sont disponibles. Chaque exercice se verifie d’une ligne de test.

Exercice 1 — decouvrir les faces

Completez decouvrir_les_namespaces() : elle interroge GET /wp-json/ et retourne un dict {namespace: [routes...]} pour les trois namespaces du plugin (mwai/v1, mwai-ui/v1, mcp/v1).

def decouvrir_les_namespaces():
    """Retourne {namespace: liste_de_routes} pour les 3 faces du plugin."""
    # A COMPLETER : GET /wp-json/, filtrer les routes par prefixe de namespace.
    return {}

Exercice 2 — la preuve par la page

Completez page_sans_secrets(slug) : elle charge la page publique du chatbot en visiteur, extrait le data-system du conteneur, et retourne True seulement si restNonce et sessionId y sont tous deux null (la page est cacheable sans risque).

def page_sans_secrets(slug):
    """True si la page chatbot n'embarque ni nonce ni sessionId."""
    # A COMPLETER : http_get, regex sur le conteneur, json.loads du
    # data-system, verifier les deux cles a null.
    return None

Exercice 3 — la conversation complete

Completez conversation_visiteur(bot_id, message) : elle execute tout le cycle — session, nonce, chats/submit — et retourne le texte de la reponse (None si un echec survient a quelque etape que ce soit).

def conversation_visiteur(bot_id, message):
    """Cycle visiteur complet (session + nonce + chat) ; le texte de reply."""
    # A COMPLETER : requests.Session, POST start_session, POST
    # chats/submit avec X-WP-Nonce, retourner reply.
    return None

Provenance et limites

  • Instance testee : http://localhost:8093, montee via instance-jetable/docker-compose.jetable.example.yml, AI Engine 3.7.0 (version gratuite, wordpress.org), corpus synthetique « Maison Valmont ».
  • Determinisme : la structure est stable (namespaces, page sans jetons, bootstrap, frontiere 401, champs de la reponse) ; le texte de reply et le compte de tokens varient a chaque execution — le notebook mesure la structure, pas ces litteraux. Une completion LLM reelle est emise (une seule).
  • Endpoints verifies ici (firsthand) : GET /wp-json/ (catalogue), POST /wp/v2/pages + DELETE .../pages/{id}?force (page ephemeere, purgee en fin de notebook), GET /discussion-valmont/ en visiteur, POST /mwai/v1/start_session, POST /mwai-ui/v1/chats/submit (x2 : refuse sans nonce, accepte avec).
  • Sources du comportement : check_rest_nonce (core.php) pour le gate a nonce ; build_front_params (modules/chatbot.php) pour les valeurs null embarquees aux visiteurs deconnectes (design anti-cache, commente dans le code) ; rest_start_session (rest.php) pour l’amorcage public.
  • Frontieres : pas de donnees client, pas de secret, pas d’IP de provider — regles du chantier CoursIA. Le nonce affiche est un jeton anonyme a courte duree d’une instance locale jetable.
Retour au sommet