Tworzenie aplikacji z generatywnym interfejsem (GenUI)

1. Wprowadzenie

W tym ćwiczeniu utworzysz aplikację do tworzenia list zadań przy użyciu Fluttera, Firebase AI Logic i nowego pakietu genui. Zaczniesz od aplikacji do czatu tekstowego, ulepszysz ją za pomocą GenUI, aby umożliwić agentowi tworzenie własnego interfejsu, a na koniec utworzysz własny, interaktywny komponent interfejsu, którym Ty i agent będziecie mogli bezpośrednio manipulować.

Aplikacja z listą zadań działająca w Chrome

Jakie zadania wykonasz

  • Tworzenie podstawowego interfejsu czatu za pomocą Fluttera i Firebase AI Logic
  • Zintegruj pakiet genui, aby generować powierzchnie oparte na AI
  • Dodawanie paska postępu, który wskazuje, kiedy aplikacja czeka na odpowiedź od agenta
  • Utwórz nazwaną powierzchnię i wyświetl ją w odpowiednim miejscu w interfejsie.
  • Tworzenie niestandardowego komponentu katalogu GenUI, który umożliwia kontrolowanie sposobu prezentowania zadań

Czego potrzebujesz

To ćwiczenie jest przeznaczone dla średnio zaawansowanych programistów Fluttera.

2. Zanim zaczniesz

Konfigurowanie projektu Flutter

Otwórz terminal i uruchom polecenie flutter create, aby utworzyć nowy projekt:

flutter create intro_to_genui
cd intro_to_genui

Dodaj do projektu we Flutterze niezbędne zależności:

flutter pub add firebase_core firebase_ai genui json_schema_builder

Ostatnia sekcja dependencies powinna wyglądać tak (numery wersji mogą się nieznacznie różnić):

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

Uruchom flutter pub get, aby pobrać wszystkie pakiety.

Włączanie interfejsów API i Firebase

Aby używać pakietu firebase_ai, musisz najpierw włączyć w projekcie Firebase AI Logic.

  1. W konsoli Firebase otwórz Firebase AI Logic.
  2. Aby uruchomić kreator, kliknij Rozpocznij.
  3. Aby skonfigurować projekt, postępuj zgodnie z instrukcjami wyświetlanymi na ekranie.

Więcej informacji znajdziesz w instrukcjach dodawania Firebase do aplikacji Flutter.

Gdy interfejsy API będą aktywne, zainicjuj Firebase w aplikacji Flutter za pomocą FlutterFire CLI:

flutterfire configure

Wybierz projekt w Firebase i postępuj zgodnie z instrukcjami, aby skonfigurować go pod kątem platform docelowych (np. Android, iOS, internet). Ten przewodnik możesz przejść, mając zainstalowane na komputerze tylko pakiet SDK Fluttera i przeglądarkę Chrome, ale aplikacja będzie działać również na innych platformach.

3. Tworzenie podstawowego interfejsu czatu

Zanim wprowadzisz Generative UI, Twoja aplikacja musi mieć podstawę: podstawową aplikację do czatu tekstowego opartą na Firebase AI Logic. Aby szybko zacząć, skopiuj i wklej cały kod konfiguracji interfejsu czatu.

wersja aplikacji oparta na tekście,

Tworzenie widżetu dymka wiadomości

Aby wyświetlać SMS-y od użytkownika i konsultanta, aplikacja potrzebuje widżetu. Utwórz nowy plik o nazwie lib/message_bubble.dart i dodaj do niego tę klasę:

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 to StatelessWidget, który wyświetla pojedynczą wiadomość na czacie. Będzie on używany później w tym ćwiczeniu do wyświetlania wiadomości od Ciebie i agenta, ale jest to w zasadzie tylko zaawansowany widżet Text.

Implementowanie interfejsu Google Chat w main.dart

Zastąp całą zawartość pliku lib/main.dart tą kompletną implementacją chatbota tekstowego:

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.
''';

Skopiowany przez Ciebie plik main.dart konfiguruje podstawowy ChatSession przy użyciu Firebase AI Logic i promptu w systemInstruction. Zarządza ona kolejnymi etapami rozmowy, utrzymując listę elementów TextItem i wyświetlając je obok zapytań użytkownika za pomocą utworzonego wcześniej widżetu MessageBubble.

Zanim przejdziesz dalej, sprawdź te kwestie:

  • Metoda initState służy do konfigurowania połączenia z Firebase AI Logic.
  • Aplikacja ma TextField i przycisk do wysyłania wiadomości do agenta.
  • W metodzie _addMessage wiadomość użytkownika jest wysyłana do agenta.
  • Lista _items to miejsce, w którym jest przechowywana historia rozmów.
  • Wiadomości są wyświetlane w ListView za pomocą widżetu MessageBubble.

Testowanie aplikacji

Po wykonaniu tych czynności możesz uruchomić aplikację i ją przetestować.

flutter run -d chrome

Spróbuj porozmawiać z agentem o zadaniach, które chcesz dziś wykonać. Interfejs oparty wyłącznie na tekście może spełniać swoje zadanie, ale GenUI może ułatwić i przyspieszyć korzystanie z usługi.

4. Integracja pakietu GenUI

Teraz nadszedł czas, aby przejść z tekstu prostego na interfejs generatywny. Zastąp podstawową pętlę przesyłania wiadomości Firebase obiektami GenUI Conversation, CatalogSurfaceController. Dzięki temu model AI może tworzyć rzeczywiste widżety Fluttera w strumieniu czatu.

wersja aplikacji z zintegrowanym interfejsem GenUI,

Pakiet genui zawiera 5 klas, których będziesz używać w tym ćwiczeniu:

  • SurfaceController mapuje interfejs użytkownika wygenerowany przez model na ekran.
  • A2uiTransportAdapter łączy wewnętrzne żądania GenUI z dowolnym zewnętrznym modelem językowym.
  • Conversation otacza kontroler i adapter transportu jednym, ujednoliconym interfejsem API dla aplikacji Flutter.
  • Catalog opisuje widżety i właściwości dostępne dla modelu językowego.
  • Surface to widżet, który wyświetla interfejs wygenerowany przez model.

Przygotuj się do wyświetlenia wygenerowanego Surface

Istniejący kod zawiera klasę TextItem, która reprezentuje pojedynczą wiadomość tekstową w konwersacji. Dodaj kolejną klasę reprezentującą Surface utworzoną przez agenta:

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

Inicjowanie elementów składowych GenUI

U góry pliku lib/main.dart zaimportuj bibliotekę genui:

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

Zarówno pakiet genui, jak i pakiet firebase_ai zawierają klasę TextPart. Importując genui w ten sposób, tworzysz przestrzeń nazw dla jego wersji TextPart jako genui.TextPart, co pozwala uniknąć kolizji nazw.

Zadeklaruj podstawowe kontrolery funkcjonalne w pliku _MyHomePageState po _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;

Następnie zaktualizuj initState, aby przygotować kontrolery biblioteki GenUI.

Usuń ten wiersz z usługi initState:

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

Następnie dodaj ten kod:

@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,
  );
}

Ten kod tworzy fasadę Conversation, która zarządza kontrolerem i adapterem. Ta rozmowa zapewnia aplikacji strumień zdarzeń, których może używać do śledzenia tego, co tworzy agent, a także metodę wysyłania do niego wiadomości.

Następnie utwórz odbiornik zdarzeń rozmowy. Obejmują one zdarzenia związane z platformą, a także zdarzenia dotyczące SMS-ów i błędów:

@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:
      }
    });
  });
}

Na koniec utwórz prompt systemowy i wyślij go do agenta:

  @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()),
    );
  }

Platformy reklamowe

Następnie zaktualizuj metodę buildListView, aby wyświetlać elementy SurfaceItem na liście _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,
            ),
          ),
        },
    ],
  ),
),

Konstruktor widżetu Surface przyjmuje obiekt surfaceContext, który określa, na jakiej powierzchni ma być wyświetlany. Utworzony wcześniej element SurfaceController, _controller, zawiera definicję i stan każdej powierzchni oraz zapewnia jej ponowne utworzenie w przypadku aktualizacji.

Łączenie GenUI z Firebase AI Logic

Pakiet genui korzysta z podejścia „Przynieś własny model”, co oznacza, że to Ty decydujesz, który LLM będzie obsługiwać Twoje usługi. W tym przypadku używasz Firebase AI Logic, ale pakiet został opracowany tak, aby współpracować z różnymi agentami i dostawcami.

Ta swoboda wiąże się z dodatkową odpowiedzialnością: musisz pobierać wiadomości wygenerowane przez pakiet genui i wysyłać je do wybranego agenta, a także pobierać odpowiedzi agenta i wysyłać je z powrotem do pakietu genui.

Aby to zrobić, zdefiniuj metodę _sendAndReceive, do której odwołuje się kod z poprzedniego kroku. Dodaj do pliku MyHomePageState ten kod:

  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!);
    }
  }

Ta metoda będzie wywoływana przez pakiet genui, gdy będzie trzeba wysłać wiadomość do agenta. Wywołanie addChunk na końcu metody przekazuje odpowiedź agenta z powrotem do pakietu genui, co umożliwia mu przetworzenie odpowiedzi i wygenerowanie interfejsu.

Na koniec całkowicie zastąp dotychczasową metodę _addMessage nową wersją, aby kierowała wiadomości do Conversation zamiast bezpośrednio do Firebase:

  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));
  }

To wszystko. Spróbuj ponownie uruchomić aplikację. Oprócz wiadomości tekstowych zobaczysz, jak agent generuje elementy interfejsu, takie jak przyciski, widżety tekstowe i inne.

Możesz nawet poprosić agenta o wyświetlenie interfejsu w określony sposób. Możesz na przykład wpisać „Pokaż mi moje zadania w kolumnie z przyciskiem oznaczania każdego z nich jako ukończonego”.

5. Dodawanie stanu oczekiwania

Generowanie przez LLM jest asynchroniczne. Podczas oczekiwania na odpowiedź interfejs czatu musi wyłączyć przyciski wprowadzania i wyświetlić wskaźnik postępu, aby użytkownik wiedział, że GenUI tworzy treści. Na szczęście pakiet genui zawiera Listenable, którego możesz użyć do śledzenia stanu rozmowy. Ta wartość ConversationState zawiera właściwość isWaiting, która określa, czy model generuje treści.

Owiń elementy sterujące wejściem tagiem ValueListenableBuilder.

Utwórz element ValueListenableBuilder, który obejmuje element Row (zawierający elementy TextFieldElevatedButton) u dołu elementu lib/main.dart, aby odtworzyć element _conversation.state. Sprawdzając state.isWaiting, możesz wyłączyć wprowadzanie danych, gdy model generuje treści.

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'),
        ),
      ],
    );
  },
),

Dodawanie paska postępu

Umieść główny widżet Column w elemencie Stack i dodaj element LinearProgressIndicator jako drugie dziecko tego stosu, zakotwiczone u dołu. Gdy skończysz, body Twojego Scaffold powinien wyglądać tak:

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. Utrwalanie powierzchni GenUI

Do tej pory lista zadań była renderowana na przewijanym czacie, a każda nowa wiadomość lub karta była dodawana do listy w miarę jej pojawiania się. W następnym kroku dowiesz się, jak nazwać powierzchnię i wyświetlić ją w określonym miejscu w interfejsie.

Najpierw u góry pliku main.dart, przed void main(), zadeklaruj stałą, która będzie używana jako identyfikator platformy:

const taskDisplaySurfaceId = 'task_display';

Po drugie, zaktualizuj instrukcję switchConversation listener, aby upewnić się, że żadna powierzchnia z tym identyfikatorem nie zostanie dodana do _items:

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

Następnie otwórz strukturę układu drzewa widżetów, aby utworzyć miejsce na przypiętą powierzchnię bezpośrednio nad dziennikiem czatu. Dodaj te 2 widżety jako pierwsze elementy podrzędne głównego elementu Column:

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

Do tej pory agent miał swobodę tworzenia i używania interfejsów w dowolny sposób. Aby podać bardziej szczegółowe instrukcje, musisz wrócić do prompta systemowego. Dodaj tę sekcję ## USER INTERFACE na końcu promptu przechowywanego w stałej 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.
''';

Ważne jest, aby przekazać agentowi jasne instrukcje dotyczące tego, kiedy i jak używać interfejsów. Jeśli powiesz agentowi, aby użył konkretnego elementu katalogu i identyfikatora powierzchni (oraz aby ponownie użył pojedynczej instancji), możesz mieć pewność, że utworzy on interfejs, który chcesz zobaczyć.

Jest jeszcze trochę do zrobienia, ale możesz spróbować ponownie uruchomić aplikację, aby zobaczyć, jak agent tworzy u góry interfejsu wyświetlanie zadania.

7. Tworzenie niestandardowego widżetu katalogu

W tym momencie produkt z katalogu TaskDisplay nie istnieje. W kolejnych krokach naprawisz ten problem, tworząc schemat danych, klasę do jego analizowania, widżet i element katalogu, który łączy wszystkie te elementy.

Najpierw utwórz plik o nazwie task_display.dart i dodaj te instrukcje importu:

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

Tworzenie schematu danych

Następnie zdefiniuj schemat danych, który agent będzie udostępniać, gdy będzie chciał utworzyć wyświetlanie zadania. Proces ten wykorzystuje kilka zaawansowanych konstruktorów z pakietu json_schema_builder, ale w zasadzie definiujesz tylko schemat JSON używany w wiadomościach wysyłanych do agenta i przez niego.

Zacznij od podstawowego elementu S.object odwołującego się do nazwy komponentu:

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

Następnie dodaj do właściwości schematu atrybuty title, tasks, name, isCompleted i completeAction.

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.',
          ),
        },
      ),
    ),
  },
);

Sprawdź właściwość completeAction. Jest on tworzony za pomocą funkcji A2uiSchemas.action, czyli konstruktora właściwości schematu, który reprezentuje działanie A2UI. Dodając działanie do schematu, aplikacja zasadniczo mówi agentowi: „Hej, kiedy przekażesz mi zadanie, podaj też nazwę i metadane działania, za pomocą którego mogę Ci powiedzieć, że zadanie zostało wykonane”. Później aplikacja wywoła to działanie, gdy użytkownik kliknie pole wyboru.

Następnie dodaj do schematu pola required. Instruują one agenta, aby za każdym razem wypełniał określone właściwości. W tym przypadku każda właściwość jest wymagana.

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'],
);

Tworzenie klas analizowania danych

Podczas tworzenia instancji tego komponentu agent będzie wysyłać dane zgodne ze schematem. Dodaj 2 klasy, aby przeanalizować przychodzący kod JSON i przekształcić go w obiekty języka Dart o ściśle określonym typie. Zwróć uwagę, jak _TaskDisplayData obsługuje strukturę główną, a parsowanie tablicy wewnętrznej przekazuje do _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');
    }
  }
}

Jeśli masz już doświadczenie w pracy z Flutterem, te klasy będą prawdopodobnie podobne do tych, które zostały przez Ciebie utworzone. Przyjmują one JsonMap i zwracają obiekt o ściśle określonym typie zawierający dane przeanalizowane z JSON.

Przyjrzyj się polom actionNameactionContext_TaskData. Są one wyodrębniane z właściwości completeAction pliku JSON i zawierają nazwę działania oraz kontekst danych (odniesienie do lokalizacji działania w modelu danych GenUI). Zostaną one później użyte do utworzenia UserActionEvent.

Model danych to centralny, obserwowalny magazyn wszystkich dynamicznych stanów interfejsu, utrzymywany przez bibliotekę genui. Gdy agent tworzy komponent interfejsu z katalogu, tworzy też obiekt danych zgodny ze schematem komponentu. Ten obiekt danych jest przechowywany w modelu danych po stronie klienta, dzięki czemu można go używać do tworzenia widżetów i odwoływać się do niego w późniejszych wiadomościach do agenta (np. do completeAction, które za chwilę połączysz z widżetem).

Dodaj widżet

Teraz utwórz widżet, który będzie wyświetlać listę. Powinna akceptować instancję klasy _TaskDisplayData i wywołanie zwrotne, które ma zostać wywołane po zakończeniu zadania.

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);
                    }
                  },
          ),
        ),
      ],
    );
  }
}

Tworzenie elementu CatalogItem

Po utworzeniu schematu, analizatora i widżetu możesz utworzyć CatalogItem, aby je połączyć.

U dołu sekcji task_display.dart utwórz zmienną najwyższego poziomu taskDisplay, użyj _TaskDisplayData do przeanalizowania przychodzącego kodu JSON i utwórz instancję widżetu _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!
      },
    );
  },
);

Implementacja funkcji onCompleteTask

Aby widżet działał, musi komunikować się z agentem po wykonaniu zadania. Zastąp pusty symbol zastępczy onCompleteTask poniższym kodem, aby utworzyć i wysłać zdarzenie za pomocą elementu completeAction z danych zadania.

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,
    ),
  );
}

Rejestrowanie produktu z katalogu

Na koniec otwórz main.dart, zaimportuj nowy plik i zarejestruj go wraz z innymi produktami z katalogu.

Dodaj ten import na początku pliku lib/main.dart:

import 'task_display.dart';

Zastąp catalog = BasicCatalogItems.asCatalog(); w funkcji initState() tym kodem:

// 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]);

To już wszystko. Aby zobaczyć zmiany, uruchom ponownie aplikację.

8. Eksperymentuj z różnymi sposobami interakcji z agentem.

Aplikacja z listą zadań działająca w Chrome

Po dodaniu nowego widżetu do katalogu i utworzeniu dla niego miejsca w interfejsie aplikacji możesz zacząć korzystać z agenta. Jedną z głównych zalet interfejsu GenUI jest to, że oferuje 2 sposoby interakcji z danymi: za pomocą interfejsu aplikacji, np. przycisków i pól wyboru, oraz za pomocą agenta, który rozumie język naturalny i może analizować dane. Wypróbuj oba te rozwiązania.

  • Wpisz w polu tekstowym 3–4 zadania i obserwuj, jak pojawiają się na liście.
  • Użyj pola wyboru, aby oznaczyć zadanie jako ukończone lub nieukończone.
  • Utwórz listę 5–6 zadań, a potem poproś agenta o usunięcie tych, które wymagają dojazdu.
  • Poproś agenta o utworzenie powtarzalnej listy zadań jako pojedynczych elementów („Muszę kupić kartki świąteczne dla mamy, taty i babci. Utwórz dla nich osobne zadania”).
  • Poproś agenta o oznaczenie wszystkich zadań jako ukończonych lub nieukończonych albo o zaznaczenie pierwszych 2–3 zadań.

9. Gratulacje

Gratulacje! Masz już aplikację do śledzenia zadań opartą na AI, która korzysta z generatywnego interfejsu i Fluttera.

Czego się dowiedziałeś(-aś)

  • Interakcja z modelami podstawowymi Google za pomocą pakietu SDK Firebase dla Fluttera
  • Renderowanie interaktywnych platform wygenerowanych przez Gemini za pomocą GenUI
  • Przypinanie powierzchni w układach za pomocą wstępnie określonych statycznych identyfikatorów renderowania
  • projektowanie niestandardowych schematów i katalogów widżetów na potrzeby złożonych pętli interakcji;

Dokumentacja