Et si l'interface de votre application n'était plus figée dans le code, mais générée à la volée par un modèle d'IA en fonction de la conversation avec l'utilisateur ? C'est la promesse de la generative UI (GenUI). Au lieu de renvoyer du texte brut, un modèle comme Gemini renvoie une description structurée d'une interface, que l'application traduit en vrais Widgets Flutter.
Dans cet article, on construit pas à pas une petite application Flutter qui fait exactement ça. L'utilisateur décrit ses envies de voyage et le modèle génère une carte de ville avec :
- une image
- une description
- un bouton pour ouvrir le site de tourisme de la ville
- un bouton pour demander une suggestion de ville similaire
Cette carte s'affiche sous la demande de l'utilisateur, à l'image d'une conversation que l'on peut avoir avec n'importe quel LLM (Large Language Model).
Pour construire ce genre d'application, on a besoin d'assembler deux briques :
- genui : le package Flutter publié par Google (labs.flutter.dev) qui gère la génération et l'affichage d'UI pilotée par IA.
Il est à noter que le package est noté comme étant en phase alpha et donc qu'il peut être amené à évoluer. - firebase_ai : le SDK officiel Firebase AI Logic qui donne accès aux modèles Gemini.
Prérequis
Avant de commencer, il faut :
- Un projet Flutter.
- Un projet Firebase (créé depuis la console Firebase) avec l'API Firebase AI Logic activée pour ce projet.
- Relier le projet Flutter à Firebase via la CLI FlutterFire (
flutterfire configure) qui génère un fichierfirebase_options.dartavec la configuration de chaque plateforme.
Étape 1 - Déclarer les dépendances
Comme tout bon projet Flutter, tout commence dans pubspec.yaml :
dependencies:
flutter:
sdk: flutter
genui: ^0.10.3
firebase_ai: ^4.0.0
firebase_app_check: ^0.4.8
firebase_core: ^4.15.0
json_schema_builder: ^0.1.7
Le rôle de chaque package :
firebase_core: socle obligatoire pour initialiser Firebase dans l'application.firebase_ai: donne accès à l'API de Firebase AI Logic.firebase_app_check: protège l'API Firebase AI Logic contre les appels non autorisés.genui: le moteur GenUI de Flutter.json_schema_builder: pour écrire des schémas JSON de façon typée (utile pour décrire au modèle la structure de données que doit produire l'IA).
Étape 2 - Initialiser Firebase et App Check
L'initialisation des services Firebase se fait dans le fichier main.dart :
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await FirebaseAppCheck.instance.activate(
providerApple: AppleDebugProvider(),
providerAndroid: AndroidDebugProvider(),
);
runApp(const MainApp());
}
-
WidgetsFlutterBinding.ensureInitialized()garantit que le lien entre le framework Flutter et le moteur natif est établi avant runApp(). C'est ce qui rend les platform channels accessibles et permet ainsi d'appeler des plugins comme Firebase dansmain(). -
Firebase.initializeApp(...)initialise Firebase dans l'application avec la configuration adaptée à la plateforme courante. -
FirebaseAppCheck.instance.activate(...)déclare un provider d'attestation par plateforme :AppleDebugProvider()pour iOS/macOSAndroidDebugProvider()pour Android.
Ce sont des providers de debug : ils permettent à l'app de passer les vérifications App Check en développement sans passer par une vraie attestation d'appareil. Pour cela, il faut récupérer le jeton de debug affiché dans les logs au lancement de l'application et l'enregistrer dans la console Firebase. Cette étape est à faire pour chaque device utilisé durant la phase de développement.
En production, on remplacerait ces deux providers par leurs équivalents réels :AppleAppAttestProviderouAppleDeviceCheckProviderpour iOSAndroidProvider.playIntegritypour Android.
App Check n'est pas strictement lié à GenUI, mais il est recommandé dès qu'on appelle une API Firebase AI Logic depuis le client. Cela évite que la clé d'API Gemini ne soit utilisable par n'importe qui ayant décompilé l'app.
Ce n'est d'ailleurs plus vraiment une option. Selon la documentation officielle Firebase AI Logic, à partir du 2 novembre 2026 App Check deviendra obligatoire pour utiliser Firebase AI Logic.
Étape 3 - Construire le catalogue GenUI
Le catalogue est le cœur du système GenUI. Il liste les items (Widgets) que le modèle d'IA a le droit d'utiliser dans ses réponses. Chaque item présent dans un catalogue est accompagné d'un schéma JSON décrivant les données qu'il attend.
Création d'un Widget Flutter
Si on reprend notre application capable de donner des exemples de ville à visiter, on peut se pencher sur le cas du Widget CityCard, dont voici le code :
class CityCard extends StatelessWidget {
const CityCard({
super.key,
required this.name,
required this.imageUrl,
required this.description,
required this.onVisitCity,
required this.onProposeNewCity,
});
final String name;
final String imageUrl;
final String description;
final VoidCallback onVisitCity;
final VoidCallback onProposeNewCity;
@override
Widget build(BuildContext context) {
return Card(
child: Container(
width: .infinity,
padding: const .all(12),
child: Column(
crossAxisAlignment: .start,
spacing: 12,
children: [
Row(
spacing: 12,
children: [
Expanded(
child: Text(
name,
style: TextStyle(fontSize: 15, fontWeight: .bold),
),
),
IconButton.filledTonal(
icon: Icon(Icons.replay),
onPressed: onProposeNewCity,
),
],
),
Image.network(
imageUrl,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
),
Text(description),
RoundedButton(
text: 'Visiter $name',
onPressed: onVisitCity,
),
],
),
),
);
}
}
Un exemple de rendu dans l'application :

Le schéma de données d'un item
Pour que le modèle sache comment générer l'UI de sa réponse, ce dernier a besoin d'un schéma décrivant chaque widget qu'il peut utiliser.
Voici le schéma correspondant au Widget CityCard :
final _schema = S.object(
properties: {
'name': S.string(description: 'The city.'),
'imageUrl': S.string(description: 'An image representing the city.'),
'description': S.string(description: 'An explanation about why the city is a good match.'),
'visitAction': A2uiSchemas.action(
description:
'The action to trigger when the button is pressed. To open the '
"city's official tourism website, use a functionCall to "
'"openUrl" with the website URL as the "url" argument.',
),
'proposeNewCityAction': A2uiSchemas.action(
description:
'The action to trigger when the button is pressed. Propose an other'
'city with the same user instruction as this city.',
),
},
required: [
'name',
'imageUrl',
'description',
'visitAction',
'proposeNewCityAction',
],
);
On retrouve une propriété par élément de l'UI avec lequel le modèle doit interagir.
Chaque propriété porte une description qui n'est pas juste de la documentation. Elle est envoyée au modèle et l'aide à comprendre comment construire sa réponse en sachant quoi mettre dans chacune des propriétés d'un item.
La plupart des propriétés ont une description simple sous forme de String. Dans le cas des propriétés visitAction et proposeNewCityAction, on utilise A2uiSchemas.action. C'est un schéma prédéfini par genui pour décrire une action déclenchable (soit un appel de fonction côté client, soit un événement remonté à l'application). Ces propriétés vont nous permettre d'expliquer au modèle comment doivent agir les boutons du Widget CityCard.
Le CatalogItem
Maintenant qu'on a défini notre Widget ainsi que le schéma associé, il nous reste à créer un CatalogItem que l'on va pouvoir insérer dans le catalogue passé au modèle :
final cityCardItem = CatalogItem(
name: 'CityCard',
dataSchema: _schema,
widgetBuilder: (itemContext) {
final json = itemContext.data as Map<String, Object?>;
final name = json['name'] as String;
final imageUrl = json['imageUrl'] as String;
final description = json['description'] as String;
final visitAction = json['visitAction'] as JsonMap?;
final proposeNewCityAction = json['proposeNewCityAction'] as JsonMap?;
void onVisitCity() async {
if (visitAction == null) return;
final functionCall = visitAction['functionCall'] as JsonMap?;
if (functionCall != null) {
final resultStream = itemContext.dataContext.resolve(functionCall);
final iterator = StreamIterator<Object?>(resultStream);
await iterator.moveNext();
await iterator.cancel();
return;
}
}
void onProposeNewCity() async {
if (proposeNewCityAction == null) return;
final eventMap = proposeNewCityAction['event'] as JsonMap?;
if (eventMap == null) return;
final actionName = eventMap['name'] as String;
final contextDefinition = eventMap['context'] as JsonMap?;
final JsonMap resolvedContext = await resolveContext(
itemContext.dataContext,
contextDefinition,
);
itemContext.dispatchEvent(
UserActionEvent(
name: actionName,
sourceComponentId: itemContext.id,
context: resolvedContext,
),
);
}
return CityCard(
name: name,
imageUrl: imageUrl,
description: description,
onVisitCity: onVisitCity,
onProposeNewCity: onProposeNewCity,
);
},
);
name: le nom du Widget, utilisé tel quel dans la réponse JSON du modèle.dataSchema: le schéma défini à l'étape précédente.widgetBuilder: le builder servant à créer le Widget à partir de la réponse du modèle. C'est le nerf desCatalogItems.
À noter, il existe deux types de données que l'on peut récupérer concernant les actions utilisateur, comme les boutons :
functionCall: une fonction déclarée dans le catalogue côté client et exécutée localement.
itemContext.dataContext.resolve(...)retourne un flux lazy qui ne s'exécute que si on l'écoute, d'où leStreamIteratorqu'on consomme manuellement.event: l'interaction utilisateur à capturer et renvoyer au modèle comme contexte pour le tour suivant de la conversation. Le modèle reçoit le nom de l'action et son contexte, puis décide de ce qu'il doit en faire.
Assembler le catalogue
Une fois qu'on a défini tous les composants que le modèle a le droit d'utiliser dans ses réponses, on vient les insérer dans un Catalog :
final Catalog appCatalog = Catalog(
[cityCardItem],
functions: [BasicFunctions.openUrlFunction],
systemPromptFragments: [BasicCatalogItems.basicCatalogRules],
catalogId: "com.example.genuiFirebaseGemini",
);
[cityCardItem]: regroupe la liste des composants disponibles (ici un seul, mais on pourrait en ajouter d'autres).functions: [BasicFunctions.openUrlFunction]: liste les fonctions fournies par GenUI et que le modèle peut appeler côté client.
On retrouve ici une fonction permettant d'ouvrir une url. Elle fait partie d'une liste de fonctions basiques mises à disposition par le packagegenui.systemPromptFragments: [BasicCatalogItems.basicCatalogRules]: comprend les instructions système génériques fournies pargenui. Elles expliquent les règles de base au modèle pour qu'il puisse produire un JSON valide vis-à-vis du catalogue.catalogId: l'identifiant qui distingue le catalogue si l'app venait à en gérer plusieurs. Le packagegenuirecommande d'utiliser une notation sous forme de reverse domain.
Étape 4 - Relier GenUI à Firebase AI (le contrôleur)
L'une des étapes les plus importantes lorsqu'on veut faire du GenUI est de bien relier les deux briques principales que sont genui et firebase_ai.
Pour ce faire, on vient créer un GenUiController pour encadrer les échanges entre l'utilisateur et le modèle. Cela permet aussi de séparer au maximum la logique de la vue.
Le SurfaceController
On commence par créer le SurfaceController en lui passant le catalogue construit à l'étape précédente :
final catalog = appCatalog;
surfaceController = SurfaceController(
catalogs: [catalog],
);
C'est cet objet qui va recevoir les réponses structurées du modèle et les transformer en une ou plusieurs Surface. Ce sont des zones d'UI affichables à l'écran.
Le prompt system et le PromptBuilder
Le prompt système sert à expliquer au modèle son rôle métier, indépendamment de tout ce qui concerne le catalogue :
static const String _systemPrompt = '''
You are an expert in finding ideas of what city to visit.
Every time I talk to you, you should generate UI that displays one city
that match my words.
''';
Pour que le modèle respecte le catalogue, il ne suffit pas de lui donner un _systemPrompt. Il faut aussi lui expliquer comment produire du JSON valide afin de pouvoir utiliser les items du catalogue ou encore lister les fonctions qu'il peut appeler. C'est le rôle du PromptBuilder, qui vient concaténer les deux :
final promptBuilder = PromptBuilder.chat(
catalog: catalog,
systemPromptFragments: [_systemPrompt],
);
Le modèle
Reste à créer le modèle Gemini avec firebase_ai :
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3.8-flash',
systemInstruction: Content.system(promptBuilder.systemPromptJoined()),
generationConfig: GenerationConfig(
thinkingConfig: .withThinkingLevel(null, includeThoughts: false),
),
);
final chatSession = model.startChat();
-
model: permet de choisir le modèle que l'on souhaite utiliser.
Le nom du modèle utilisé ici fait partie de la famillegemini-3.x-flash. C'est la famille de modèles de Gemini la plus adaptée pour avoir des réponses structurées (format JSON). -
systemInstruction: passage de la concaténation du system prompt et du prompt explicatif du catalogue. -
generationConfig: permet de choisir le niveau de réflexion du modèle (augmente le temps de latence en fonction du niveau choisi) et l'inclusion ou non de ses réflexions dans ses réponses. Ici, vu qu'on ne souhaite afficher que des composantsCityCard, on décide de ne pas inclure les réflexions du modèle dans ses réponses. On consomme ainsi moins de tokens et réduit le temps de latence entre les messages utilisateur et les réponses du modèle. -
model.startChat(): ouvre une session de chat qui garde en mémoire l'historique de la conversation côté SDK. Chaque nouveau message envoyé vient s'ajouter à ce qui a déjà été échangé avec le modèle.
Le pont entre le modèle et GenUI : A2uiTransportAdapter
Pour faire communiquer notre modèle Gemini et genui, il nous faut un A2uiTransportAdapter. Son rôle est de définir comment les messages et interactions utilisateur sont envoyées vers le modèle et comment elles sont réceptionnées :
int _surfaceCounter = 0;
String reserveSurfaceId() {
_surfaceCounter++;
return 'surface_$_surfaceCounter';
}
transportAdapter = A2uiTransportAdapter(
onSend: (message) async {
final buffer = StringBuffer();
for (final part in message.parts) {
if (part.isUiInteractionPart) {
buffer.write(part.asUiInteractionPart!.interaction);
} else if (part is genui.TextPart) {
buffer.write(part.text);
}
}
if (buffer.isEmpty) return;
final freshSurfaceId = _reserveSurfaceId();
buffer.write(
'\n\n(If you display a UI for this response, use exactly '
'"$freshSurfaceId" as the surfaceId in your createSurface message — '
'a brand new surface. Never reuse the surfaceId from a previous '
'turn, so earlier UI stays visible above it.)',
);
final stream = chatSession.sendMessageStream(
Content.text(buffer.toString()),
);
await for (final chunk in stream) {
transportAdapter.addChunk(chunk.text ?? '');
}
},
);
Un message envoyé par genui peut contenir une ou plusieurs parts. Ici, on ne traite que deux types de parts à savoir du texte classique (TextPart) et des interactions utilisateur à partir d'éléments UI déjà générés (isUiInteractionPart). On concatène tous les parts pour n'avoir qu'un seul message qu'on envoie au modèle avec chatSession.sendMessageStream(...).
Vu qu'on choisit de travailler avec des streams, la réponse arrive au compte goutte plutôt qu'en un seul bloc. Chaque bout de la réponse est poussé individuellement dans transportAdapter afin que l'UI se charge progressivement, sans attendre la fin de la réponse du modèle.
À noter qu'on vient ajouter une instruction concernant le surfaceId à utiliser à chaque réponse du modèle. C'est un choix arbitraire qui a été fait pour éviter de laisser le modèle décider seul du surfaceId. Le risque étant qu'il réutilise un ancien surfaceId et écrase l'historique visuel de la conversation. Bien que cette approche ne garantit pas un résultat déterministe, les observations montrent qu’elle produit des résultats relativement fiables.
Assembler la conversation
Enfin, SurfaceController et A2uiTransportAdapter sont assemblés dans une Conversation, l'objet que le reste de l'application va manipuler :
conversation = Conversation(
controller: surfaceController,
transport: transportAdapter,
);
L'envoie des messages utilisateur
Le contrôleur expose une méthode pour que la vue puisse déclencher l'envoi des messages utilisateur :
Future<void> sendMessage(String text) async {
final trimmed = text.trim();
if (trimmed.isEmpty) {
return;
}
await conversation.sendRequest(ChatMessage.user(trimmed));
}
On passe par l'objet conversation et la méthode conversation.sendRequest(), qui déclenche le onSend du transportAdapter, pour envoyer le message de l'utilisateur au modèle.
Étape 5 - Afficher la conversation à l'écran
Une fois GenuiController capable d'envoyer un message et de récupérer la mise à jour d'une Surface en retour, il reste à construire l'écran qui va accueillir la conversation entre l'utilisateur et le modèle.
Initialiser l'écran
Ayant besoin de gérer l'état de la conversation, on utilise un StatefulWidget dont l'initialisation est la suivante :
final _textController = TextEditingController();
final _scrollController = ScrollController();
final GenuiController _genUiController = GenuiController();
final List<_Turn> _turns = [];
String? _error;
StreamSubscription<ConversationEvent>? _conversationEventsSubscription;
-
_textController: récupère les entrées utilisateur. -
_scrollController: utilisé pour scroller vers la fin de la conversation lorsqu'un nouveau widget est affiché. -
_genUiController: gère les échanges entre l'utilisateur et le modèle. -
_turns: conserve l'historique des échanges où_Turncorrespond au type de tour dans la conversation (user, model) :sealed class _Turn {} class _UserTurn(final String text) extends _Turn {} class _ModelTurn(final String surfaceId) extends _Turn {} -
_error: stocke la dernière erreur rencontrée. -
_conversationEventsSubscription: souscrit aux événements de la conversation.
Écouter les événements de la conversation
conversation.events qui est exposé par le contrôleur donne accès à un flux d'événements, séparé de l'état d'affichage. On s'y abonne dès initState pour écouter les événements remontées et agir en conséquence :
@override
void initState() {
super.initState();
_conversationEventsSubscription = _genUiController.conversation.events.listen((event) {
if (!mounted) return;
switch (event) {
case ConversationError():
setState(() => _error = event.error.toString());
case ConversationSurfaceAdded():
setState(() => _turns.add(_ModelTurn(event.surfaceId)));
_scrollDown();
case ConversationComponentsUpdated():
_scrollDown();
default:
break;
}
});
}
ConversationError: remonte les erreurs rencontrées au cours de la conversation.ConversationSurfaceAdded: indique qu'uneSurfacevient d'être créée par le modèle.ConversationComponentsUpdated: indique qu'uneSurfaceexistante vient d'être mise à jour. Dans notre cas, on rencontre régulièrement cet événement car on stream les réponses du modèle.ConversationWaiting: indique que le modèle est en train de répondre.
Savoir si le modèle est en train de répondre
Lorsque l'utilisateur échange avec le modèle, il est important de savoir à quel moment ce dernier est entrain de lui répondre. Cela permet de mettre à jour la vue pour indiquer à l'utilisateur qu'il est dans un état d'attente. Pour ce faire, on encapsule l'écran dans un ValueListenableBuilder :
ValueListenableBuilder<ConversationState>(
valueListenable: _genUiController.conversation.state,
builder: (context, state, _) {
return Scaffold(
appBar: AppBar(
title: const Text('Firebase AI (Gemini) + GenUI'),
),
body: Column(
children: [
if (_error != null) _buildErrorBanner(),
Expanded(
child: _buildConversation(state),
),
_buildInput(state.isWaiting),
],
),
);
}
)
Avec _genUiController.conversation.state, on a accès à l'état de la conversation ainsi qu'au booléen isWaiting. De cette manière, la vue peut suivre le cycle de vie de la conversation entre l'utilisateur et le modèle et se reconstruire dès lors qu'il y a un changement.
Afficher la conversation
Grâce à l'état de la conversation que l'on vient de récupérer, on peut maintenant l'afficher à l'écran :
Widget _buildConversation() {
return ListView(
padding: const .all(16),
controller: _scrollController,
children: [
for (final turn in _turns)
Padding(
padding: const .only(bottom: 24),
child: switch (turn) {
_UserTurn() => _buildUserBubble(turn),
_ModelTurn() =>
state.surfaces.contains(turn.surfaceId) ? _buildModelSurface(turn) : const SizedBox.shrink(),
},
),
],
);
}
Afficher la Surface générée
Pour afficher le contenu d'une Surface générée par le modèle, le package genui met à disposition son propre Widget Surface :
Widget _buildModelSurface(_ModelTurn turn) {
return Align(
alignment: .centerLeft,
child: FractionallySizedBox(
widthFactor: 0.85,
alignment: .centerLeft,
child: Surface(
surfaceContext: _genUiController.surfaceController.contextFor(turn.surfaceId),
),
),
);
}
La Surface se récupère via l'id que l'on a stocké dans _ModelTurn.
À noter qu'on ne retrouve à aucun moment le Widget CityCard que l'on a défini au début de cet article. C'est le widgetBuilder de l'item cityCardItem qui se charge de créer le widget associé à la réponse du modèle. Le Widget Surface se contente simplement d'appeler le bon CatalogItem dès qu'un composant doit être affiché dans une surface.
Envoyer le message de l'utilisateur
Enfin, l'écran étant créé, il ne reste plus qu'à définir la méthode qui va nous servir à envoyer le message de l'utilisateur au modèle. Cette méthode reliée à un TextField dans lequel l'utilisateur va pouvoir taper son message :
Widget _buildInput(bool isWaiting) {
return SafeArea(
top: false,
child: Padding(
padding: .all(12),
child: Row(
crossAxisAlignment: .end,
spacing: 8.0,
children: [
Expanded(
child: TextField(
controller: _textController,
minLines: 1,
maxLines: 5,
textInputAction: .send,
onSubmitted: (_) {
_send();
},
decoration: const InputDecoration(
hintText: 'Écris un message…',
border: OutlineInputBorder(),
),
),
),
IconButton.filled(
onPressed: isWaiting ? null : _send,
icon: isWaiting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.send),
),
],
),
),
);
}
et la méthode _send() utilisée pour envoyer le message au modèle :
Future<void> _send() async {
final text = _textController.text.trim();
final isWaiting = _genUiController.conversation.state.value.isWaiting;
if (text.isEmpty || isWaiting) {
return;
}
_textController.clear();
setState(() {
_error = null;
_turns.add(_UserTurn(text));
_scrollDown();
});
await _genUiController.sendMessage(text);
}
Ce qu'on fait dans cette méthode c'est :
- Récupérer le message de l'utilisateur et l'ajouter dans la liste
_turnsous forme de_UserTurn. - Scroller tout en bas de la conversation si jamais elle est longue et qu'on n'y est pas déjà.
- Envoyer le message au contrôleur avec
_genUiController.sendMessage(...)pour qu'il le fasse parvenir jusqu'au modèle.
Quelques screenshots pour illustrer cet article :
| Envoie du message utilisateur | Réponse du modèle | Nouvelle proposition de ville |
|
|
|
Conclusion
Pour récapituler le fonctionnement de GenUI en Flutter, voici ce qui se passe entre le moment où l'utilisateur tape un message et celui où la carte de la ville apparaît à l'écran :
- L'utilisateur tape son message dans le
TextFieldet l'envoie. Cela déclenche l'appel de la méthodesendMessageexposée par le contrôleur. - Le contrôleur transmet le message à la
Conversation, avecconversation.sendRequest. genuidélègue l'envoi réel àA2uiTransportAdapter.onSendqui appellechatSession.sendMessageStream, avec la contrainte desurfaceId, sur le modèle.- Le modèle répond en streaming et chaque fragment est réinjecté dans
transportAdapter.addChunk. genuiinterprète le flux, se tourne vers leCatalogdéfini et met à jour laSurfacecorrespondante avec leSurfaceController.- L'écran observe
conversation.stateet affiche le widgetCityCardconstruit par lewidgetBuilderdecityCarItem. - L'utilisateur peut décider d'intéragir avec les boutons de
CityCardgrâce aufunctionCallet à l'eventdéfinis dans le schéma decityCardItem.
Pour aller plus loin
Ce projet ne déclare qu'un seul CatalogItem, à savoir CityCard, mais la logique s'étend naturellement. Ajouter un composant revient à définir un nouveau schéma JSON avec un widgetBuilder puis ajouter l'item dans le Catalog passé au modèle. C'est ce mécanisme qui fait tout l'intérêt de l'approche GenUI. Le modèle ne peut générer que ce que l'application autorise explicitement, ce qui permet de garder le contrôle sur le rendu final tout en laissant l'IA composer dynamiquement l'interface.
À garder en tête : comme mentionné dans l'intro de cet article, le package genui est encore en phase alpha. L'API peut évoluer sensiblement d'une version mineure à l'autre, ce qui est à prendre en compte avant d'envisager une mise en production.
Aussi, dans cet article, on ne couvre que le cas d'usage de Firebase AI. Son avantage est de facilement donner accès à la famille de modèles Gemini que l'on vient d'utiliser (gemini-3.x-flash). Cependant, cela implique d'avoir une dépendance à Firebase et une bonne gestion des coûts.
Cependant, il est tout à fait possible de mettre en place son propre serveur en suivant le protocole A2A (Agent2Agent) et en utilisant notamment le package genui_a2a (qui est lui aussi en phase alpha). Voici un schéma pour illustrer cette ouverture :

