So baust du mit shadcn/ui und dem Vercel AI SDK eine KI-Chat-UI, die echte Nutzer übersteht
Ein streamender KI-Chat ist leicht zu demonstrieren und schwer auszuliefern. Die Zustände, die ein Chat mit shadcn/ui und AI SDK abdecken muss, von Stop und Retry bis zu Tool-Freigaben und CJK-Eingabe.
Preet Suthar · · 6 Min. Lesezeit
- AI
- React
- shadcn/ui
- Next.js
Die Demo dauert einen Nachmittag. useChat aus dem AI SDK, eine Liste von Nachrichten, eine Textarea, und Tokens streamen auf den Bildschirm. Das ganze Team ist beeindruckt.
Dann benutzt es ein echter Mensch. Er drückt Enter, während er Japanisch eingibt, und sendet ein halbes Wort. Er stoppt eine Antwort, und die UI zeigt nichts. Ein Tool Call will mitten im Stream eine Freigabe, und dafür gibt es keine UI. Er scrollt nach oben, um etwas nachzulesen, und die Ansicht reißt ihn mit jedem Token zurück nach unten.
Ich habe diesen Screen öfter gebaut, als mir lieb ist, zuletzt als die Blocks hinter HextaUI. Das hier ist die Checkliste dessen, was eine Chat-UI abdecken muss, bevor sie fertig ist, jeweils mit den Besonderheiten von shadcn/ui und AI SDK.
Beginne mit Message-Parts, nicht mit Strings
Das useChat des AI SDK liefert Nachrichten, die aus Parts bestehen: Text, Reasoning, Tool Calls mit ihrem Zustand, Quellen, Dateien. Rendere die Parts, nicht einen zusammengefügten String. Jedes Feature unten hängt davon ab.
Tool-Parts sind als tool-<name> typisiert, deshalb ist isToolUIPart die verlässliche Prüfung.
Halte deine Blocks frei vom SDK-Import. Nimm Parts als Props entgegen. Dann funktionieren dieselben Komponenten mit einem LangGraph-Backend oder deinem eigenen Server, sobald du dessen Ausgabe auf dieselbe Struktur abbildest.
Der Composer
In der Textarea sitzen die meisten Bugs.
Enter sendet, Shift+Enter macht einen Zeilenumbruch. Das machen alle. Fast niemand behandelt Composition. Wenn jemand Japanisch, Chinesisch oder Koreanisch tippt, ist der IME offen und Enter bestätigt den Kandidaten, nicht die Nachricht. Prüfe event.nativeEvent.isComposing und steig aus:
Mit dem Text wachsen, in Grenzen. Eine Zeile zu Beginn, bis etwa acht Zeilen, dann scrollen. Animiere die Höhenänderung, und nie über React-State bei jedem Tastendruck. Miss scrollHeight in einem Layout-Effekt und setze die Höhe direkt am DOM-Knoten. Jedes setState in einem nativen Input-Listener wird unter Last irgendwann Tasten verschlucken oder verzögern.
Entwürfe überleben einen Reload. Speichere den Entwurf in localStorage, nach Chat-ID, mit Debounce. Leute verlieren lange Prompts durch versehentliche Reloads und verzeihen dir das nie.
Nachrichten, die während einer Antwort getippt werden, in eine Warteschlange stellen. Wenn das Modell noch schreibt und der Nutzer eine weitere Nachricht sendet, deaktiviere entweder das Senden oder stelle sie in die Warteschlange und sende sie, wenn der Stream endet. Sie stillschweigend zu verwerfen ist die schlechteste Option, und sie ist der Standard.
Anhänge zeigen Fortschritt und Retry. Uploads schlagen fehl. Zeig den Fehler auf der Anhang-Karte mit einem Retry, nicht als Toast, der verschwindet.
Streaming, das sich gut liest
Modelle geben Tokens in Schüben aus. Renderst du genau das, was ankommt, stottert der Text: nichts, dann zwölf Wörter, dann nichts. Glätte es. Zeige Zeichen in gleichmäßigem Tempo, das schneller wird, wenn der Puffer wächst, sodass du nie weit hinter den Stream zurückfällst, aber auch nie stotterst.
Markdown beim Streaming hat seine eigene Falle: halb getippte Syntax. Das Modell hat **bold geschrieben und noch nicht geschlossen, also zeigt dein Renderer für einen Frame buchstäblich Sternchen und dann fetten Text. Halte unvollständige Inline-Syntax zurück, bis sie geschlossen wird oder der Stream endet.
Codeblöcke brauchen dieselbe Sorgfalt. Hebe inkrementell hervor und hebe nie bei jedem Token den ganzen Block neu hervor.
Stop, Retry, Bearbeiten und Versionen
Stop sollte die unvollständige Antwort auf dem Bildschirm lassen, mit einer sichtbaren „Stopped“-Markierung, damit der Nutzer weiß, dass sie unvollständig ist. Lösche sie nicht.
Retry bei einer fehlgeschlagenen Antwort ist ein Button an der Nachricht, keine versteckte Geste. Verbinde ihn mit regenerate().
Bearbeiten einer früheren Nachricht erzeugt einen Zweig. Die alte und die neue Antwort existieren beide. Zeig einen kleinen 1 / 2-Umschalter an der Nachricht, damit der Nutzer zwischen Versionen wechseln kann. Hier geben viele selbstgebaute Chat-UIs auf, und es ist das Feature, das Leute von ChatGPT und Claude am meisten vermissen.
Fehlgeschlagene Antworten bekommen einen Grund und einen Retry. „Something went wrong“ ohne Aktion ist eine Sackgasse.
Scrollen
Folge neuen Tokens, wenn der Nutzer ganz unten ist. Sobald er nach oben scrollt, hör auf zu folgen und zeig einen „jump to latest“-Button mit der Anzahl dessen, was er verpasst hat. Springt er, folge wieder.
Älteren Verlauf oben nachzuladen darf die Ansicht nicht verschieben. Miss die Scrollhöhe vor und nach dem Voranstellen und korrigiere scrollTop im selben Frame um die Differenz.
Sendet der Nutzer eine Nachricht, scrolle so, dass seine Nachricht oben im Viewport hängen bleibt und die Antwort darunter streamt. So verhalten sich die beiden großen Chat-Apps inzwischen, und Nutzer erwarten es.
Tool Calls und Freigaben
Das AI SDK gibt jedem Tool-Part einen Zustand: input-streaming, input-available, output-available, output-error und mit Human-in-the-Loop approval-requested, approval-responded und output-denied. Jeder Zustand braucht eine Darstellung.
Lesezugriffe und Suchen können zu einer einzeiligen Zusammenfassung eingeklappt werden. Bearbeitungen, Befehle und Fehler sollten ausgeklappt bleiben. Freigaben blockieren den Stream und brauchen ein klares Allow und Deny, plus eine Möglichkeit, „do this instead“ zu sagen, die Text als Ablehnungsgrund an das Modell zurückschickt.
Zeig das Ergebnis, wenn es nützlich ist. Eine Dateibearbeitung öffnet sich zu einem Diff. Ein Befehl öffnet sich zu einem Terminal mit der Ausgabe. Eine Suche öffnet sich zu Ergebnissen. Ein Tool Call, der nur „Done“ sagt, bringt Nutzern bei, Tool Calls zu ignorieren.
Denken und Quellen
Reasoning-Parts sollten einklappbar sein und eingeklappt, sobald die Antwort beginnt. Solange das Modell noch nachdenkt, beruhigt ein Live-Label, das jedem Schritt folgt („Searching docs“, „Reading three files“), weit mehr als ein Spinner.
Quellenangaben gehören neben die Aussagen, die sie belegen, inline, nicht in eine Liste ganz unten, die niemand liest.
Barrierefreiheit, die du nicht überspringen kannst
- Neue Nachrichten werden über eine
role="log"-Region angekündigt, damit Screenreader-Nutzer Antworten hören. - Der Stop-Button ist per Tastatur erreichbar und hat ein Label.
- Streamender Text sollte nicht hundert Live-Region-Ankündigungen auslösen. Kündige an, wenn die Nachricht fertig ist, oder an Satzgrenzen.
- Jeder Button, der nur ein Icon zeigt, hat ein
aria-label.
Alles zusammengesetzt
Wenn du die Einzelteile willst: shadcn/ui hat im Oktober 2026 AI Elements veröffentlicht, 21 Patterns, die du in dein Projekt kopierst und selbst zusammensetzt, darunter Chat, Prompt Input, Reasoning und Tool. Vercels frühere AI-Elements-Bibliothek wird Ende November 2026 zu ihren Gunsten eingestellt.
Wenn du die Screens fertig zusammengesetzt willst, mit den Zuständen oben schon ausgearbeitet, dann sind das die HextaUI-Blocks: ein Chat Thread mit Stop, Retry und Versionen, Tool Calls mit jedem AI-SDK-Zustand und Freigaben, Streaming und Markdown, die nie halb getippte Syntax zeigen, und ein Prompt Input, der CJK-Eingabe, Uploads, Warteschlangen und Entwürfe beherrscht. Prompt Input ist kostenlos. Der Rest ist ein einmaliger Kauf. Alles liest AI-SDK-Message-Parts und nichts davon importiert das SDK, es funktioniert also mit jedem Backend, das du abbilden kannst.
Du kannst das Ganze fertig zusammengesetzt als Chat-App unter hextaui.com/blocks/hexta-ai ausprobieren, bevor du irgendetwas installierst.
Häufige Fragen
Brauche ich das Vercel AI SDK, um diese Patterns zu nutzen? Nein. Das Nützliche ist die Struktur der Message-Parts. Jedes Backend funktioniert, sobald du seine Ausgabe auf Parts abbildest.
Wie gehe ich mit Enter bei einem IME um? Prüfe event.nativeEvent.isComposing in deinem keydown-Handler und kehre früh zurück.
Sollte streamender Text bei jedem Token eine Live-Region aktualisieren? Nein. Kündige bei Abschluss oder an Satzgrenzen an, sonst werden Screenreader unbenutzbar.
Was passiert mit einer gestoppten Antwort? Behalte den Teiltext und markiere ihn als gestoppt. Lösche nie, was der Nutzer schon gelesen hat.