Comment construire une interface de chat IA avec shadcn/ui et l’AI SDK de Vercel qui survit à de vrais utilisateurs
Un chat IA en streaming est facile à démontrer et difficile à livrer. Les états qu’un chat shadcn/ui et AI SDK doit gérer, de l’arrêt et la nouvelle tentative aux approbations d’outils et à la saisie CJK.
Preet Suthar · · 6 min de lecture
- AI
- React
- shadcn/ui
- Next.js
La démo prend un après-midi. useChat de l’AI SDK, une liste de messages, un textarea, et les tokens défilent à l’écran. Toute l’équipe est impressionnée.
Puis une vraie personne l’utilise. Elle appuie sur Entrée en composant du japonais et envoie un demi-mot. Elle arrête une réponse et l’interface n’affiche rien. Un appel d’outil demande une approbation en plein stream et il n’y a aucune interface pour ça. Elle remonte pour relire quelque chose et la vue la ramène brutalement en bas à chaque token.
J’ai construit cet écran plus de fois que je ne le voudrais, plus récemment sous la forme des blocks derrière HextaUI. Voici la checklist de ce qu’une interface de chat doit gérer avant d’être terminée, avec les spécificités shadcn/ui et AI SDK pour chaque point.
Partez des parties de message, pas des chaînes
Le useChat de l’AI SDK renvoie des messages faits de parties : texte, raisonnement, appels d’outils avec leur état, sources, fichiers. Affichez les parties, pas une chaîne concaténée. Toutes les fonctionnalités ci-dessous en dépendent.
Les parties d’outil sont typées tool-<name>, donc isToolUIPart est la vérification fiable.
Gardez vos blocs sans import du SDK. Prenez les parties en props. Les mêmes composants fonctionnent alors avec un backend LangGraph ou votre propre serveur dès que vous convertissez sa sortie vers la même forme.
Le composeur
Le textarea est l’endroit où vivent la plupart des bugs.
Entrée envoie, Maj+Entrée revient à la ligne. Tout le monde fait ça. Presque personne ne gère la composition. Quand quelqu’un tape en japonais, en chinois ou en coréen, l’IME est ouvert et Entrée confirme le candidat, pas le message. Vérifiez event.nativeEvent.isComposing et sortez :
Grandissez avec le texte, dans certaines limites. Une ligne au départ, jusqu’à environ huit lignes, puis défilement. Animez le changement de hauteur, et jamais via l’état React à chaque frappe. Mesurez scrollHeight dans un layout effect et définissez la hauteur sur le nœud DOM. Tout setState dans un écouteur natif d’input finira par perdre ou retarder des touches sous charge.
Les brouillons survivent à un rechargement. Enregistrez le brouillon dans localStorage, indexé par identifiant de chat, avec un debounce. Les gens perdent de longs prompts dans des rafraîchissements accidentels et ne vous le pardonnent jamais.
Mettez en file d’attente les messages tapés pendant une réponse. Si le modèle écrit encore et que l’utilisateur envoie un autre message, soit désactivez l’envoi, soit mettez-le en file d’attente et envoyez-le à la fin du stream. Le perdre silencieusement est la pire option, et c’est celle par défaut.
Les pièces jointes affichent la progression et une nouvelle tentative. Les uploads échouent. Affichez l’échec sur la carte de la pièce jointe avec un bouton pour réessayer, pas dans un toast qui disparaît.
Un streaming qui se lit bien
Les modèles émettent les tokens par rafales. Si vous affichez exactement ce qui arrive, le texte saccade : rien, puis douze mots, puis rien. Lissez-le. Révélez les caractères à un rythme régulier qui accélère quand le tampon grossit, pour ne jamais prendre beaucoup de retard sur le stream sans jamais saccader non plus.
Le Markdown pendant le streaming a son propre piège : la syntaxe à moitié tapée. Le modèle a écrit **bold sans encore le fermer, donc votre moteur de rendu affiche des astérisques littéraux pendant une frame, puis du texte en gras. Retenez la syntaxe inline incomplète jusqu’à sa fermeture, ou jusqu’à la fin du stream.
Les blocs de code demandent le même soin. Colorez de façon incrémentale et ne recolorez jamais tout le bloc à chaque token.
Arrêt, nouvelle tentative, modification et versions
Arrêter doit laisser la réponse partielle à l’écran, avec un marqueur « Stopped » visible pour que l’utilisateur sache qu’elle est incomplète. Ne la supprimez pas.
Réessayer une réponse en échec est un bouton sur le message, pas un geste caché. Reliez-le à regenerate().
Modifier un message antérieur crée une branche. L’ancienne et la nouvelle réponse existent toutes les deux. Affichez un petit sélecteur 1 / 2 sur le message pour que l’utilisateur puisse basculer entre les versions. C’est là que beaucoup d’interfaces de chat maison abandonnent, et c’est la fonctionnalité de ChatGPT et Claude qui manque le plus aux gens.
Les réponses en échec reçoivent une raison et une nouvelle tentative. « Something went wrong » sans action est une impasse.
Défilement
Suivez les nouveaux tokens quand l’utilisateur est en bas. Dès qu’il remonte, arrêtez de suivre et affichez un bouton « aller au plus récent » avec le nombre de ce qu’il a manqué. Quand il y saute, suivez à nouveau.
Charger l’historique plus ancien en haut ne doit pas déplacer la vue. Mesurez la hauteur de défilement avant et après l’insertion, et ajustez scrollTop de la différence dans la même frame.
Si l’utilisateur envoie un message, faites défiler pour que son message s’épingle en haut de la fenêtre et que la réponse se diffuse en dessous. C’est ainsi que les deux grandes applications de chat se comportent aujourd’hui, et les utilisateurs s’y attendent.
Appels d’outils et approbations
L’AI SDK donne à chaque partie d’outil un état : input-streaming, input-available, output-available, output-error, et avec un humain dans la boucle, approval-requested, approval-responded et output-denied. Chaque état a besoin d’un rendu.
Les lectures et les recherches peuvent se replier en un résumé d’une ligne. Les modifications, les commandes et les erreurs doivent rester dépliées. Les approbations bloquent le stream et ont besoin d’un Allow et d’un Deny clairs, plus d’un moyen de dire « fais plutôt ceci » qui renvoie du texte au modèle comme raison du refus.
Affichez le résultat quand il est utile. Une modification de fichier s’ouvre sur un diff. Une commande s’ouvre sur un terminal avec la sortie. Une recherche s’ouvre sur des résultats. Un appel d’outil qui dit seulement « Done » apprend aux utilisateurs à ignorer les appels d’outils.
Réflexion et sources
Les parties de raisonnement doivent être repliables, et repliées par défaut dès que la réponse commence. Pendant que le modèle réfléchit encore, un libellé en direct qui suit chaque étape (« Searching docs », « Reading three files ») rassure bien plus qu’un spinner.
Les citations vont à côté des affirmations qu’elles étayent, en ligne, pas dans une liste en bas que personne ne lit.
L’accessibilité que vous ne pouvez pas sauter
- Les nouveaux messages sont annoncés via une région
role="log", pour que les utilisateurs de lecteurs d’écran entendent les réponses. - Le bouton d’arrêt est accessible au clavier et possède un libellé.
- Le texte en streaming ne doit pas déclencher cent annonces de live region. Annoncez quand le message est terminé, ou aux limites de phrase.
- Chaque bouton avec seulement une icône a un
aria-label.
Assembler le tout
Si vous voulez les pièces, shadcn/ui a publié AI Elements en octobre 2026 : 21 patterns que vous copiez dans votre projet et composez vous-même, dont Chat, Prompt Input, Reasoning et Tool. L’ancienne bibliothèque AI Elements de Vercel est dépréciée fin novembre 2026 à leur profit.
Si vous voulez les écrans déjà assemblés avec les états ci-dessus réglés, c’est ce que sont les blocks HextaUI : un Chat Thread avec arrêt, nouvelle tentative et versions, des Tool Calls avec chaque état de l’AI SDK et les approbations, Streaming et Markdown qui n’affichent jamais de syntaxe à moitié tapée, et un Prompt Input qui gère la saisie CJK, les uploads, les files d’attente et les brouillons. Prompt Input est gratuit. Le reste est un achat unique. Tout lit les parties de message de l’AI SDK et rien n’importe le SDK, donc cela fonctionne avec n’importe quel backend que vous pouvez convertir.
Vous pouvez essayer l’ensemble assemblé en application de chat sur hextaui.com/blocks/hexta-ai avant d’installer quoi que ce soit.
FAQ
Ai-je besoin de l’AI SDK de Vercel pour utiliser ces patterns ? Non. La forme en parties de message est ce qui est utile. N’importe quel backend fonctionne dès que vous convertissez sa sortie en parties.
Comment gérer Entrée avec un IME ? Vérifiez event.nativeEvent.isComposing dans votre gestionnaire keydown et sortez tôt.
Le texte en streaming doit-il mettre à jour une live region à chaque token ? Non. Annoncez à la fin ou aux limites de phrase, sinon les lecteurs d’écran deviennent inutilisables.
Qu’advient-il d’une réponse arrêtée ? Gardez le texte partiel et marquez-le comme arrêté. Ne supprimez jamais ce que l’utilisateur a déjà lu.