Xây dựng ứng dụng có giao diện người dùng tạo sinh (GenUI)

1. Giới thiệu

Trong lớp học lập trình này, bạn sẽ tạo một ứng dụng danh sách việc cần làm bằng Flutter, Firebase AI Logic và gói genui mới. Bạn sẽ bắt đầu với một ứng dụng trò chuyện dựa trên văn bản, nâng cấp ứng dụng đó bằng GenUI để cấp cho tác nhân khả năng tạo giao diện người dùng riêng, rồi cuối cùng tạo thành phần giao diện người dùng tuỳ chỉnh, tương tác của riêng bạn mà bạn và tác nhân có thể thao tác trực tiếp.

Một ứng dụng danh sách việc cần làm chạy trong Chrome

Bạn sẽ thực hiện

  • Tạo giao diện trò chuyện cơ bản bằng Flutter và Firebase AI Logic
  • Tích hợp gói genui để tạo các nền tảng do AI điều khiển
  • Thêm thanh tiến trình để cho biết khi ứng dụng đang chờ phản hồi từ tác nhân
  • Tạo một nền tảng có tên và hiển thị nền tảng đó ở một vị trí riêng trong giao diện người dùng.
  • Tạo một thành phần danh mục GenUI tuỳ chỉnh giúp bạn kiểm soát cách trình bày các nhiệm vụ

Bạn cần có

Lớp học lập trình này dành cho các nhà phát triển Flutter ở trình độ trung cấp.

2. Trước khi bắt đầu

Thiết lập dự án Flutter

Mở cửa sổ dòng lệnh rồi chạy flutter create để tạo một dự án mới:

flutter create intro_to_genui
cd intro_to_genui

Thêm các phần phụ thuộc cần thiết vào dự án Flutter:

flutter pub add firebase_core firebase_ai genui json_schema_builder

Phần dependencies cuối cùng sẽ có dạng như sau (số phiên bản có thể hơi khác một chút):

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

Chạy flutter pub get để tải tất cả các gói xuống.

Bật API và Firebase

Để sử dụng gói firebase_ai, trước tiên, bạn phải bật Firebase AI Logic trong dự án của mình.

  1. Chuyển đến Firebase AI Logic trong bảng điều khiển của Firebase.
  2. Nhấp vào Bắt đầu để khởi chạy quy trình làm việc có hướng dẫn.
  3. Làm theo hướng dẫn trên màn hình để thiết lập dự án.

Để biết thêm thông tin, hãy xem hướng dẫn thêm Firebase vào ứng dụng Flutter.

Sau khi các API hoạt động, hãy khởi chạy Firebase trong ứng dụng Flutter bằng FlutterFire CLI:

flutterfire configure

Chọn dự án Firebase của bạn và làm theo hướng dẫn để định cấu hình dự án đó cho các nền tảng mục tiêu (ví dụ: Android, iOS, web). Bạn có thể hoàn thành lớp học lập trình này chỉ bằng cách cài đặt SDK Flutter và Chrome trên máy, nhưng ứng dụng này cũng sẽ hoạt động trên các nền tảng khác.

3. Tạo khung giao diện trò chuyện cơ bản

Trước khi giới thiệu Giao diện người dùng tạo sinh, ứng dụng của bạn cần có một nền tảng: một ứng dụng trò chuyện cơ bản dựa trên văn bản do Firebase AI Logic hỗ trợ. Để bắt đầu nhanh chóng, bạn sẽ sao chép và dán toàn bộ quy trình thiết lập cho giao diện trò chuyện.

Một phiên bản dựa trên văn bản của ứng dụng

Tạo tiện ích bong bóng tin nhắn

Để hiển thị tin nhắn văn bản của người dùng và tác nhân, ứng dụng của bạn cần có một tiện ích. Tạo một tệp mới có tên là lib/message_bubble.dart rồi thêm lớp sau:

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 là một StatelessWidget hiển thị một tin nhắn trò chuyện. Lớp này sẽ được sử dụng sau trong lớp học lập trình này để hiển thị tin nhắn của cả bạn và tác nhân, nhưng chủ yếu chỉ là một tiện ích Text nâng cao.

Triển khai giao diện người dùng trò chuyện trong main.dart

Thay thế toàn bộ nội dung của lib/main.dart bằng cách triển khai chatbot văn bản hoàn chỉnh này:

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

Tệp main.dart mà bạn vừa sao chép và dán sẽ thiết lập một ChatSession cơ bản bằng Firebase AI Logic và câu lệnh trong systemInstruction. Tệp này quản lý các lượt trò chuyện bằng cách duy trì danh sách các phần tử TextItem và hiển thị các phần tử đó cùng với truy vấn của người dùng bằng tiện ích MessageBubble mà bạn đã tạo trước đó.

Dưới đây là một số điều bạn cần kiểm tra trước khi tiếp tục:

  • Phương thức initState là nơi thiết lập kết nối với Firebase AI Logic.
  • Ứng dụng này cung cấp một TextField và một nút để gửi tin nhắn cho tác nhân.
  • Phương thức _addMessage là nơi gửi tin nhắn của người dùng đến tác nhân.
  • Danh sách _items là nơi lưu trữ nhật ký trò chuyện.
  • Tin nhắn được hiển thị trong ListView bằng tiện ích MessageBubble.

Kiểm thử ứng dụng

Sau khi thiết lập xong, giờ đây, bạn có thể chạy và kiểm thử ứng dụng.

flutter run -d chrome

Hãy thử trò chuyện với tác nhân về một số việc bạn muốn hoàn thành hôm nay. Mặc dù giao diện người dùng chỉ dựa trên văn bản có thể hoàn thành công việc, nhưng GenUI có thể giúp trải nghiệm trở nên dễ dàng và nhanh chóng hơn.

4. Tích hợp gói GenUI

Giờ đây, bạn có thể nâng cấp từ văn bản thuần tuý lên Giao diện người dùng tạo sinh. Bạn sẽ thay thế vòng lặp nhắn tin Firebase cơ bản bằng các đối tượng Conversation, CatalogSurfaceController của GenUI. Điều này cho phép mô hình AI tạo thực thể cho các tiện ích Flutter thực tế trong luồng trò chuyện.

Một phiên bản của ứng dụng có tích hợp GenUI

Gói genui cung cấp 5 lớp mà bạn sẽ sử dụng trong suốt lớp học lập trình này:

  • SurfaceController ánh xạ giao diện người dùng do mô hình tạo ra vào màn hình.
  • A2uiTransportAdapter kết nối các yêu cầu GenUI nội bộ với mọi mô hình ngôn ngữ bên ngoài.
  • Conversation bao bọc bộ điều khiển và bộ chuyển đổi truyền tải bằng một API duy nhất, hợp nhất cho ứng dụng Flutter.
  • Catalog mô tả các tiện ích và thuộc tính có sẵn cho mô hình ngôn ngữ.
  • Surface là một tiện ích hiển thị giao diện người dùng do mô hình tạo ra.

Chuẩn bị sẵn sàng hiển thị Surface được tạo

Mã hiện có bao gồm một lớp TextItem đại diện cho một tin nhắn văn bản trong cuộc trò chuyện. Thêm một lớp khác để đại diện cho Surface do tác nhân tạo:

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

Khởi chạy các khối xây dựng GenUI

Ở đầu lib/main.dart, hãy nhập thư viện genui:

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

Cả gói genui và gói firebase_ai đều bao gồm lớp TextPart. Bằng cách nhập genui theo cách này, bạn đang đặt không gian tên cho phiên bản TextPart của gói này dưới dạng genui.TextPart, tránh xung đột tên.

Khai báo các bộ điều khiển chức năng cốt lõi trong _MyHomePageState sau _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;

Tiếp theo, hãy cập nhật initState để chuẩn bị bộ điều khiển của thư viện GenUI.

Xoá dòng này khỏi initState:

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

Sau đó, thêm mã sau:

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

Mã này tạo một facade Conversation quản lý bộ điều khiển và bộ chuyển đổi. Cuộc trò chuyện đó cung cấp cho ứng dụng của bạn một luồng sự kiện mà ứng dụng có thể sử dụng để theo dõi những gì tác nhân đang tạo, cũng như một phương thức để gửi tin nhắn cho tác nhân.

Tiếp theo, hãy tạo một trình nghe cho các sự kiện trò chuyện. Các sự kiện này bao gồm các sự kiện liên quan đến nền tảng cũng như các sự kiện cho tin nhắn văn bản và lỗi:

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

Cuối cùng, hãy tạo câu lệnh hệ thống và gửi cho tác nhân:

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

Hiển thị nền tảng

Tiếp theo, hãy cập nhật phương thức build của ListView để hiển thị SurfaceItem trong danh sách _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,
            ),
          ),
        },
    ],
  ),
),

Hàm khởi tạo cho tiện ích Surface lấy một surfaceContext cho biết nền tảng mà tiện ích này chịu trách nhiệm hiển thị. SurfaceController được tạo trước đó, _controller, cung cấp định nghĩa và trạng thái cho từng nền tảng, đồng thời đảm bảo rằng nền tảng đó sẽ xây dựng lại khi có bản cập nhật.

Kết nối GenUI với Firebase AI Logic

Gói genui sử dụng phương pháp "Bring Your Own Model" (Mang mô hình của riêng bạn), nghĩa là bạn kiểm soát LLM nào hỗ trợ trải nghiệm của mình. Trong trường hợp này, bạn đang sử dụng Firebase AI Logic, nhưng gói này được xây dựng để hoạt động với nhiều tác nhân và nhà cung cấp.

Sự tự do đó dẫn đến một chút trách nhiệm bổ sung: bạn cần lấy các tin nhắn do gói genui tạo và gửi cho tác nhân đã chọn, đồng thời bạn cần lấy phản hồi của tác nhân và gửi lại vào genui.

Để thực hiện việc đó, bạn sẽ xác định phương thức _sendAndReceive được tham chiếu trong mã cho bước trước. Thêm mã này vào 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!);
    }
  }

Phương thức này sẽ được gói genui gọi bất cứ khi nào cần gửi tin nhắn cho tác nhân. Lệnh gọi đến addChunk ở cuối phương thức sẽ gửi phản hồi của tác nhân trở lại gói genui, cho phép gói này xử lý phản hồi và tạo giao diện người dùng.

Cuối cùng, hãy thay thế hoàn toàn phương thức _addMessage hiện có bằng phiên bản mới này để phương thức này định tuyến tin nhắn vào Conversation thay vì Firebase trực tiếp:

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

Vậy là xong! Hãy thử chạy lại ứng dụng. Ngoài tin nhắn văn bản, bạn sẽ thấy tác nhân tạo các nền tảng giao diện người dùng như nút, tiện ích văn bản và nhiều thành phần khác.

Bạn thậm chí có thể thử yêu cầu tác nhân hiển thị giao diện người dùng theo một cách cụ thể. Ví dụ: hãy thử một tin nhắn như "Hiển thị các việc cần làm của tôi trong một cột, với một nút để đánh dấu từng việc là đã hoàn thành".

5. Thêm trạng thái chờ

Quá trình tạo LLM là không đồng bộ. Trong khi chờ phản hồi, giao diện trò chuyện cần vô hiệu hoá các nút nhập và hiển thị chỉ báo tiến trình để người dùng biết rằng GenUI đang tạo nội dung. May mắn là gói genui cung cấp một Listenable mà bạn có thể sử dụng để theo dõi trạng thái của cuộc trò chuyện. Giá trị ConversationState đó bao gồm thuộc tính isWaiting để xác định xem mô hình có đang tạo nội dung hay không.

Bao bọc các chế độ điều khiển nhập bằng ValueListenableBuilder

Tạo một ValueListenableBuilder bao bọc Row (chứa TextFieldElevatedButton) ở cuối lib/main.dart để theo dõi _conversation.state. Bằng cách kiểm tra state.isWaiting, bạn có thể vô hiệu hoá tính năng nhập trong khi mô hình đang tạo nội dung.

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

Thêm thanh tiến trình

Bao bọc tiện ích Column chính bên trong Stack và thêm LinearProgressIndicator làm thành phần con thứ hai của ngăn xếp đó, được neo vào phía dưới cùng. Khi bạn hoàn tất, body của Scaffold sẽ có dạng như sau:

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. Lưu giữ một nền tảng GenUI

Cho đến nay, danh sách việc cần làm đã được kết xuất trong luồng trò chuyện cuộn, với mỗi tin nhắn hoặc nền tảng mới được thêm vào danh sách khi đến. Trong bước tiếp theo, bạn sẽ thấy cách đặt tên cho một nền tảng và hiển thị nền tảng đó ở một vị trí cụ thể trong giao diện người dùng.

Trước tiên, ở đầu main.dart, trước void main(), hãy khai báo một hằng số để sử dụng làm mã nhận dạng nền tảng:

const taskDisplaySurfaceId = 'task_display';

Thứ hai, hãy cập nhật câu lệnh switch trong trình nghe Conversation để đảm bảo rằng mọi nền tảng có mã nhận dạng đó đều không được thêm vào _items:

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

Tiếp theo, hãy mở cấu trúc bố cục của cây tiện ích để tạo một không gian cho nền tảng được ghim ngay phía trên nhật ký trò chuyện. Thêm 2 tiện ích này làm thành phần con đầu tiên của Column chính:

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

Cho đến nay, tác nhân của bạn đã có quyền tự do tạo và sử dụng các nền tảng theo ý muốn. Để đưa ra hướng dẫn cụ thể hơn, bạn cần xem lại câu lệnh hệ thống. Thêm phần ## USER INTERFACE sau đây vào cuối câu lệnh được lưu trữ trong hằng số 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.
''';

Điều quan trọng là bạn phải đưa ra hướng dẫn rõ ràng cho tác nhân về thời điểm và cách sử dụng các nền tảng giao diện người dùng. Bằng cách yêu cầu tác nhân sử dụng một mục danh mục và mã nhận dạng nền tảng cụ thể (và sử dụng lại một thực thể duy nhất), bạn có thể giúp đảm bảo rằng tác nhân sẽ tạo giao diện mà bạn muốn thấy.

Bạn vẫn còn nhiều việc phải làm, nhưng bạn có thể thử chạy lại ứng dụng để xem tác nhân tạo nền tảng hiển thị việc cần làm ở đầu giao diện người dùng.

7. Tạo tiện ích danh mục tuỳ chỉnh

Tại thời điểm này, mục danh mục TaskDisplay không tồn tại. Trong vài bước tiếp theo, bạn sẽ khắc phục vấn đề đó bằng cách tạo một lược đồ dữ liệu, một lớp để phân tích cú pháp lược đồ đó, một tiện ích và mục danh mục kết hợp mọi thứ.

Trước tiên, hãy tạo một tệp có tên là task_display.dart rồi thêm các mục nhập sau:

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

Tạo lược đồ dữ liệu

Tiếp theo, hãy xác định lược đồ dữ liệu mà tác nhân sẽ cung cấp khi muốn tạo màn hình hiển thị việc cần làm. Quá trình này sử dụng một số hàm khởi tạo nâng cao từ gói json_schema_builder, nhưng về cơ bản, bạn chỉ cần xác định một lược đồ JSON được sử dụng trong tin nhắn gửi đến và gửi từ tác nhân.

Bắt đầu với một S.object cơ bản tham chiếu đến tên thành phần:

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

Tiếp theo, thêm title, tasks, name, isCompletedcompleteAction vào các thuộc tính lược đồ.

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

Hãy xem thuộc tính completeAction. Thuộc tính này được tạo bằng A2uiSchemas.action, hàm khởi tạo cho một thuộc tính lược đồ đại diện cho Hành động A2UI. Bằng cách thêm một hành động vào lược đồ, ứng dụng về cơ bản đang nói với tác nhân: "Này, khi bạn giao cho tôi một việc cần làm, hãy cung cấp cả tên và siêu dữ liệu cho một hành động mà tôi có thể sử dụng để cho bạn biết rằng việc cần làm đó đã hoàn thành". Sau đó, ứng dụng sẽ gọi hành động đó khi người dùng nhấn vào hộp kiểm.

Tiếp theo, hãy thêm các trường required vào lược đồ. Các trường này hướng dẫn tác nhân điền một số thuộc tính mỗi lần. Trong trường hợp này, mọi thuộc tính đều là bắt buộc!

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

Tạo các lớp phân tích cú pháp dữ liệu

Khi tạo các thực thể của thành phần này, tác nhân sẽ gửi dữ liệu khớp với lược đồ. Thêm 2 lớp để phân tích cú pháp JSON đến đó thành các đối tượng Dart có kiểu dữ liệu mạnh. Lưu ý cách _TaskDisplayData xử lý cấu trúc gốc, đồng thời uỷ quyền phân tích cú pháp mảng bên trong cho _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');
    }
  }
}

Nếu bạn đã từng tạo bằng Flutter, thì các lớp này có thể tương tự như các lớp mà bạn đã tạo. Các lớp này chấp nhận một JsonMap và trả về một đối tượng có kiểu dữ liệu mạnh chứa dữ liệu được phân tích cú pháp từ JSON.

Hãy xem các trường actionNameactionContext trong _TaskData. Các trường này được trích xuất từ thuộc tính completeAction của JSON và chứa tên của hành động cũng như ngữ cảnh dữ liệu của hành động đó (tham chiếu đến vị trí của hành động trong mô hình dữ liệu của GenUI). Các trường này sẽ được sử dụng sau để tạo UserActionEvent.

Mô hình dữ liệu là một kho lưu trữ tập trung, có thể quan sát được cho tất cả trạng thái giao diện người dùng động, do thư viện genui duy trì. Khi tác nhân tạo một thành phần giao diện người dùng từ danh mục, tác nhân cũng tạo một đối tượng dữ liệu khớp với lược đồ của thành phần đó. Đối tượng dữ liệu này được lưu trữ trong mô hình dữ liệu ở ứng dụng để có thể dùng để tạo tiện ích và được tham chiếu trong các tin nhắn sau này gửi đến tác nhân (như completeAction mà bạn sắp kết nối với một tiện ích).

Thêm tiện ích

Bây giờ, hãy tạo một tiện ích để hiển thị danh sách. Tiện ích này phải chấp nhận một thực thể của lớp _TaskDisplayData và một lệnh gọi lại để gọi khi một việc cần làm hoàn tất.

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

Tạo CatalogItem

Sau khi tạo lược đồ, trình phân tích cú pháp và tiện ích, giờ đây, bạn có thể tạo một CatalogItem để liên kết tất cả các thành phần đó với nhau.

Ở cuối task_display.dart, hãy tạo taskDisplay làm biến cấp cao nhất, sử dụng _TaskDisplayData để phân tích cú pháp JSON đến và tạo một thực thể của tiện ích _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!
      },
    );
  },
);

Triển khai onCompleteTask

Để tiện ích hoạt động, tiện ích này cần giao tiếp lại với tác nhân khi một việc cần làm hoàn tất. Thay thế phần giữ chỗ onCompleteTask trống bằng mã sau để tạo và gửi một sự kiện bằng completeAction từ dữ liệu việc cần làm.

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

Đăng ký mục danh mục

Cuối cùng, hãy mở main.dart, nhập tệp mới và đăng ký tệp đó cùng với các mục danh mục khác.

Thêm mục nhập này vào đầu lib/main.dart:

import 'task_display.dart';

Thay thế catalog = BasicCatalogItems.asCatalog(); trong hàm initState() bằng:

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

Bạn đã hoàn tất! Khởi động lại ứng dụng để xem các thay đổi.

8. Thử nghiệm nhiều cách tương tác với tác nhân

Một ứng dụng danh sách việc cần làm chạy trong Chrome

Giờ đây, bạn đã thêm tiện ích mới vào Danh mục và tạo một không gian cho tiện ích đó trong giao diện người dùng của ứng dụng, đã đến lúc bạn vui vẻ làm việc với tác nhân. Một trong những lợi ích chính của GenUI là cung cấp 2 cách tương tác với dữ liệu: thông qua giao diện người dùng ứng dụng như nút và hộp kiểm, và thông qua một tác nhân hiểu ngôn ngữ tự nhiên và có thể suy luận về dữ liệu. Hãy thử nghiệm cả hai cách!

  • Sử dụng trường văn bản để mô tả 3 hoặc 4 việc cần làm và xem các việc đó xuất hiện trong danh sách.
  • Sử dụng hộp kiểm để chuyển đổi một việc cần làm thành đã hoàn thành hoặc chưa hoàn thành.
  • Tạo danh sách gồm 5 đến 6 việc cần làm, sau đó yêu cầu tác nhân xoá những việc cần bạn lái xe đến một nơi nào đó.
  • Yêu cầu tác nhân tạo danh sách các việc cần làm lặp đi lặp lại dưới dạng các mục riêng lẻ ("Tôi cần mua một tấm thiệp chúc mừng cho Mẹ, Bố và Bà. Hãy tạo các việc cần làm riêng cho những người đó").
  • Yêu cầu tác nhân đánh dấu tất cả các việc cần làm là đã hoàn thành hoặc chưa hoàn thành, hoặc đánh dấu 2 hoặc 3 việc đầu tiên.

9. Xin chúc mừng

Xin chúc mừng! Bạn đã tạo một ứng dụng theo dõi việc cần làm do AI hỗ trợ bằng Giao diện người dùng tạo sinh và Flutter.

Kiến thức bạn học được

  • Tương tác với các mô hình nền tảng của Google bằng SDK Firebase Flutter
  • Kết xuất các nền tảng tương tác do Gemini tạo bằng GenUI
  • Ghim các nền tảng trong bố cục bằng cách sử dụng mã kết xuất tĩnh được xác định trước
  • Thiết kế lược đồ tuỳ chỉnh và danh mục tiện ích cho các vòng lặp tương tác mạnh mẽ

Tài liệu tham khảo