Projektowanie interfejsu AG-UI: strumienie zdarzeń, rejestr widgetów i lokalne modele językowe
Przestań prosić modele o JSX. Przesyłaj tekst typowany oraz zdarzenia narzędzi i widgetów, waliduj właściwości, rejestruj widgety React i utrzymuj proces renderowania zamknięty w celu możliwości modyfikacji.
Źródła: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama
Demo boty do czatowania, które wykorzystują tylko Markdown, wyglądają profesjonalnie, dopóki ktoś nie poprosi o tabelę, wykres, kalendarz lub formularz. W tym momencie produkt przestaje być zwykłym narzędziem do czatowania i staje się aplikacją – a Markdown jest słabym formatem dla aplikacji.
Lepsze rozwiązanie pojawiło się po kilku nieudanych próbach: należy przestać prosić model o tworzenie interfejsu użytkownika. Należy poprosić go o wybór działań. Frontend już wie, jak renderować treść; backend zna lokalizację danych. To model powinien decydować, co nastąpi dalej.
Taki podział doprowadził do stworzenia asystenta, który renderuje szesnaście komponentów React przeznaczonych do użycia w produkcji, obsługuje interfejs sterowany przez backend, działa z lokalnym modelem językowym, nie dodaje nowych zależności frontendowych i rozszerza się poprzez rejestrację jednego komponentu.
Krótko mówiąc: odrzuć zapisy tworzone przez model. Niech serwer wytwarza uporządkowaną listę zdarzeń typowanych – tekstu, stanu narzędzi oraz intencji widgetów – podczas gdy klient utrzymuje rejestr i wyświetla te zdarzenia w odpowiedniej kolejności. Jedynym zadaniem modelu jest wybór tej funkcji serwera, która ma odpowiedzieć. Dzięki temu uzyskujemy bardziej stabilny wynik, komponenty wielokrotnie używalne oraz proces renderowania, który nie ulega zmianom wraz ze wzrostem liczby elementów katalogu.
Problem z interfejsami generowanymi przez AI
Wiele aplikacji opartych na AI podąża wąską ścieżką:
User
↓
LLM
↓
Markdown
↓
React renders Markdown
Jest to proste, ale ograniczające. Użytkownicy z czasem proszą o pokazanie aktywnych członków, porównanie miesięcznych przychodów, wylistowanie umów lub otwarcie profilu. Te odpowiedzi to interfejsy, a nie akapity.
Zapytanie modelu o wygenerowanie HTML lub JSX zawodzi, gdy ten wymyśla brakujące komponenty, pomija niezbędne atrybuty lub nazywa widget, którego nie istnieje. Frontend wtedy zawala z powodu fikcyjnego kodu. Wygenerowany kod nie może być również skompilowany, przetestowany jednostkowo, ponownie wykorzystany poza czatem ani przejrzany w prośbie o pull request. Gwarancje biblioteki komponentów znikają – celem staje się natomiast determinizm.
Główna idea: model wybiera działania, a nie komponenty
Backend nigdy nie zwraca HTML, JSX ani nazw komponentów React. Każda odpowiedź to uporządkowany strumień zdarzeń:
{
"events": [
{ "type": "tool", "tool": "list_members", "status": "completed" },
{ "type": "widget", "widget": "table", "props": { "rows": [] } },
{ "type": "text", "text": "I found 24 active members." }
]
}
Backend komunikuje intencję; frontend decyduje o prezentacji. Obowiązki są jasno rozdzielone: AI decyduje co, React decyduje jak, a widgety decydują o wyglądzie. Ciąg znaków "table" to klucz semantyczny, a nie nazwa klasy – komputer stacjonarny może wirtualizować tabelę, podczas gdy urządzenie mobilne układa karty, bez żadnego udziału backendu.
Protokół zdarzeń
Kontrakt pozostaje na tyle mały, by zmieścić się w pamięci roboczej. Zdarzenia wpisane ręcznie zapewniają jego bezpieczeństwo:
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;
Trzy rodzaje zdarzeń obejmują odpowiedzi asystenta. Kolejność ma znaczenie: chip narzędzia może pojawić się przed widgetem, który wygenerowało, a następnie może nastąpić zdanie podsumowujące. Transkrypcja przypomina opisany proces pracy.
Jeden pipeline renderowania, brak instrukcji switch
Każda wiadomość przemieszcza się jedną ścieżką:
AiDialog
↓
ChatMessages
↓
ChatMessage
↓
MessagePart
↓
WidgetRenderer
↓
widgetRegistry[widget]
W drzewie nie ma przypadkowych instrukcji switch. Jeden renderer obsługuje każdy rodzaj zdarzenia: tekst staje się treścią czatu, narzędzie – stanem postępu, widget – komponentem React. Dodawanie nowych funkcji nie wymaga przepisywania całego pipeline’u – wystarczy zarejestrować kolejny widget. Renderer, który obsługiwał widget numer jeden, nadal obsługuje widget numer szesnaście.
Otwarty na rozbudowę, zamknięty na modyfikację
Nowe elementy interfejsu są rejestrowane; łańcuch przetwarzania pozostaje zamknięty. To właśnie jest zasada otwarto-zamknięcia zastosowana w interfejsie czatu. Rozwój produktu staje się problemem katalogowania, a nie koniecznością przepisywania kodu.
Nigdy nie ufać modelowi: waliduj właściwości na granicy
Wynik modelu jest danymi niezaufanymi. Zanim element interfejsu zostanie załadowany, jego właściwości przechodzą przez walidację schematu (w przykładzie użyto Zod). Nieprawidłowe dane zamieniają się w bezpieczny interfejs błędów, zamiast powodować wyjątki w trakcie działania aplikacji.
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} />;
}
Walidacja na granicy zapobiega awariom w komponentach najniższego poziomu i daje operatorom jasny obraz sposobu wystąpienia błędu, gdy model niewłaściwie przetwarza dane.
Budowanie prawdziwej biblioteki elementów interfejsu
Elementy interfejsu znajdują się w rejestrze opartym na semantycznych nazwach:
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 jest pobierany z rejestru, więc TypeScript odrzuca nieznane klucze. Tabele, profile, wykresy i formularze stają się zwykłymi komponentami, które reszta aplikacji może ponownie wykorzystać poza czatem.
Dlaczego dodałem zerowe zależności
Obudowa czatu wykorzystywała istniejący system projektowy oraz stack React. Żaden nowy zestaw interfejsu nie trafił do pliku package.json. To ograniczenie zmusiło asystenta do korzystania z tych samych przycisków, tabel i elementów, które są już dostępne w produkcie — dzięki temu ekran czatu i pozostałe ekrany zachowują spójny wygląd wizualny.
Interaktywne elementy bez callbacki React
Elementy te nie mogą importować obudowy czatu. Wysyłają one zdarzenia DOM CustomEvents dla operacji takich jak odświeżenie lub otwarcie profilu:
// 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
Dyalog słucha raz, mapuje wydarzenia na wywołania narzędzi i zapewnia przenośność widgetów. Ta sama tabela może znajdować się na stronie członków oraz w czacie bez konieczności użycia specyficznych dla czatu właściwości.
Sztuczna inteligencja nie decyduje o interfejsie
Model nigdy nie wybiera kolorów, układów ani drzew komponentów. Wybiera jedynie narzędzia. Orkiestracja na poziomie backendu przekształca wyniki narzędzi w wydarzenia widgetów z zweryfikowanymi właściwościami. Projekt pozostaje w gestii inżynierów frontendu.
Zamiana sztywno zdefiniowanych punktów końcowych na katalog narzędzi
We wczesnych prototypach używano sztywno zdefiniowanych adresów URL. W wersji ostatecznej dostarczany jest katalog narzędzi, który host może odkryć:
{
"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" }
]
}
Każde narzędzie określa punkt końcowy oraz schemat. Model wybiera narzędzie; backend je uruchamia, a wydarzenia widgetów przenoszą wynik. Zmiana infrastruktury polega wtedy na edycji katalogu, a nie na przepisywaniu instrukcji.
Strumieniowanie bez naruszania umowy
Striming nadal wysyła te same typy zdarzeń. Częściowy stan narzędzia może zmienić się z running na completed w miarę przychodzenia tokenów tekstowych. Aplikacja renderująca dodaje lub aktualizuje zdarzenia, nie wymyślając przy tym nowego protokołu. Użytkownicy widzą informacje o postępach przed pojawieniem się końcowych elementów interfejsu.
Budowanie nowoczesnego doświadczenia czatowego
Strona gospodarza pozostaje zwykłą listą. Asystent ukrywa się za interfejsem Chat with AI i odpowiada, wykorzystując te same dane oraz komponenty, które już są używane na stronie. Ciągłość działania jest ważniejsza niż oddzielna „skóra” produktu AI.
Flagi funkcjonalne są istotne
Jedna flaga kontroluje to doświadczenie:
VITE_ENABLE_AI_CHAT
Flagi umożliwiają zespołom wdrożenie dialogu w trybie ciemnym, wyłączenie go pod obciążeniem lub udostępnienie go tylko pracownikom, bez konieczności ponownego wdrażania kodu elementów interfejsu.
Testowanie frameworka elementów interfejsu
Testy obejmują trzy warstwy: walidacja schematu odrzuca błędne właściwości, wyszukiwanie w rejestrze rozwiązuje problem znanych elementów interfejsu, a uporządkowanie zdarzeń sprawia, że wyświetla się kolejno narzędzie → element interfejsu → tekst zgodnie z oczekiwaniami. Testy jednostkowe elementów interfejsu są wykonywane poza czatem. Testy end-to-end otwierają dialog, proszą o informacje o członkach i sprawdzają, czy wyświetla się tabela.
Kiedy ta architektura jest przesadna
Czyste boty FAQ, które potrzebują tylko akapitów, mogą pozostać w formacie Markdown. Protokół zdarzeń okazuje się przydatny, gdy odpowiedzi mają postać interfejsów, gdy elementy interfejsu muszą być ponownie wykorzystywane poza czatem oraz gdy zespół odmawia pozwalania modelowi na tworzenie drzew komponentów.
Zasady projektowe, które kierowały każdą decyzją
- Model wybiera działania, a nie formatowanie.
- Jeden pipeline renderowania dla każdej wiadomości.
- Elementy interfejsu są rejestrowane; pipeline nie dzieli się.
- Waliduj właściwości przed ich wyświetleniem.
- Elementy interfejsu komunikują się za pomocą CustomEvents, a nie przez importy z czatu.
Wynik
Asystent transmisuje wpisywane zdarzenia, renderuje szesnaście widgetów za pomocą jednego procesu, weryfikuje każdą właściwość i rozszerza funkcjonalności poprzez rejestrację. Lokalne backendy LLM pozostają wymiennymi elementami, ponieważ umowa opiera się na zdarzeniach, a nie na SDK dostawców w interfejsie użytkownika.
Ostateczne refleksje
Nauczanie modeli rysowania interfejsów to walka z niewłaściwym wrogiem. Nauczanie ich wyboru odpowiednich funkcji – oraz pozwalanie zdyscyplinowanemu frontendowi na renderowanie tych funkcji – chroni systemy projektowe, możliwości testowania i dalszy rozwój. Chat staje się kolejną platformą dla tych samych komponentów, którym produkt już ufa.
Ekran z elementami w przykładzie pozostaje zwykłą stroną produktu. Asystent nie jest drugim produktem; znajduje się za kontrolką „Rozmowa z AI” i odpowiada przy użyciu tego samego poziomu danych oraz tych samych komponentów, które już jest używane na tej stronie. Ta ciągłość jest celowa. Gdy rozmowa tworzy równoległy język wizualny, operatorzy przestają jej ufać. Gdy rozmowa i strona dzielą się widgetami, każda poprawka w tabeli ma wpływ na oba miejsca.
Kierowanie kolejnością elementów w tablicy zdarzeń stanowi część języka produktu. Zdarzenie tool o stanie running może pojawić się pierwsze, aby użytkownik zobaczył rozpoczęcie pracy. Następnie może pojawić się zdarzenie widget z wierszami. Na końcu może znaleźć się zdarzenie text z jednozdaniowym podsumowaniem. Zmiana kolejności tych zdarzeń wpływa na odbiór rozmowy, bez konieczności modyfikowania żadnego komponentu React. Traktuj tablicę jako scenariusz, który autorzy backendu tworzą, a frontend realizuje.
member:open lub table:refresh, może funkcjonować wszędzie. Dialog staje się adaptatorem, który przekształca te zdarzenia w wywołania narzędzi. To właśnie ten adaptator jest jedynym miejscem, które zna informacje o sesji asystenta.
Striming podlega temu samemu kontraktowi. W miarę jak przychodzą dane o tokenach i stanach narzędzi, aplikacja klienta dodaje nowe wydarzenia lub aktualizuje istniejące. Nie istnieje żaden drugi „format strymingu”. Jeśli narzędzie zawiedzie w trakcie pracy, status failed zastępuje running, a opcjonalne wydarzenie tekstowe wyjaśnia przyczynę awarii. Użytkownicy widzą ciągłość działania zamiast elementu wspinającego się, który milcząco przestaje działać.
Flagi funkcjonalne zapewniają bezpieczeństwo operacyjne. Używanie szesnastu elementów interfejsu pod kontrolą flagi VITE_ENABLE_AI_CHAT pozwala zespołom wyłączyć te elementy pod dużym obciążeniem bez konieczności modyfikacji kodu. Należy połączyć tę flagę z uprawnieniami dla poszczególnych narzędzi, aby katalog odczytu tylko mógł pozostać aktywny, podczas gdy narzędzia niszczące mogły być wyłączone.
Strategia testowania odzwierciedla architekturę. Testy jednostkowe obejmują schematy oraz renderowanie czystych elementów interfejsu. Testy integracyjne wprowadzają ustaloną tablicę zdarzeń do narzędzia renderującego i tworzą jej kopię w celu sprawdzenia struktury. Testy end-to-end zwracają się do rzeczywistego lub symulowanego backendu po dane, a następnie sprawdzają, czy element tabeli jest renderowany z oczekiwanymi kolumnami. Ponieważ elementy interfejsu są zwykłymi komponentami, istniejące zestawy testów regresji wizualnej mogą je obejmować bez specjalnych narzędzi opartych na sztucznej inteligencji.
Nadal istnieją przypadki, w których takie podejście jest przesadne. Jeśli produkt odpowiada jedynie na pytania definicyjne krótkimi akapitami, Markdown pozostaje tańszym rozwiązaniem. Architektura oparta na zdarzeniach okazuje się korzystna, gdy odpowiedzi przybierają postać interfejsów, gdy te same elementy pojawiają się poza czatem oraz gdy zespół odmawia pozwalania modelowi na tworzenie drzew komponentów. Te trzy warunki zazwyczaj występują jednocześnie, gdy produkt narzędzi wewnętrznych dojrzewa.
Brak nowych zależności frontendowych był celowym ograniczeniem, a nie konkurencją o czystość. Sam dodanie kolejnej biblioteki do tworzenia wykresów dla czatu doprowadziłoby do rozdzielenia systemu projektowego. Ponowne użycie istniejących pakietów do wykresów i tabel zmusiło asystenta do mówienia wizualnym językiem produktu. Kosztem były sporadyczne potrzeby tworzenia kodu adaptacyjnego; korzyścią natomiast jednolity wygląd i standardy dostępności.
Obsługa lokalnych modeli LLM ma znaczenie w przypadku implementacji wymagających ochrony prywatności. Ponieważ interfejs rozumie tylko zdarzenia, zamiana modelu chmurowego na lokalny silnik to kwestia backendu. Rejestr, walidatory oraz system CustomEvent pozostają identyczne. To rozdzielenie umożliwia używanie tej samej demonstracji frontendu z Ollamą w fazie rozwoju oraz z modelem hostowanym w produkcji, bez konieczności modyfikacji interfejsu czatu.
Rozszerzanie systemu jest celowo nudne. Wystarczy dodać plik z widgetem, zarejestrować go za pomocą schematu, powiązać wynik narzędzia z właściwościami i napisać test. Nie ma potrzeby zmieniać komponentów renderujących. Nowe typy zdarzeń nie są konieczne, chyba że produkt naprawdę wymaga czwartego rodzaju – a jak dotąd trzy wystarczyły. Ta nudność jest zaletą: oznacza, że pracownicy początkujący mogą przyczyniać się do tworzenia widgetów bez konieczności nauki specjalistycznego frameworku interfejsu AI.
Ostateczny kształt rozwiązania przypomina raczej router rozmów prowadzący do istniejących możliwości produktu niż chatbot przymocowany do niego. To właśnie jest sukcesem architektonicznym. Model staje się dysponentem zadań, system projektowy pozostaje źródłem prawdy dotyczącym prezentacji, a backend – źródłem prawdy dotyczącym danych i uprawnień.
Z punktu widzenia funkcjonalnego należy prowadzić rejestr zmian dotyczących rejestracji widgetów obok zmian w katalogu narzędzi. Gdy zmienia się struktura odpowiedzi danego narzędzia, schemat odpowiadającego widgetu musi ulec zmianie w tym samym pull request. Takie połączenie zapobiega klasycznemu problemowi, w którym agent nadal odpowiada poprawnie tekstowo, podczas gdy widget w tle wyświetla puste kolumny. Recenzenci powinni odrzucić PR-y narzędzi, które zapominają o towarzyszącym schemacie.
Dostępność wynika z ponownego wykorzystania widgetów produkcyjnych. Jeśli tabela członków już obsługuje nawigację klawiaturą oraz etykiety dla czytników ekranu, tabela umieszczona w czacie odziedzicza te funkcje. Tworzenie ad-hoc HTML na podstawie modelu zmarnowałoby te inwestycje. Architektura zdarzeń jest więc nie tylko wyborem ze względu na niezawodność, ale także strategią zachowania dostępności.
Główne wnioski
- Należy preferować strumienie zdarzeń (tekst, narzędzie, widget) zamiast HTML/JSX generowanego przez modele.
W połączeniu strumień zdarzeń, rejestr, walidatory oraz bus CustomEvent tworzą małą, lecz kompletną ramę dla interfejsu produktu wspomaganego przez AI, bez oddawania kontroli nad projektem modelowi. Kontynuuj doskonalenie widgetów, a nie jednorazowych zapytań o formatowanie, aby asystent pozostał łatwy w utrzymaniu w miarę rozwoju produktu.
Literatura pokrewna
- React micro-frontends z Webpack Module Federation — Zastąp rozwiązania oparte na iframe metodami Webpack 5 Module Federation, aby aplikacje React zarówno lokalne, jak i zdalne mogły dzielić się środowiskiem wykonawczym, być wdrażane niezależnie, a jednocześnie zachowywać spójność jako jeden produkt.
- Self-adaptive JavaScript frontends dla interfejsów zasilanych AI — W jaki sposób transmisja strumieniowa, wyszukiwanie wektorów, stan reaktywny, komponenty dynamiczne oraz możliwości obserwacji przekształcają architekturę klienta w przypadku interfejsów typu AI-native.