Créer une application d'interface utilisateur générative (GenUI)

1. Introduction

Dans cet atelier de programmation, vous allez créer une application de liste de tâches à l'aide de Flutter, de Firebase AI Logic et du nouveau package genui. Vous commencerez par une application de chat textuelle, que vous améliorerez avec GenUI pour permettre à l'agent de créer sa propre UI. Enfin, vous créerez votre propre composant d'UI interactif et personnalisé que vous et l'agent pourrez manipuler directement.

Application de liste de tâches exécutée dans Chrome

Objectifs de l'atelier

  • Créer une interface de chat de base à l'aide de Flutter et de Firebase AI Logic
  • Intégrer le package genui pour générer des surfaces basées sur l'IA
  • Ajouter une barre de progression pour indiquer quand l'application attend une réponse de l'agent
  • Créez une surface nommée et affichez-la dans un emplacement dédié de l'UI.
  • Créer un composant de catalogue GenUI personnalisé qui vous permet de contrôler la façon dont les tâches sont présentées

Prérequis

Cet atelier de programmation s'adresse aux développeurs Flutter de niveau intermédiaire.

2. Avant de commencer

Configurer le projet Flutter

Ouvrez votre terminal et exécutez flutter create pour créer un projet :

flutter create intro_to_genui
cd intro_to_genui

Ajoutez les dépendances nécessaires à votre projet Flutter :

flutter pub add firebase_core firebase_ai genui json_schema_builder

Votre section dependencies finale devrait se présenter comme suit (les numéros de version peuvent varier légèrement) :

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  firebase_core: ^4.5.0
  firebase_ai: ^3.9.0
  genui: ^0.8.0
  json_schema_builder: ^0.1.3

Exécutez flutter pub get pour télécharger tous les packages.

Activer les API et Firebase

Pour utiliser le package firebase_ai, vous devez d'abord activer Firebase AI Logic dans votre projet.

  1. Accédez à Firebase AI Logic dans la console Firebase.
  2. Cliquez sur Commencer pour lancer le workflow guidé.
  3. Suivez les instructions à l'écran pour configurer votre projet.

Pour en savoir plus, consultez les instructions pour ajouter Firebase à une application Flutter.

Une fois les API activées, initialisez Firebase dans votre application Flutter à l'aide de la CLI FlutterFire :

flutterfire configure

Sélectionnez votre projet Firebase et suivez les instructions pour le configurer pour vos plates-formes cibles (par exemple, Android, iOS, Web). Vous pouvez effectuer cet atelier de programmation avec le SDK Flutter et Chrome installés sur votre ordinateur. Toutefois, l'application fonctionnera également sur d'autres plates-formes.

3. Créer une interface de chat de base

Avant d'introduire l'UI générative, votre application a besoin d'une base : une application de chat textuelle de base optimisée par Firebase AI Logic. Pour commencer rapidement, vous allez copier-coller l'intégralité de la configuration de l'interface de chat.

Version textuelle de l'application

Créer le widget de bulle de message

Pour afficher les messages texte de l'utilisateur et de l'agent, votre application a besoin d'un widget. Créez un fichier nommé lib/message_bubble.dart et ajoutez la classe suivante :

import 'package:flutter/material.dart';

class MessageBubble extends StatelessWidget {
  final String text;
  final bool isUser;

  const MessageBubble({super.key, required this.text, required this.isUser});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    final bubbleColor = isUser
        ? colorScheme.primary
        : colorScheme.surfaceContainerHighest;

    final textColor = isUser
        ? colorScheme.onPrimary
        : colorScheme.onSurfaceVariant;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0),
      child: Column(
        crossAxisAlignment: isUser
            ? CrossAxisAlignment.end
            : CrossAxisAlignment.start,
        children: [
          Row(
            mainAxisAlignment: isUser
                ? MainAxisAlignment.end
                : MainAxisAlignment.start,
            children: [
              Flexible(
                child: Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 16.0,
                    vertical: 12.0,
                  ),
                  decoration: BoxDecoration(
                    color: bubbleColor,
                    borderRadius: BorderRadius.only(
                      topLeft: const Radius.circular(20),
                      topRight: const Radius.circular(20),
                      bottomLeft: Radius.circular(isUser ? 20 : 0),
                      bottomRight: Radius.circular(isUser ? 0 : 20),
                    ),
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withAlpha(20),
                        blurRadius: 4,
                        offset: const Offset(0, 2),
                      ),
                    ],
                    gradient: isUser
                        ? LinearGradient(
                            colors: [
                              colorScheme.primary,
                              colorScheme.primary.withAlpha(200),
                            ],
                            begin: Alignment.topLeft,
                            end: Alignment.bottomRight,
                          )
                        : null,
                  ),
                  child: Text(
                    text,
                    style: theme.textTheme.bodyLarge?.copyWith(
                      color: textColor,
                      height: 1.3,
                    ),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 2),
        ],
      ),
    );
  }
}

MessageBubble est un StatelessWidget qui affiche un seul message de chat. Il sera utilisé plus tard dans cet atelier de programmation pour afficher les messages de l'utilisateur et de l'agent, mais il s'agit principalement d'un widget Text sophistiqué.

Implémenter l'UI de chat dans main.dart

Remplacez l'intégralité du contenu de lib/main.dart par cette implémentation complète du chatbot textuel :

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:intro_to_genui/message_bubble.dart';
import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Just Today',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

sealed class ConversationItem {}

class TextItem extends ConversationItem {
  final String text;
  final bool isUser;
  TextItem({required this.text, this.isUser = false});
}

class _MyHomePageState extends State<MyHomePage> {
  final List<ConversationItem> _items = [];
  final _textController = TextEditingController();
  final _scrollController = ScrollController();
  late final ChatSession _chatSession;

  @override
  void initState() {
    super.initState();
    final model = FirebaseAI.googleAI().generativeModel(
      model: 'gemini-3-flash-preview',
    );
    _chatSession = model.startChat();
    _chatSession.sendMessage(Content.text(systemInstruction));
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  Future<void> _addMessage() async {
    final text = _textController.text;

    if (text.trim().isEmpty) {
      return;
    }
    _textController.clear();

    setState(() {
      _items.add(TextItem(text: text, isUser: true));
    });

    _scrollToBottom();

    final response = await _chatSession.sendMessage(Content.text(text));

    if (response.text?.isNotEmpty ?? false) {
      setState(() {
        _items.add(TextItem(text: response.text!, isUser: false));
      });
      _scrollToBottom();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Just Today'),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              children: [
                for (final item in _items)
                  switch (item) {
                    TextItem() => MessageBubble(
                          text: item.text,
                          isUser: item.isUser,
                        ),
                  },
              ],
            ),
          ),
          SafeArea(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      onSubmitted: (_) => _addMessage(),
                      decoration: const InputDecoration(
                        hintText: 'Enter a message',
                      ),
                    ),
                  ),
                  const SizedBox(width: 8),
                  ElevatedButton(
                    onPressed: _addMessage,
                    child: const Text('Send'),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

const systemInstruction = '''
  ## PERSONA
  You are an expert task planner.

  ## GOAL
  Work with me to produce a list of tasks that I should do today, and then track
  the completion status of each one.

  ## RULES
  Talk with me only about tasks that I should do today.
  Do not engage in conversation about any other topic.
  Do not offer suggestions unless I ask for them.
  Do not offer encouragement unless I ask for it.
  Do not offer advice unless I ask for it.
  Do not offer opinions unless I ask for them.

  ## PROCESS
  ### Planning
  * Ask me for information about tasks that I should do today.
  * Synthesize a list of tasks from that information.
  * Ask clarifying questions if you need to.
  * When you have a list of tasks that you think I should do today, present it
    to me for review.
  * Respond to my suggestions for changes, if I have any, until I accept the
    list.

  ### Tracking
  * Once the list is accepted, ask me to let you know when individual tasks are
    complete.
  * If I tell you a task is complete, mark it as complete.
  * Once all tasks are complete, send a message acknowledging that, and then
    end the conversation.
''';

Le fichier main.dart que vous venez de copier-coller configure un ChatSession de base à l'aide de Firebase AI Logic et de la requête dans systemInstruction. Il gère les tours de conversation en conservant une liste d'éléments TextItem et en les affichant à côté des requêtes des utilisateurs à l'aide du widget MessageBubble que vous avez créé précédemment.

Voici quelques points à vérifier avant de continuer :

  • La méthode initState permet de configurer la connexion à Firebase AI Logic.
  • L'application propose un TextField et un bouton permettant d'envoyer des messages à l'agent.
  • La méthode _addMessage permet d'envoyer le message de l'utilisateur à l'agent.
  • La liste _items est l'endroit où l'historique des conversations est stocké.
  • Les messages s'affichent dans un ListView à l'aide du widget MessageBubble.

Tester l'application

Maintenant que tout est en place, vous pouvez exécuter l'application et la tester.

flutter run -d chrome

Essayez de discuter avec l'agent des tâches que vous souhaitez accomplir aujourd'hui. Bien qu'une interface utilisateur purement textuelle puisse faire l'affaire, GenUI peut rendre l'expérience plus facile et plus rapide.

4. Intégrer le package GenUI

Il est maintenant temps de passer du texte brut à l'UI générative. Vous remplacerez la boucle de messagerie Firebase de base par des objets GenUI Conversation, Catalog et SurfaceController. Cela permet au modèle d'IA d'instancier des widgets Flutter réels dans le flux de chat.

Version de l&#39;application avec GenUI intégré

Le package genui fournit cinq classes que vous utiliserez tout au long de cet atelier de programmation :

  • SurfaceController mappe l'UI générée par le modèle à l'écran.
  • A2uiTransportAdapter fait le lien entre les requêtes internes de l'interface utilisateur de GenAI et n'importe quel modèle de langage externe.
  • Conversation encapsule le contrôleur et l'adaptateur de transport avec une seule API unifiée pour votre application Flutter.
  • Catalog décrit les widgets et les propriétés disponibles pour le modèle linguistique.
  • Surface est un widget qui affiche l'UI générée par le modèle.

Préparez-vous à afficher un Surface généré.

Le code existant inclut une classe TextItem qui représente un seul message texte dans la conversation. Ajoutez une autre classe pour représenter un Surface créé par l'agent :

class SurfaceItem extends ConversationItem {
  final String surfaceId;
  SurfaceItem({required this.surfaceId});
}

Initialiser les composants de base de GenUI

En haut de lib/main.dart, importez la bibliothèque genui :

import 'package:genui/genui.dart' hide TextPart;
import 'package:genui/genui.dart' as genui;

Les packages genui et firebase_ai incluent une classe TextPart. En important genui de cette manière, vous définissez l'espace de noms de sa version de TextPart en tant que genui.TextPart, ce qui évite une collision de noms.

Déclarez les contrôleurs fonctionnels de base dans _MyHomePageState après _chatSession :

class _MyHomePageState extends State<MyHomePage> {
  // ... existing members
  late final ChatSession _chatSession;

  // Add GenUI controllers
  late final SurfaceController _controller;
  late final A2uiTransportAdapter _transport;
  late final Conversation _conversation;
  late final Catalog catalog;

Ensuite, mettez à jour initState pour préparer les contrôleurs de la bibliothèque GenUI.

Supprimez cette ligne de initState :

_chatSession.sendMessage(Content.text(systemInstruction));

Ajoutez ensuite le code suivant :

@override
void initState() {
  // ... existing code ...

  // Initialize the GenUI Catalog.
  // The genui package provides a default set of primitive widgets (like text
  // and basic buttons) out of the box using this class.
  catalog = BasicCatalogItems.asCatalog();

  // Create a SurfaceController to manage the state of generated surfaces.
  _controller = SurfaceController(catalogs: [catalog]);

  // Create a transport adapter that will process messages to and from the
  // agent, looking for A2UI messages.
  _transport = A2uiTransportAdapter(onSend: _sendAndReceive);

  // Link the transport and SurfaceController together in a Conversation,
  // which provides your app a unified API for interacting with the agent.
  _conversation = Conversation(
    controller: _controller,
    transport: _transport,
  );
}

Ce code crée une façade Conversation qui gère le contrôleur et l'adaptateur. Cette conversation fournit à votre application un flux d'événements qu'elle peut utiliser pour suivre ce que l'agent crée, ainsi qu'une méthode pour envoyer des messages à l'agent.

Ensuite, créez un écouteur pour les événements de conversation. Il s'agit notamment des événements liés à la surface, ainsi que ceux concernant les messages et les erreurs :

@override
void initState() {
  // ... existing code ...

  // Listen to GenUI stream events to update the UI
  _conversation.events.listen((event) {
    setState(() {
      switch (event) {
        case ConversationSurfaceAdded added:
          _items.add(SurfaceItem(surfaceId: added.surfaceId));
          _scrollToBottom();
        case ConversationSurfaceRemoved removed:
          _items.removeWhere(
            (item) =>
                item is SurfaceItem && item.surfaceId == removed.surfaceId,
          );
        case ConversationContentReceived content:
          _items.add(TextItem(text: content.text, isUser: false));
          _scrollToBottom();
        case ConversationError error:
          debugPrint('GenUI Error: ${error.error}');
        default:
      }
    });
  });
}

Enfin, créez l'invite système et envoyez-la à l'agent :

  @override
  void initState() {
    // ... existing code ...

    // Create the system prompt for the agent, which will include this app's
    // system instruction as well as the schema for the catalog.
    final promptBuilder = PromptBuilder.chat(
      catalog: catalog,
      systemPromptFragments: [systemInstruction],
    );

    // Send the prompt into the Conversation, which will subsequently route it
    // to Firebase using the transport mechanism.
    _conversation.sendRequest(
      ChatMessage.system(promptBuilder.systemPromptJoined()),
    );
  }

Surfaces d'affichage

Ensuite, mettez à jour la méthode build de ListView pour afficher les SurfaceItem dans la liste _items :

Expanded(
  child: ListView(
    controller: _scrollController,
    padding: const EdgeInsets.all(16),
    children: [
      for (final item in _items)
        switch (item) {
          TextItem() => MessageBubble(
            text: item.text,
            isUser: item.isUser,
          ),
          // New!
          SurfaceItem() => Surface(
            surfaceContext: _controller.contextFor(
              item.surfaceId,
            ),
          ),
        },
    ],
  ),
),

Le constructeur du widget Surface accepte un surfaceContext qui lui indique la surface qu'il doit afficher. Le SurfaceController créé précédemment, _controller, fournit la définition et l'état de chaque surface, et s'assure qu'elle est reconstruite en cas de mise à jour.

Associer GenUI à Firebase AI Logic

Le package genui utilise une approche "Bring Your Own Model" (Apportez votre propre modèle), ce qui signifie que vous contrôlez le LLM qui alimente votre expérience. Dans ce cas, vous utilisez Firebase AI Logic, mais le package est conçu pour fonctionner avec différents agents et fournisseurs.

Cette liberté s'accompagne d'une responsabilité supplémentaire : vous devez prendre les messages générés par le package genui et les envoyer à l'agent de votre choix, puis prendre les réponses de l'agent et les renvoyer dans genui.

Pour ce faire, vous allez définir la méthode _sendAndReceive référencée dans le code de l'étape précédente. Ajoutez ce code à MyHomePageState :

  Future<void> _sendAndReceive(ChatMessage msg) async {
    final buffer = StringBuffer();

    // Reconstruct the message part fragments
    for (final part in msg.parts) {
      if (part.isUiInteractionPart) {
        buffer.write(part.asUiInteractionPart!.interaction);
      } else if (part is genui.TextPart) {
        buffer.write(part.text);
      }
    }

    if (buffer.isEmpty) {
      return;
    }

    final text = buffer.toString();

    // Send the string to Firebase AI Logic.
    final response = await _chatSession.sendMessage(Content.text(text));

    if (response.text?.isNotEmpty ?? false) {
      // Feed the response back into GenUI's transportation layer
      _transport.addChunk(response.text!);
    }
  }

Cette méthode sera appelée par le package genui chaque fois qu'il devra envoyer un message à l'agent. L'appel à addChunk à la fin de la méthode renvoie la réponse de l'agent dans le package genui, ce qui lui permet de traiter la réponse et de générer l'UI.

Enfin, remplacez entièrement votre méthode _addMessage existante par cette nouvelle version, afin qu'elle achemine les messages vers Conversation au lieu de Firebase directement :

  Future<void> _addMessage() async {
    final text = _textController.text;

    if (text.trim().isEmpty) {
      return;
    }

    _textController.clear();

    setState(() {
      _items.add(TextItem(text: text, isUser: true));
    });

    _scrollToBottom();

    // Send the user's input through GenUI instead of directly to Firebase.
    await _conversation.sendRequest(ChatMessage.user(text));
  }

Et voilà ! Essayez d'exécuter de nouveau l'application. En plus des messages, vous verrez l'agent générer des surfaces d'UI telles que des boutons, des widgets de texte et plus encore.

Vous pouvez même demander à l'agent d'afficher l'UI d'une manière particulière. Par exemple, essayez un message comme "Affiche mes tâches dans une colonne, avec un bouton pour marquer chacune d'elles comme terminée".

5. Ajouter un état d'attente

La génération de LLM est asynchrone. En attendant une réponse, l'interface de chat doit désactiver les boutons de saisie et afficher un indicateur de progression pour que l'utilisateur sache que GenUI est en train de créer du contenu. Heureusement, le package genui fournit un Listenable que vous pouvez utiliser pour suivre l'état de la conversation. Cette valeur ConversationState inclut une propriété isWaiting permettant de déterminer si le modèle génère du contenu.

Encapsulez les contrôles d'entrée avec un ValueListenableBuilder.

Créez un ValueListenableBuilder qui encapsule le Row (qui contient vos TextField et ElevatedButton) en bas de lib/main.dart pour écouter le _conversation.state. En inspectant state.isWaiting, vous pouvez désactiver la saisie pendant que le modèle génère du contenu.

ValueListenableBuilder<ConversationState>(
  valueListenable: _conversation.state,
  builder: (context, state, child) {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: _textController,
            // Also disable the Enter key submission when waiting!
            onSubmitted: state.isWaiting ? null : (_) => _addMessage(),
            decoration: const InputDecoration(
              hintText: 'Enter a message',
            ),
          ),
        ),
        const SizedBox(width: 8),
        ElevatedButton(
          // Disable the send button when the model is generating
          onPressed: state.isWaiting ? null : _addMessage,
          child: const Text('Send'),
        ),
      ],
    );
  },
),

Ajouter une barre de progression

Encapsulez le widget Column principal dans un Stack et ajoutez le LinearProgressIndicator en tant que deuxième enfant de cette pile, ancré en bas. Lorsque vous avez terminé, le body de votre Scaffold doit se présenter comme suit :

body: Stack( // New!
  children: [
    Column(
      children: [
        Expanded(
          child: ListView(
            controller: _scrollController,
            padding: const EdgeInsets.all(16),
            children: [
              for (final item in _items)
                switch (item) {
                  TextItem() => MessageBubble(
                    text: item.text,
                    isUser: item.isUser,
                  ),
                  SurfaceItem() => Surface(
                    surfaceContext: _controller.contextFor(
                      item.surfaceId,
                    ),
                  ),
                },
            ],
          ),
        ),
        SafeArea(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
            child: ValueListenableBuilder<ConversationState>(
              valueListenable: _conversation.state,
              builder: (context, state, child) {
                return Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _textController,
                        onSubmitted:
                            state.isWaiting ? null : (_) => _addMessage(),
                        decoration: const InputDecoration(
                          hintText: 'Enter a message',
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    ElevatedButton(
                      onPressed: state.isWaiting ? null : _addMessage,
                      child: const Text('Send'),
                    ),
                  ],
                );
              },
            ),
          ),
        ),
      ],
    ),
    // Listen to the state again, this time to render a progress indicator
    ValueListenableBuilder<ConversationState>(
      valueListenable: _conversation.state,
      builder: (context, state, child) {
        if (state.isWaiting) {
          return const LinearProgressIndicator();
        }
        return const SizedBox.shrink();
      },
    ),
  ],
),

6. Persister une surface GenUI

Jusqu'à présent, la liste des tâches était affichée dans le flux de chat défilant, chaque nouveau message ou surface étant ajouté à la liste à son arrivée. Dans l'étape suivante, vous allez découvrir comment nommer une surface et l'afficher à un emplacement spécifique de l'UI.

Commencez par déclarer une constante à utiliser comme ID de surface en haut de main.dart, avant void main() :

const taskDisplaySurfaceId = 'task_display';

Ensuite, mettez à jour l'instruction switch dans l'écouteur Conversation pour vous assurer qu'aucune surface avec cet ID n'est ajoutée à _items :

case ConversationSurfaceAdded added:
  if (added.surfaceId != taskDisplaySurfaceId) {
    _items.add(SurfaceItem(surfaceId: added.surfaceId));
    _scrollToBottom();
  }

Ensuite, ouvrez la structure de mise en page de l'arborescence de vos widgets pour créer un espace pour la surface épinglée immédiatement au-dessus de votre journal de chat. Ajoutez ces deux widgets en tant que premiers enfants du Column principal :

AnimatedSize(
  duration: const Duration(milliseconds: 300),
  child: Container(
    padding: const EdgeInsets.all(16),
    alignment: Alignment.topLeft,
    child: Surface(
      surfaceContext: _controller.contextFor(
        taskDisplaySurfaceId,
      ),
    ),
  ),
),
const Divider(),

Jusqu'à présent, votre agent était libre de créer et d'utiliser des surfaces comme il l'entendait. Pour lui donner des instructions plus spécifiques, vous devez revenir à l'invite système. Ajoutez la section ## USER INTERFACE suivante à la fin de l'invite stockée dans la constante systemInstruction :

const systemInstruction = '''
  // ... existing prompt content ...

  ## USER INTERFACE
  * To display the list of tasks create one and only one instance of the
    TaskDisplay catalog item. Use "$taskDisplaySurfaceId" as its surface ID.
  * Update $taskDisplaySurfaceId as necessary when the list changes.
  * $taskDisplaySurfaceId must include a button for each task that I can use
    to mark it complete. When I use that button to mark a task complete, it
    should send you a message indicating what I've done.
  * Avoid repeating the same information in a single message.
  * When responding with text, rather than A2UI messages, be brief.
''';

Il est important de donner à votre agent des instructions claires sur quand et comment utiliser les surfaces d'interface utilisateur. En indiquant à l'agent d'utiliser un ID de surface et un élément de catalogue spécifiques (et de réutiliser une seule instance), vous pouvez vous assurer qu'il crée l'interface que vous souhaitez voir.

Il reste encore du travail, mais vous pouvez essayer d'exécuter à nouveau votre application pour voir l'agent créer la surface d'affichage des tâches en haut de l'UI.

7. Créer votre widget de catalogue personnalisé

À ce stade, l'élément de catalogue TaskDisplay n'existe pas. Dans les prochaines étapes, vous allez résoudre ce problème en créant un schéma de données, une classe pour analyser ce schéma, un widget et l'élément de catalogue qui rassemble le tout.

Tout d'abord, créez un fichier nommé task_display.dart et ajoutez les importations suivantes :

import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart';

Créer le schéma de données

Définissez ensuite le schéma de données que l'agent fournira lorsqu'il souhaitera créer un affichage de tâche. Ce processus utilise des constructeurs sophistiqués du package json_schema_builder, mais vous ne faites qu'essentiellement définir un schéma JSON utilisé dans les messages envoyés à l'agent et reçus de sa part.

Commencez par un S.object de base faisant référence au nom du composant :

final taskDisplaySchema = S.object(
  properties: {
    'component': S.string(enumValues: ['TaskDisplay']),
  },
);

Ensuite, ajoutez title, tasks, name, isCompleted et completeAction aux propriétés du schéma.

final taskDisplaySchema = S.object(
  properties: {
    'component': S.string(enumValues: ['TaskDisplay']),
    'title': S.string(description: 'The title of the task list'),
    'tasks': S.list(
      description: 'A list of tasks to be completed today',
      items: S.object(
        properties: {
          'name': S.string(description: 'The name of the task to be completed'),
          'isCompleted': S.boolean(
            description: 'Whether the task is completed',
          ),
          'completeAction': A2uiSchemas.action(
            description:
                'The action performed when the user has completed the task.',
          ),
        },
      ),
    ),
  },
);

Examinez la propriété completeAction. Il est créé avec A2uiSchemas.action, le constructeur d'une propriété de schéma qui représente une action A2UI. En ajoutant une action au schéma, l'application indique essentiellement à l'agent : "Hé, quand tu me donnes une tâche, fournis-moi aussi le nom et les métadonnées d'une action que je peux utiliser pour te dire que la tâche est terminée." L'application invoquera ensuite cette action lorsque l'utilisateur appuiera sur une case à cocher.

Ensuite, ajoutez des champs required au schéma. Elles indiquent à l'agent de renseigner certaines propriétés à chaque fois. Dans ce cas, toutes les propriétés sont obligatoires.

final taskDisplaySchema = S.object(
  properties: {
    'component': S.string(enumValues: ['TaskDisplay']),
    'title': S.string(description: 'The title of the task list'),
    'tasks': S.list(
      description: 'A list of tasks to be completed today',
      items: S.object(
        properties: {
          'name': S.string(description: 'The name of the task to be completed'),
          'isCompleted': S.boolean(
            description: 'Whether the task is completed',
          ),
          'completeAction': A2uiSchemas.action(
            description:
                'The action performed when the user has completed the task.',
          ),
        },
        // New!
        required: ['name', 'isCompleted', 'completeAction'],
      ),
    ),
  },
  // New!
  required: ['title', 'tasks'],
);

Créer des classes d'analyse des données

Lorsque vous créez des instances de ce composant, l'agent envoie des données correspondant au schéma. Ajoutez deux classes pour analyser le fichier JSON entrant en objets Dart fortement typés. Notez comment _TaskDisplayData gère la structure racine, tout en déléguant l'analyse du tableau interne à _TaskData.

class _TaskData {
  final String name;
  final bool isCompleted;
  final String actionName;
  final JsonMap actionContext;

  _TaskData({
    required this.name,
    required this.isCompleted,
    required this.actionName,
    required this.actionContext,
  });

  factory _TaskData.fromJson(Map<String, Object?> json) {
    try {
      final action = json['completeAction']! as JsonMap;
      final event = action['event']! as JsonMap;

      return _TaskData(
        name: json['name'] as String,
        isCompleted: json['isCompleted'] as bool,
        actionName: event['name'] as String,
        actionContext: event['context'] as JsonMap,
      );
    } catch (e) {
      throw Exception('Invalid JSON for _TaskData: $e');
    }
  }
}

class _TaskDisplayData {
  final String title;
  final List<_TaskData> tasks;

  _TaskDisplayData({required this.title, required this.tasks});

  factory _TaskDisplayData.fromJson(Map<String, Object?> json) {
    try {
      return _TaskDisplayData(
        title: (json['title'] as String?) ?? 'Tasks',
        tasks: (json['tasks'] as List<Object?>)
            .map((e) => _TaskData.fromJson(e as Map<String, Object?>))
            .toList(),
      );
    } catch (e) {
      throw Exception('Invalid JSON for _TaskDisplayData: $e');
    }
  }
}

Si vous avez déjà développé avec Flutter, ces classes sont probablement similaires à celles que vous avez créées. Ils acceptent un JsonMap et renvoient un objet fortement typé contenant les données analysées à partir de JSON.

Examinez les champs actionName et actionContext dans _TaskData. Elles sont extraites de la propriété completeAction du fichier JSON et contiennent le nom de l'action et son contexte de données (une référence à l'emplacement de l'action dans le modèle de données de GenUI). Elles seront utilisées ultérieurement pour créer un UserActionEvent.

Le modèle de données est un magasin centralisé et observable pour tous les états d'UI dynamiques, géré par la bibliothèque genui. Lorsque l'agent crée un composant d'interface utilisateur à partir du catalogue, il crée également un objet de données correspondant au schéma du composant. Cet objet de données est stocké dans le modèle de données du client. Il peut ainsi être utilisé pour créer des widgets et être référencé dans les messages ultérieurs envoyés à l'agent (comme le completeAction que vous allez associer à un widget).

Ajoutez le widget

Créez maintenant un widget pour afficher la liste. Il doit accepter une instance de la classe _TaskDisplayData et un rappel à invoquer lorsqu'une tâche est terminée.

class _TaskDisplay extends StatelessWidget {
  final _TaskDisplayData data;
  final void Function(_TaskData) onCompleteTask;

  const _TaskDisplay({required this.data, required this.onCompleteTask});

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            data.title,
            style: Theme.of(context).textTheme.titleLarge,
          ),
        ),
        ...data.tasks.map(
          (task) => CheckboxListTile(
            title: Text(
              task.name,
              style: TextStyle(
                decoration: task.isCompleted
                    ? TextDecoration.lineThrough
                    : TextDecoration.none,
              ),
            ),
            value: task.isCompleted,
            onChanged: task.isCompleted
                ? null
                : (val) {
                    if (val == true) {
                      onCompleteTask(task);
                    }
                  },
          ),
        ),
      ],
    );
  }
}

Créer le CatalogItem

Maintenant que vous avez créé le schéma, l'analyseur et le widget, vous pouvez créer un CatalogItem pour les relier entre eux.

En bas de task_display.dart, créez taskDisplay en tant que variable de premier niveau, utilisez _TaskDisplayData pour analyser le JSON entrant et créez une instance du widget _TaskDisplay.

final taskDisplay = CatalogItem(
  name: 'TaskDisplay',
  dataSchema: taskDisplaySchema,
  widgetBuilder: (itemContext) {
    final json = itemContext.data as Map<String, Object?>;
    final data = _TaskDisplayData.fromJson(json);

    return _TaskDisplay(
      data: data,
      onCompleteTask: (task) async {
        // We will implement this next!
      },
    );
  },
);

Implémenter onCompleteTask

Pour que le widget fonctionne, il doit communiquer avec l'agent lorsqu'une tâche est terminée. Remplacez l'espace réservé onCompleteTask vide par le code suivant pour créer et envoyer un événement à l'aide de completeAction à partir des données de la tâche.

onCompleteTask: (task) async {
  // A data context is a reference to a location in the data model. This line
  // turns that reference into a concrete data object that the agent can use.
  // It's kind of like taking a pointer and replacing it with the value it
  // points to.
  final JsonMap resolvedContext = await resolveContext(
    itemContext.dataContext,
    task.actionContext,
  );

  // Dispatch an event back to the agent, letting it know a task was completed.
  // This will be sent to the agent in an A2UI message that includes the name
  // of the action, the surface ID, and the resolved data context.
  itemContext.dispatchEvent(
    UserActionEvent(
      name: task.actionName,
      sourceComponentId: itemContext.id,
      context: resolvedContext,
    ),
  );
}

Enregistrer un élément de catalogue

Enfin, ouvrez main.dart, importez le nouveau fichier et enregistrez-le avec les autres éléments du catalogue.

Ajoutez cette importation en haut de lib/main.dart :

import 'task_display.dart';

Remplacez catalog = BasicCatalogItems.asCatalog(); dans votre fonction initState() par le code suivant :

// The Catalog is immutable, so use copyWith to create a new version
// that includes our custom catalog item along with the basics.
catalog = BasicCatalogItems.asCatalog().copyWith(newItems: [taskDisplay]);

Vous avez terminé ! Effectuez un hot restart de l'application pour voir les modifications.

8. Testez différentes façons d'interagir avec l'agent.

Application de liste de tâches exécutée dans Chrome

Maintenant que vous avez ajouté le nouveau widget au catalogue et créé un espace pour lui dans l'UI de l'application, il est temps de vous amuser avec l'agent. L'un des principaux avantages de GenUI est qu'il offre deux façons d'interagir avec vos données : via l'interface utilisateur de l'application, comme les boutons et les cases à cocher, et via un agent qui comprend le langage naturel et peut raisonner sur les données. Essayez les deux !

  • Utilisez le champ de texte pour décrire trois ou quatre tâches, et regardez-les apparaître dans la liste.
  • Utilisez une case à cocher pour marquer une tâche comme terminée ou non terminée.
  • Créez une liste de cinq ou six tâches, puis demandez à l'agent de supprimer celles qui nécessitent de se déplacer en voiture.
  • Demandez à l'agent de créer une liste de tâches répétitives sous forme d'éléments individuels ("Je dois acheter une carte de vœux pour maman, papa et grand-mère. Crée des tâches distinctes pour cela.").
  • Demandez à l'agent de marquer toutes les tâches comme terminées ou non terminées, ou de cocher les deux ou trois premières.

9. Félicitations

Félicitations ! Vous avez créé une application de suivi des tâches optimisée par l'IA à l'aide de Generative UI et Flutter.

Connaissances acquises

  • Interagir avec les modèles de base de Google à l'aide du SDK Flutter Firebase
  • Afficher des surfaces interactives générées par Gemini à l'aide de GenUI
  • Épingler des surfaces dans des mises en page à l'aide d'ID de rendu statique prédéterminés
  • Concevoir des schémas et des catalogues de widgets personnalisés pour des boucles d'interaction robustes

Documents de référence