ساخت یک اپلیکیشن رابط کاربری مولد (GenUI)

۱. مقدمه

در این آزمایشگاه کد، شما یک برنامه لیست وظایف با استفاده از Flutter، Firebase AI Logic و بسته جدید genui خواهید ساخت. شما با یک برنامه چت مبتنی بر متن شروع خواهید کرد، آن را با GenUI ارتقا خواهید داد تا به عامل (agent) قدرت ایجاد رابط کاربری (UI) خود را بدهید و در نهایت کامپوننت رابط کاربری سفارشی و تعاملی خود را خواهید ساخت که شما و عامل می‌توانید مستقیماً آن را دستکاری کنید.

یک برنامه لیست وظایف که در کروم اجرا می‌شود

کاری که انجام خواهید داد

  • ساخت یک رابط چت ساده با استفاده از Flutter و Firebase AI Logic
  • ادغام بسته genui برای تولید سطوح مبتنی بر هوش مصنوعی
  • یک نوار پیشرفت اضافه کنید تا نشان دهد چه زمانی برنامه منتظر پاسخ از عامل است
  • یک سطح نامگذاری شده ایجاد کنید و آن را در یک نقطه اختصاصی در رابط کاربری نمایش دهید.
  • یک کامپوننت کاتالوگ GenUI سفارشی بسازید که به شما امکان کنترل نحوه ارائه وظایف را می‌دهد.

آنچه نیاز دارید

این آزمایشگاه کد برای توسعه‌دهندگان سطح متوسط ​​فلاتر است.

۲. قبل از شروع

راه‌اندازی پروژه فلاتر

ترمینال خود را باز کنید و 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 را در پروژه خود فعال کنید.

  1. در کنسول فایربیس به بخش Firebase AI Logic بروید.
  2. برای شروع گردش کار هدایت‌شده، روی «شروع » کلیک کنید.
  3. برای تنظیم پروژه خود، دستورالعمل‌های روی صفحه را دنبال کنید.

برای اطلاعات بیشتر، دستورالعمل‌های افزودن Firebase به یک برنامه Flutter را بررسی کنید.

پس از فعال شدن APIها، با استفاده از FlutterFire CLI، Firebase را در برنامه Flutter خود راه‌اندازی کنید:

flutterfire configure

پروژه Firebase خود را انتخاب کنید و دستورالعمل‌ها را برای پیکربندی آن برای پلتفرم‌های مورد نظر خود (مثلاً اندروید، iOS، وب) دنبال کنید. این آزمایشگاه کد را می‌توان فقط با نصب Flutter SDK و Chrome روی دستگاه شما تکمیل کرد، اما برنامه روی پلتفرم‌های دیگر نیز کار خواهد کرد.

۳. یک رابط چت پایه بسازید

قبل از معرفی رابط کاربری Generative، برنامه شما به یک پایه نیاز دارد: یک برنامه چت مبتنی بر متن پایه که توسط 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 است که یک پیام چت واحد را نمایش می‌دهد. بعداً در این آزمایشگاه کد برای نمایش پیام‌های شما و عامل (agent) استفاده خواهد شد، اما عمدتاً فقط یک ویجت Text widget) جذاب است.

رابط کاربری چت را در 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 و prompt موجود در systemInstruction تنظیم می‌کند. این فایل با نگهداری لیستی از عناصر TextItem و نمایش آنها در کنار کوئری‌های کاربر با استفاده از ویجت MessageBubble که قبلاً ایجاد کرده‌اید، نوبت‌های مکالمه را مدیریت می‌کند.

در اینجا چند نکته وجود دارد که قبل از ادامه باید بررسی کنید:

  • متد initState جایی است که اتصال به Firebase AI Logic برقرار می‌شود.
  • این برنامه یک TextField و یک دکمه برای ارسال پیام به عامل ارائه می‌دهد.
  • متد _addMessage جایی است که پیام کاربر به agent ارسال می‌شود.
  • لیست _items جایی است که تاریخچه مکالمات ذخیره می‌شود.
  • پیام‌ها با استفاده از ویجت MessageBubble در یک ListView نمایش داده می‌شوند.

برنامه را تست کنید

با انجام این کار، اکنون می‌توانید برنامه را اجرا کرده و آن را آزمایش کنید.

flutter run -d chrome

سعی کنید با نماینده در مورد برخی از کارهایی که می‌خواهید امروز انجام دهید چت کنید. در حالی که یک رابط کاربری کاملاً متنی می‌تواند کار را انجام دهد، GenUI می‌تواند این تجربه را آسان‌تر و سریع‌تر کند.

۴. ادغام بسته GenUI

اکنون زمان آن رسیده است که از متن ساده به رابط کاربری مولد ارتقا دهید. شما حلقه پیام‌رسانی پایه Firebase را با اشیاء GenUI Conversation ، Catalog و SurfaceController جایگزین خواهید کرد. این به مدل هوش مصنوعی اجازه می‌دهد تا ویجت‌های واقعی Flutter را در جریان چت نمونه‌سازی کند.

نسخه‌ای از برنامه با GenUI یکپارچه

بسته genui پنج کلاس را که در طول این آزمایشگاه کد از آنها استفاده خواهید کرد، ارائه می‌دهد:

  • 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 ایجاد می‌کند که کنترلر و آداپتور را مدیریت می‌کند. این مکالمه به برنامه شما جریانی از رویدادها را ارائه می‌دهد که می‌تواند از آن برای همگام شدن با آنچه عامل ایجاد می‌کند استفاده کند، و همچنین روشی برای ارسال پیام به عامل ارائه می‌دهد.

در مرحله بعد، یک شنونده برای رویدادهای مکالمه ایجاد کنید. این رویدادها شامل رویدادهای مربوط به سطح و همچنین رویدادهای مربوط به پیام‌های متنی و خطاها می‌شود:

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

سطوح نمایش

در مرحله‌ی بعد، متد build مربوط به ListView را به‌روزرسانی کنید تا SurfaceItem ها را در لیست _items نمایش دهد:

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

سازنده‌ی ویجت Surface یک surfaceContext می‌گیرد که به آن می‌گوید کدام سطح را باید نمایش دهد. SurfaceController که قبلاً ایجاد شده است، _controller ، تعریف و حالت هر سطح را ارائه می‌دهد و اطمینان حاصل می‌کند که هنگام به‌روزرسانی، دوباره ساخته می‌شود.

اتصال GenUI به Firebase AI Logic

بسته genui از رویکرد «مدل خودتان را بیاورید» استفاده می‌کند، به این معنی که شما کنترل می‌کنید کدام LLM به تجربه شما قدرت می‌دهد. در این حالت، شما از Firebase AI Logic استفاده می‌کنید، اما این بسته برای کار با انواع عامل‌ها و ارائه‌دهندگان ساخته شده است.

این آزادی منجر به کمی مسئولیت بیشتر می‌شود: شما باید پیام‌های تولید شده توسط بسته genui را دریافت کرده و آنها را به عامل انتخابی خود ارسال کنید، و باید پاسخ‌های عامل را دریافت کرده و آنها را به genui ارسال کنید.

برای انجام این کار، متد _sendAndReceive را که در کد مرحله قبل به آن ارجاع داده شده است، تعریف خواهید کرد. این کد را به MyHomePageState اضافه کنید:

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

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

    if (buffer.isEmpty) {
      return;
    }

    final text = buffer.toString();

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

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

این متد توسط پکیج genui هر زمان که نیاز به ارسال پیام به agent باشد، فراخوانی می‌شود. فراخوانی addChunk در انتهای متد، پاسخ agent را به پکیج genui برمی‌گرداند و به آن اجازه می‌دهد تا پاسخ را پردازش کرده و رابط کاربری (UI) تولید کند.

در نهایت، متد _addMessage موجود خود را به طور کامل با این نسخه جدید جایگزین کنید، بنابراین پیام‌ها به جای اینکه مستقیماً به Firebase هدایت شوند، به Conversation هدایت می‌شوند:

  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));
  }

همین! دوباره برنامه را اجرا کنید. علاوه بر پیام‌های متنی، خواهید دید که عامل، سطوح رابط کاربری مانند دکمه‌ها، ویجت‌های متنی و موارد دیگر را ایجاد می‌کند.

شما حتی می‌توانید از اپراتور بخواهید که رابط کاربری را به شکل خاصی نمایش دهد. برای مثال، پیامی مانند «وظایف من را در یک ستون به من نشان بده، با یک دکمه برای علامت‌گذاری هر یک به عنوان تکمیل‌شده» را امتحان کنید.

۵. اضافه کردن حالت انتظار

تولید 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 را به عنوان فرزند دوم آن پشته، که به پایین آن متصل است، اضافه کنید. وقتی کار تمام شد، 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();
      },
    ),
  ],
),

۶. یک سطح 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.
''';

مهم است که به عامل خود دستورالعمل‌های واضحی در مورد زمان و نحوه استفاده از سطوح رابط کاربری بدهید. با گفتن به عامل برای استفاده از یک آیتم کاتالوگ خاص و شناسه سطح (و استفاده مجدد از یک نمونه واحد)، می‌توانید مطمئن شوید که رابطی را که می‌خواهید ببینید، ایجاد می‌کند.

کار بیشتری برای انجام دادن وجود دارد، اما می‌توانید دوباره برنامه خود را اجرا کنید تا ببینید که عامل، سطح نمایش وظیفه را در بالای رابط کاربری ایجاد می‌کند.

۷. ابزارک کاتالوگ سفارشی خود را بسازید

در این مرحله، آیتم کاتالوگ 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 ، سازنده‌ی یک ویژگی schema که نشان‌دهنده‌ی یک اکشن A2UI است، ایجاد شده است. با افزودن یک اکشن به schema، برنامه اساساً به عامل می‌گوید: «وقتی به من یک وظیفه می‌دهی، نام و فراداده‌ی یک اکشن را نیز ارائه بده تا بتوانم از آن برای اطلاع از تکمیل شدن آن وظیفه استفاده کنم.» بعداً، برنامه وقتی کاربر روی یک کادر انتخاب ضربه می‌زند، آن اکشن را فراخوانی می‌کند.

سپس، فیلدهای 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 با نوع داده Strongly-typed اضافه کنید. توجه کنید که چگونه _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 می‌پذیرند و یک شیء با نوع داده Strongly-typed حاوی داده‌های تجزیه‌شده از 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 ایجاد کنید تا همه آنها را به هم مرتبط کنید.

در پایین task_display.dart ، taskDisplay به عنوان یک متغیر سطح بالا ایجاد کنید، _TaskDisplayData برای تجزیه JSON ورودی استفاده کنید و یک نمونه از ویجت _TaskDisplay بسازید.

final taskDisplay = CatalogItem(
  name: 'TaskDisplay',
  dataSchema: taskDisplaySchema,
  widgetBuilder: (itemContext) {
    final json = itemContext.data as Map<String, Object?>;
    final data = _TaskDisplayData.fromJson(json);

    return _TaskDisplay(
      data: data,
      onCompleteTask: (task) async {
        // We will implement this next!
      },
    );
  },
);

پیاده‌سازی onCompleteTask

برای اینکه ویجت کار کند، باید پس از اتمام یک کار، با عامل ارتباط برقرار کند. جای خالی onCompleteTask را با کد زیر جایگزین کنید تا یک رویداد با استفاده از completeAction از داده‌های وظیفه ایجاد و ارسال شود.

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

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

ثبت کالا در کاتالوگ

در آخر، main.dart را باز کنید، فایل جدید را وارد کنید و آن را به همراه سایر آیتم‌های کاتالوگ ثبت کنید.

این import را به بالای lib/main.dart اضافه کنید:

import 'task_display.dart';

در initState() ‎ خود catalog = BasicCatalogItems.asCatalog(); ‎ را با کد زیر جایگزین کنید:

// 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]);

کار تمام است! برای دیدن تغییرات، برنامه را مجدداً راه‌اندازی کنید.

۸. روش‌های مختلف تعامل با اپراتور را آزمایش کنید

یک برنامه لیست وظایف که در کروم اجرا می‌شود

حالا که ویجت جدید را به کاتالوگ اضافه کرده‌اید و در رابط کاربری برنامه جایی برای آن ایجاد کرده‌اید، وقت آن رسیده که از کار با عامل (agent) لذت ببرید. یکی از مزایای اصلی GenUI این است که دو راه برای تعامل با داده‌های شما ارائه می‌دهد: از طریق رابط کاربری برنامه مانند دکمه‌ها و کادرهای انتخاب، و از طریق عاملی که زبان طبیعی را می‌فهمد و می‌تواند در مورد داده‌ها استدلال کند. سعی کنید هر دو را آزمایش کنید!

  • از فیلد متنی برای توصیف سه یا چهار وظیفه استفاده کنید و ببینید که چگونه در لیست ظاهر می‌شوند.
  • برای تغییر وضعیت یک کار به عنوان کامل یا ناقص، از کادر انتخاب استفاده کنید.
  • فهرستی از ۵-۶ کار تهیه کنید، سپس به نماینده بگویید مواردی را که نیاز به رانندگی تا جایی دارند، حذف کند.
  • به نماینده بگویید که یک لیست تکراری از وظایف را به صورت موارد جداگانه ایجاد کند ("من باید برای مامان، بابا و مادربزرگ کارت تبریک بخرم. برای آنها وظایف جداگانه ای تعیین کنید.").
  • به نماینده بگویید که تمام کارها را به عنوان تمام شده یا ناتمام علامت گذاری کند، یا دو یا سه مورد اول را تیک بزند.

۹. تبریک

تبریک می‌گویم! شما یک اپلیکیشن ردیابی وظایف مبتنی بر هوش مصنوعی با استفاده از Generative UI و Flutter ساخته‌اید.

آنچه آموخته‌اید

  • تعامل با مدل‌های بنیادی گوگل با استفاده از کیت توسعه نرم‌افزار فلاتر فایربیس
  • رندرینگ سطوح تعاملی تولید شده توسط Gemini با استفاده از GenUI
  • پین کردن سطوح در طرح‌بندی‌ها با استفاده از شناسه‌های رندر استاتیک از پیش تعیین‌شده
  • طراحی طرحواره‌های سفارشی و کاتالوگ‌های ویجت برای حلقه‌های تعاملی قوی

اسناد مرجع