Entwurf eines AG-UI-Frontends: Ereignisströme, Widget-Register und lokale LLMs
Hören Sie auf, von Modellen JSX anzufordern. Streamen Sie typisierten Text sowie Ereignisse von Tools/Widgets, validieren Sie Props, registrieren Sie React-Widgets und halten Sie den Renderingschachtelprozess geschlossen, um Änderungen zu vermeiden.
Quellen: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama
Demo-Chattenbots, die nur Markdown darstellen, wirken erstklassig – bis jemand nach einer Tabelle, einem Diagramm, einem Kalender oder einem Formular fragt. Ab diesem Punkt ist das Produkt nicht mehr nur ein Chat-Wrapper, sondern wird zu einer Anwendung – und Markdown ist dabei ein unzureichendes Anwendungsformat.
Nach mehreren Sackgassen entstand eine bessere Herangehensweise: Man sollte das Modell nicht mehr bitten, die Benutzeroberfläche zu erfinden, sondern es dazu bringen, Aktionen auszuwählen. Die Frontend-Plattform weiß bereits, wie Inhalte dargestellt werden sollen; die Backend-Plattform kennt bereits den Speicherort der Daten. Das Modell sollte entscheiden, was als Nächstes passiert.
Durch diese Trennung entstand ein Assistent, der sechzehn Produktions-React-Widgets darstellt, eine vom Backend gesteuerte Benutzeroberfläche unterstützt, mit einem lokalen LLM arbeitet, keine neuen Frontend-Abhängigkeiten hinzufügt und sich durch die Registrierung eines einzigen Komponenten erweitern lässt.
Kurz gesagt: Lehnen Sie Markup ab, das vom Modell erstellt wird. Lassen Sie den Backend-Teil eine sequenzielle Liste von typisierten Ereignissen ausgeben – Prosa, Status der Tools sowie Absichten der Widgets – während der Client ein Register führt und diese Ereignisse in der richtigen Reihenfolge anzeigt. Die einzige Aufgabe des Modells besteht darin, auszuwählen, welche Backend-Funktion antworten soll. Dadurch entsteht eine stabilere Ausgabe, wiederverwendbare Komponenten sowie ein Darstellungsprozess, der sich nicht verändert, wenn das Katalogangebot wächst.
Das Problem mit von KI erzeugten Benutzeroberflächen
Viele KI-Apps folgen einem engen Ansatz:
User
↓
LLM
↓
Markdown
↓
React renders Markdown
Er ist einfach, aber begrenzt. Die Nutzer bitten schließlich darum, aktive Mitglieder anzusehen, den monatlichen Umsatz zu vergleichen, Termine aufzulisten oder ein Profil anzusehen. Diese Antworten sind Interfaces, keine Absätze.
Wenn vom Modell verlangt wird, HTML oder JSX auszugeben, scheitert dies, wenn es fehlende Komponenten erfindet, erforderliche Eigenschaften weglässt oder ein nicht existierendes Widget bezeichnet. Der Frontend kollabiert anschließend aufgrund solcher imaginären Markup-Strukturen. Die generierten Markup-Strukturen können außerdem weder getippt, unitgetestet, außerhalb des Chats wiederverwendet noch in einem Pull Request überprüft werden. Die Garantien der Komponentenbibliothek verschwinden dabei. Stattdessen soll Determinismus erreicht werden.
Die Kernidee: Das Modell wählt Aktionen, nicht Komponenten
Der Backend gibt niemals HTML, JSX oder Namen von React-Komponenten zurück. Jede Antwort ist ein geordneter Ereignisstrom:
{
"events": [
{ "type": "tool", "tool": "list_members", "status": "completed" },
{ "type": "widget", "widget": "table", "props": { "rows": [] } },
{ "type": "text", "text": "I found 24 active members." }
]
}
Der Backend übermittelt die Absicht; der Frontend entscheidet über die Darstellung. Die Verantwortlichkeiten bleiben klar getrennt: Die KI entscheidet, was dargestellt werden soll, React entscheidet, wie es dargestellt wird, und Widgets bestimmen das Aussehen. Der String "table" ist eine semantische Schlüsselwörter, kein Klassenname – der Desktop kann ein Gitter virtualisieren, während das Mobile Karten stapelt, ohne dass sich der Backend darum kümmert.
Das Ereignisprotokoll
Der Vertrag bleibt klein genug, um in dem Arbeitsspeicher unterzubringen. Eingetippte Ereignisse sorgen dafür, dass er sicher bleibt:
type TextEvent = {
type: 'text';
text: string;
};
type ToolEvent = {
type: 'tool';
tool: string;
status: 'running' | 'completed' | 'failed';
error?: string;
};type WidgetEvent = {
type: 'widget';
widget: WidgetName; // union of registered keys
props: Record<string, unknown>; // validated at the boundary
};export type AssistantEvent = TextEvent | ToolEvent | WidgetEvent;
Drei Arten von Ereignissen umfassen die Antworten des Assistenten. Die Reihenfolge ist wichtig: Ein Tool-Chip kann vor dem Widget erscheinen, das er erzeugt hat, und danach kann ein Zusammenfassungssatz folgen. Die Transkript liest sich wie eine erzählte Arbeitsablaufbeschreibung.
Ein Rendering-Pipeline, keine Switch-Anweisungen
Jede Nachricht folgt einem einzigen Pfad:
AiDialog
↓
ChatMessages
↓
ChatMessage
↓
MessagePart
↓
WidgetRenderer
↓
widgetRegistry[widget]
Keine ad-hoc Switch-Anweisungen verunreinigen den Ablauf. Jeder Renderer ist für eine bestimmte Ereignisart zuständig: Text wird zum Chatinhalt, Tools werden zu Fortschrittsanzeigen, Widgets werden zu React-Komponenten. Das Hinzufügen neuer Funktionen erfordert keine Umgestaltung der Pipeline – es werden einfach weitere Widgets registriert. Der Renderer, der bereits ein Widget verarbeitet hat, kümmert sich weiterhin um das sechzehnte Widget.
Für Erweiterungen offen, für Änderungen geschlossen
Neue Widgets werden registriert; der Pipeline bleibt geschlossen. Das ist das Open/Closed-Prinzip, das auf die Chat-Oberfläche angewandt wird. Das Wachstum des Produkts wird zu einem Katalogproblem und nicht zu einem Problem der Neuimplementierung.
Vermuten Sie dem Modell niemals etwas: Validieren Sie die Props an der Grenze
Die Ausgabe des Modells gilt als unzuverlässige Eingabe. Bevor ein Widget geladen wird, durchlaufen die Props eine Schema-Validierung (in dem Beispiel Zod). Ungültige Datenmengen führen zu einer sicheren Fehleranzeige statt zu Laufzeitausnahmen tief im Systembaum.
const registry = {
table: {
schema: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.unknown())).default([]),
}),
component: TableWidget,
},
// ...
};
function WidgetRenderer({ widget, props }: WidgetEvent) {
const entry = registry[widget];
if (!entry) return <UnknownWidget name={widget} />; const parsed = entry.schema.safeParse(props);
if (!parsed.success) return <WidgetError name={widget} issues={parsed.error.issues} />; const Widget = entry.component;
return <Widget {...parsed.data} />;
}
Durch die Validierung an der Grenze werden Abstürze in den Endkomponenten verhindert, und die Entwickler erhalten eine klare Vorstellung davon, wie Fehler auftreten, wenn das Modell die Daten falsch formatiert.
Erstellung einer echten Widget-Bibliothek
Widgets befinden sich in einem Register, das nach semantischen Namen sortiert ist:
export const widgetRegistry = {
table,
profile,
chart,
timeline,
calendar,
markdown,
json,
stats,
progress,
alert,
fileList,
} satisfies Record<string, ComponentType<any>>;
export type WidgetName = keyof typeof widgetRegistry;
WidgetName wird aus dem Registry-System abgeleitet, weshalb TypeScript unbekannte Schlüssel ablehnt. Tabellen, Profile, Diagramme und Formulare werden zu gewöhnlichen Komponenten, die der Rest der Anwendung außerhalb des Chats wiederverwenden kann.
Warum ich keine Abhängigkeiten hinzugefügt habe
Die Chat-Oberfläche nutzte bereits das vorhandene Design-System sowie die React-Stack. Kein neues UI-Kit wurde in package.json aufgenommen. Diese Einschränkung zwang den Assistenten dazu, dieselben Schaltflächen, Tabellen und Token zu verwenden, die das Produkt bereits bereitstellt – damit Chat- und Nicht-Chat-Bildschirme visuell übereinstimmen.
Interaktive Widgets ohne React-Callbacks
Widgets dürfen die Chat-Oberfläche nicht importieren. Sie geben DOM-CustomEvents für Aktionen wie Aktualisierung oder Öffnen eines Profils aus:
// Inside a widget — it knows nothing about the app
element.dispatchEvent(
new CustomEvent('ai:action', {
bubbles: true,
detail: { action: 'open_member', payload: { id: member.id } },
})
);
Widget
↓
CustomEvent
↓
AiDialog
↓
AI Agent
Das Dialogfeld hört einmal zu, ordnet Ereignisse Toolaufrufen zu und sorgt dafür, dass Widgets portabel bleiben. Derselbe Tabellentext kann auf einer Mitgliederseite sowie innerhalb eines Chats verwendet werden, ohne spezifische Chat-Eigenschaften erfordern zu müssen.
Die KI entscheidet nicht über die Benutzeroberfläche
Das Modell wählt niemals Farben, Layouts oder Komponentenstrukturen aus. Es wählt lediglich Tools aus. Die Backend-Orchestrierung wandelt die Ergebnisse der Tools in Widget-Ereignisse mit validierten Eigenschaften um. Das Design bleibt weiterhin in den Händen der Frontend-Entwickler.
Ersatz von fest codierten Endpunkten durch ein Tool-Katalog
In frühen Prototypen waren URLs fest codiert. Die finale Version bietet einen Tool-Katalog, den der Host entdecken kann:
{
"message": "Show me all active members",
"tools": [
{ "name": "list_members", "endpoint": "/api/v1/members", "method": "GET" },
{ "name": "get_member", "endpoint": "/api/v1/members/{id}", "method": "GET" },
{ "name": "member_stats", "endpoint": "/api/v1/members/stats", "method": "GET" }
]
}
Jedes Tool gibt einen Endpunkt sowie ein Schema an. Das Modell wählt ein Tool aus; das Backend führt es aus; Widget-Ereignisse übertragen das Ergebnis. Der Austausch der Infrastruktur erfolgt durch Bearbeitung des Katalogs und nicht durch Neuformulierung von Anfragen.
Streaming ohne Verletzung des Vertrags
Das Streaming sendet weiterhin die gleichen Ereignistypen. Der teilweise Status der Tool kann von running auf completed wechseln, während Text-Token eintreffen. Die Render-App fügt Ereignisse hinzu oder aktualisiert sie, ohne ein zweites Protokoll zu erfinden. Die Benutzer sehen Fortschrittsanzeigen, bevor die endgültigen Widgets angezeigt werden.
Eine moderne Chat-Erfahrung erstellen
Die Host-Seite bleibt eine gewöhnliche Auflistung. Der Assistent versteckt sich hinter einer Chat-Funktion mit KI-Steuerung und antwortet unter Verwendung derselben Daten und Komponenten, die die Seite bereits verwendet. Kontinuität ist wichtiger als ein separates „KI-Produkt“-Design.
Feature Flags sind entscheidend
Ein einzelner Flag steuert die Funktionalität:
VITE_ENABLE_AI_CHAT
Flags ermöglichen es Teams, den Dialog in einer Testversion zu starten, ihn unter Last auszuschalten oder ihn nur Mitarbeitern zugänglich zu machen, ohne den Widget-Code neu bereitzustellen.
Testen eines Widget-Frameworks
Die Tests richten sich auf drei Ebenen: Die Schema-Validierung lehnt fehlerhafte Eigenschaften ab, die Registrierungsabfrage findet bekannte Widgets, und die Ereignisreihenfolge stellt wie erwartet Tool → Widget → Text dar. Die Unit-Tests für Widgets laufen außerhalb des Chats. End-to-End-Tests öffnen das Dialogfeld, fragen nach Mitgliedern und überprüfen, ob eine Tabelle angezeigt wird.
Wann diese Architektur übertrieben ist
Pure FAQ-Bots, die nur Absätze benötigen, können weiterhin in Markdown verwendet werden. Das Ereignisprotokoll ist sinnvoll, wenn die Antworten als Schnittstellen dienen, wenn Widgets außerhalb des Chats wiederverwendet werden müssen und wenn das Team nicht zulässt, dass das Modell Komponentenbäume erfindet.
Entwurfsprinzipien, die jede Entscheidung leiteten
- Das Modell wählt Aktionen aus, nicht die Markierung.
- Ein Rendering-Pipeline für jede Nachricht.
- Widgets werden registriert; die Pipeline teilt sich nicht auf.
- Eigenschaften werden vor dem Anzeigen validiert.
- Widgets kommunizieren über CustomEvents, nicht durch Chat-Importe.
Das Ergebnis
Der Assistent strahlt eingegebene Ereignisse aus, rendernt sechzehn Widgets über einen einzigen Pipeline-Prozess, validiert jede Eigenschaft und erweitert sich durch Registrierung. Lokale LLM-Backends bleiben austauschbar, da der Vertrag auf Ereignissen beruht und nicht auf Anbieter-SDKs in der Benutzeroberfläche.
Zusammenfassung
Es ist sinnlos, Modelle dazu zu bringen, Benutzeroberflächen zu erstellen. Besser ist es, sie dazu anzuleiten, Funktionen auszuwählen – und einem strukturierten Frontend zu ermöglichen, diese Funktionen darzustellen –, um Designsysteme, Testbarkeit und weiteren Entwicklungsfortschritt zu bewahren. Das Chat-Fenster wird so zu einer weiteren Plattform für dieselben Komponenten, denen das Produkt bereits vertraut.
Der Mitglieder-Überblick im Beispiel bleibt eine gewöhnliche Produktseite. Der Assistent ist kein zweites Produkt; er befindet sich hinter einer „Chat with AI“-Steuerung und antwortet mit derselben Datenebene sowie denselben Komponenten, die bereits auf der Seite verwendet werden. Diese Kontinuität ist beabsichtigt. Wenn der Chat eine parallele visuelle Sprache entwickelt, verlieren die Nutzer das Vertrauen darin. Wenn Chat und Seite Widgets teilen, wirkt sich jede Verbesserung der Tabelle an beiden Stellen aus.
Die Anordnung innerhalb eines Ereignisarrays gehört zur Produktsprache. Ein tool-Ereignis mit Status running kann zuerst erscheinen, damit der Nutzer sieht, dass die Arbeit beginnt. Ein widget-Ereignis kann anschließend mit Zeilen folgen. Ein text-Ereignis kann mit einer einzeiligen Zusammenfassung abschließen. Die Umordnung dieser Ereignisse verändert das Gefühl beim Gespräch, ohne irgendwelche React-Komponenten zu ändern. Betrachten Sie das Array als Drehbuch, das von den Entwicklern im Backend und den Frontend-Entwicklern umgesetzt wird.
Schemavergleich ist keine optionalen Formalität. Modelle überspringen Felder, zwingen Zahlen in Zeichenketten um und benennen Schlüssel gelegentlich um. Zod (oder ein Äquivalent) an der Grenze zum Registry-System verwandelt diese Fehler in kontrollierte Fehleranzeigen statt in einen leeren Bildschirm. Das Protokollieren des Validierungsfehlers zusammen mit dem Rohdatenpaket ermöglicht schnellere Korrekturen mithilfe von Tools als das Raten aufgrund eines Benutzerberichts.
CustomEvents sorgen für klare Zuordnungen. Ein Widget, das Chat-Hooks importiert, kann nicht auf einer statischen Seite wiederverwendet werden. Ein Widget, das nur member:open oder table:refresh aussendet, kann überall verwendet werden. Das Dialogfeld fungiert dabei als Adapter, der diese Ereignisse in Aufrufe von Tools übersetzt. Dieser Adapter ist der einzige Ort, der über die Assistentensitzung Bescheid weiß.
Das Streaming unterliegt demselben Vertrag. Sobald Tokens und Statusinformationen der Tools vorliegen, fügt die Client-App Events hinzu oder aktualisiert sie direkt. Es gibt kein zweites „Streaming-Format“. Wenn ein Tool während des Betriebs versagt, ersetzt der Status failed den Status running, und ein optionaler Textevent erklärt den Fehler. Die Benutzer sehen somit Kontinuität anstelle eines leise versagenden Spinners.
Feature Flags gewährleisten Betriebssicherheit. Durch das Bereitstellen von sechzehn Widgets unter VITE_ENABLE_AI_CHAT können Teams den Dialog bei hohem Auslastungsdruck deaktivieren, ohne Code entfernen zu müssen. Kombinieren Sie das Flag mit einer pro-Tool-Autorisierung, damit ein nur zum Lesen zugänglicher Katalog weiterhin verfügbar bleibt, während zerstörerische Tools deaktiviert bleiben.
Die Teststrategie spiegelt die Architektur wider. Unit-Tests decken Schemata sowie die reine Darstellung von Widgets ab. Integrationstests liefern ein festes Ereignisarray an den Renderer und erstellen einen Schnappschuss der Struktur. End-to-End-Tests fordern Mitglieder von einem echten oder emulierten Backend an und überprüfen, ob ein Tabelle-Widget mit den erwarteten Spalten angezeigt wird. Da Widgets gewöhnliche Komponenten sind, können bestehende Visual-Regression-Suiten sie ohne spezielle KI-Hilfsmittel einbeziehen.
Auch in Fällen von Überdimensionierung gibt es noch Anwendungsmöglichkeiten. Wenn das Produkt nur definitorische Fragen mit kurzen Absätzen beantwortet, bleibt Markdown kostengünstiger. Die Ereignisarchitektur lohnt sich, wenn die Antworten in Form von Schnittstellen vorliegen, wenn dieselben Widgets außerhalb des Chats angezeigt werden und wenn das Team nicht zulässt, dass das Modell Komponentenbäume erfindet. Diese drei Bedingungen treten in der Regel gemeinsam auf, sobald ein internes Tools-Produkt ausgereift ist.
Keine neuen Frontend-Abhängigkeiten zu verwenden, war eine bewusste Einschränkung und kein Wettbewerb um Reinheit. Allein die Hinzufügung einer weiteren Diagrammbibliothek für Chats würde das Design-System verzweigen. Die Wiederverwendung der vorhandenen Diagramm- und Tabellenpakete zwang den Assistenten dazu, die visuelle Sprache des Produkts zu verwenden. Der Preis dafür waren gelegentlicher Adaptercode; der Vorteil war ein einheitliches Erscheinungsbild sowie eine gemeinsame Barrierefreiheitsbasis.
Die Unterstützung lokaler LLMs ist bei datenschutzkritischen Implementierungen wichtig. Da die Benutzeroberfläche nur Ereignisse versteht, ist der Austausch eines Cloud-Modells gegen einen lokalen Laufzeitumgebung ein Problem des Backends. Das Registry, die Validatoren sowie der CustomEvent-Bus bleiben unverändert. Genau diese Trennung ermöglicht es, dieselbe Frontend-Demo sowohl mit Ollama in der Entwicklung als auch mit einem gehosteten Modell in der Produktion zu nutzen, ohne das Chat-Interface verändern zu müssen.
Die Erweiterung des Systems ist absichtlich langweilig. Fügen Sie eine Widget-Datei hinzu, registrieren Sie sie mit einem Schema, mappen Sie das Ergebnis eines Tools auf Eigenschaften um und schreiben Sie einen Test. Es gibt keine Änderungen am Renderer. Keine neuen Ereignistypen – es sei denn, das Produkt benötigt tatsächlich eine vierte Art, und bisher haben drei ausgereicht. Die Langeweile ist hier ein Vorteil: Sie bedeutet, dass Junior-Mitarbeiter Widgets beitragen können, ohne ein speziell entwickeltes AI-UI-Framework lernen zu müssen.
Der Endzustand erinnert weniger an einen Chatbot, der auf ein Produkt geklebt wurde, und mehr an einen konversationalen Router zu den vorhandenen Fähigkeiten des Produkts. Das ist der architektonische Vorteil. Das Modell wird zum Dispatcher. Das Design-System bleibt die Quelle der Wahrheit für die Darstellung. Der Backend bleibt die Quelle der Wahrheit für Daten und Berechtigungen.
Operativ gesehen sollte man neben den Änderungen im Toolkatalog auch ein Changelog der Widget-Registrierungen führen. Wenn sich die Antwortstruktur eines Tools ändert, muss das entsprechende Widget-Schema in derselben Pull-Request angepasst werden. Diese Koppeling verhindert das klassische Problem, bei dem der Agent weiterhin korrekt im Text antwortet, während das Widget stumm leere Spalten anzeigt. Reviewer sollten Tool-PRs ablehnen, die das zugehörige Schema vergessen.
Accessibilität ergibt sich aus der Wiederverwendung von Produktions-Widgets. Wenn die Mitgliedertabelle bereits Tastaturnavigation und Beschriftungen für Screenreader unterstützt, erbt die im Chat angezeigte Tabelle diese Funktionen. Die Erstellung von ad-hoc HTML aus einem Modell würde diese Investition zunichtemachen. Die Event-Architektur ist daher nicht nur eine Wahl aus Gründen der Zuverlässigkeit, sondern auch eine Strategie zum Erhalt der Accessibilität.
Kernpunkte
- Bevorzugen Sie Event-Ströme (Text, Tool, Widget) vor HTML/JSX, das von Modellen erzeugt wird.
Zusammen bilden der Event-Stream, das Register, die Validatoren sowie der CustomEvent-Bus ein kleines, aber vollständiges Framework für eine von KI unterstützte Produkt-UI, ohne die Gestaltungskontrolle an das Modell abzugeben. Iterieren Sie kontinuierlich an den Widgets, statt nur einmalige Markup-Anfragen zu erstellen – so bleibt der Assistent auch bei wachsendem Produkt wartbar.