Si vous avez déjà passé 20 minutes à retrouver “pourquoi ce champ ne se sauvegarde pas” dans l’admin WordPress, un assistant IA contextuel peut vous faire gagner du temps — à condition de le brancher proprement, sans exposer votre clé API et sans transformer wp-admin en moulin à requêtes payantes.
Le besoin / Le cas d’usage
L’admin WordPress (6.9.4 en avril 2026) est riche, mais elle reste “muette” quand vous êtes bloqué sur un écran précis : édition d’article, liste des médias, réglages d’un plugin, écran d’erreur, etc. L’idée ici est simple : afficher un panneau “Assistant” dans wp-admin, capable de répondre en tenant compte du contexte (écran courant, post en cours, messages d’erreur visibles, rôle utilisateur), puis de renvoyer une réponse immédiatement utilisable (étapes, extrait de code, checklist).
J’ai souvent vu ce besoin sur :
- Sites éditoriaux : l’équipe demande “comment optimiser ce titre / ce chapô / ces tags” sans quitter l’éditeur.
- Sites WooCommerce : questions répétitives sur TVA, emails, statuts de commande (même si vous n’utilisez pas Woo, la logique est identique).
- Agences : support interne (“pourquoi Elementor n’affiche pas mon widget”, “Divi 5 n’importe pas mon layout”, “Avada ne charge pas le CSS”).
- Admins techniques : interpréter rapidement un message d’erreur, proposer une piste de debug, rappeler une commande WP-CLI.
À la fin, vous saurez implémenter :
- Un panneau assistant dans l’admin (barre latérale) avec un champ de question.
- Un appel AJAX sécurisé vers WordPress (
admin-ajax.php), avec nonce et permissions. - Un appel API OpenAI via
wp_remote_post()(sans SDK, sans Composer). - Du cache (Transients) + un rate limit par utilisateur.
- Une réponse IA sanitisée et affichée sans XSS.
Résumé rapide
- On ajoute un panneau “Assistant IA” dans wp-admin via
admin_menu+ un peu de CSS/JS enadmin_enqueue_scripts. - Le JS appelle
admin-ajax.phpavec nonce et récupère une réponse JSON. - Le PHP construit un “contexte” (écran courant, ID du post, extrait de contenu) et appelle OpenAI avec
wp_remote_post(). - On met en cache les réponses via
set_transient()pour limiter les coûts. - On filtre/sanitize la sortie avec
wp_kses()(HTML autorisé minimal) et on évite d’exposer la clé API côté client. - On ajoute un tableau de diagnostic + des erreurs fréquentes (hooks, enqueue, nonce, timeouts).
Quand utiliser l’IA pour ça
Utilisez ce type d’assistant quand le “support” dépend du contexte et que vous perdez du temps à reposer les mêmes questions.
- Aide à la rédaction : reformulation, plan, méta description, variantes de titres, checklist SEO éditoriale.
- Assistance technique : interpréter un message d’erreur, proposer des hypothèses, guider vers les écrans pertinents.
- Support interne : un assistant “maison” qui répond selon vos règles (ex. “chez nous, on n’utilise jamais tel plugin”).
- Onboarding : aider un nouveau rédacteur à publier correctement, sans documentation de 20 pages.
Le point clé : vous gagnez quand la réponse n’est pas un simple lien de doc, mais une action guidée et contextualisée.
Quand ne PAS utiliser l’IA
Si une règle est déterministe, l’IA est souvent une mauvaise idée (plus chère, moins fiable).
- Validation de formulaire : utilisez la validation PHP/JS classique.
- Recherche dans vos contenus : commencez par
WP_Query, la recherche native, ou un moteur dédié. L’IA n’est pas un moteur de recherche fiable. - Traduction systématique en masse : mieux vaut un pipeline dédié + mémoire de traduction, sinon la facture grimpe vite.
- Actions “dangereuses” (modifier des réglages, installer des plugins, exécuter du code) : ne laissez pas un modèle décider. À la rigueur, qu’il propose, et vous validez.
- Sites soumis à fortes contraintes RGPD : si vous ne pouvez pas envoyer de données à un tiers, n’essayez pas de “bricoler”.
Dans mon expérience, le piège classique est de remplacer une checklist (simple) par une requête IA (coûteuse) juste “parce que c’est cool”.
Prérequis
Versions
- WordPress : 6.9.4+ (admin moderne, API HTTP stable)
- PHP : 8.1+ (recommandé, typage et gestion d’erreurs plus propre)
- HTTPS : obligatoire (admin + appels API)
Clé API OpenAI
Créez une clé côté OpenAI et stockez-la dans wp-config.php. Ne la mettez jamais dans un plugin, et encore moins dans du JavaScript.
/**
* wp-config.php
* Stockez la clé API hors du code versionné si possible (variable d'environnement).
*/
define( 'BPCAB_OPENAI_API_KEY', 'sk-proxxxxxxxxxxxxxxxx' );
/**
* Optionnel : modèle par défaut (vous pourrez le changer ensuite).
* Choisissez un modèle “mini” si votre usage est surtout du support.
*/
define( 'BPCAB_OPENAI_MODEL', 'gpt-4.1-mini' );
Références officielles utiles
- wp_remote_post() (developer.wordpress.org)
- check_ajax_referer() (developer.wordpress.org)
- set_transient() (developer.wordpress.org)
- wp_kses() (developer.wordpress.org)
- Extension JSON (php.net)
Architecture de la solution
Voici le flux, sans jargon inutile :
wp-admin (JS) → AJAX vers admin-ajax.php (nonce + permissions) → PHP construit le contexte → wp_remote_post() vers OpenAI → parse JSON → sanitize → cache Transient → renvoi JSON → affichage dans le panneau
Ce qui se passe en coulisses
- Collecte de contexte : on lit l’écran courant (
get_current_screen()), l’ID du post si on est dans l’éditeur, et un extrait du contenu (limité). - Construction du prompt : on demande une réponse courte, actionnable, et on impose un format (liste d’étapes, code si nécessaire).
- Appel HTTP : on utilise l’API HTTP de WordPress (pas cURL direct) pour compatibilité hébergeur.
- Cache : même question + même contexte = même réponse, pendant X minutes.
- Sécurité : nonce,
current_user_can(), limitation de fréquence, et filtration HTML stricte.
Le code complet — étape par étape
Je vous conseille de commencer en mu-plugin pour éviter le “j’ai désactivé le plugin et perdu l’assistant”. Créez :
wp-content/mu-plugins/bpcab-admin-ai-assistant.php
Si vous n’avez pas le dossier mu-plugins, créez-le. WordPress charge automatiquement ces fichiers. (Erreur fréquente : mettre ce fichier dans plugins et oublier de l’activer.)
1) Le squelette du mu-plugin
<?php
/**
* Plugin Name: BPCAB Admin AI Assistant (OpenAI)
* Description: Assistant IA contextuel dans wp-admin via AJAX + OpenAI.
* Author: Votre Nom
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
2) Ajouter une page/panneau dans l’admin
On va créer une page “Assistant IA” accessible via le menu Outils. C’est volontairement simple et robuste.
add_action( 'admin_menu', function () {
add_management_page(
'Assistant IA',
'Assistant IA',
'edit_posts', // Rédacteurs et +. Ajustez selon votre besoin.
'bpcab-ai-assistant',
'bpcab_ai_assistant_render_page'
);
} );
function bpcab_ai_assistant_render_page() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Accès refusé.', 'bpcab' ) );
}
$nonce = wp_create_nonce( 'bpcab_ai_assistant_nonce' );
echo '<div class="wrap">';
echo '<h2>Assistant IA (contextuel)</h2>';
echo '<p>Posez une question liée à l’écran actuel (éditeur, médias, réglages…).</p>';
echo '<div id="bpcab-ai-assistant" data-nonce="' . esc_attr( $nonce ) . '">';
echo ' <div class="bpcab-row">';
echo ' <textarea id="bpcab-ai-question" class="large-text" rows="4" placeholder="Ex : Pourquoi mon bouton Elementor ne s’affiche pas sur mobile ?"></textarea>';
echo ' </div>';
echo ' <p><button class="button button-primary" id="bpcab-ai-ask">Demander</button> ';
echo ' <span id="bpcab-ai-status" style="margin-left:10px;"></span></p>';
echo ' <hr />';
echo ' <div id="bpcab-ai-answer" style="background:#fff;border:1px solid #ccd0d4;padding:12px;min-height:80px;"></div>';
echo '</div>';
echo '</div>';
}
3) Charger le JavaScript uniquement sur la page
Piège classique : enqueue sur toutes les pages admin, puis conflit avec d’autres scripts. On limite au strict nécessaire.
add_action( 'admin_enqueue_scripts', function ( $hook_suffix ) {
// Notre page Outils → Assistant IA : tools_page_bpcab-ai-assistant
if ( 'tools_page_bpcab-ai-assistant' !== $hook_suffix ) {
return;
}
wp_enqueue_script(
'bpcab-ai-assistant-admin',
plugins_url( 'bpcab-ai-assistant-admin.js', __FILE__ ),
array(),
'1.0.0',
true
);
wp_localize_script(
'bpcab-ai-assistant-admin',
'BPCAB_AI',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
)
);
} );
Créez ensuite le fichier : wp-content/mu-plugins/bpcab-ai-assistant-admin.js
(function () {
function qs(sel) { return document.querySelector(sel); }
const root = qs('#bpcab-ai-assistant');
if (!root) return;
const nonce = root.getAttribute('data-nonce');
const btn = qs('#bpcab-ai-ask');
const questionEl = qs('#bpcab-ai-question');
const statusEl = qs('#bpcab-ai-status');
const answerEl = qs('#bpcab-ai-answer');
let busy = false;
async function ask() {
if (busy) return;
const question = (questionEl.value || '').trim();
if (!question) {
statusEl.textContent = 'Écrivez une question.';
return;
}
busy = true;
statusEl.textContent = 'Requête en cours…';
answerEl.innerHTML = '';
const body = new URLSearchParams();
body.set('action', 'bpcab_ai_assistant_ask');
body.set('_ajax_nonce', nonce);
body.set('question', question);
try {
const res = await fetch(BPCAB_AI.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString()
});
const json = await res.json();
if (!json || !json.success) {
const msg = (json && json.data && json.data.message) ? json.data.message : 'Erreur inconnue.';
statusEl.textContent = 'Erreur';
answerEl.textContent = msg;
return;
}
statusEl.textContent = 'OK';
// Le HTML est déjà filtré côté serveur (wp_kses). On l’injecte tel quel.
answerEl.innerHTML = json.data.html;
} catch (e) {
statusEl.textContent = 'Erreur réseau';
answerEl.textContent = String(e);
} finally {
busy = false;
}
}
btn.addEventListener('click', ask);
})();
4) L’endpoint AJAX sécurisé
On utilise check_ajax_referer(), on vérifie les capacités, puis on applique un rate limit par utilisateur. Ça évite le “j’ai cliqué 30 fois et j’ai explosé mon quota”.
add_action( 'wp_ajax_bpcab_ai_assistant_ask', 'bpcab_ai_assistant_ajax_ask' );
function bpcab_ai_assistant_ajax_ask() {
check_ajax_referer( 'bpcab_ai_assistant_nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
array( 'message' => 'Permissions insuffisantes.' ),
403
);
}
$question = isset( $_POST['question'] ) ? sanitize_text_field( wp_unslash( $_POST['question'] ) ) : '';
if ( '' === $question ) {
wp_send_json_error( array( 'message' => 'Question vide.' ), 400 );
}
// Rate limit simple : 10 requêtes / 5 minutes / utilisateur
$user_id = get_current_user_id();
$rate_key = 'bpcab_ai_rate_' . $user_id;
$rate = (int) get_transient( $rate_key );
if ( $rate >= 10 ) {
wp_send_json_error(
array( 'message' => 'Trop de requêtes. Réessayez dans quelques minutes.' ),
429
);
}
set_transient( $rate_key, $rate + 1, 5 * MINUTE_IN_SECONDS );
$context = bpcab_ai_assistant_build_context();
// Cache : même question + même contexte = même réponse
$cache_key = 'bpcab_ai_' . md5( wp_json_encode( array(
'q' => $question,
'c' => $context,
) ) );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && isset( $cached['html'] ) ) {
wp_send_json_success( array( 'html' => $cached['html'], 'cached' => true ) );
}
$result = bpcab_ai_assistant_call_openai( $question, $context );
if ( is_wp_error( $result ) ) {
wp_send_json_error(
array( 'message' => $result->get_error_message() ),
500
);
}
// Filtrage HTML strict : on n’autorise que quelques balises de mise en forme.
$allowed = array(
'p' => array(),
'br' => array(),
'strong' => array(),
'em' => array(),
'code' => array(),
'pre' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
'a' => array(
'href' => array(),
'target' => array(),
'rel' => array(),
),
);
$html = wp_kses( $result['html'], $allowed );
// Cache 30 minutes (ajustez selon votre usage)
set_transient( $cache_key, array( 'html' => $html ), 30 * MINUTE_IN_SECONDS );
wp_send_json_success( array( 'html' => $html, 'cached' => false ) );
}
5) Construire un contexte utile (sans sur-partager)
On reste pragmatique : écran courant, post ID si présent, titre + extrait de contenu limité. N’envoyez pas tout le contenu complet par défaut, surtout sur des sites multi-auteurs.
function bpcab_ai_assistant_build_context() : array {
$context = array(
'site_url' => home_url(),
'wp_version' => get_bloginfo( 'version' ),
'locale' => get_locale(),
'user_role' => bpcab_ai_assistant_get_primary_role(),
);
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( $screen ) {
$context['screen_id'] = $screen->id;
$context['screen_base'] = $screen->base;
$context['post_type'] = $screen->post_type ?: '';
}
}
// Si on est sur un écran qui a un post en paramètre
$post_id = 0;
if ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_id'] ) ) {
$post_id = (int) $_POST['post_id'];
}
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post instanceof WP_Post && current_user_can( 'edit_post', $post_id ) ) {
$context['post_id'] = $post_id;
$context['post_title'] = wp_strip_all_tags( get_the_title( $post_id ) );
// Extrait limité pour réduire les tokens et éviter d’envoyer trop de données.
$content = wp_strip_all_tags( $post->post_content );
$context['post_excerpt_800'] = mb_substr( $content, 0, 800 );
}
}
return $context;
}
function bpcab_ai_assistant_get_primary_role() : string {
$user = wp_get_current_user();
if ( empty( $user->roles ) ) {
return 'none';
}
return (string) $user->roles[0];
}
6) Appeler OpenAI via wp_remote_post()
On utilise un timeout explicite, on gère les erreurs HTTP, et on parse le JSON proprement. Si votre hébergeur est capricieux, c’est souvent ici que ça casse (SSL, DNS, proxy).
Référence : l’API HTTP WordPress gère cURL/streams selon l’environnement. Ça évite beaucoup de surprises. Voir HTTP API (developer.wordpress.org).
function bpcab_ai_assistant_call_openai( string $question, array $context ) {
if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || '' === (string) BPCAB_OPENAI_API_KEY ) {
return new WP_Error( 'bpcab_no_key', 'Clé API OpenAI manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
}
$model = defined( 'BPCAB_OPENAI_MODEL' ) ? (string) BPCAB_OPENAI_MODEL : 'gpt-4.1-mini';
// Prompt : on force une réponse actionnable et courte.
$system = "Vous êtes un assistant WordPress senior. Répondez en français. Donnez des étapes concrètes, puis éventuellement un extrait de code. Ne proposez pas d’actions destructrices sans avertissement. Si vous manquez d’informations, posez 1 à 3 questions ciblées.";
$user = "Contexte (JSON) :n" . wp_json_encode( $context ) . "nnQuestion :n" . $question . "nnFormat attendu :n- Une réponse courte en HTML (p, ul, ol, li, strong, em, code, pre, a)n- Si vous proposez un code, mettez-le dans <pre><code>...</code></pre>.";
$payload = array(
'model' => $model,
'messages' => array(
array( 'role' => 'system', 'content' => $system ),
array( 'role' => 'user', 'content' => $user ),
),
// Limitez la taille : l’admin n’a pas besoin d’un roman.
'max_output_tokens' => 500,
'temperature' => 0.2,
);
$args = array(
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
),
'timeout' => 20, // Secondes. Ajustez si votre réseau est lent.
'body' => wp_json_encode( $payload ),
);
$response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', $args );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'bpcab_http_error', 'Erreur HTTP vers OpenAI : ' . $response->get_error_message() );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = (string) wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
// On évite de renvoyer le body brut tel quel (peut contenir des infos).
return new WP_Error( 'bpcab_openai_http_' . $code, 'OpenAI a renvoyé HTTP ' . $code . '. Vérifiez la clé, le quota, ou le modèle.' );
}
$data = json_decode( $body, true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'bpcab_json', 'Réponse OpenAI illisible (JSON invalide).' );
}
// Format attendu (chat.completions) : choices[0].message.content
$content = $data['choices'][0]['message']['content'] ?? '';
$content = is_string( $content ) ? $content : '';
$content = trim( $content );
if ( '' === $content ) {
return new WP_Error( 'bpcab_empty', 'Réponse OpenAI vide.' );
}
// Petite normalisation : on force rel noopener sur les liens si le modèle en met.
// (Ce n’est pas parfait, mais wp_kses filtrera de toute façon.)
$content = str_replace( 'target="_blank"', 'target="_blank" rel="noopener"', $content );
return array(
'html' => $content,
'raw' => $data,
);
}
Note : si OpenAI fait évoluer ses endpoints, gardez un œil sur la doc officielle. Ici on reste sur un appel HTTP “pur” via WordPress, ce qui se migre facilement. Voir OpenAI API reference.
Le code assemblé complet
Copiez-collez ce fichier complet dans wp-content/mu-plugins/bpcab-admin-ai-assistant.php, puis créez le JS à côté (fourni plus haut). Ne mettez pas la clé dans ce fichier.
<?php
/**
* Plugin Name: BPCAB Admin AI Assistant (OpenAI)
* Description: Assistant IA contextuel dans wp-admin via AJAX + OpenAI.
* Author: Votre Nom
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'admin_menu', function () {
add_management_page(
'Assistant IA',
'Assistant IA',
'edit_posts',
'bpcab-ai-assistant',
'bpcab_ai_assistant_render_page'
);
} );
function bpcab_ai_assistant_render_page() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Accès refusé.', 'bpcab' ) );
}
$nonce = wp_create_nonce( 'bpcab_ai_assistant_nonce' );
echo '<div class="wrap">';
echo '<h2>Assistant IA (contextuel)</h2>';
echo '<p>Posez une question liée à l’écran actuel (éditeur, médias, réglages…).</p>';
echo '<div id="bpcab-ai-assistant" data-nonce="' . esc_attr( $nonce ) . '">';
echo ' <div class="bpcab-row">';
echo ' <textarea id="bpcab-ai-question" class="large-text" rows="4" placeholder="Ex : Pourquoi mon bouton Elementor ne s’affiche pas sur mobile ?"></textarea>';
echo ' </div>';
echo ' <p><button class="button button-primary" id="bpcab-ai-ask">Demander</button> ';
echo ' <span id="bpcab-ai-status" style="margin-left:10px;"></span></p>';
echo ' <hr />';
echo ' <div id="bpcab-ai-answer" style="background:#fff;border:1px solid #ccd0d4;padding:12px;min-height:80px;"></div>';
echo '</div>';
echo '</div>';
}
add_action( 'admin_enqueue_scripts', function ( $hook_suffix ) {
if ( 'tools_page_bpcab-ai-assistant' !== $hook_suffix ) {
return;
}
wp_enqueue_script(
'bpcab-ai-assistant-admin',
plugins_url( 'bpcab-ai-assistant-admin.js', __FILE__ ),
array(),
'1.0.0',
true
);
wp_localize_script(
'bpcab-ai-assistant-admin',
'BPCAB_AI',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
)
);
} );
add_action( 'wp_ajax_bpcab_ai_assistant_ask', 'bpcab_ai_assistant_ajax_ask' );
function bpcab_ai_assistant_ajax_ask() {
check_ajax_referer( 'bpcab_ai_assistant_nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
array( 'message' => 'Permissions insuffisantes.' ),
403
);
}
$question = isset( $_POST['question'] ) ? sanitize_text_field( wp_unslash( $_POST['question'] ) ) : '';
if ( '' === $question ) {
wp_send_json_error( array( 'message' => 'Question vide.' ), 400 );
}
$user_id = get_current_user_id();
$rate_key = 'bpcab_ai_rate_' . $user_id;
$rate = (int) get_transient( $rate_key );
if ( $rate >= 10 ) {
wp_send_json_error(
array( 'message' => 'Trop de requêtes. Réessayez dans quelques minutes.' ),
429
);
}
set_transient( $rate_key, $rate + 1, 5 * MINUTE_IN_SECONDS );
$context = bpcab_ai_assistant_build_context();
$cache_key = 'bpcab_ai_' . md5( wp_json_encode( array(
'q' => $question,
'c' => $context,
) ) );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && isset( $cached['html'] ) ) {
wp_send_json_success( array( 'html' => $cached['html'], 'cached' => true ) );
}
$result = bpcab_ai_assistant_call_openai( $question, $context );
if ( is_wp_error( $result ) ) {
wp_send_json_error(
array( 'message' => $result->get_error_message() ),
500
);
}
$allowed = array(
'p' => array(),
'br' => array(),
'strong' => array(),
'em' => array(),
'code' => array(),
'pre' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
'a' => array(
'href' => array(),
'target' => array(),
'rel' => array(),
),
);
$html = wp_kses( $result['html'], $allowed );
set_transient( $cache_key, array( 'html' => $html ), 30 * MINUTE_IN_SECONDS );
wp_send_json_success( array( 'html' => $html, 'cached' => false ) );
}
function bpcab_ai_assistant_build_context() : array {
$context = array(
'site_url' => home_url(),
'wp_version' => get_bloginfo( 'version' ),
'locale' => get_locale(),
'user_role' => bpcab_ai_assistant_get_primary_role(),
);
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( $screen ) {
$context['screen_id'] = $screen->id;
$context['screen_base'] = $screen->base;
$context['post_type'] = $screen->post_type ?: '';
}
}
$post_id = 0;
if ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_id'] ) ) {
$post_id = (int) $_POST['post_id'];
}
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post instanceof WP_Post && current_user_can( 'edit_post', $post_id ) ) {
$context['post_id'] = $post_id;
$context['post_title'] = wp_strip_all_tags( get_the_title( $post_id ) );
$content = wp_strip_all_tags( $post->post_content );
$context['post_excerpt_800'] = mb_substr( $content, 0, 800 );
}
}
return $context;
}
function bpcab_ai_assistant_get_primary_role() : string {
$user = wp_get_current_user();
if ( empty( $user->roles ) ) {
return 'none';
}
return (string) $user->roles[0];
}
function bpcab_ai_assistant_call_openai( string $question, array $context ) {
if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || '' === (string) BPCAB_OPENAI_API_KEY ) {
return new WP_Error( 'bpcab_no_key', 'Clé API OpenAI manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
}
$model = defined( 'BPCAB_OPENAI_MODEL' ) ? (string) BPCAB_OPENAI_MODEL : 'gpt-4.1-mini';
$system = "Vous êtes un assistant WordPress senior. Répondez en français. Donnez des étapes concrètes, puis éventuellement un extrait de code. Ne proposez pas d’actions destructrices sans avertissement. Si vous manquez d’informations, posez 1 à 3 questions ciblées.";
$user = "Contexte (JSON) :n" . wp_json_encode( $context ) . "nnQuestion :n" . $question . "nnFormat attendu :n- Une réponse courte en HTML (p, ul, ol, li, strong, em, code, pre, a)n- Si vous proposez un code, mettez-le dans <pre><code>...</code></pre>.";
$payload = array(
'model' => $model,
'messages' => array(
array( 'role' => 'system', 'content' => $system ),
array( 'role' => 'user', 'content' => $user ),
),
'max_output_tokens' => 500,
'temperature' => 0.2,
);
$args = array(
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
),
'timeout' => 20,
'body' => wp_json_encode( $payload ),
);
$response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', $args );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'bpcab_http_error', 'Erreur HTTP vers OpenAI : ' . $response->get_error_message() );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = (string) wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'bpcab_openai_http_' . $code, 'OpenAI a renvoyé HTTP ' . $code . '. Vérifiez la clé, le quota, ou le modèle.' );
}
$data = json_decode( $body, true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'bpcab_json', 'Réponse OpenAI illisible (JSON invalide).' );
}
$content = $data['choices'][0]['message']['content'] ?? '';
$content = is_string( $content ) ? $content : '';
$content = trim( $content );
if ( '' === $content ) {
return new WP_Error( 'bpcab_empty', 'Réponse OpenAI vide.' );
}
$content = str_replace( 'target="_blank"', 'target="_blank" rel="noopener"', $content );
return array(
'html' => $content,
'raw' => $data,
);
}
Explication du code
Pourquoi une page “Outils” plutôt qu’un widget partout ?
Parce que c’est le chemin le plus stable pour un premier jet. Les widgets partout finissent souvent par entrer en conflit avec des admins très chargés (plugins SEO, WooCommerce, builders). Une fois que ça marche, vous pourrez l’injecter dans d’autres écrans.
Pourquoi admin-ajax.php (AJAX) et pas REST API ?
Les deux marchent. Pour un assistant interne à wp-admin, admin-ajax.php reste rapide à mettre en place, avec une surface d’exposition plus limitée. Si vous voulez l’utiliser côté front, basculez vers REST + authent (application passwords, cookies, etc.).
Docs AJAX admin : AJAX in Plugins (developer.wordpress.org).
Pourquoi un transient cache “par question + contexte” ?
Sans cache, vous payez plusieurs fois la même réponse. Et l’admin encourage les doubles clics. Le hash (md5) sur JSON est simple et efficace pour une clé courte.
Erreur que je vois souvent : mettre un cache trop long (24h) sur un contexte qui change (post en édition). Ici 30 minutes est un bon départ.
Pourquoi wp_kses() au lieu de “faire confiance” au modèle ?
Parce qu’un modèle peut produire des balises non prévues, des attributs, ou du HTML “créatif”. Dans wp-admin, une XSS est un vrai problème. wp_kses() limite strictement la sortie. Doc : wp_kses() (developer.wordpress.org).
Pourquoi un rate limit via transient ?
C’est basique, mais ça protège :
- votre facture,
- les boucles de clic,
- les scripts qui martèlent l’endpoint (même en interne).
Si vous avez un site très actif, remplacez par un stockage plus robuste (objet cache persistant, table dédiée), mais pour beaucoup de blogs, ce transient suffit.
Coûts API et optimisation
Les coûts varient selon le modèle et la tarification du moment. Le calcul reste le même : tokens en entrée + tokens en sortie. Dans ce setup, on limite :
- Contexte : extrait à 800 caractères max
- Sortie :
max_output_tokens = 500 - Température : 0.2 (moins de blabla, plus de répétabilité donc plus de cache utile)
Estimation “ordre de grandeur”
Supposons :
- 400–800 tokens en entrée (question + contexte + consignes)
- 200–400 tokens en sortie
- 20 requêtes/jour (petite équipe), soit ~600/mois
Avec un modèle “mini”, ça reste généralement dans une enveloppe raisonnable, surtout si le cache absorbe 20–40% des requêtes répétées. Le meilleur levier, dans la vraie vie, c’est le cache + des réponses plus courtes.
Optimisations concrètes
- Réduire le contexte : n’envoyez pas l’extrait du post si la question n’est pas éditoriale (détectez via mots-clés).
- Cache plus intelligent : inclure une version “context_version” et l’incrémenter quand vous changez le prompt.
- Modèle plus petit : support technique simple = modèle mini ; rédaction premium = modèle plus capable.
- Déduplication : normalisez la question (trim, espaces) avant hash.
Variantes et cas d’usage avancés
1) Assistant dans la barre latérale de l’éditeur (Gutenberg)
Si votre objectif est l’éditeur de blocs, le bon pattern est un plugin JS Gutenberg (slotfill / sidebar). Ici, on a volontairement évité cette complexité. Mais vous pouvez garder le même endpoint AJAX et changer uniquement l’UI.
Référence : Block Editor Handbook (developer.wordpress.org).
2) Variantes “page builders” (Divi 5 / Elementor / Avada)
Ces builders ajoutent leurs propres écrans, et le contexte screen_id aide déjà l’IA à comprendre où vous êtes. Quelques adaptations utiles :
- Elementor : si vous détectez
$_GET['action']=elementorou un screen lié, ajoutezbuilder=elementorau contexte. - Divi 5 : si Divi charge un écran dédié, ajoutez
builder=divi. J’ai souvent vu des réponses IA trop génériques si vous ne précisez pas “Divi 5” explicitement. - Avada : ajoutez
builder=avadasi vous êtes dans ses pages de réglages, pour obtenir des étapes cohérentes avec Fusion Builder.
function bpcab_ai_assistant_detect_builder_hint() : string {
// Détection heuristique (à ajuster selon vos URLs/admin screens).
if ( isset( $_GET['action'] ) && 'elementor' === $_GET['action'] ) {
return 'elementor';
}
// Divi/Avada : souvent via pages d’admin dédiées. Exemple générique :
if ( isset( $_GET['page'] ) && is_string( $_GET['page'] ) ) {
$page = sanitize_key( wp_unslash( $_GET['page'] ) );
if ( str_contains( $page, 'et_' ) || str_contains( $page, 'divi' ) ) {
return 'divi';
}
if ( str_contains( $page, 'avada' ) || str_contains( $page, 'fusion' ) ) {
return 'avada';
}
}
return '';
}
Ensuite, dans bpcab_ai_assistant_build_context() :
$builder = bpcab_ai_assistant_detect_builder_hint();
if ( '' !== $builder ) {
$context['builder'] = $builder;
}
3) Réponses structurées (JSON) puis rendu HTML côté serveur
Si vous voulez éviter que le modèle produise du HTML, demandez-lui du JSON strict (étapes, avertissements, code), puis vous générez l’HTML vous-même. C’est plus long à coder, mais plus sûr et plus stable. Quand le projet grandit, c’est souvent la meilleure évolution.
Sécurité et bonnes pratiques
Ne jamais exposer la clé API côté client
La clé doit rester en PHP, côté serveur. Si vous la mettez dans le JS (même “minifié”), elle finira copiée. À ce moment-là, n’importe qui peut consommer votre quota.
Valider et limiter ce que vous envoyez
- Sanitizer la question :
sanitize_text_field(). - Limiter la taille : vous pouvez tronquer à 500–1000 caractères.
- Éviter les données sensibles : pas d’emails, pas d’adresses, pas de logs complets, pas de tokens.
RGPD / données personnelles
Si vous envoyez du contenu d’article, vous envoyez potentiellement des données personnelles (noms, emails, etc.). À minima :
- documentez ce traitement dans votre registre,
- limitez le contexte (extrait court),
- évitez d’envoyer des contenus privés (brouillons sensibles),
- désactivez l’assistant pour certains rôles si nécessaire.
Rate limiting et permissions
Le combo minimal :
check_ajax_referer()(nonce)current_user_can()(capability)- limitation par utilisateur (transient)
Anti-patterns que je vois souvent
- Mettre l’appel OpenAI dans
admin_init“pour précharger” (vous allez appeler l’API à chaque chargement de page). - Oublier
timeout: certains hébergeurs bloquent et votre admin “freeze”. - Afficher la réponse brute sans
wp_kses: XSS potentielle. - Tester sur production sans sauvegarde ni environnement de staging.
Comment tester et déboguer
1) Activez les logs WordPress
Dans wp-config.php (sur un environnement de dev/staging) :
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Ensuite surveillez wp-content/debug.log. Doc : Debugging in WordPress (developer.wordpress.org).
2) Testez d’abord une question “simple”
- “Expliquez-moi où trouver les réglages des permaliens.”
- “Pourquoi mon image ne s’affiche pas dans l’article ?”
Si ça échoue, le problème est probablement réseau/clé/quota, pas le contexte.
3) Ajoutez un log temporaire (sans données sensibles)
Évitez de logger la clé API ou le contenu complet. Loggez plutôt le code HTTP et un identifiant.
// Exemple : à placer temporairement après wp_remote_post()
error_log( '[BPCAB_AI] HTTP=' . (int) wp_remote_retrieve_response_code( $response ) );
4) Vérifiez que votre JS est chargé
Erreur réaliste : mauvais $hook_suffix dans admin_enqueue_scripts → aucun JS → bouton inactif.
- Ouvrez les DevTools (Console) et cherchez des erreurs.
- Réseau : vérifiez l’appel à
admin-ajax.php.
Si ça ne marche pas
Voici les pannes que je croise le plus souvent, avec une méthode de vérification rapide.
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| “Clé API manquante” | Constante non définie | Vérifier wp-config.php et l’absence de faute de frappe |
Définir BPCAB_OPENAI_API_KEY et recharger |
| Erreur 403 AJAX | Capability insuffisante ou nonce invalide | Réponse JSON + Network tab | Vérifier current_user_can(), régénérer le nonce, vider cache |
| Erreur 500 “OpenAI a renvoyé HTTP 401/403” | Clé invalide / droits / projet | Regarder code HTTP | Régénérer la clé, vérifier restrictions côté OpenAI |
| Erreur 500 “HTTP 429” | Quota dépassé ou rate limit OpenAI | Tableau de bord fournisseur | Attendre, réduire fréquence, activer cache, modèle plus petit |
| Réponse vide | Parsing JSON / format inattendu | Logger un extrait de $body (sans données sensibles) |
Adapter l’extraction choices[0].message.content |
| Le bouton ne fait rien | JS non chargé, mauvais chemin de fichier | Console + onglet Sources | Corriger plugins_url(), vérifier le nom du fichier |
| Timeout / admin qui “tourne” | Réseau lent, DNS, proxy, SSL | Augmenter timeout, tester depuis serveur | Timeout 30s, vérifier firewall sortant, DNS, OpenSSL |
Erreurs “bêtes” mais fréquentes
- Copier le code au mauvais endroit : un mu-plugin doit être dans
wp-content/mu-plugins/, pas dans un thème enfant. - Oublier un point-virgule : si wp-admin affiche une page blanche, vérifiez les logs PHP.
- Hook inadapté :
admin_enqueue_scriptsest le bon endroit pour charger JS/CSS admin, paswp_enqueue_scripts. - Cache navigateur : vous modifiez le JS mais rien ne change. Changez la version du script (ici
1.0.0→1.0.1), ou force-reload. - Tester sur production : faites-le sur staging. Une erreur fatale en mu-plugin casse tout wp-admin.
Ressources
- wp_remote_post() (developer.wordpress.org)
- WordPress HTTP API (developer.wordpress.org)
- AJAX in Plugins (developer.wordpress.org)
- check_ajax_referer() (developer.wordpress.org)
- wp_kses() (developer.wordpress.org)
- OpenAI API reference (platform.openai.com)
- WordPress Core (github.com/WordPress)
- WordPress Core Trac (core.trac.wordpress.org)
- json_decode() (php.net)
FAQ
Peut-on utiliser REST API au lieu d’admin-ajax.php ?
Oui. Pour un usage wp-admin, AJAX est plus rapide à brancher. Si vous avez besoin d’un assistant côté front (ou multi-apps), REST est plus propre. Gardez les mêmes règles : nonce/capabilities, rate limit, pas de clé côté client.
Pourquoi ne pas utiliser un SDK OpenAI ?
Sur WordPress, ajouter Composer/SDK peut être une source de conflits (autoloaders multiples, versions PHP différentes). Pour un assistant admin, wp_remote_post() suffit et reste portable. Vous pourrez passer à un SDK plus tard si vous en avez réellement besoin.
Est-ce compatible avec WordPress 6.9.4 et PHP 8.1 ?
Oui : le code s’appuie sur l’API HTTP, les transients, AJAX admin, et des fonctions stables depuis longtemps. Évitez de le déployer sur PHP < 8.1.
Comment éviter d’envoyer du contenu sensible à OpenAI ?
Ne mettez pas le contenu complet dans le contexte. Envoyez un extrait court, ou rien du tout selon les écrans. Vous pouvez aussi masquer certains écrans (ex. pages de réglages contenant des emails).
Pourquoi ma réponse contient du HTML “bizarre” ou des liens douteux ?
Le modèle peut halluciner. Filtrez avec wp_kses() (fait ici) et, si nécessaire, passez à une sortie JSON structurée que vous rendez vous-même.
Comment augmenter la qualité des réponses ?
Ajoutez des consignes plus strictes dans le message system, réduisez la température, et enrichissez légèrement le contexte (ex. builder détecté, post type). La qualité monte aussi si vous posez une question plus précise.
Pourquoi j’obtiens “Trop de requêtes” alors que j’ai peu cliqué ?
Votre navigateur peut relancer des requêtes si vous double-cliquez, ou un autre onglet utilise le même endpoint. Montez la limite, ou affichez un état “busy” (déjà fait) et désactivez le bouton pendant la requête.
Le cache transient ne semble pas fonctionner
Deux causes fréquentes :
- Vous changez légèrement le contexte (ex. post_id différent) → clé différente → pas de hit.
- Un plugin de cache objet/persistant a une politique d’expiration particulière.
Testez en posant exactement la même question sur le même écran, puis loggez si cached=true est renvoyé.
Puis-je afficher l’assistant sur toutes les pages admin ?
Oui, mais faites-le progressivement. Commencez par une page dédiée (comme ici), puis injectez un “panneau” sur des écrans ciblés. Charger le JS partout augmente les risques de conflit.
Est-ce que ça marche avec Elementor/Divi/Avada ?
Oui pour l’assistant dans wp-admin. Pour des intégrations plus profondes (dans l’éditeur du builder lui-même), vous devrez adapter l’UI, mais l’endpoint PHP restera identique.
Quelle est la prochaine amélioration la plus rentable ?
Passer de “HTML généré par le modèle” à “JSON structuré + rendu serveur”, puis ajouter une mémoire locale (petite base de connaissances interne) et n’appeler l’IA que si la réponse n’est pas déjà connue.