สร้างแอป Generative UI (GenUI)

1. บทนำ

ใน Codelab นี้ คุณจะได้สร้างแอปรายการงานโดยใช้ Flutter, Firebase AI Logic และแพ็กเกจ genui ใหม่ คุณจะเริ่มต้นด้วยแอปแชทแบบข้อความ อัปเกรดด้วย GenUI เพื่อให้ Agent มีสิทธิ์สร้าง UI ของตัวเอง และสุดท้ายสร้างคอมโพเนนต์ UI แบบอินเทอร์แอกทีฟที่กำหนดเองซึ่งคุณและ Agent สามารถจัดการได้โดยตรง

แอปรายการงานที่ทำงานใน Chrome

สิ่งที่คุณต้องดำเนินการ

  • สร้างอินเทอร์เฟซแชทพื้นฐานโดยใช้ Flutter และ Firebase AI Logic
  • ผสานรวมgenuiแพ็กเกจเพื่อสร้างแพลตฟอร์มที่ทำงานด้วยระบบ AI
  • เพิ่มแถบความคืบหน้าเพื่อระบุเมื่อแอปกำลังรอการตอบกลับจากตัวแทน
  • สร้างแพลตฟอร์มที่มีชื่อและแสดงในจุดที่กำหนดใน UI
  • สร้างคอมโพเนนต์แคตตาล็อก GenUI ที่กำหนดเองซึ่งช่วยให้คุณควบคุมวิธีนำเสนองานได้

สิ่งที่คุณต้องมี

  • เว็บเบราว์เซอร์ เช่น Chrome
  • Flutter SDK ที่ติดตั้งในเครื่อง
  • ติดตั้งและกำหนดค่า Firebase CLI แล้ว

Codelab นี้มีไว้สำหรับนักพัฒนาแอป Flutter ระดับกลาง

2. ก่อนเริ่มต้น

ตั้งค่าโปรเจ็กต์ Flutter

เปิดเทอร์มินัลแล้วเรียกใช้ flutter create เพื่อสร้างโปรเจ็กต์ใหม่

flutter create intro_to_genui
cd intro_to_genui

เพิ่มทรัพยากร Dependency ที่จำเป็นลงในโปรเจ็กต์ 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 โดยใช้ FlutterFire CLI ดังนี้

flutterfire configure

เลือกโปรเจ็กต์ Firebase แล้วทําตามข้อความแจ้งเพื่อกําหนดค่าสําหรับแพลตฟอร์มเป้าหมาย (เช่น Android, iOS, เว็บ) คุณสามารถทำ Codelab นี้ให้เสร็จได้โดยใช้เพียง Flutter SDK และ Chrome ที่ติดตั้งในเครื่อง แต่แอปจะทำงานบนแพลตฟอร์มอื่นๆ ได้ด้วย

3. สร้างอินเทอร์เฟซแชทพื้นฐาน

ก่อนที่จะเปิดตัว Generative UI แอปของคุณต้องมีพื้นฐานคือแอปพลิเคชันแชทแบบข้อความพื้นฐานที่ขับเคลื่อนโดย 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 ที่แสดงข้อความแชทเดียว โดยจะใช้ใน Codelab นี้ในภายหลังเพื่อแสดงข้อความจากทั้งคุณและตัวแทน แต่ส่วนใหญ่จะเป็นเพียงวิดเจ็ต Text ที่ดูดี

ใช้ UI ของ Chat ใน 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

ลองแชทกับตัวแทนเกี่ยวกับงานบางอย่างที่คุณต้องการทำให้เสร็จในวันนี้ แม้ว่า UI ที่เป็นข้อความอย่างเดียวจะทำงานได้ แต่ GenUI จะช่วยให้ประสบการณ์การใช้งานง่ายขึ้นและเร็วขึ้น

4. ผสานรวมแพ็กเกจ GenUI

ถึงเวลาอัปเกรดจากข้อความธรรมดาเป็น Generative UI แล้ว คุณจะสลับลูปการรับส่งข้อความ Firebase พื้นฐานกับออบเจ็กต์ GenUI Conversation, Catalog และ SurfaceController ซึ่งจะช่วยให้โมเดล AI สร้างอินสแตนซ์วิดเจ็ต Flutter จริงๆ ภายในสตรีมแชทได้

แอปเวอร์ชันที่ผสานรวม GenUI

genui แพ็กเกจมีคลาส 5 คลาสที่คุณจะใช้ตลอดทั้งโค้ดแล็บนี้

  • SurfaceController UI ของ Maps ที่โมเดลสร้างขึ้นไปยังหน้าจอ
  • A2uiTransportAdapter เชื่อมต่อคำขอ GenUI ภายในกับโมเดลภาษาภายนอก
  • Conversation จะห่อหุ้มตัวควบคุมและอแดปเตอร์การรับส่งด้วย API เดียวแบบรวมเป็นหนึ่งเดียวสำหรับแอป Flutter
  • Catalog อธิบายวิดเจ็ตและพร็อพเพอร์ตี้ที่พร้อมใช้งานสำหรับโมเดลภาษา
  • Surface เป็นวิดเจ็ตที่แสดง UI ที่โมเดลสร้างขึ้น

เตรียมพร้อมที่จะแสดง 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 Facade ที่จัดการคอนโทรลเลอร์และอแดปเตอร์ การสนทนานั้นจะให้สตรีมเหตุการณ์แก่แอปของคุณ ซึ่งแอปสามารถใช้เพื่อติดตามสิ่งที่เอเจนต์สร้างขึ้น รวมถึงวิธีการส่งข้อความไปยังเอเจนต์

จากนั้นสร้าง Listener สำหรับเหตุการณ์การสนทนา ซึ่งรวมถึงเหตุการณ์ที่เกี่ยวข้องกับพื้นผิว รวมถึงเหตุการณ์สำหรับข้อความและข้อผิดพลาด

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

แพลตฟอร์ม Display

จากนั้น อัปเดตเมธอด 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 ซึ่งจะช่วยให้แพ็กเกจประมวลผลคำตอบและสร้าง UI ได้

สุดท้าย ให้แทนที่เมธอด _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));
  }

เท่านี้ก็เรียบร้อย ลองเรียกใช้แอปอีกครั้ง นอกจากข้อความแล้ว คุณจะเห็นเอเจนต์สร้างแพลตฟอร์ม UI เช่น ปุ่ม วิดเจ็ตข้อความ และอื่นๆ

คุณยังลองขอให้ตัวแทนแสดง UI ในลักษณะที่ต้องการได้ด้วย เช่น ลองใช้ข้อความอย่าง "แสดงงานของฉันในคอลัมน์ โดยมีปุ่มทำเครื่องหมายแต่ละรายการว่าเสร็จแล้ว"

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 เป็นองค์ประกอบย่อยที่ 2 ของสแต็กนั้น โดยยึดไว้ที่ด้านล่าง เมื่อเสร็จแล้ว 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

ที่ผ่านมา ระบบแสดงรายการงานในสตรีมแชทแบบเลื่อน โดยจะเพิ่มข้อความหรือแพลตฟอร์มใหม่แต่ละรายการลงในรายการเมื่อมาถึง ในขั้นตอนถัดไป คุณจะเห็นวิธีตั้งชื่อพื้นผิวและแสดงในตำแหน่งที่เฉพาะเจาะจงภายใน UI

ก่อนอื่น ที่ด้านบนของ main.dart ก่อน void main() ให้ประกาศค่าคงที่เพื่อใช้เป็นรหัสแพลตฟอร์ม

const taskDisplaySurfaceId = 'task_display';

ประการที่ 2 ให้อัปเดตคำสั่ง switch ใน Listener Conversation เพื่อให้แน่ใจว่าจะไม่มีการเพิ่มพื้นผิวที่มีรหัสดังกล่าวลงใน _items

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

จากนั้นเปิดโครงสร้างเลย์เอาต์ของแผนผังวิดเจ็ตเพื่อสร้างพื้นที่สำหรับพื้นผิวที่ปักหมุดไว้เหนือบันทึกการแชททันที เพิ่มวิดเจ็ต 2 รายการนี้เป็นองค์ประกอบย่อยแรกของ 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(),

ที่ผ่านมา Agent ของคุณมีอิสระในการสร้างและใช้แพลตฟอร์มตามที่เห็นสมควร หากต้องการให้คำสั่งที่เฉพาะเจาะจงมากขึ้น คุณต้องกลับไปดูพรอมต์ของระบบ เพิ่มส่วน ## 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.
''';

คุณควรให้วิธีการที่ชัดเจนแก่ตัวแทนเกี่ยวกับเวลาและวิธีใช้แพลตฟอร์ม UI การบอกให้ตัวแทนใช้รายการแคตตาล็อกและรหัสพื้นผิวที่เฉพาะเจาะจง (และใช้ซ้ำอินสแตนซ์เดียว) จะช่วยให้มั่นใจได้ว่าตัวแทนจะสร้างอินเทอร์เฟซที่คุณต้องการเห็น

ยังต้องดำเนินการอีก แต่คุณลองเรียกใช้แอปอีกครั้งเพื่อดูเอเจนต์สร้างพื้นผิวการแสดงงานที่ด้านบนของ UI ได้

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

สร้างคลาสการแยกวิเคราะห์ข้อมูล

เมื่อสร้างอินสแตนซ์ของคอมโพเนนต์นี้ เอเจนต์จะส่งข้อมูลที่ตรงกับสคีมา เพิ่ม 2 คลาสเพื่อแยกวิเคราะห์ 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

โมเดลข้อมูลคือที่เก็บแบบรวมศูนย์ที่สังเกตได้สำหรับสถานะ UI แบบไดนามิกทั้งหมด ซึ่งดูแลโดยไลบรารี genui เมื่อ Agent สร้างคอมโพเนนต์ UI จากแคตตาล็อก Agent จะสร้างออบเจ็กต์ข้อมูลที่ตรงกับสคีมาของคอมโพเนนต์ด้วย ระบบจะจัดเก็บออบเจ็กต์ข้อมูลนี้ไว้ในโมเดลข้อมูลในไคลเอ็นต์ เพื่อให้ใช้สร้างวิดเจ็ตและอ้างอิงในข้อความในภายหลังถึงตัวแทนได้ (เช่น completeAction ที่คุณกำลังจะเชื่อมต่อกับวิดเจ็ต)

เพิ่มวิดเจ็ต

ตอนนี้ให้สร้างวิดเจ็ตเพื่อแสดงรายการ โดยควรยอมรับอินสแตนซ์ของคลาส _TaskDisplayData และ Callback ที่จะเรียกใช้เมื่องานเสร็จสมบูรณ์

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

เมื่อสร้างสคีมา ตัวแยกวิเคราะห์ และวิดเจ็ตแล้ว ตอนนี้คุณก็สร้าง 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

ตอนนี้คุณได้เพิ่มวิดเจ็ตใหม่ลงในแคตตาล็อกและสร้างพื้นที่สำหรับวิดเจ็ตใน UI ของแอปแล้ว ก็ถึงเวลาสนุกกับการทำงานร่วมกับเอเจนต์ ข้อดีหลักอย่างหนึ่งของ GenUI คือมี 2 วิธีในการโต้ตอบกับข้อมูล ได้แก่ ผ่าน UI ของแอปพลิเคชัน เช่น ปุ่มและช่องทำเครื่องหมาย และผ่าน Agent ที่เข้าใจภาษามนุษย์และสามารถให้เหตุผลเกี่ยวกับข้อมูลได้ ลองทดสอบกับทั้ง 2 อย่าง

  • ใช้ช่องข้อความเพื่ออธิบายงาน 3-4 อย่าง แล้วดูงานเหล่านั้นปรากฏในรายการ
  • ใช้ช่องทำเครื่องหมายเพื่อสลับสถานะของงานเป็นเสร็จแล้วหรือยังไม่เสร็จ
  • สร้างรายการงาน 5-6 รายการ แล้วบอกให้ตัวแทนนำงานที่ต้องขับรถออก
  • บอกตัวแทนให้สร้างรายการงานที่ต้องทำซ้ำๆ เป็นรายการแยกกัน ("ฉันต้องซื้อการ์ดวันหยุดให้แม่ พ่อ และย่า สร้างงานแยกต่างหากสำหรับรายการเหล่านั้น")
  • บอกตัวแทนให้ทำเครื่องหมายงานทั้งหมดว่าเสร็จแล้วหรือยังไม่เสร็จ หรือให้ทำเครื่องหมาย 2-3 งานแรก

9. ขอแสดงความยินดี

ยินดีด้วย คุณได้สร้างแอปติดตามงานที่ทำงานด้วยระบบ AI โดยใช้ Generative UI และ Flutter

สิ่งที่คุณได้เรียนรู้

  • การโต้ตอบกับโมเดลพื้นฐานของ Google โดยใช้ Flutter Firebase SDK
  • การแสดงผลแพลตฟอร์มแบบอินเทอร์แอกทีฟที่ Gemini สร้างขึ้นโดยใช้ GenUI
  • การปักหมุดพื้นผิวในเลย์เอาต์โดยใช้รหัสการแสดงผลแบบคงที่ที่กำหนดไว้ล่วงหน้า
  • การออกแบบสคีมาที่กำหนดเองและแคตตาล็อกวิดเจ็ตสำหรับลูปการโต้ตอบที่แข็งแกร่ง

เอกสารอ้างอิง