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 create כדי ליצור פרויקט חדש:
flutter create intro_to_genui
cd intro_to_genui
מוסיפים את יחסי התלות הנדרשים לפרויקט 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 בפרויקט.
- עוברים אל Firebase AI Logic במסוף Firebase.
- לוחצים על Get started (תחילת העבודה) כדי להפעיל את תהליך העבודה המודרך.
- פועלים לפי ההנחיות במסך כדי להגדיר את הפרויקט.
לקבלת מידע נוסף, אפשר לעיין בהוראות להוספת Firebase לאפליקציית Flutter.
אחרי שה-APIs פעילים, מפעילים את 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 מיוחד.
הטמעת ממשק המשתמש של 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
אפשר לנסות לשוחח עם הסוכן על כמה משימות שרוצים לבצע היום. ממשק משתמש שמבוסס על טקסט בלבד יכול לעשות את העבודה, אבל ממשק משתמש גנרטיבי יכול להפוך את החוויה לקלה ומהירה יותר.
4. שילוב חבילת GenUI
עכשיו הגיע הזמן לשדרג מטקסט פשוט לממשק משתמש גנרטיבי. תחליפו את לולאת ההודעות הבסיסית של Firebase באובייקטים של GenUI Conversation, Catalog ו-SurfaceController. כך מודל ה-AI יכול ליצור בפועל ווידג'טים של Flutter בתוך זרם הצ'אט.

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

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