Generative UI ist das neue Frontend. Wir haben es vor Monaten veröffentlicht.
Alle reden gerade über Generative UI. CopilotKit hat einen Entwicklerleitfaden veröffentlicht. Google hat einen Forschungsblog dazu.
Wir haben es im Januar veröffentlicht. Seitdem läuft es im Produktivbetrieb innerhalb von Elba.
Das ist keine gewagte These. Es ist eine detaillierte Beschreibung, wie wir es tatsächlich gebaut haben, wie die Architektur aussieht und was uns auf dem Weg aufgehalten hat.
Was Generative UI tatsächlich bedeutet
Die Kurzversion: Anstatt jeden Bildschirmzustand fest zu codieren, entscheidet der KI-Agent zur Laufzeit, welche UI-Komponente angezeigt werden soll und mit welchen Daten sie gefüllt wird.
Die Chat-Oberfläche gibt nicht mehr nur Text zurück. Sie liefert Live-, interaktive UI, die direkt in der Konversation gerendert wird.
CopilotKit unterteilt dies in drei Muster:
- Statische Generative UI: Das Frontend besitzt die Komponenten. Der Agent wählt aus, welche angezeigt wird, und füllt sie mit Daten. Hohe Kontrolle, vorhersehbar.
- Deklarative Generative UI: Der Agent gibt eine UI-Spezifikation zurück (Karten, Formulare, Listen in JSON), und das Frontend rendert basierend auf dieser Spezifikation. Flexibler, unvorhersehbarer.
- Offene Generative UI: Der Agent gibt eine vollständige UI-Oberfläche zurück, oft ein iframe oder beliebiges HTML. Maximale Flexibilität, minimale Konsistenz.
Wir haben uns für das erste Muster entschieden. Wir haben jede Komponente vorab erstellt. Der Agent berührt nie das Layout. Er entscheidet nur, wann etwas erscheint und was darin enthalten ist.
Das war die richtige Entscheidung für einen Support-Assistenten in einem Produktivprodukt. Man möchte, dass der Agent Entscheidungen trifft, nicht Schnittstellen entwirft.
Wie Frida funktioniert
Frida ist der In-Product-KI-Assistent innerhalb von Elba. Er läuft in einem Chat-Panel. Benutzer können ihn nach ihrem Konto, aktuellen Anrufen, Guthaben, Rechnungen und allem anderen rund um die Plattform fragen.
Was es zu Generative UI macht: Wenn Sie fragen "Wie hoch ist mein Guthaben?", antwortet Frida nicht mit Text. Sie rendert eine Karte.
Wenn Sie nach aktuellen Anrufen fragen, erhalten Sie eine interaktive Anrufliste. Wenn Sie sich im Enterprise-Pfad befinden, erhalten Sie ein klickbares Qualifizierungsformular, keine Textaufforderung, Ihren Anwendungsfall einzugeben.
Die Benutzeroberfläche passt sich an das an, was der Agent vom Benutzer benötigt.
Der Stack
- Frontend: React, CopilotKit v2, AG-UI-Protokoll
- Backend: PydanticAI-Agent, der auf unserem Cognition Hub-Dienst läuft
- Protokoll: CopilotKit's
useFrontendTool-Hook über AG-UI
Die Komponenten-Registry
Jede generative Komponente in Frida ist an einem Ort registriert:
// 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;
}
Beim Start registriert sich jede Komponente selbst:
registerToolComponent('showCreditBalance', CreditBalanceCard);
registerToolComponent('showRecentCalls', RecentCallsCard);
registerToolComponent('showCreditBurnRates', CreditBurnRateCard);
registerToolComponent('showSalesQualifier', SalesQualifierInChat);
registerToolComponent('showSalesInquiryPreview', SalesInquiryPreview);
Wenn der Agent einen Tool-Aufruf auslöst, sucht der Chat-Renderer ihn in dieser Registry und bindet ihn inline ein.
Die Tool-Definitionen
Jede Komponente hat eine entsprechende useFrontendTool-Definition. Hier lernt der Agent, wann und wie er sie verwenden kann:
useFrontendTool(
{
name: 'showCreditBalance',
description:
'Verwenden Sie dieses Tool IMMER, um eine visuelle Guthabenkarte anzuzeigen, wenn der Benutzer nach seinen Guthaben, dem Saldo, den verbleibenden Minuten oder dem Abrechnungsstatus fragt.',
parameters: creditBalanceSchema,
handler: async (params) => JSON.stringify(params),
},
[],
);
Parameter werden mit Zod validiert:
const creditBalanceSchema = z.object({
credits: z.number().describe('Aktueller Guthabensaldo'),
plan: z.string().describe('Name des aktuellen Tarifs'),
status: z.enum(['healthy', 'low', 'critical']).describe(
'Guthabenstatus: healthy (>100), low (10-100), critical (<10)'
),
estimatedMinutes: z.number().describe('Geschätzte verbleibende Gesprächsminuten'),
});
Der Agent liest das Schema. Die Schema-Beschreibungen trainieren ihn, welche Werte übergeben werden sollen. estimatedMinutes mit seiner Beschreibung (credits / 10) bedeutet, dass der Agent den richtigen Wert berechnet, anstatt zu raten.
Der Backend-Agent
Die andere Hälfte davon befindet sich im Python-Backend. Frida ist ein PydanticAI-Agent mit Tools, die er aufrufen kann, um reale Daten abzurufen:
@agent.tool
async def fetch_recent_calls(ctx: RunContext[FridaSupportDeps], limit: int = 5) -> dict:
"""Ruft aktuelle Anrufe für die Organisation des Benutzers ab."""
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)})
Der Agent hat zwei Arten von Tools: Backend-Tools, die Daten abrufen (Anrufe, Organisationsinformationen, Wissensdatenbank), und Frontend-Tools, die UI rendern. Die Backend-Tools liefern Daten an die Frontend-Tools. Das ist der vollständige Kreislauf.
Wenn ein Benutzer fragt "Was sind meine letzten Anrufe?", dann:
- Ruft der Agent
fetch_recent_calls auf, um die Daten von der API zu erhalten.
- Ruft
showRecentCalls mit den formatierten Ergebnissen auf.
- Das Frontend empfängt den Tool-Aufruf, sucht
RecentCallsCard in der Registry und bindet es ein.
Kein Text. Keine Verarbeitung. Nur eine gerenderte Komponente mit echten Daten.
Kontextinjektion
Der Agent weiß mehr als nur, was der Benutzer eingegeben hat. Wir injizieren Laufzeitkontext über dynamische Instruktionsgeneratoren:
def page_context(ctx: RunContext[FridaSupportDeps]) -> str:
"""Fügt den aktuellen Seitenkontext hinzu."""
page = ctx.deps.current_page
for prefix, hint in _PAGE_HINTS.items():
if page.startswith(prefix):
return f"Aktueller Seitenkontext: {hint}"
return f"Der Benutzer befindet sich gerade auf der Seite: {page}"
Der Agent weiß, auf welcher Seite sich der Benutzer befindet, seinen Abrechnungsstatus, sein Guthaben, seinen Tarif, die Anzahl der Agenten und das heutige Datum. Dieser Kontext prägt jede Antwort. "Überprüfen Sie mein Guthaben" von jemandem auf der Seite /billing wird anders behandelt als von jemandem auf /agents.
Die Datumsinjektion ist es wert, besonders hervorgehoben zu werden:
def date_context(ctx: RunContext[FridaSupportDeps]) -> str:
parts.append(
f"WICHTIG — Das heutige Datum ist {ctx.deps.current_date}. "
"Verwenden Sie dies als Wahrheitsquelle für 'heute', 'gestern', 'diese Woche' usw. "
"Leiten Sie das aktuelle Datum NICHT aus Anruf-Zeitstempeln oder anderen Daten ab."
)
Ohne dies würde das Modell versuchen, das aktuelle Datum aus dem neuesten Anruf-Zeitstempel abzuleiten, was falsch ist. Es würde einen Anruf von vor drei Tagen anzeigen und sagen "Heute". Wir haben dies beim Testen festgestellt. Die explizite Datumsinjektion mit der Anweisung "Nicht ableiten" hat es behoben.
Die Teile, über die niemand schreibt
Der Happy Path ist einfach. Hier ist, was tatsächlich Zeit gekostet hat.
Skeleton-Zustände während des Streamings
CopilotKit streamt Tool-Aufrufargumente. Die Komponente wird geladen, bevor alle Argumente eingegangen sind. Wenn Sie sofort mit teilweisen Daten rendern, erhalten Sie störende, halb geladene Zustände.
Wir haben eine Verzögerung von 500 ms hinzugefügt, bevor die echte Komponente angezeigt wird, mit einem Skeleton-Schimmer in der Lücke:
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}</>;
};
Das Skeleton passt zur Form der echten Komponente. Die Guthabenkarte erhält ein Skeleton, das wie eine Guthabenkarte aussieht. Dies ließ die Erfahrung absichtlich und nicht fehlerhaft wirken.
Fehlergrenzen pro Komponente
Wenn eine Komponente abstürzt, möchten Sie nicht, dass der gesamte Chat abstürzt. Wir haben jedes Tool-Komponenten-Rendering in eine Fehlergrenze (Error Boundary) eingeschlossen:
class ToolComponentErrorBoundary extends Component<...> {
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return (
<div className="...amber border...">
<p>Inhalt konnte nicht angezeigt werden – bitte versuchen Sie es erneut.</p>
</div>
);
}
return this.props.children;
}
}
Dies gibt dem Benutzer einen wiederherstellbaren Fehler anstelle einer unterbrochenen Chat-Sitzung. Der bernsteinfarbene Fallback ist leicht genug, dass er nicht alarmierend wirkt, aber klar genug, dass der Benutzer weiß, dass etwas nicht gerendert wurde.
Interaktive Komponenten, die den nächsten Agenten-Zug auslösen
Die Verkaufsqualifizierungs-Karte ist interaktiv. Der Benutzer klickt auf Optionen, klickt auf Senden, und seine Auswahl muss zur nächsten Nachricht in der Konversation werden. Wir tun dies über ein benutzerdefiniertes DOM-Ereignis:
const SalesQualifierInChat: React.FC = () => {
const handleSubmit = (data: QualifierState) => {
const message = `Anwendungsfall: ${data.useCase}. Volumen: ${data.volume}. Zeitplan: ${data.timeline}.`;
window.dispatchEvent(
new CustomEvent('frida:qualifier-submit', { detail: message })
);
};
return <SalesQualifierCard onSubmit={handleSubmit} />;
};
Der Chat hört auf dieses Ereignis und fügt die formatierte Zeichenfolge als Benutzernachricht ein. Der Agent greift sie dann auf und setzt den Workflow fort, indem er die E-Mail entwirft und die Vorschaukarte anzeigt. Zwei Tool-Komponenten, ein Konversationsfluss.
Warum statische Generative UI die richtige Wahl war
Wir hätten deklarativ vorgehen können. Wir hätten den Agenten A2UI-JSON zurückgeben und aus Spezifikationen rendern lassen können. Das hätte Frida die Fähigkeit gegeben, neue Layouts zu erfinden.
Das wollten wir nicht. Frida lebt innerhalb eines Produkts mit einem Designsystem. Ein Agent, der beliebige Karten erfinden kann, ist auch ein Agent, der um 2 Uhr morgens nicht markenkonforme oder fehlerhafte UI produzieren kann, wenn ein Kunde blockiert ist.
Statische Generative UI bietet Ihnen die Reaktionsfähigkeit einer KI-Oberfläche mit der Vorhersehbarkeit einer handgefertigten Komponentenbibliothek. Der Agent trifft Entscheidungen. Das Frontend erstellt Pixel. Keiner von beiden überschreitet die Grenzen des anderen.
Die Einschränkung ist auch das, was es versendbar machte. Frida wurde mit sechs Komponenten gestartet. Jede ist ein klar definierter Vertrag zwischen dem Agenten und der UI. Wenn der Agent showCreditBalance aufruft, wissen wir genau, was gerendert wird. Wenn nicht, wird nichts gerendert. Keine Überraschungen.
Generative UI ist kein neues Konzept. Der Name ist neu. Die Idee, dass ein KI-Assistent aufgabenbezogene UI rendern sollte, anstatt alles als Text zu erzählen, ist schon seit einiger Zeit offensichtlich.
Wenn Sie einen In-Product-KI-Assistenten bauen und dieser nur Text zurückgibt, ist das das Nächste, was Sie sich ansehen sollten. Wählen Sie eine Komponente, einen Tool-Aufruf, einen Anwendungsfall. Das Muster ist klein genug, um an einem Tag versendet zu werden. Der Unterschied in der Erfahrung ist überhaupt nicht klein.
#Elba #BuildingVoiceAI