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

הפעולות שתבצעו:
- יצירת ממשק צ'אט בסיסי באמצעות Flutter ו-Firebase AI Logic
- שילוב חבילת
genuiליצירת ממשקים מבוססי-AI - הוספת סרגל התקדמות כדי לציין מתי האפליקציה מחכה לתשובה מהסוכן
- ליצור משטח עם שם ולהציג אותו במקום ייעודי בממשק המשתמש.
- יצירת רכיב קטלוג GenUI מותאם אישית שמאפשר לכם לשלוט באופן הצגת המשימות
הדרישות
- דפדפן אינטרנט, כמו Chrome
- Flutter SDK מותקן באופן מקומי
- Firebase CLI מותקן ומוגדר
ה-Codelab הזה מיועד למפתחי Flutter ברמת ביניים.
2. לפני שמתחילים
הגדרת פרויקט Flutter
- אם עדיין לא עשיתם זאת, מתקינים את Flutter SDK באופן מקומי.
- פותחים את הטרמינל ומריצים את הפקודה
flutter createכדי ליצור פרויקט חדש:flutter create intro_to_genui cd intro_to_genui - מוסיפים את יחסי התלות הנדרשים לפרויקט Flutter:
הקטע הסופיflutter pub add firebase_core firebase_ai genui json_schema_builderdependenciesאמור להיראות כך (מספרי הגרסאות עשויים להיות שונים מעט):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 - מריצים את הפקודה
flutter pub getכדי להוריד את כל החבילות.
הגדרת פרויקט Firebase
- אם עדיין לא עשיתם זאת, מתקינים את Firebase CLI.
- מתחברים ל-Firebase באמצעות חשבון Google:
firebase login - מתקינים את FlutterFire CLI:
dart pub global activate flutterfire_cli - מריצים את הפקודה הבאה מספריית פרויקט Flutter כדי להגדיר את פרויקט Flutter לשימוש ב-Firebase:
הפקודה הזו תבצע את הפעולות הבאות:flutterfire configure- נשאל אתכם אם אתם רוצים להשתמש בפרויקט Firebase קיים או ליצור פרויקט Firebase חדש. בוחרים באפשרות יצירת פרויקט חדש ב-Firebase.
- תשאלו אתכם איזו פלטפורמה (iOS, Android, Web) אתם רוצים לטרגט באפליקציית Flutter. בשלב הזה, בוחרים באפשרות Web.
הפקודה flutterfire configure יוצרת באופן אוטומטי פרויקט Firebase ואפליקציית אינטרנט חדשה ב-Firebase בפרויקט הזה. הפקודה יוצרת קובץ הגדרות של Firebase (firebase_options.dart) ומוסיפה אותו באופן אוטומטי לספרייה lib/ של פרויקט Flutter.
שימו לב שהאפליקציה של ה-codelab הזה פועלת רק עם Flutter SDK ו-Chrome שמותקנים במחשב (כלומר, היא נוצרת כאפליקציית אינטרנט). אבל האפליקציה הזו מבוססת על Flutter, ולכן היא תפעל גם בפלטפורמות אחרות. לכן, בסוף ה-codelab, כדאי להריץ מחדש את flutterfire configure כדי להוסיף תמיכה ב-iOS, ב-Android או בפלטפורמה אחרת, ואז לבנות מחדש את האפליקציה בפלטפורמה הזו.
מידע נוסף זמין בהוראות להוספת Firebase לאפליקציית Flutter.
הגדרה של Firebase AI Logic
- נכנסים למסוף Firebase. משתמשים באותו חשבון Google שבו השתמשתם כדי להתחבר ל-Firebase CLI.
- בוחרים את פרויקט Firebase שיצרתם באמצעות FlutterFire CLI.
- בתפריט הניווט הימני, בוחרים באפשרות AI Services > AI Logic (שירותי AI > לוגיקת AI).
- לוחצים על Get started (תחילת העבודה) כדי להפעיל את תהליך העבודה המודרך.
- בוחרים באפשרות להתחיל עם 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ממשק המשתמש של מפות Google נוצר על ידי המודל במסך.-
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 כדי להציג את SurfaceItems ברשימה _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 תפעיל את ה-method הזו בכל פעם שהיא תצטרך לשלוח הודעה לנציג. הקריאה ל-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 כצאצא שני של ה-Stack הזה, כשהוא מעוגן לתחתית. בסיום התהליך, body של Scaffold אמור להיראות כך:
body: Stack( // New!
children: [
Column(
children: [
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
SurfaceItem() => Surface(
surfaceContext: _controller.contextFor(
item.surfaceId,
),
),
},
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted:
state.isWaiting ? null : (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: state.isWaiting ? null : _addMessage,
child: const Text('Send'),
),
],
);
},
),
),
),
],
),
// Listen to the state again, this time to render a progress indicator
ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
if (state.isWaiting) {
return const LinearProgressIndicator();
}
return const SizedBox.shrink();
},
),
],
),
6. שמירה של ממשק GenUI
עד עכשיו, רשימת המשימות הוצגה בצ'אט עם גלילה, וכל הודעה או משטח חדשים שנוספו לרשימה הוצגו כשהם הגיעו. בשלב הבא נראה איך נותנים שם למשטח ומציגים אותו במיקום ספציפי בממשק המשתמש.
קודם כל, בחלק העליון של main.dart, לפני void main(), מגדירים קבוע לשימוש כמזהה משטח:
const taskDisplaySurfaceId = 'task_display';
שנית, מעדכנים את ההצהרה switch ב-listener של 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. התנסות בדרכים שונות לאינטראקציה עם הסוכן

אחרי שהוספתם את הווידג'ט החדש לקטלוג ופיניתם לו מקום בממשק המשתמש של האפליקציה, הגיע הזמן ליהנות מהעבודה עם הסוכן. אחד היתרונות העיקריים של GenUI הוא שהוא מציע שתי דרכים ליצור אינטראקציה עם הנתונים: באמצעות ממשק משתמש של אפליקציה, כמו לחצנים ותיבות סימון, ובאמצעות סוכן שמבין שפה טבעית ויכול להסיק מסקנות לגבי הנתונים. כדאי להתנסות בשניהם.
- מתארים בשדה הטקסט שלוש או ארבע משימות, ורואים אותן מופיעות ברשימה.
- משתמשים בתיבת סימון כדי לסמן משימה כהושלמה או כלא הושלמה.
- יוצרים רשימה של 5-6 משימות, ואז אומרים לסוכן להסיר את המשימות שדורשות נסיעה למקום כלשהו.
- אומרים לסוכן ליצור רשימה של משימות שחוזרות על עצמן כפריטים נפרדים ("אני צריך לקנות כרטיס ברכה לחג לאמא, לאבא ולסבתא. תצור משימות נפרדות לכל אחת מהן").
- אומרים לסוכן לסמן את כל המשימות כהושלמו או כלא הושלמו, או לסמן את שתי המשימות הראשונות או שלוש המשימות הראשונות.
9. מזל טוב
מעולה! יצרתם אפליקציה למעקב אחרי משימות שמבוססת על AI באמצעות ממשק משתמש גנרטיבי (GenUI) ו-Flutter.
מה למדתם
- איך מתקשרים עם מודלים בסיסיים של Google באמצעות Flutter Firebase SDK
- עיבוד ממשקים אינטראקטיביים שנוצרו על ידי Gemini באמצעות GenUI
- הצמדת משטחים לפריסות באמצעות מזהי עיבוד סטטיים שנקבעו מראש
- תכנון סכימות בהתאמה אישית וקטלוגים של ווידג'טים ליצירת לולאות אינטראקציה חזקות