פיתוח אפליקציית ממשק משתמש גנרטיבי (GenUI)

1. מבוא

בשיעור Codelab הזה נסביר איך ליצור אפליקציה של רשימת משימות באמצעות Flutter,‏ Firebase AI Logic וחבילת genui החדשה. תתחילו עם אפליקציית צ'אט מבוססת-טקסט, תשדרגו אותה באמצעות GenUI כדי לתת לסוכן את היכולת ליצור ממשק משתמש משלו, ולבסוף תיצרו רכיב ממשק משתמש אינטראקטיבי מותאם אישית שאתם והסוכן יכולים לתפעל ישירות.

אפליקציה של רשימת משימות שפועלת ב-Chrome

הפעולות שתבצעו:

  • יצירת ממשק צ'אט בסיסי באמצעות Flutter ו-Firebase AI Logic
  • שילוב חבילת genui ליצירת ממשקים מבוססי-AI
  • הוספת סרגל התקדמות כדי לציין מתי האפליקציה מחכה לתשובה מהסוכן
  • ליצור משטח עם שם ולהציג אותו במקום ייעודי בממשק המשתמש.
  • יצירת רכיב קטלוג GenUI מותאם אישית שמאפשר שליטה באופן הצגת המשימות

הדרישות

ה-Codelab הזה מיועד למפתחי Flutter ברמת ביניים.

‫2. לפני שמתחילים

הגדרת פרויקט Flutter

  1. אם עדיין לא עשיתם זאת, מתקינים את Flutter SDK באופן מקומי.
  2. פותחים את הטרמינל ומריצים את הפקודה flutter create כדי ליצור פרויקט חדש:
    flutter create intro_to_genui
    cd intro_to_genui
    
  3. מוסיפים את יחסי התלות הנדרשים לפרויקט 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.9.0
      firebase_ai: ^3.12.1
      genui: ^0.9.0
      json_schema_builder: ^0.1.3
    
  4. מריצים את הפקודה flutter pub get כדי להוריד את כל החבילות.

הגדרת פרויקט Firebase

  1. אם עדיין לא עשיתם זאת, מתקינים את Firebase CLI.
  2. מתחברים ל-Firebase באמצעות חשבון Google:
    firebase login
    
  3. מתקינים את FlutterFire CLI:
    dart pub global activate flutterfire_cli
    
  4. מריצים את הפקודה הבאה מספריית פרויקט Flutter כדי להגדיר את פרויקט Flutter לשימוש ב-Firebase:
    flutterfire configure
    
    הפקודה הזו תבצע את הפעולות הבאות:
    1. נשאל אתכם אם אתם רוצים להשתמש בפרויקט Firebase קיים או ליצור פרויקט Firebase חדש. בוחרים באפשרות יצירת פרויקט חדש ב-Firebase.
    2. תשאלו אתכם איזו פלטפורמה (iOS, ‏ Android, אינטרנט) אתם רוצים לטרגט באפליקציית Flutter. בשלב הזה, בוחרים באפשרות אינטרנט.

הפקודה flutterfire configure יוצרת באופן אוטומטי פרויקט Firebase ואפליקציית אינטרנט חדשה ב-Firebase בפרויקט הזה. הפקודה יוצרת קובץ הגדרות של Firebase ‏ (firebase_options.dart) ומוסיפה אותו באופן אוטומטי לספרייה lib/ של פרויקט Flutter.

שימו לב שהאפליקציה הזו פועלת רק עם Flutter SDK ו-Chrome שמותקנים במחשב (כלומר, היא נוצרת כאפליקציית אינטרנט). אבל בגלל שהאפליקציה הזו מבוססת על Flutter, היא תפעל גם בפלטפורמות אחרות. לכן, בסוף שיעור ה-Codelab, כדאי לנסות להריץ מחדש את הפקודה flutterfire configure כדי להוסיף תמיכה ב-iOS, ב-Android או בפלטפורמה אחרת, ואז לבנות מחדש את האפליקציה בפלטפורמה הזו.

מידע נוסף זמין בהוראות להוספת Firebase לאפליקציית Flutter.

הגדרה של Firebase AI Logic

  1. נכנסים למסוף Firebase. משתמשים באותו חשבון Google שבו השתמשתם כדי להתחבר ל-Firebase CLI.
  2. בוחרים את פרויקט Firebase שיצרתם באמצעות FlutterFire CLI.
  3. בתפריט הניווט הימני, בוחרים באפשרות AI Services > AI Logic (שירותי AI > לוגיקת AI).
  4. לוחצים על Get started (תחילת העבודה) כדי להפעיל את תהליך העבודה המודרך.
  5. בוחרים באפשרות להתחיל עם Gemini Developer API ופועלים לפי ההנחיות במסך כדי להגדיר את Firebase AI Logic.

כבר יש לכם את הפלאגינים הנדרשים של FlutterFire כדי להשתמש ב-Firebase AI Logic מהקטע 'הגדרת פרויקט Flutter'. בשלב הבא תוכלו להתחיל לתכנת את האפליקציה.

3. יצירת ממשק צ'אט בסיסי

לפני שמשתמשים בממשק משתמש גנרטיבי, צריך ליצור בסיס לאפליקציה: אפליקציית צ'אט בסיסית מבוססת-טקסט שמבוססת על Firebase AI Logic. כדי להתחיל במהירות, מעתיקים ומדביקים את כל ההגדרה של ממשק הצ'אט.

גרסה של האפליקציה שמבוססת על טקסט

יצירת ווידג'ט של בועת הודעה

כדי להציג הודעות טקסט מהמשתמש ומהנציג, האפליקציה צריכה להכיל ווידג'ט. יוצרים קובץ חדש בשם lib/message_bubble.dart ומוסיפים את המחלקה הבאה:

import 'package:flutter/material.dart';

class MessageBubble extends StatelessWidget {
  final String text;
  final bool isUser;

  const MessageBubble({super.key, required this.text, required this.isUser});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    final bubbleColor = isUser
        ? colorScheme.primary
        : colorScheme.surfaceContainerHighest;

    final textColor = isUser
        ? colorScheme.onPrimary
        : colorScheme.onSurfaceVariant;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0),
      child: Column(
        crossAxisAlignment: isUser
            ? CrossAxisAlignment.end
            : CrossAxisAlignment.start,
        children: [
          Row(
            mainAxisAlignment: isUser
                ? MainAxisAlignment.end
                : MainAxisAlignment.start,
            children: [
              Flexible(
                child: Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 16.0,
                    vertical: 12.0,
                  ),
                  decoration: BoxDecoration(
                    color: bubbleColor,
                    borderRadius: BorderRadius.only(
                      topLeft: const Radius.circular(20),
                      topRight: const Radius.circular(20),
                      bottomLeft: Radius.circular(isUser ? 20 : 0),
                      bottomRight: Radius.circular(isUser ? 0 : 20),
                    ),
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withAlpha(20),
                        blurRadius: 4,
                        offset: const Offset(0, 2),
                      ),
                    ],
                    gradient: isUser
                        ? LinearGradient(
                            colors: [
                              colorScheme.primary,
                              colorScheme.primary.withAlpha(200),
                            ],
                            begin: Alignment.topLeft,
                            end: Alignment.bottomRight,
                          )
                        : null,
                  ),
                  child: Text(
                    text,
                    style: theme.textTheme.bodyLarge?.copyWith(
                      color: textColor,
                      height: 1.3,
                    ),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 2),
        ],
      ),
    );
  }
}

MessageBubble הוא StatelessWidget שמציג הודעה אחת בצ'אט. נשתמש בו בהמשך ה-Codelab כדי להציג הודעות גם מכם וגם מהסוכן, אבל הוא בעיקר ווידג'ט Text מיוחד.

הטמעת ממשק המשתמש של 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.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.
''';

קובץ main.dart שהעתקתם והדבקתם עכשיו מגדיר ChatSession בסיסי באמצעות Firebase AI Logic וההנחיה בקובץ systemInstruction. הוא מנהל את תורות השיחה על ידי שמירה של רשימת רכיבי TextItem והצגתם לצד שאילתות המשתמש באמצעות הווידג'ט MessageBubble שיצרתם קודם.

לפני שממשיכים, כדאי לבדוק את הדברים הבאים:

  • השיטה initState היא המקום שבו מוגדר החיבור ל-Firebase AI Logic.
  • האפליקציה מציעה TextField ולחצן לשליחת הודעות לנציג.
  • השיטה _addMessage היא השיטה שבה ההודעה של המשתמש נשלחת לנציג.
  • הרשימה _items היא המקום שבו נשמרת היסטוריית השיחות.
  • ההודעות מוצגות בListView באמצעות הווידג'ט MessageBubble.

בדיקת האפליקציה

אחרי שמגדירים את זה, אפשר להריץ את האפליקציה ולבדוק אותה.

flutter run -d chrome

אפשר לשוחח עם הסוכן או ליצור סוכן אחר. ממשק משתמש שמבוסס על טקסט בלבד יכול לעשות את העבודה, אבל ממשק משתמש גנרטיבי יכול להפוך את החוויה לקלה ומהירה יותר.

4. שילוב חבילת GenUI

עכשיו הגיע הזמן לשדרג מטקסט פשוט לממשק משתמש גנרטיבי. תחליפו את לולאת ההודעות הבסיסית של Firebase באובייקטים של GenUI Conversation,‏ Catalog ו-SurfaceController. כך מודל ה-AI יכול ליצור מופעים של ווידג'טים אמיתיים של Flutter בסטרימינג של הצ'אט.

גרסה של האפליקציה עם ממשק משתמש גנרטיבי משולב

חבילת genui כוללת חמש מחלקות שבהן תשתמשו במהלך ה-Codelab הזה:

  • SurfaceController ממשק המשתמש של המפות נוצר על ידי המודל ומוצג על המסך.
  • A2uiTransportAdapter מגשר בין בקשות פנימיות של GenUI לבין כל מודל שפה חיצוני.
  • Conversation עוטף את בקר ואת מתאם התעבורה באמצעות ממשק API יחיד ומאוחד לאפליקציית Flutter.
  • Catalog מתאר את הווידג'טים והמאפיינים שזמינים למודל השפה.
  • Surface הוא ווידג'ט שמציג ממשק משתמש שנוצר על ידי המודל.

הכנה להצגת Surface

הקוד הקיים כולל מחלקה TextItem שמייצגת הודעת טקסט אחת בשיחה. מוסיפים עוד כיתה שמייצגת Surface שנוצר על ידי הסוכן:

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

איך מאתחלים אבני בניין של GenUI

בחלק העליון של lib/main.dart, מייבאים את הספרייה של genui:

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

גם חבילה genui וגם חבילה firebase_ai כוללות מחלקה TextPart. כשמייבאים את genui בדרך הזו, יוצרים מרחב שמות לגרסה של TextPart בתור genui.TextPart, וכך נמנעת התנגשות שמות.

מצהירים על בקרי הפונקציות העיקריים ב-_MyHomePageState אחרי _chatSession:

class _MyHomePageState extends State<MyHomePage> {
  // ... existing members
  late final ChatSession _chatSession;

  // Add GenUI controllers
  late final SurfaceController _controller;
  late final A2uiTransportAdapter _transport;
  late final Conversation _conversation;
  late final Catalog catalog;

בשלב הבא, מעדכנים את initState כדי להכין את אמצעי הבקרה של ספריית GenUI.

הסרת השורה הזו מהחשבון initState:

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

לאחר מכן, מוסיפים את הקוד הבא:

@override
void initState() {
  // ... existing code ...

  // Initialize the GenUI Catalog.
  // The genui package provides a default set of primitive widgets (like text
  // and basic buttons) out of the box using this class.
  catalog = BasicCatalogItems.asCatalog();

  // Create a SurfaceController to manage the state of generated surfaces.
  _controller = SurfaceController(catalogs: [catalog]);

  // Create a transport adapter that will process messages to and from the
  // agent, looking for A2UI messages.
  _transport = A2uiTransportAdapter(onSend: _sendAndReceive);

  // Link the transport and SurfaceController together in a Conversation,
  // which provides your app a unified API for interacting with the agent.
  _conversation = Conversation(
    controller: _controller,
    transport: _transport,
  );
}

הקוד הזה יוצר חזית Conversation שמנהלת את בקר ואת המתאם. השיחה הזו מספקת לאפליקציה שלכם זרם של אירועים שהיא יכולה להשתמש בהם כדי להתעדכן במה שהסוכן יוצר, וגם שיטה לשליחת הודעות לסוכן.

לאחר מכן, יוצרים 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()),
    );
  }

פלטפורמות של מודעות לרשת המדיה

בשלב הבא, מעדכנים את השיטה של ListViewbuild כדי להציג את SurfaceItem ברשימה _items:

Expanded(
  child: ListView(
    controller: _scrollController,
    padding: const EdgeInsets.all(16),
    children: [
      for (final item in _items)
        switch (item) {
          TextItem() => MessageBubble(
            text: item.text,
            isUser: item.isUser,
          ),
          // New!
          SurfaceItem() => Surface(
            surfaceContext: _controller.contextFor(
              item.surfaceId,
            ),
          ),
        },
    ],
  ),
),

הבנאי של הווידג'ט Surface מקבל surfaceContext שמציין את המשטח שבו הווידג'ט אחראי להצגה. ה-SurfaceController שנוצר קודם, _controller, מספק את ההגדרה והמצב של כל ממשק, ומוודא שהוא נבנה מחדש כשמתבצע עדכון.

קישור GenUI ל-Firebase AI Logic

genui החבילה משתמשת בגישה של 'הבאת מודל משלך', כלומר אתם קובעים איזה מודל שפה גדול (LLM) יפעיל את החוויה שלכם. בדוגמה הזו נעשה שימוש ב-Firebase AI Logic, אבל החבילה נועדה לעבוד עם מגוון ספקים ונציגים.

החופש הזה מגיע עם קצת יותר אחריות: אתם צריכים לקחת את ההודעות שנוצרות על ידי חבילת genui ולשלוח אותן לסוכן שבחרתם, ולקחת את התשובות של הסוכן ולשלוח אותן בחזרה אל genui.

כדי לעשות זאת, צריך להגדיר את השיטה _sendAndReceive שאליה מתייחס הקוד בשלב הקודם. מוסיפים את הקוד הזה אל MyHomePageState:

  Future<void> _sendAndReceive(ChatMessage msg) async {
    final buffer = StringBuffer();

    // Reconstruct the message part fragments
    for (final part in msg.parts) {
      if (part.isUiInteractionPart) {
        buffer.write(part.asUiInteractionPart!.interaction);
      } else if (part is genui.TextPart) {
        buffer.write(part.text);
      }
    }

    if (buffer.isEmpty) {
      return;
    }

    final text = buffer.toString();

    // Send the string to Firebase AI Logic.
    final response = await _chatSession.sendMessage(Content.text(text));

    if (response.text?.isNotEmpty ?? false) {
      // Feed the response back into GenUI's transportation layer
      _transport.addChunk(response.text!);
    }
  }

השיטה הזו תופעל על ידי חבילת genui בכל פעם שצריך לשלוח הודעה לסוכן. הקריאה ל-addChunk בסוף השיטה מעבירה את התגובה של הסוכן בחזרה לחבילה genui, ומאפשרת לה לעבד את התגובה וליצור ממשק משתמש.

לבסוף, מחליפים את השיטה הקיימת של _addMessage בגרסה החדשה הזו, כך שההודעות ינותבו אל Conversation במקום אל Firebase ישירות:

  Future<void> _addMessage() async {
    final text = _textController.text;

    if (text.trim().isEmpty) {
      return;
    }

    _textController.clear();

    setState(() {
      _items.add(TextItem(text: text, isUser: true));
    });

    _scrollToBottom();

    // Send the user's input through GenUI instead of directly to Firebase.
    await _conversation.sendRequest(ChatMessage.user(text));
  }

זהו! מנסים להפעיל את האפליקציה שוב. בנוסף להודעות טקסט, תראו את הסוכן יוצר ממשקי משתמש כמו לחצנים, ווידג'טים של טקסט ועוד.

אפשר גם לבקש מהסוכן להציג את ממשק המשתמש בצורה מסוימת. לדוגמה, אפשר לנסות הודעה כמו "תציג לי את המשימות שלי בעמודה, עם לחצן לסימון כל אחת מהן כהושלמה".

5. הוספת מצב המתנה

היצירה של LLM היא אסינכרונית. בזמן ההמתנה לתשובה, בממשק הצ'אט צריך להשבית את לחצני הקלט ולהציג אינדיקטור התקדמות, כדי שהמשתמש ידע שממשק המשתמש הגנרטיבי יוצר תוכן. למזלנו, חבילת genui מספקת 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 כצאצא שני של הערימה הזו, כשהוא מעוגן לתחתית. בסיום התהליך, body של Scaffold אמור להיראות כך:

body: Stack( // New!
  children: [
    Column(
      children: [
        Expanded(
          child: ListView(
            controller: _scrollController,
            padding: const EdgeInsets.all(16),
            children: [
              for (final item in _items)
                switch (item) {
                  TextItem() => MessageBubble(
                    text: item.text,
                    isUser: item.isUser,
                  ),
                  SurfaceItem() => Surface(
                    surfaceContext: _controller.contextFor(
                      item.surfaceId,
                    ),
                  ),
                },
            ],
          ),
        ),
        SafeArea(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
            child: ValueListenableBuilder<ConversationState>(
              valueListenable: _conversation.state,
              builder: (context, state, child) {
                return Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _textController,
                        onSubmitted:
                            state.isWaiting ? null : (_) => _addMessage(),
                        decoration: const InputDecoration(
                          hintText: 'Enter a message',
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    ElevatedButton(
                      onPressed: state.isWaiting ? null : _addMessage,
                      child: const Text('Send'),
                    ),
                  ],
                );
              },
            ),
          ),
        ),
      ],
    ),
    // Listen to the state again, this time to render a progress indicator
    ValueListenableBuilder<ConversationState>(
      valueListenable: _conversation.state,
      builder: (context, state, child) {
        if (state.isWaiting) {
          return const LinearProgressIndicator();
        }
        return const SizedBox.shrink();
      },
    ),
  ],
),

6. שמירת פלטפורמת GenUI

עד עכשיו, רשימת המשימות הוצגה בצ'אט עם גלילה, וכל הודעה או משטח חדשים שנוספו לרשימה הוצגו כשהם הגיעו. בשלב הבא נסביר איך לתת שם למשטח ולהציג אותו במיקום ספציפי בממשק המשתמש.

קודם כל, בחלק העליון של main.dart, לפני void main(), מגדירים קבוע לשימוש כמזהה משטח:

const taskDisplaySurfaceId = 'task_display';

בשלב השני, מעדכנים את ההצהרה switch במאזין Conversation כדי לוודא שאף משטח עם המזהה הזה לא יתווסף אל _items:

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

לאחר מכן, פותחים את מבנה הפריסה של עץ הווידג'טים כדי ליצור מקום למשטח המוצמד מיד מעל יומן הצ'אט. מוסיפים את שני הווידג'טים האלה כרכיבי הצאצא הראשונים של הרכיב הראשי Column:

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

עד עכשיו, לסוכן הייתה יד חופשית ליצור שטחי פרסום ולהשתמש בהם לפי שיקול דעתו. כדי לתת לו הוראות ספציפיות יותר, צריך לחזור להנחיית המערכת. מוסיפים את הקטע הבא ## USER INTERFACE לסוף ההנחיה שמאוחסנת בקבוע systemInstruction:

const systemInstruction = '''
  // ... existing prompt content ...

  ## USER INTERFACE
  *   To display the list of tasks create one and only one instance of the
    TaskDisplay catalog item. Use "$taskDisplaySurfaceId" as its surface ID.
  *   Update $taskDisplaySurfaceId as necessary when the list changes.
  *   $taskDisplaySurfaceId must include a button for each task that I can use
    to mark it complete. When I use that button to mark a task complete, it
    should send you a message indicating what I've done.
  *   Avoid repeating the same information in a single message.
  *   When responding with text, rather than A2UI messages, be brief.
''';

חשוב לתת לנציג הוראות ברורות לגבי מתי ואיך להשתמש בממשקי משתמש. כדי לוודא שהסוכן ייצור את הממשק שאתם רוצים לראות, אתם יכולים להנחות אותו להשתמש בפריט ספציפי בקטלוג ובמזהה של פריט ספציפי (ולעשות שימוש חוזר במופע יחיד).

יש עוד עבודה לעשות, אבל אפשר לנסות להריץ שוב את האפליקציה כדי לראות את הסוכן יוצר את משטח התצוגה של המשימה בחלק העליון של ממשק המשתמש.

7. יצירת ווידג'ט קטלוג בהתאמה אישית

בשלב הזה, פריט הקטלוג TaskDisplay לא קיים. בשלבים הבאים נתקן את זה על ידי יצירת סכימת נתונים, מחלקה לניתוח הסכימה, ווידג'ט ופריט קטלוג שמשלב את הכול.

קודם יוצרים קובץ בשם task_display.dart ומוסיפים את שורות הייבוא הבאות:

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

יצירת סכימת הנתונים

לאחר מכן, מגדירים את סכימת הנתונים שהסוכן יספק כשהוא ירצה ליצור תצוגה של משימה. בתהליך נעשה שימוש בכמה קונסטרוקטורים מורכבים מהחבילה json_schema_builder, אבל בעצם מגדירים סכימת JSON שמשמשת בהודעות אל הסוכן וממנו.

מתחילים עם S.object בסיסי שמתייחס לשם הרכיב:

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

לאחר מכן, מוסיפים את title,‏ tasks,‏ name,‏ isCompleted ו-completeAction למאפייני הסכימה.

final taskDisplaySchema = S.object(
  properties: {
    'component': S.string(enumValues: ['TaskDisplay']),
    'title': S.string(description: 'The title of the task list'),
    'tasks': S.list(
      description: 'A list of tasks to be completed today',
      items: S.object(
        properties: {
          'name': S.string(description: 'The name of the task to be completed'),
          'isCompleted': S.boolean(
            description: 'Whether the task is completed',
          ),
          'completeAction': A2uiSchemas.action(
            description:
                'The action performed when the user has completed the task.',
          ),
        },
      ),
    ),
  },
);

בודקים את המאפיין completeAction. הוא נוצר באמצעות A2uiSchemas.action, הקונסטרוקטור של מאפיין סכימה שמייצג פעולה של A2UI. כשמוסיפים פעולה לסכימה, האפליקציה בעצם אומרת לסוכן: "היי, כשאתה נותן לי משימה, תספק גם את השם והמטא-נתונים של פעולה שאוכל להשתמש בה כדי להודיע לך שהמשימה הושלמה". בהמשך, האפליקציה תפעיל את הפעולה הזו כשהמשתמש יקיש על תיבת סימון.

בשלב הבא, מוסיפים required שדות לסכימה. ההוראות האלה מנחות את הסוכן לאכלס מאפיינים מסוימים בכל פעם. במקרה כזה, חובה להגדיר את כל המאפיינים.

final taskDisplaySchema = S.object(
  properties: {
    'component': S.string(enumValues: ['TaskDisplay']),
    'title': S.string(description: 'The title of the task list'),
    'tasks': S.list(
      description: 'A list of tasks to be completed today',
      items: S.object(
        properties: {
          'name': S.string(description: 'The name of the task to be completed'),
          'isCompleted': S.boolean(
            description: 'Whether the task is completed',
          ),
          'completeAction': A2uiSchemas.action(
            description:
                'The action performed when the user has completed the task.',
          ),
        },
        // New!
        required: ['name', 'isCompleted', 'completeAction'],
      ),
    ),
  },
  // New!
  required: ['title', 'tasks'],
);

יצירת מחלקות לניתוח נתונים

כשיוצרים מופעים של הרכיב הזה, הסוכן שולח נתונים שתואמים לסכימה. מוסיפים שתי מחלקות כדי לנתח את ה-JSON הנכנס לאובייקטים מסוג Dart עם הקלדה חזקה. שימו לב איך _TaskDisplayData מטפל במבנה הבסיסי, ומעביר את הניתוח של המערך הפנימי אל _TaskData.

class _TaskData {
  final String name;
  final bool isCompleted;
  final String actionName;
  final JsonMap actionContext;

  _TaskData({
    required this.name,
    required this.isCompleted,
    required this.actionName,
    required this.actionContext,
  });

  factory _TaskData.fromJson(Map<String, Object?> json) {
    try {
      final action = json['completeAction']! as JsonMap;
      final event = action['event']! as JsonMap;

      return _TaskData(
        name: json['name'] as String,
        isCompleted: json['isCompleted'] as bool,
        actionName: event['name'] as String,
        actionContext: event['context'] as JsonMap,
      );
    } catch (e) {
      throw Exception('Invalid JSON for _TaskData: $e');
    }
  }
}

class _TaskDisplayData {
  final String title;
  final List<_TaskData> tasks;

  _TaskDisplayData({required this.title, required this.tasks});

  factory _TaskDisplayData.fromJson(Map<String, Object?> json) {
    try {
      return _TaskDisplayData(
        title: (json['title'] as String?) ?? 'Tasks',
        tasks: (json['tasks'] as List<Object?>)
            .map((e) => _TaskData.fromJson(e as Map<String, Object?>))
            .toList(),
      );
    } catch (e) {
      throw Exception('Invalid JSON for _TaskDisplayData: $e');
    }
  }
}

אם כבר יצרתם אפליקציות באמצעות Flutter, סביר להניח שהמחלקות האלה דומות למחלקות שיצרתם. הם מקבלים JsonMap ומחזירים אובייקט עם הקלדה חזקה שמכיל נתונים שנותחו מ-JSON.

כדאי לעיין בשדות actionName ו-actionContext ב-_TaskData. הם מחולצים מהמאפיין completeAction של ה-JSON ומכילים את שם הפעולה ואת הקשר הנתונים שלה (הפניה למיקום הפעולה במודל הנתונים של GenUI). הנתונים האלה ישמשו בהמשך ליצירת UserActionEvent.

מודל הנתונים הוא מאגר מרכזי שניתן לצפייה של כל מצב ממשק המשתמש הדינמי, שמתוחזק על ידי ספריית genui. כשהסוכן יוצר רכיב ממשק משתמש מהקטלוג, הוא יוצר גם אובייקט נתונים שתואם לסכימה של הרכיב. אובייקט הנתונים הזה מאוחסן במודל הנתונים בלקוח, כדי שאפשר יהיה להשתמש בו ליצירת ווידג'טים ולהפנות אליו בהודעות מאוחרות יותר לסוכן (כמו completeAction שאתם עומדים לקשר לווידג'ט).

הוספת הווידג'ט

עכשיו יוצרים ווידג'ט להצגת הרשימה. היא צריכה לקבל מופע של המחלקה _TaskDisplayData וקריאה חוזרת (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

כדי שהווידג'ט יפעל, הוא צריך לשלוח לסוכן הודעה כשהמשימה מסתיימת. מחליפים את ה-placeholder הריק onCompleteTask בקוד הבא כדי ליצור ולשלוח אירוע באמצעות completeAction מנתוני המשימה.

onCompleteTask: (task) async {
  // A data context is a reference to a location in the data model. This line
  // turns that reference into a concrete data object that the agent can use.
  // It's kind of like taking a pointer and replacing it with the value it
  // points to.
  final JsonMap resolvedContext = await resolveContext(
    itemContext.dataContext,
    task.actionContext,
  );

  // Dispatch an event back to the agent, letting it know a task was completed.
  // This will be sent to the agent in an A2UI message that includes the name
  // of the action, the surface ID, and the resolved data context.
  itemContext.dispatchEvent(
    UserActionEvent(
      name: task.actionName,
      sourceComponentId: itemContext.id,
      context: resolvedContext,
    ),
  );
}

רישום פריט בקטלוג

לבסוף, פותחים את main.dart, מייבאים את הקובץ החדש ורושמים אותו יחד עם פריטים אחרים בקטלוג.

מוסיפים את הייבוא הזה לחלק העליון של lib/main.dart:

import 'task_display.dart';

מחליפים את catalog = BasicCatalogItems.asCatalog(); בפונקציה initState() ב:

// The Catalog is immutable, so use copyWith to create a new version
// that includes our custom catalog item along with the basics.
catalog = BasicCatalogItems.asCatalog().copyWith(newItems: [taskDisplay]);

סיימתם! כדי לראות את השינויים, צריך להפעיל מחדש את האפליקציה.

8. התנסות בדרכים שונות ליצירת אינטראקציה עם הסוכן

אפליקציה של רשימת משימות שפועלת ב-Chrome

אחרי שהוספתם את הווידג'ט החדש לקטלוג ופיניתם לו מקום בממשק המשתמש של האפליקציה, הגיע הזמן ליהנות מהעבודה עם הסוכן. אחד היתרונות העיקריים של GenUI הוא שהוא מציע שתי דרכים ליצור אינטראקציה עם הנתונים: באמצעות ממשק משתמש של אפליקציה, כמו לחצנים ותיבות סימון, ובאמצעות סוכן שמבין שפה טבעית ויכול להסיק מסקנות לגבי הנתונים. כדאי להתנסות בשניהם.

  • משתמשים בשדה הטקסט כדי לתאר שלוש או ארבע משימות, ורואים אותן מופיעות ברשימה.
  • משתמשים בתיבת סימון כדי לסמן משימה כ'הושלמה' או כ'לא הושלמה'.
  • יוצרים רשימה של 5-6 משימות, ואז מבקשים מהסוכן להסיר את המשימות שדורשות נסיעה למקום כלשהו.
  • אומרים לסוכן ליצור רשימה של משימות שחוזרות על עצמן כפריטים נפרדים ("אני צריך לקנות כרטיס לחג לאמא, לאבא ולסבתא. תכין משימות נפרדות לכל אחת מהן").
  • אפשר להגיד לסוכן לסמן את כל המשימות כהושלמו או כלא הושלמו, או לסמן את שתי המשימות הראשונות או שלוש המשימות הראשונות.

9. מזל טוב

מעולה! יצרתם אפליקציה למעקב אחרי משימות שמבוססת על AI באמצעות ממשק משתמש גנרטיבי ו-Flutter.

מה למדתם

  • איך מתקשרים עם מודלים בסיסיים של Google באמצעות Flutter Firebase SDK
  • עיבוד ממשקים אינטראקטיביים שנוצרו על ידי Gemini באמצעות GenUI
  • הצמדת משטחים לפריסות באמצעות מזהי עיבוד סטטיים שנקבעו מראש
  • תכנון סכימות בהתאמה אישית וקטלוגים של ווידג'טים ליצירת לולאות אינטראקציה חזקות

מסמכים לדוגמה