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 để cho phép tác nhân tạo giao diện người dùng của riêng mình, rồi cuối cùng xây dựng thành phần giao diện người dùng tuỳ chỉnh, mang tính tương tác 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

  • Xây dựng 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 dựa trên AI
  • Thêm một thanh tiến trình để cho biết thời điểm ứng dụng đang chờ phản hồi từ trợ lý ảo
  • Tạo một thành phần hiển thị được đặt tên và hiển thị thành phần đó ở 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 để kiểm soát cách trình bày các nhiệm vụ

Bạn cần có

  • Một trình duyệt web, chẳng hạn như Chrome
  • Flutter SDK được cài đặt cục bộ
  • Firebase CLI đã được cài đặt và định cấu hình

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

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

Thiết lập dự án Flutter

  1. Nếu bạn chưa cài đặt, hãy cài đặt Flutter SDK cục bộ.
  2. 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
    
  3. Thêm các phần phụ thuộc cần thiết vào dự án Flutter của bạn:
    flutter pub add firebase_core firebase_ai genui json_schema_builder
    
    Phần dependencies cuối cùng của bạn sẽ có dạng như sau (số phiên bản có thể hơi khác):
    dependencies:
      flutter:
        sdk: flutter
    
      cupertino_icons: ^1.0.8
      firebase_core: ^4.9.0
      firebase_ai: ^3.12.1
      genui: ^0.9.0
      json_schema_builder: ^0.1.3
    
  4. Chạy flutter pub get để tải tất cả các gói xuống.

Thiết lập dự án Firebase

  1. Nếu chưa có, hãy cài đặt Giao diện dòng lệnh (CLI) của Firebase.
  2. Đăng nhập vào Firebase bằng Tài khoản Google của bạn:
    firebase login
    
  3. Cài đặt FlutterFire CLI:
    dart pub global activate flutterfire_cli
    
  4. Trong thư mục dự án Flutter, hãy chạy lệnh sau để định cấu hình dự án Flutter sử dụng Firebase:
    flutterfire configure
    
    Lệnh này sẽ thực hiện những việc sau:
    1. Hỏi bạn xem bạn muốn sử dụng dự án Firebase hiện có hay tạo một dự án Firebase mới. Chọn Tạo dự án Firebase mới.
    2. Hỏi bạn muốn nhắm đến nền tảng nào (iOS, Android, Web) cho ứng dụng Flutter của mình. Hiện tại, hãy chọn Web.

Lệnh flutterfire configure sẽ tự động tạo một dự án Firebase và một Ứng dụng web Firebase mới trong dự án Firebase đó. Sau đó, lệnh này sẽ tạo một tệp cấu hình Firebase (firebase_options.dart) và tự động thêm tệp đó vào thư mục lib/ của dự án Flutter.

Xin lưu ý rằng ứng dụng cho lớp học lập trình này chỉ hoạt động với Flutter SDK và Chrome được cài đặt trên máy của bạn (tức là ứng dụng này sẽ tạo dưới dạng một ứng dụng web). Tuy nhiên, vì ứng dụng này được xây dựng bằng Flutter, nên ứng dụng cũng sẽ hoạt động trên các nền tảng khác! Vì vậy, khi kết thúc lớp học lập trình, hãy thử chạy lại flutterfire configure để thêm tính năng hỗ trợ cho iOS, Android hoặc một nền tảng khác, rồi tạo lại ứng dụng trên nền tảng đó.

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

Thiết lập Firebase AI Logic

  1. Đăng nhập vào bảng điều khiển của Firebase. Sử dụng cùng Tài khoản Google mà bạn đã dùng để đăng nhập vào Firebase CLI.
  2. Chọn dự án Firebase mà bạn vừa tạo bằng FlutterFire CLI.
  3. Trong trình đơn điều hướng bên trái, hãy chọn Dịch vụ AI > Logic AI.
  4. Nhấp vào Bắt đầu để chạy quy trình có hướng dẫn.
  5. Chọn bắt đầu bằng Gemini Developer API rồi làm theo lời nhắc trên màn hình để thiết lập Firebase AI Logic.

Bạn đã có các trình bổ trợ FlutterFire cần thiết để sử dụng Firebase AI Logic trong phần "Thiết lập dự án Flutter". Bạn đã sẵn sàng bắt đầu viết mã cho ứng dụng của mình trong bước tiếp theo!

3. Tạo một 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 được hỗ trợ bởi Firebase AI Logic. Để bắt đầu nhanh, bạn sẽ sao chép và dán toàn bộ chế độ 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à nhân viên hỗ trợ, ứ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 duy nhất. Sau này, bạn sẽ dùng lớp này trong lớp học lập trình để hiện tin nhắn của cả bạn và trợ lý, nhưng chủ yếu đây chỉ là một tiện ích Text đẹp mắt.

Triển khai giao diện người dùng Chat 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.5-flash',
    );
    _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. Thành phần 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ị chúng cùng với các truy vấn của người dùng bằng cách sử dụ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 nhân viên hỗ trợ.
  • Phương thức _addMessage là nơi tin nhắn của người dùng được gửi đến nhân viên hỗ trợ.
  • Danh sách _items là nơi lưu trữ nhật ký trò chuyện.
  • Thông báo sẽ xuất hiện 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 trợ lý về một số việc bạn muốn làm 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

Đã đến lúc 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 cơ bản của Firebase 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 ra các tiện ích thực tế của Flutter 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ới 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 dữ liệu bằng một API duy nhất, hợp nhất cho ứng dụng Flutter của bạn.
  • 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.

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 để biểu thị Surface do tác nhân tạo:

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

Khởi động các thành phần 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 có một 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 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ị các 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 đó, hãy 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 lớp 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ể dùng để theo dõi những gì mà tác nhân đang tạo, cũng như một phương thức để gửi thông báo đến 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. Những 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 về 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 lời nhắc 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()),
    );
  }

Nền tảng hiển thị

Tiếp theo, hãy cập nhật phương thức build của ListView để hiển thị các 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 bề mặt 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 thành phần, đồng thời đảm bảo thành phần đó sẽ được tạo 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 "Tự mang mô hình của bạn", tức là bạn kiểm soát LLM nào hỗ trợ trải nghiệm của bạn. 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 thông báo do gói genui tạo và gửi chúng đến tác nhân bạn chọn, đồng thời bạn cần lấy các câu trả lời của tác nhân và gửi chúng trở lại genui.

Để làm 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 một thông báo đến nhân viên hỗ trợ. Lệnh gọi đến addChunk ở cuối phương thức sẽ truyền 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 các thông báo vào Conversation thay vì 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));
  }

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 ra các thành phần 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ể 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 thông báo như "Cho tôi xem các việc cần làm của tôi trong một cột, có nút đánh dấu từng việc là đã hoàn thành".

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

Việc 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ị một chỉ báo tiến trình để người dùng biết rằng GenUI đang tạo nội dung. Rất may là gói genui cung cấp một Listenable mà bạn có thể dùng để theo dõi trạng thái của cuộc trò chuyện. Giá trị ConversationState đó bao gồm một thuộc tính isWaiting để xác định xem mô hình có đang tạo nội dung hay không.

Gói các chế độ điều khiển đầu vào bằng một ValueListenableBuilder

Tạo một ValueListenableBuilder bao bọc Row (chứa TextFieldElevatedButton) ở cuối lib/main.dart để nghe _conversation.state. Bằng cách kiểm tra state.isWaiting, bạn có thể tắt chế độ 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

Bọc tiện ích Column chính bên trong một Stack và thêm LinearProgressIndicator làm thành phần con thứ hai của ngăn xếp đó, được cố định ở 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. Duy trì một bề mặt GenUI

Cho đến nay, danh sách việc cần làm đã được hiển thị trong luồng trò chuyện có thể cuộn, với mỗi tin nhắn hoặc bề mặt 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 thành phần và hiển thị thành phần đó ở 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ố để 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 thành phần 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 bề mặt đượ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 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.
''';

Bạn cần đưa ra hướng dẫn rõ ràng cho trợ lý ảo về thời điểm và cách sử dụng các thành phần 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 trong danh mục và mã nhận dạng nền tảng cụ thể (và sử dụng lại một phiên bản duy nhất), bạn có thể giúp đảm bảo tác nhân tạo ra giao diện mà bạn muốn thấy.

Bạn cần làm thêm một số việc, nhưng bạn có thể thử chạy lại ứng dụng để xem tác nhân phần mềm tạo giao diện hiển thị tác vụ ở đầ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 TaskDisplay trong danh mục không tồn tại. Trong một vài bước tiếp theo, bạn sẽ khắc phục vấn đề đó bằng cách tạo một giản đồ dữ liệu, một lớp để phân tích cú pháp giản đồ đó, một tiện ích và mục trong 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 nội dung nhập sau:

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

Tạo giản đồ dữ liệu

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

Bắt đầu bằng 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, hãy thêm title, tasks, name, isCompletedcompleteAction vào các thuộc tính của 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. Thành phần này được tạo bằng A2uiSchemas.action, hàm khởi tạo cho một thuộc tính giản đồ đại diện cho một Thao tác A2UI. Bằng cách thêm một thao tác vào giản đồ, về cơ bản, ứng dụng đang nói với tác nhân: "Này, khi bạn giao cho tôi một việc, hãy cung cấp cả tên và siêu dữ liệu cho một thao tác mà tôi có thể dùng để cho bạn biết rằng việc đó đã hoàn tất". Sau đó, ứng dụng sẽ gọi thao tác đó khi người dùng nhấn vào một hộp đánh dấu.

Tiếp theo, hãy thêm các trường required vào giản đồ. Những thuộc tính này hướng dẫn tác nhân điền sẵn một số thuộc tính mỗi lần. Trong trường hợp này, bạn phải có mọi thuộc tính!

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 phiên bản của thành phần này, tác nhân sẽ gửi dữ liệu khớp với giản đồ. 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 mạnh. Lưu ý cách _TaskDisplayData xử lý cấu trúc gốc, trong khi uỷ quyền việc 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ạo bằng Flutter trước đây, thì những lớp này có thể tương tự như những lớp bạn đã tạo. Chúng chấp nhận một JsonMap và trả về một đối tượng được nhập mạnh mẽ 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. Chúng được trích xuất từ thuộc tính completeAction của JSON và chứa tên của thao tác cũng như bối cảnh dữ liệu của thao tác đó (một thông tin tham chiếu đến vị trí của thao tác trong mô hình dữ liệu của GenUI). Các biến này sẽ được dùng sau này để tạo UserActionEvent.

Mô hình dữ liệu là một kho lưu trữ tập trung, có thể ghi nhận đượ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ạ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 giản đồ 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 các tiện ích và được tham chiếu trong các thông báo sau này cho tác nhân (chẳng hạ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. Phương thức này sẽ 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 tác vụ 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 giản đồ, 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 này với nhau.

Ở cuối task_display.dart, hãy tạo taskDisplay làm biến cấp cao nhất, 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

Để hoạt động, tiện ích này cần giao tiếp lại với nhân viên hỗ trợ khi một tác vụ 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 cách dùng completeAction từ dữ liệu của tác vụ.

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 trong 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 khác trong danh mục.

Thêm nội dung 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 nóng ứng dụng để xem các thay đổi.

8. Thử nghiệm nhiều cách tương tác với trợ lý

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

Giờ đây khi đã 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 có thể thoải mái 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 của bạn: thông qua giao diện người dùng ứng dụng (chẳng hạn như nút và hộp đánh dấu) và thông qua một tác nhân có thể hiểu ngôn ngữ tự nhiên và suy luận về dữ liệu. Hãy thử nghiệm cả hai!

  • 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 đánh dấu để chuyển đổi trạng thái của 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 trợ lý ảo xoá những việc cần bạn lái xe đến một nơi nào đó.
  • Yêu cầu trợ lý tạo một danh sách các việc cần làm lặp lại dưới dạng các mục riêng lẻ ("Tôi cần mua thiệp chúc mừng cho Mẹ, Bố và Bà. Tạo các việc cần làm riêng cho những việc đó").
  • Yêu cầu nhân viên hỗ trợ đá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 hai hoặc ba 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 công việc dựa trên AI 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 cơ sở của Google bằng Flutter Firebase SDK
  • Kết xuất các nền tảng tương tác do Gemini tạo bằng GenUI
  • Ghim các thành phần 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ế các giản đồ và danh mục tiện ích tuỳ chỉnh cho các vòng lặp tương tác mạnh mẽ

Tài liệu tham khảo