Generativní UI je nový frontend. Dodali jsme ho před měsíci.
Všichni právě teď mluví o Generativním UI. CopilotKit publikoval průvodce pro vývojáře. Google má o tom výzkumný blog.
Dodali jsme ho v lednu. Od té doby běží v produkci uvnitř Elby.
Toto není horká novinka. Je to popis toho, jak jsme to skutečně postavili, jak vypadá architektura a co nás cestou zdrželo.
Co Generativní UI vlastně znamená
Krátká verze: místo pevného kódování každého stavu obrazovky se agent AI v době běhu rozhodne, kterou komponentu UI zobrazit a jakými daty ji vyplnit.
Chatovací rozhraní už nevrací jen text. Vrací živé, interaktivní UI, vykreslené přímo v konverzaci.
CopilotKit to rozděluje do tří vzorů:
- Statické Generativní UI: frontend vlastní komponenty. Agent vybírá, kterou zobrazit, a vyplňuje ji daty. Vysoká kontrola, předvídatelné.
- Deklarativní Generativní UI: agent vrací specifikaci UI (karty, formuláře, seznamy v JSON), a frontend vykresluje podle této specifikace. Flexibilnější, nepředvídatelnější.
- Otevřené Generativní UI: agent vrací celou plochu UI, často iframe nebo libovolný HTML. Maximální flexibilita, minimální konzistence.
Zvolili jsme první vzor. Všechny komponenty jsme předem vytvořili. Agent se nikdy nedotkne rozvržení. Pouze rozhoduje, kdy se něco objeví a co se v tom objeví.
Pro podpůrného asistenta v produkčním produktu to bylo správné rozhodnutí. Chcete, aby agent dělal rozhodnutí, ne navrhoval rozhraní.
Jak Frida funguje
Frida je asistent AI v produktu Elba. Běží v chatovacím panelu. Uživatelé se jí mohou ptát na svůj účet, nedávné hovory, zůstatek kreditu, fakturaci a cokoli jiného o platformě.
Co z ní dělá Generativní UI: když se zeptáte „jaký je můj zůstatek kreditu?“, Frida neodpoví textem. Vykreslí kartu.
Když se zeptáte na nedávné hovory, dostanete interaktivní záznam hovorů. Když jste na cestě k Enterprise, dostanete klikací kvalifikační formulář, nikoli textový dotaz, abyste zadali svůj případ použití.
Rozhraní se přizpůsobuje tomu, co agent potřebuje, aby uživatel udělal.
Stack
- Frontend: React, CopilotKit v2, AG-UI protokol
- Backend: PydanticAI agent běžící na naší službě Cognition Hub
- Protokol: hook
useFrontendTool od CopilotKit přes AG-UI
Registr komponent
Každá generativní komponenta ve Fridě je registrována na jednom místě:
// toolComponentRegistry.ts
export const TOOL_COMPONENTS: Record<string, React.FC<ToolComponentProps>> = {};
export function registerToolComponent(
name: string,
component: React.FC<ToolComponentProps>
): void {
TOOL_COMPONENTS[name] = component;
}
Při spuštění se každá komponenta zaregistruje sama:
registerToolComponent('showCreditBalance', CreditBalanceCard);
registerToolComponent('showRecentCalls', RecentCallsCard);
registerToolComponent('showCreditBurnRates', CreditBurnRateCard);
registerToolComponent('showSalesQualifier', SalesQualifierInChat);
registerToolComponent('showSalesInquiryPreview', SalesInquiryPreview);
Když agent spustí volání nástroje, renderer chatu ho vyhledá v tomto registru a vloží ho inline.
Definice nástrojů
Každá komponenta má odpovídající definici useFrontendTool. Zde se agent dozví, kdy a jak ji použít:
useFrontendTool(
{
name: 'showCreditBalance',
description:
'VŽDY použijte tento nástroj k vykreslení vizuální karty zůstatku kreditu, když se uživatel ptá na své kredity, zůstatek, zbývající minuty nebo stav fakturace.',
parameters: creditBalanceSchema,
handler: async (params) => JSON.stringify(params),
},
[],
);
Parametry jsou ověřeny pomocí Zod:
const creditBalanceSchema = z.object({
credits: z.number().describe('Aktuální zůstatek kreditu'),
plan: z.string().describe('Název aktuálního tarifu'),
status: z.enum(['healthy', 'low', 'critical']).describe(
'Stav zůstatku: healthy (>100), low (10-100), critical (<10)'
),
estimatedMinutes: z.number().describe('Odhadovaný zbývající počet hlasových minut'),
});
Agent čte schéma. Popisy schématu ho učí, jaké hodnoty předat. estimatedMinutes s popisem (credits / 10) znamená, že agent vypočítá správnou hodnotu, místo aby hádal.
Backendový agent
Druhá polovina tohoto žije v Python backendu. Frida je PydanticAI agent s nástroji, které může volat k získání skutečných dat:
@agent.tool
async def fetch_recent_calls(ctx: RunContext[FridaSupportDeps], limit: int = 5) -> dict:
"""Načte nedávné hovory pro organizaci uživatele."""
calls = await _get_recent_calls(client, ctx.deps.org_id, limit=limit)
formatted = [_format_call_record(c) for c in calls]
return tool_result({"calls": formatted, "total": len(formatted)})
Agent má dva typy nástrojů: backendové nástroje, které načítají data (hovory, informace o organizaci, znalostní báze), a frontendové nástroje, které vykreslují UI. Backendové nástroje dodávají data frontendovým nástrojům. To je celý cyklus.
Když se uživatel zeptá „jaké jsou mé nedávné hovory?“, agent:
- Volá
fetch_recent_calls k získání dat z API
- Volá
showRecentCalls s formátovanými výsledky
- Frontend přijme volání nástroje, vyhledá
RecentCallsCard v registru a vloží ho
Žádný text. Žádné parsování. Jen vykreslená komponenta s reálnými daty.
Vkládání kontextu
Agent ví víc než jen to, co uživatel napsal. Vkládáme kontext za běhu pomocí generátorů dynamických instrukcí:
def page_context(ctx: RunContext[FridaSupportDeps]) -> str:
"""Přidá kontext aktuální stránky."""
page = ctx.deps.current_page
for prefix, hint in _PAGE_HINTS.items():
if page.startswith(prefix):
return f"Kontext aktuální stránky: {hint}"
return f"Uživatel je aktuálně na stránce: {page}"
Agent ví, na které stránce uživatel je, stav jeho fakturace, zůstatek kreditu, tarif, počet agentů a dnešní datum. Tento kontext formuje každou odpověď. „Zkontrolujte můj zůstatek kreditu“ od někoho na stránce /billing dostane jinou úpravu než od někoho na /agents.
Vkládání data je hodné zvláštního zmínění:
def date_context(ctx: RunContext[FridaSupportDeps]) -> str:
parts.append(
f"DŮLEŽITÉ — Dnešní datum je {ctx.deps.current_date}. "
"Použijte toto jako zdroj pravdy pro 'dnes', 'včera', 'tento týden' atd. "
"NEODVOZUJTE aktuální datum z časových razítek hovorů ani z jiných dat."
)
Bez toho by se model pokusil odvodit aktuální datum z nejnovějšího časového razítka hovoru, což je špatně. Zobrazil by hovor starý tři dny a řekl „Dnes“. Zachytili jsme to při testování. Explicitní vkládání data s instrukcí „NEODVOZUJTE“ to opravilo.
Části, o kterých nikdo nepíše
Šťastná cesta je snadná. Zde je to, co skutečně zabralo čas.
Skeletové stavy během streamování
CopilotKit streamuje argumenty volání nástrojů. Komponenta se vloží předtím, než dorazí všechny argumenty. Pokud vykreslíte okamžitě s částečnými daty, získáte rušivé, napůl načtené stavy.
Před zobrazením skutečné komponenty jsme přidali 500ms zpoždění s kostrou (skeleton) v mezeře:
const DelayedToolComponent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [ready, setReady] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setReady(true), 500);
return () => clearTimeout(timer);
}, []);
if (!ready) return <ToolComponentSkeleton />;
return <>{children}</>;
};
Kostra odpovídá tvaru skutečné komponenty. Karta zůstatku kreditu dostane kostru, která vypadá jako karta zůstatku kreditu. Díky tomu zážitek působil záměrně, nikoli rozbitě.
Chybové hranice pro každou komponentu
Když se komponenta zhroutí, nechcete, aby se celý chat rozbil. Každé vykreslení komponenty nástroje jsme obalili do chybové hranice:
class ToolComponentErrorBoundary extends Component<...> {
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return (
<div className="...amber border...">
<p>Obsah nelze zobrazit — zkuste to prosím znovu.</p>
</div>
);
}
return this.props.children;
}
}
To dává uživateli obnovitelnou chybu místo rozbité chatovací relace. Jantarová záloha je dostatečně lehká, aby nevypadala alarmující, ale dostatečně jasná, aby uživatel věděl, že se něco nevykreslilo.
Interaktivní komponenty, které spouštějí další kolo agenta
Kvalifikační karta prodeje je interaktivní. Uživatel klikne na možnosti, stiskne odeslat a jeho výběry se musí stát další zprávou v konverzaci. Děláme to pomocí vlastního DOM události:
const SalesQualifierInChat: React.FC = () => {
const handleSubmit = (data: QualifierState) => {
const message = `Případ použití: ${data.useCase}. Objem: ${data.volume}. Časová osa: ${data.timeline}.`;
window.dispatchEvent(
new CustomEvent('frida:qualifier-submit', { detail: message })
);
};
return <SalesQualifierCard onSubmit={handleSubmit} />;
};
Chat naslouchá této události a vloží formátovaný řetězec jako uživatelskou zprávu. Agent ji pak zachytí a pokračuje v pracovním postupu, sestaví e-mail a zobrazí náhledovou kartu. Dvě komponenty nástroje, jeden konverzační tok.
Proč bylo Statické Generativní UI správnou volbou
Mohli jsme jít deklarativně. Mohli jsme nechat agenta vracet A2UI JSON a vykreslovat podle specifikace. To by dalo Fridu schopnost vymýšlet nové rozvržení.
To jsme nechtěli. Frida žije uvnitř produkčního produktu s designovým systémem. Agent, který dokáže vymýšlet libovolné karty, je také agent, který může produkovat nebrandované nebo rozbité UI ve 2 hodiny ráno, když je zákazník zablokován.
Statické Generativní UI vám dává odezvu AI rozhraní s předvídatelností ručně vytvořené knihovny komponent. Agent dělá rozhodnutí. Frontend dělá pixely. Ani jeden nepřekračuje práci toho druhého.
Omezení je také to, co to umožnilo dodat. Frida byla spuštěna se šesti komponentami. Každá z nich je jasně definovanou smlouvou mezi agentem a UI. Když agent zavolá showCreditBalance, přesně víme, co se vykreslí. Když ne, nic se nevykreslí. Žádná překvapení.
Generativní UI není nový koncept. Název je nový. Myšlenka, že by AI asistent měl renderovat UI vhodné pro daný úkol místo toho, aby vše vyprávěl textem, ta je už nějakou dobu zřejmá.
Pokud stavíte AI asistenta v produktu a ten vrací pouze text, je to další věc, na kterou se podívat. Vyberte jednu komponentu, jedno volání nástroje, jeden případ použití. Vzor je dostatečně malý na to, aby se dal dodat za den. Rozdíl v zážitku ale není vůbec malý.
#Elba #BuildingVoiceAI