Создайте приложение с генеративным пользовательским интерфейсом (GenUI).

1. Введение

В этом практическом занятии вы создадите приложение-список задач, используя Flutter, Firebase AI Logic и новый пакет genui . Вы начнете с текстового чата, дополните его с помощью GenUI, чтобы дать агенту возможность создавать собственный пользовательский интерфейс, и, наконец, создадите собственный интерактивный компонент пользовательского интерфейса, которым вы и агент сможете управлять напрямую.

Приложение для составления списков задач, работающее в Chrome.

Что вы будете делать

  • Создайте простой интерфейс чата, используя Flutter и Firebase AI Logic.
  • Интегрируйте пакет genui для генерации поверхностей с помощью искусственного интеллекта.
  • Добавьте индикатор выполнения, показывающий, когда приложение ожидает ответа от агента.
  • Создайте именованную поверхность и отобразите её в специально отведённом месте в пользовательском интерфейсе.
  • Создайте пользовательский компонент каталога GenUI, который позволит вам контролировать способ отображения задач.

Что вам понадобится

  • Веб-браузер, например Chrome.
  • Flutter SDK установлен локально.
  • Установлен и настроен Firebase CLI .

Этот практический урок предназначен для разработчиков Flutter среднего уровня.

2. Прежде чем начать

Настройка проекта Flutter

Откройте терминал и запустите flutter create , чтобы создать новый проект:

flutter create intro_to_genui
cd intro_to_genui

Добавьте необходимые зависимости в свой Flutter-проект:

flutter pub add firebase_core firebase_ai genui json_schema_builder

Итоговый раздел dependencies должен выглядеть следующим образом (номера версий могут немного отличаться):

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

Запустите команду flutter pub get , чтобы загрузить все пакеты.

Включите API и Firebase.

Для использования пакета firebase_ai необходимо сначала включить Firebase AI Logic в вашем проекте.

  1. Перейдите в раздел Firebase AI Logic в консоли Firebase.
  2. Нажмите « Начать» , чтобы запустить пошаговый рабочий процесс.
  3. Следуйте инструкциям на экране, чтобы настроить свой проект.

Для получения более подробной информации ознакомьтесь с инструкциями по добавлению Firebase в приложение Flutter .

После активации API инициализируйте Firebase в своем приложении Flutter с помощью CLI FlutterFire:

flutterfire configure

Выберите свой проект Firebase и следуйте инструкциям, чтобы настроить его для целевых платформ (например, Android, iOS, веб). Этот практический пример можно выполнить, имея на компьютере только Flutter SDK и Chrome, но приложение будет работать и на других платформах.

3. Создайте базовый интерфейс чата.

Прежде чем внедрять генеративный пользовательский интерфейс, вашему приложению необходима основа: базовое текстовое приложение для чата на базе Firebase AI Logic. Чтобы быстро начать, вы можете просто скопировать и вставить всю конфигурацию интерфейса чата.

Текстовая версия приложения

Создайте виджет "пузырь сообщения"

Для отображения текстовых сообщений от пользователя и агента вашему приложению необходим виджет. Создайте новый файл с именем lib/message_bubble.dart и добавьте в него следующий класс:

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 — это StatelessWidget , отображающий одно сообщение чата. Позже в этом практическом занятии он будет использоваться для отображения сообщений как от вас, так и от агента, но по сути это просто улучшенный Text виджет.

Реализуйте интерфейс чата в main.dart

Замените всё содержимое файла lib/main.dart этой полной реализацией текстового чат-бота:

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

Файл main.dart , который вы только что скопировали, настраивает базовую ChatSession с использованием Firebase AI Logic и запроса в systemInstruction . Он управляет ходами разговора, поддерживая список элементов TextItem и отображая их вместе с запросами пользователей с помощью виджета MessageBubble который вы создали ранее.

Вот несколько моментов, которые стоит проверить, прежде чем двигаться дальше:

  • В методе initState устанавливается соединение с Firebase AI Logic.
  • Приложение содержит TextField и кнопку для отправки сообщений агенту.
  • Метод ` _addMessage используется для отправки сообщения пользователя агенту.
  • В списке _items хранится история переписки.
  • Сообщения отображаются в ListView с помощью виджета MessageBubble .

Протестируйте приложение

После этого вы можете запустить приложение и протестировать его.

flutter run -d chrome

Попробуйте пообщаться с агентом и обсудить задачи, которые вы хотели бы выполнить сегодня. Хотя чисто текстовый интерфейс тоже подойдёт, GenUI сделает этот процесс проще и быстрее.

4. Интегрируйте пакет GenUI.

Теперь пришло время перейти от простого текста к генеративному пользовательскому интерфейсу. Вы замените базовый цикл обмена сообщениями Firebase на объекты GenUI Conversation , Catalog и SurfaceController . Это позволит модели ИИ создавать реальные виджеты Flutter в потоке чата.

Версия приложения со встроенным GenUI.

Пакет genui предоставляет пять классов, которые вы будете использовать на протяжении всего этого практического занятия:

  • SurfaceController сопоставляет пользовательский интерфейс, сгенерированный моделью, с экраном.
  • A2uiTransportAdapter обеспечивает связь внутренних запросов GenUI с любой внешней языковой моделью.
  • Conversation объединяет контроллер и адаптер транспорта в единый, унифицированный API для вашего Flutter-приложения.
  • Catalog описывает виджеты и свойства, доступные для языковой модели.
  • Surface — это виджет, отображающий пользовательский интерфейс, сгенерированный моделью.

Приготовьтесь отобразить сгенерированную Surface

Существующий код включает класс TextItem , представляющий собой отдельное текстовое сообщение в диалоге. Добавьте еще один класс, представляющий собой Surface созданный агентом:

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

Инициализация строительных блоков GenUI.

В верхней части lib/main.dart импортируйте библиотеку genui :

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

И пакет genui , и пакет firebase_ai содержат класс TextPart . Импортируя genui таким образом, вы используете пространство имен для его версии TextPart как genui.TextPart , избегая конфликта имен.

Объявите основные функциональные контроллеры в _MyHomePageState после _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;

Далее обновите initState , чтобы подготовить контроллеры библиотеки GenUI.

Удалите эту строку из initState :

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

Затем добавьте следующий код:

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

Этот код создает фасад Conversation , который управляет контроллером и адаптером. Этот диалог предоставляет вашему приложению поток событий, который оно может использовать для отслеживания действий агента, а также метод для отправки сообщений агенту.

Далее создайте обработчик событий диалога. Сюда входят события, связанные с внешним интерфейсом, а также события, связанные с текстовыми сообщениями и ошибками:

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

Наконец, создайте системное приглашение и отправьте его агенту:

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

Поверхности дисплея

Далее обновите метод build элемента ListView , чтобы он отображал элементы SurfaceItem в списке _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,
            ),
          ),
        },
    ],
  ),
),

Конструктор виджета Surface принимает surfaceContext , который указывает, за отображение какой поверхности он отвечает. Созданный ранее SurfaceController , _controller , предоставляет определение и состояние для каждой поверхности и обеспечивает её перестроение при каждом обновлении.

Подключите GenUI к Firebase AI Logic

Пакет genui использует подход «Используйте свою собственную модель», то есть вы сами контролируете, какая модель LLM будет использоваться для вашего приложения. В данном случае вы используете Firebase AI Logic, но пакет разработан для работы с различными агентами и поставщиками.

Эта свобода влечет за собой некоторую дополнительную ответственность: вам нужно взять сообщения, сгенерированные пакетом genui , и отправить их выбранному вами агенту, а также взять ответы агента и отправить их обратно в genui .

Для этого вам нужно определить метод _sendAndReceive указанный в коде предыдущего шага. Добавьте этот код в 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!);
    }
  }

Этот метод будет вызываться пакетом genui всякий раз, когда ему потребуется отправить сообщение агенту. Вызов метода addChunk в конце метода передает ответ агента обратно в пакет genui , позволяя ему обработать ответ и сгенерировать пользовательский интерфейс.

Наконец, полностью замените существующий метод _addMessage на эту новую версию, чтобы он направлял сообщения в Conversation , а не напрямую в 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));
  }

Вот и всё! Попробуйте запустить приложение ещё раз. Помимо текстовых сообщений, вы увидите, как агент генерирует элементы пользовательского интерфейса, такие как кнопки, текстовые виджеты и многое другое.

Вы даже можете попробовать попросить агента отобразить пользовательский интерфейс определенным образом. Например, попробуйте отправить сообщение типа: «Покажите мне мои задачи в столбце, с кнопкой для пометки каждой из них как выполненной».

5. Добавить состояние ожидания.

Генерация LLM происходит асинхронно. Во время ожидания ответа интерфейс чата должен отключить кнопки ввода и отобразить индикатор выполнения, чтобы пользователь знал, что GenUI создает контент. К счастью, пакет genui предоставляет объект Listenable , который можно использовать для отслеживания состояния разговора. Значение ConversationState включает свойство isWaiting , определяющее, генерирует ли модель контент.

Оберните элементы управления ввода в ValueListenableBuilder

Создайте ValueListenableBuilder , который будет оборачивать Row (содержащий ваше TextField и ElevatedButton ) в нижней части файла lib/main.dart , чтобы отслеживать состояние _conversation.state . Проверив state.isWaiting , вы можете отключить ввод, пока модель генерирует контент.

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

Добавить индикатор выполнения

Оберните основной виджет Column в Stack и добавьте LinearProgressIndicator в качестве второго дочернего элемента этого Stack, закрепив его внизу. В итоге body вашего Scaffold должно выглядеть примерно так:

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. Сохранение интерфейса GenUI

До сих пор список задач отображался в прокручиваемом потоке чата, при этом каждое новое сообщение или объект добавлялись в список по мере их поступления. На следующем шаге вы увидите, как присвоить имя объекту и отобразить его в определенном месте пользовательского интерфейса.

Во-первых, в верхней части main.dart , перед вызовом void main() , объявите константу, которая будет использоваться в качестве идентификатора поверхности:

const taskDisplaySurfaceId = 'task_display';

Во-вторых, обновите оператор switch в обработчике событий Conversation , чтобы убедиться, что любая поверхность с этим ID не добавляется в _items :

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

Далее откройте структуру дерева виджетов, чтобы создать пространство для закрепленной поверхности непосредственно над журналом чата. Добавьте эти два виджета в качестве первых дочерних элементов основной 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(),

До сих пор ваш агент имел полную свободу действий для создания и использования поверхностей по своему усмотрению. Чтобы дать ему более конкретные инструкции, вам необходимо вернуться к системной подсказке. Добавьте следующий раздел ## USER INTERFACE в конец подсказки, хранящейся в константе 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.
''';

Важно дать вашему агенту четкие инструкции о том, когда и как использовать элементы пользовательского интерфейса. Указав агенту использовать конкретный элемент каталога и идентификатор элемента интерфейса (и повторно использовать один экземпляр), вы можете помочь убедиться, что он создаст именно тот интерфейс, который вы хотите видеть.

Предстоит еще поработать, но вы можете попробовать запустить приложение еще раз, чтобы увидеть, как агент создаст панель отображения задач в верхней части пользовательского интерфейса.

7. Создайте свой собственный виджет каталога.

На данном этапе элемент каталога TaskDisplay отсутствует. На следующих шагах вы исправите это, создав схему данных, класс для анализа этой схемы, виджет и элемент каталога, который объединит все это воедино.

Сначала создайте файл с именем task_display.dart и добавьте следующие импорты:

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

Создайте схему данных.

Далее определите схему данных, которую агент будет предоставлять при создании отображения задачи. В этом процессе используются некоторые сложные конструкторы из пакета json_schema_builder , но по сути вы просто определяете схему JSON, используемую в сообщениях, отправляемых агенту и получаемых от него.

Начните с простого S.object ссылающегося на имя компонента:

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

Далее добавьте в свойства схемы поля title , tasks , name , isCompleted и 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.',
          ),
        },
      ),
    ),
  },
);

Взгляните на свойство completeAction . Оно создается с помощью A2uiSchemas.action , конструктора свойства схемы, представляющего действие A2UI. Добавляя действие в схему, приложение, по сути, сообщает агенту: «Привет, когда ты даешь мне задачу, укажи также имя и метаданные для действия, которое я смогу использовать, чтобы сообщить тебе о завершении задачи». Позже приложение вызовет это действие, когда пользователь нажмет на флажок.

Далее добавьте в схему required поля. Они указывают агенту, какие свойства нужно заполнять каждый раз. В данном случае обязательным является каждое свойство!

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

Создайте классы для анализа данных.

При создании экземпляров этого компонента агент будет отправлять данные, соответствующие схеме. Добавьте два класса для разбора входящего JSON и преобразования его в строго типизированные объекты Dart. Обратите внимание, как _TaskDisplayData обрабатывает корневую структуру, делегируя разбор внутреннего массива классу _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');
    }
  }
}

Если вы уже работали с Flutter, эти классы, вероятно, похожи на те, которые вы создавали раньше. Они принимают JsonMap и возвращают строго типизированный объект, содержащий данные, полученные из JSON.

Обратите внимание на поля actionName и actionContext в _TaskData . Они извлекаются из свойства completeAction объекта JSON и содержат имя действия и его контекст данных (ссылка на местоположение действия в модели данных GenUI). Эти данные будут использованы позже для создания UserActionEvent .

Модель данных представляет собой централизованное, наблюдаемое хранилище всего динамического состояния пользовательского интерфейса, поддерживаемое библиотекой genui . Когда агент создает компонент пользовательского интерфейса из каталога, он также создает объект данных, соответствующий схеме компонента. Этот объект данных хранится в модели данных на стороне клиента, чтобы его можно было использовать для создания виджетов и ссылаться на него в последующих сообщениях агенту (например, в методе completeAction который вы собираетесь связать с виджетом).

Добавить виджет

Теперь создайте виджет для отображения списка. Он должен принимать экземпляр класса _TaskDisplayData и функцию обратного вызова, которая будет вызываться после завершения задачи.

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

Создать элемент каталога

После создания схемы, парсера и виджета вы можете создать CatalogItem , чтобы связать их все воедино.

В нижней части task_display.dart создайте переменную верхнего уровня taskDisplay , используйте _TaskDisplayData для анализа входящего JSON и создайте экземпляр виджета _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!
      },
    );
  },
);

Реализовать onCompleteTask

Для корректной работы виджета необходимо, чтобы он сообщал агенту о завершении задачи. Замените пустой плейсхолдер onCompleteTask следующим кодом, чтобы создать и отправить событие, используя completeAction из данных задачи.

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

Зарегистрировать товар из каталога

Наконец, откройте main.dart , импортируйте новый файл и зарегистрируйте его вместе с другими элементами каталога.

Добавьте следующий импорт в начало файла lib/main.dart :

import 'task_display.dart';

Замените строку catalog = BasicCatalogItems.asCatalog(); в функции ` initState() ` на:

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

Готово! Просто перезапустите приложение, чтобы увидеть изменения.

8. Поэкспериментируйте с различными способами взаимодействия с агентом.

Приложение для составления списков задач, работающее в Chrome.

Теперь, когда вы добавили новый виджет в Каталог и выделили для него место в пользовательском интерфейсе приложения, пришло время поработать с агентом. Одно из главных преимуществ GenUI заключается в том, что он предлагает два способа взаимодействия с вашими данными: через пользовательский интерфейс приложения, например, кнопки и флажки, и через агента, который понимает естественный язык и может анализировать данные. Попробуйте поэкспериментировать с обоими способами!

  • Используйте текстовое поле, чтобы описать три или четыре задачи, и вы увидите, как они появятся в списке.
  • Используйте флажок, чтобы отметить задачу как выполненную или невыполненную.
  • Составьте список из 5-6 задач, а затем попросите агента удалить те, которые требуют поездки куда-либо.
  • Попросите агента создать повторяющийся список задач в виде отдельных пунктов («Мне нужно купить поздравительную открытку для мамы, папы и бабушки. Создайте для них отдельные задачи»).
  • Попросите агента отметить все задачи как выполненные или незавершенные, или отметить галочкой первые две-три.

9. Поздравляем!

Поздравляем! Вы создали приложение для отслеживания задач с использованием искусственного интеллекта, применяя Generative UI и Flutter.

Что вы узнали

  • Взаимодействие с базовыми моделями Google с помощью SDK Flutter Firebase.
  • Отображение интерактивных поверхностей, созданных Gemini, с использованием GenUI.
  • Закрепление поверхностей в макетах с использованием заранее определенных статических идентификаторов рендеринга.
  • Разработка пользовательских схем и каталогов виджетов для создания надежных циклов взаимодействия.

Справочная документация