Sztuczna inteligencja zalała naszą codzienność tak szybko, że łatwo nabawić się technologicznego zawrotu głowy. Zamiast jednak teoretyzować o tym, czy roboty przejmą władzę nad światem, wolę podejść do tematu jak praktyk. Ostatnio mocno przyglądam się temu, jak ewoluują interfejsy czatowe. Jeśli spojrzymy na rozwój LLM-ów, widzimy jasny trend: od prostego "pisania linijka po linijce" przechodzimy do bogatych, wizualnych komponentów.
Zarówno w Gemini, jak i w innych nowoczesnych produktach, coraz częściej w odpowiedziach dostajemy nie tylko suchy tekst, ale piękne grafiki, interaktywne kontrolki, formularze czy przyciski. Pewnie zastanawiacie się, w jaki sposób bezpiecznie osiągnąć taki cel we własnej aplikacji? Przecież nie wrzucimy surowego HTML-a wyplutego przez model prosto do DOM-u klienta... prawda?
Na stanowisku bojowym: Dlaczego surowy HTML to przepis na katastrofę?
Wysyłanie surowego kodu HTML z JavaScriptem bezpośrednio z modelu językowego i renderowanie go po stronie przeglądarki użytkownika to – mówiąc krótko – bardzo zły pomysł. HTML i JS wykonywany "na żywo" to otwarta brama do ataków typu Cross-Site Scripting (XSS). Wystarczy, że model zostanie podatny na prompt injection albo po prostu "wyhalucynuje" złośliwy skrypt, a użytkownik końcowy uruchomi w swojej sesji kod, który może ukraść tokeny autoryzacyjne czy zmanipulować interfejs.
Frameworki takie jak A2UI (Agent-to-User Interface), o którym chcę Wam dzisiaj opowiedzieć, rozwiązują ten problem zupełnie inaczej. Zamiast przesyłać kod do wykonania, przesyłają strukturyzowane dane na podstawie ściśle zdefiniowanego kontraktu.
Jak to działa? Proste słowa i jasny kontrakt
Cała magia opiera się na architekturze sterowanej danymi (data-driven UI). Wygląda to następująco:
- Po stronie front-endu (klienta): Budujemy nasze komponenty (np. w Angularze czy Reakcie) z zachowaniem naszych stylów, logiki i interakcji. Następnie definiujemy kontrakt – czyli rejestr komponentów, które nasz front-end potrafi zrozumieć i wyrenderować (na przykład zaawansowane formularze dynamiczne czy karty produktów).
- Po stronie back-endu / LLM: Do zapytań, które przesyłamy do naszego modelu językowego, dołączamy możliwe schematy odpowiedzi odzwierciedlające strukturę naszych komponentów. Model uzupełnia te schematy o realne dane (często pobrane w locie z baz danych czy innych narzędzi poprzez np. MCP – Model Context Protocol).
- Powrót do klienta: Czysty, bezpieczny JSON trafia na front-end. Specjalny renderer mapuje nazwę komponentu na nasz gotowy, bezpieczny komponent Angularowy, zasila go danymi i wyświetla jako estetyczne okienko wewnątrz czatu. Zero ryzyka XSS!
Co ważne, sam framework posiada wiele wbudowanych struktur komponentów (przyciski, pola tekstowe, panele), które możemy łatwo dostosować do stylu naszej aplikacji. Nic nie stoi też na przeszkodzie, by tworzyć własne, bardzo skomplikowane klocki realizujące zaawansowane potrzeby biznesowe.
Dlaczego warto iść w tę stronę? (Wzorce z CLI)
Wielu deweloperów zapyta: po co komplikować, skoro użytkownik może po prostu napisać, co chce? No właśnie nie do końca. W niektórych kwestiach dużo łatwiej, szybciej i wygodniej jest po prostu pokazać użytkownikowi formularz ze zdefiniowanymi opcjami czy polami wyboru, niż zmuszać go do opisywania wszystkiego pełnymi zdaniami.
To genialnie przyspiesza dalszą interakcję. Znamy ten wzorzec chociażby z interfejsów CLI (wiersza poleceń), gdzie zamiast pisać elaboraty, wybieramy poszczególne akcje za pomocą strzałek czy numerów. W podobnym stylu możemy dynamicznie generować formularze w czacie, które zmieniają się w zależności od tego, na jakim etapie rozmowy jest użytkownik i czego w danej chwili potrzebuje. Dla ludzi używanie znanych kontrolek (checkbox, select, button) jest naturalne i zmniejsza obciążenie kognitywne.
Jak zbudowałem moje rozwiązanie? (Mój tech stack)
Jeśli chcecie wejść w temat, najlepiej zacząć od oficjalnej dokumentacji A2UI, gdzie znajdziecie świetne przykłady – można je odpalić w kilka minut, podpinając własny klucz API Gemini. Warto dodać, że narzędzie świetnie wspiera Angulara, Reacta, Fluttera oraz natywne platformy mobilne.
Jako że na co dzień namiętnie pracuję z ekosystemem Google, mój wybór padł na następujący zestaw:
- Front-end: Angular (do zbudowania interfejsu czatowego i dynamicznego renderowania).
- Back-end: FastAPI (Python) – lekki, niesamowicie szybki i idealny do integracji z modelami.
- Mózg operacyjny (LLM): Na pierwszy ogień poszło Gemini, które radzi sobie z generowaniem ustrukturyzowanych danych wybitnie dobrze. Testowałem też lokalne modele przez Ollamę, ale przy mniejszych modelach kontekst i precyzja schematów niestety kulały. Do szerszych testów wykorzystałem też OpenRoutera, gdzie można łatwo żonglować modelami wspierającymi mechanizm narzędziowy (Tool Calling).
Konfiguracja po stronie Angulara i Back-endu
Głównym elementem układanki na froncie była konfiguracja providerów oraz definicja kontraktu dla naszych customowych komponentów. Przygotowałem specjalne akcje obsługujące zdarzenia z tych komponentów (np. kliknięcie przycisku formularza wygenerowanego przez AI).
Oto jak wygląda autentyczny, deklaratywny pakiet danych JSON (zgodny ze specyfikacją A2UI), który model Gemini wysyła do aplikacji klienckiej w celu wygenerowania interaktywnego formularza satysfakcji:
[
{
"createSurface": {
"surfaceId": "survey_surface",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}
},
{
"updateDataModel": {
"surfaceId": "survey_surface",
"value": {
"satisfaction": 8,
"comments": "",
"subscribe": true
}
}
},
{
"updateComponents": {
"surfaceId": "survey_surface",
"components": [
{
"id": "root",
"component": "Card",
"title": "Ankieta Satysfakcji Klienta",
"children": ["content_layout"]
},
{
"id": "content_layout",
"component": "Column",
"parent": "root",
"children": ["rating_text", "rating_slider", "comments_field", "subscribe_check", "submit_btn"]
},
{
"id": "rating_text",
"component": "Text",
"parent": "content_layout",
"text": "Jak oceniasz naszą pomoc? (1-10)"
},
{
"id": "rating_slider",
"component": "Slider",
"parent": "content_layout",
"min": 1,
"max": 10,
"step": 1,
"value": { "path": "/satisfaction" }
},
{
"id": "comments_field",
"component": "TextField",
"parent": "content_layout",
"label": "Dodatkowe uwagi",
"value": { "path": "/comments" }
},
{
"id": "subscribe_check",
"component": "CheckBox",
"parent": "content_layout",
"label": "Chcę zapisać się na newsletter sklepu",
"value": { "path": "/subscribe" }
},
{
"id": "submit_btn",
"component": "Button",
"parent": "content_layout",
"text": "Wyślij opinię",
"action": {
"event": {
"name": "submit_survey",
"context": {
"score": { "path": "/satisfaction" },
"feedback": { "path": "/comments" },
"newsletter": { "path": "/subscribe" }
}
}
}
}
]
}
}
]
Po stronie Angulara mapujemy ten kontrakt na rzeczywiste komponenty z naszego katalogu. Kod uproszczonego komponentu dynamicznego w Angularze, który nasłuchuje poleceń i renderuje hierarchię komponentów, wygląda tak:
import { Component, Input, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-a2ui-surface',
standalone: true,
imports: [CommonModule],
template: `
<div class="a2ui-surface" [attr.id]="surfaceId">
<ng-container *ngFor="let comp of rootComponents">
<ng-container *ngComponentOutlet="getComponentType(comp.component); inputs: comp"></ng-container>
</ng-container>
</div>
`
})
export class A2uiSurfaceComponent {
@Input() surfaceId!: string;
@Input() components: any[] = [];
get rootComponents() {
return this.components.filter(c => !c.parent || c.parent === 'root');
}
getComponentType(name: string): Type<any> {
const registry: Record<string, Type<any>> = {
'Card': CardComponent,
'Column': ColumnComponent,
'Text': TextComponent,
'Slider': SliderComponent,
'TextField': TextFieldComponent,
'CheckBox': CheckBoxComponent,
'Button': ButtonComponent
};
return registry[name] || FallbackComponent;
}
}
Z kolei na backendzie (FastAPI + Python) definiujemy odpowiednie schematy Pydantic reprezentujące polecenia protokołu A2UI, wymuszając na modelu Gemini poprawną strukturę odpowiedzi:
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import List, Literal, Optional, Union, Dict, Any
app = FastAPI()
class CreateSurface(BaseModel):
surfaceId: str
catalogId: str
class UpdateDataModel(BaseModel):
surfaceId: str
value: Dict[str, Any]
class UIComponent(BaseModel):
id: str
component: str
parent: Optional[str] = None
title: Optional[str] = None
text: Optional[str] = None
min: Optional[float] = None
max: Optional[float] = None
step: Optional[float] = None
label: Optional[str] = None
children: Optional[List[str]] = None
value: Optional[Dict[str, str]] = None
action: Optional[Dict[str, Any]] = None
class UpdateComponents(BaseModel):
surfaceId: str
components: List[UIComponent]
class A2UICommand(BaseModel):
createSurface: Optional[CreateSurface] = None
updateDataModel: Optional[UpdateDataModel] = None
updateComponents: Optional[UpdateComponents] = None
@app.post("/api/chat")
async def chat_endpoint(user_message: str):
# Wywołanie modelu z wymuszeniem schematu odpowiedzi (Structured Outputs)
# response = gemini_client.generate(
# prompt=user_message,
# response_schema=List[A2UICommand]
# )
# return response
pass
Definicje promptów i schematów komponentów muszą być idealnie spójne z tym, co zarejestrowaliśmy w Angularze, żeby cała komunikacja oparta na czystych paczkach danych (JSON) działała prawidłowo.
Przykład interaktywnego formularza wyrenderowanego bezpośrednio w oknie czatu na bazie danych JSON z modelu AI.
A2UI w praktyce: Gdzie błyszczy, a gdzie zgrzyta?
Co mnie najbardziej zaskoczyło na plus? Przede wszystkim łatwość integracji. Budowanie własnych komponentów w Angularze wygląda całkowicie standardowo, a zapakowanie ich w funkcjonalność A2UI do odbierania i mapowania danych jest niesamowicie intuicyjne. Szybkość działania również stoi na bardzo wysokim poziomie.
Żeby jednak zachować pełen obiektywizm, muszę wspomnieć o wyzwaniach, które trochę studzą entuzjazm:
Zalety podejścia A2UI:
- Ochrona przed XSS i UI Injection – brak surowego kodu HTML/JS eliminuje ryzyko klasycznego wstrzykiwania kodu.
- Spójność wizualna – komponenty korzystają z globalnego systemu stylów aplikacji.
- Mniejsze obciążenie kognitywne – interaktywne kontrolki skracają i upraszczają interakcję z botem.
Wyzwania i ograniczenia:
- Ryzyko phishingu – bezpieczny format JSON chroni przed XSS, ale model wciąż może wygenerować przekonujący, fałszywy formularz wyłudzający wrażliwe dane.
- Wymagania wobec modeli – małe i tańsze modele LLM gubią się w schematach i potrafią halucynować niepoprawny JSON.
- Konieczność walidacji – na backendzie trzeba bezwzględnie wdrożyć walidatory schematów i mechanizmy automatycznych powtórzeń zapytań (retry).
Podsumowanie: Czy AI-to-UI to tylko chwilowa moda?
Patrząc na rozwój modeli oraz narzędzi, które miałem okazję testować, jestem przekonany, że to nie jest tymczasowy trend. Wiele zespołów deweloperskich na całym świecie próbowało pisać własne mechanizmy do dynamicznego renderowania interfejsów, bo tekst w wielu scenariuszach biznesowych po prostu nie wystarcza. Ludzkość od dekad używa formularzy, przycisków i wykresów – to dla nas naturalny, skondensowany sposób przyswajania wiedzy i przekazywania informacji.
Przy dynamicznym rozwoju AI czysty tekst i chatboty będą powoli ewoluować. Hybrydowe interfejsy, które płynnie łączą konwersację z dynamicznym UI sterującym kierunkiem rozmowy, staną się standardem nowoczesnych aplikacji.
A Wy co o tym sądzicie? Czy generowanie interfejsów w locie zastąpi tradycyjne podejście do designu aplikacji, czy może czeka nas era hybrydowa? Jakie komponenty najchętniej zobaczylibyście w swoim systemie czatowym? Piszcie śmiało w komentarzach!
Komentarze (0)
Dodaj komentarz