L'interface utilisateur générative est le nouveau frontend. Nous l'avons lancée il y a des mois.
Tout le monde parle d'interface utilisateur générative en ce moment. CopilotKit a publié un guide pour développeurs. Google a un article de recherche à ce sujet.
Nous l'avons lancée en janvier. Elle fonctionne en production dans Elba depuis.
Ce n'est pas une opinion tranchée. C'est un compte rendu de la façon dont nous l'avons réellement construite, à quoi ressemble l'architecture et ce qui nous a fait trébucher en cours de route.
Ce que signifie réellement l'interface utilisateur générative
La version courte : au lieu de coder en dur chaque état de l'écran, l'agent IA décide à l'exécution quel composant d'interface utilisateur afficher et avec quelles données le remplir.
L'interface de chat ne renvoie plus seulement du texte. Elle renvoie une interface utilisateur interactive en direct, rendue dans la conversation elle-même.
CopilotKit décompose cela en trois modèles :
- Interface Utilisateur Générative Statique : le frontend possède les composants. L'agent choisit lequel afficher et le remplit avec des données. Contrôle élevé, prévisible.
- Interface Utilisateur Générative Déclarative : l'agent renvoie une spécification d'interface utilisateur (cartes, formulaires, listes en JSON), et le frontend rend à partir de cette spécification. Plus flexible, plus imprévisible.
- Interface Utilisateur Générative Ouverte : l'agent renvoie une surface d'interface utilisateur complète, souvent une iframe ou du HTML arbitraire. Flexibilité maximale, cohérence minimale.
Nous avons choisi le premier modèle. Nous avons pré-construit chaque composant. L'agent ne touche jamais à la mise en page. Il décide seulement quand quelque chose apparaît et ce qu'il contient.
C'était la bonne décision pour un assistant de support dans un produit en production. Vous voulez que l'agent prenne des décisions, pas qu'il conçoive des interfaces.
Comment fonctionne Frida
Frida est l'assistant IA intégré dans Elba. Il fonctionne dans un panneau de chat. Les utilisateurs peuvent lui poser des questions sur leur compte, les appels récents, le solde de crédit, la facturation et tout autre élément de la plateforme.
Ce qui en fait une interface utilisateur générative : lorsque vous demandez "quel est mon solde de crédit ?", Frida ne répond pas avec du texte. Elle affiche une carte.
Lorsque vous demandez des appels récents, vous obtenez un journal d'appels interactif. Lorsque vous êtes sur le chemin de l'entreprise, vous obtenez un formulaire de qualification cliquable, pas une invite textuelle vous demandant de taper votre cas d'utilisation.
L'interface s'adapte à ce que l'agent a besoin que l'utilisateur fasse.
La pile technique
- Frontend : React, CopilotKit v2, protocole AG-UI
- Backend : Agent PydanticAI fonctionnant sur notre service Cognition Hub
- Protocole : Hook
useFrontendTool de CopilotKit via AG-UI
Le registre de composants
Chaque composant génératif dans Frida est enregistré en un seul endroit :
// 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;
}
Au démarrage, chaque composant s'enregistre lui-même :
registerToolComponent('showCreditBalance', CreditBalanceCard);
registerToolComponent('showRecentCalls', RecentCallsCard);
registerToolComponent('showCreditBurnRates', CreditBurnRateCard);
registerToolComponent('showSalesQualifier', SalesQualifierInChat);
registerToolComponent('showSalesInquiryPreview', SalesInquiryPreview);
Lorsque l'agent déclenche un appel d'outil, le moteur de rendu du chat le recherche dans ce registre et le monte en ligne.
Les définitions d'outils
Chaque composant a une définition useFrontendTool correspondante. C'est là que l'agent apprend quand et comment l'utiliser :
useFrontendTool(
{
name: 'showCreditBalance',
description:
'UTILISEZ TOUJOURS cet outil pour afficher une carte visuelle du solde de crédit lorsque l'utilisateur pose des questions sur ses crédits, son solde, les minutes restantes ou son statut de facturation.',
parameters: creditBalanceSchema,
handler: async (params) => JSON.stringify(params),
},
[],
);
Les paramètres sont validés avec Zod :
const creditBalanceSchema = z.object({
credits: z.number().describe('Solde de crédit actuel'),
plan: z.string().describe('Nom du plan actuel'),
status: z.enum(['healthy', 'low', 'critical']).describe(
'Statut du solde : sain (>100), faible (10-100), critique (<10)'
),
estimatedMinutes: z.number().describe('Minutes de voix estimées restantes'),
});
L'agent lit le schéma. Les descriptions du schéma l'entraînent sur les valeurs à passer. estimatedMinutes avec sa description (crédits / 10) signifie que l'agent calcule la bonne valeur au lieu de deviner.
L'agent backend
L'autre moitié de cela réside dans le backend Python. Frida est un agent PydanticAI avec des outils qu'il peut appeler pour récupérer des données réelles :
@agent.tool
async def fetch_recent_calls(ctx: RunContext[FridaSupportDeps], limit: int = 5) -> dict:
"""Récupérer les appels récents pour l'organisation de l'utilisateur."""
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)})
L'agent a deux types d'outils : des outils backend qui récupèrent des données (appels, informations sur l'organisation, base de connaissances) et des outils frontend qui rendent l'interface utilisateur. Les outils backend alimentent les outils frontend. C'est la boucle complète.
Lorsqu'un utilisateur demande "quelles sont mes appels récents ?", l'agent :
- Appelle
fetch_recent_calls pour obtenir les données de l'API
- Appelle
showRecentCalls avec les résultats formatés
- Le frontend reçoit l'appel d'outil, recherche
RecentCallsCard dans le registre et le monte
Pas de texte. Pas d'analyse. Juste un composant rendu avec des données réelles.
Injection de contexte
L'agent en sait plus que ce que l'utilisateur a tapé. Nous injectons le contexte d'exécution via des générateurs d'instructions dynamiques :
def page_context(ctx: RunContext[FridaSupportDeps]) -> str:
"""Ajouter le contexte de la page actuelle."""
page = ctx.deps.current_page
for prefix, hint in _PAGE_HINTS.items():
if page.startswith(prefix):
return f"Contexte de la page actuelle : {hint}"
return f"L'utilisateur est actuellement sur la page : {page}"
L'agent sait sur quelle page se trouve l'utilisateur, son statut de facturation, son solde de crédit, son plan, le nombre d'agents et la date du jour. Ce contexte façonne chaque réponse. "Vérifiez mon solde de crédit" de quelqu'un sur la page /billing est traité différemment que de quelqu'un sur /agents.
L'injection de la date mérite d'être soulignée spécifiquement :
def date_context(ctx: RunContext[FridaSupportDeps]) -> str:
parts.append(
f"IMPORTANT — La date d'aujourd'hui est le {ctx.deps.current_date}. "
"Utilisez ceci comme source de vérité pour 'aujourd'hui', 'hier', 'cette semaine', etc. "
"NE PAS déduire la date actuelle des horodatages d'appels ou de toute autre donnée."
)
Sans cela, le modèle essaiera de déduire la date actuelle à partir de l'horodatage de l'appel le plus récent, ce qui est faux. Il affichera un appel d'il y a trois jours et dira "Aujourd'hui". Nous avons détecté cela lors des tests. L'injection explicite de la date avec l'instruction "Ne pas déduire" l'a corrigé.
Les parties dont personne ne parle
Le scénario idéal est facile. Voici ce qui a réellement pris du temps.
États squelettes pendant le streaming
CopilotKit diffuse les arguments des appels d'outils. Le composant se monte avant que tous les arguments ne soient arrivés. Si vous effectuez le rendu immédiatement avec des données partielles, vous obtenez des états semi-chargés gênants.
Nous avons ajouté un délai de 500 ms avant d'afficher le vrai composant, avec un scintillement squelette dans l'intervalle :
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}</>;
};
Le squelette correspond à la forme du vrai composant. La carte du solde de crédit obtient un squelette qui ressemble à une carte de solde de crédit. Cela a donné l'impression que l'expérience était intentionnelle plutôt que défectueuse.
Gestionnaires d'erreurs par composant
Lorsqu'un composant plante, vous ne voulez pas que tout le chat soit interrompu. Nous avons encapsulé le rendu de chaque composant d'outil dans un gestionnaire d'erreurs :
class ToolComponentErrorBoundary extends Component<...> {
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return (
<div className="...amber border...">
<p>Impossible d'afficher ce contenu — veuillez réessayer de demander.</p>
</div>
);
}
return this.props.children;
}
}
Cela donne à l'utilisateur une erreur récupérable au lieu d'une session de chat interrompue. Le fallback ambre est suffisamment léger pour ne pas paraître alarmant, mais suffisamment clair pour que l'utilisateur sache que quelque chose n'a pas été rendu.
Composants interactifs qui déclenchent le prochain tour de l'agent
La carte de qualification des ventes est interactive. L'utilisateur clique sur des options, clique sur soumettre, et ses sélections doivent devenir le prochain message de la conversation. Nous le faisons via un événement DOM personnalisé :
const SalesQualifierInChat: React.FC = () => {
const handleSubmit = (data: QualifierState) => {
const message = `Cas d'utilisation : ${data.useCase}. Volume : ${data.volume}. Calendrier : ${data.timeline}.`;
window.dispatchEvent(
new CustomEvent('frida:qualifier-submit', { detail: message })
);
};
return <SalesQualifierCard onSubmit={handleSubmit} />;
};
Le chat écoute cet événement et injecte la chaîne formatée comme message utilisateur. L'agent la récupère ensuite et poursuit le flux de travail, rédigeant l'e-mail et affichant la carte d'aperçu. Deux composants d'outils, un flux de conversation.
Pourquoi l'interface utilisateur générative statique était le bon choix
Nous aurions pu opter pour le déclaratif. Nous aurions pu laisser l'agent renvoyer du JSON A2UI et rendre à partir de la spécification. Cela aurait donné à Frida la capacité d'inventer de nouvelles mises en page.
Nous ne voulions pas de cela. Frida vit dans un produit en production avec un système de conception. Un agent capable d'inventer des cartes arbitraires est aussi un agent capable de produire une interface utilisateur hors marque ou défectueuse à 2h du matin lorsqu'un client est bloqué.
L'interface utilisateur générative statique vous offre la réactivité d'une interface IA avec la prévisibilité d'une bibliothèque de composants artisanale. L'agent prend des décisions. Le frontend crée les pixels. Aucun des deux ne franchit le travail de l'autre.
La contrainte est aussi ce qui l'a rendu livrable. Frida a été lancée avec six composants. Chacun est un contrat clairement défini entre l'agent et l'interface utilisateur. Lorsque l'agent appelle showCreditBalance, nous savons exactement ce qui est rendu. Quand il ne le fait pas, rien n'est rendu. Pas de surprises.
L'interface utilisateur générative n'est pas un nouveau concept. Le nom est nouveau. L'idée qu'un assistant IA devrait rendre une interface utilisateur adaptée à la tâche au lieu de tout raconter sous forme de texte, cela a été évident pendant un certain temps.
Si vous construisez un assistant IA intégré à un produit et qu'il ne renvoie que du texte, c'est la prochaine chose à examiner. Choisissez un composant, un appel d'outil, un cas d'utilisation. Le modèle est suffisamment petit pour être livré en une journée. La différence dans l'expérience n'est pas du tout petite.
#Elba #BuildingVoiceAI