إنشاء تطبيق واجهة مستخدم توليدية (GenUI)

1. مقدمة

في هذا الدرس التطبيقي حول الترميز، ستنشئ تطبيق قائمة مهام باستخدام Flutter وFirebase AI Logic وحزمة genui الجديدة. ستبدأ بتطبيق محادثة مستند إلى النصوص، ثم ستطوّره باستخدام GenUI لمنح الوكيل القدرة على إنشاء واجهة المستخدم الخاصة به، وأخيرًا ستنشئ مكوّن واجهة مستخدم تفاعليًا مخصّصًا يمكنك أنت والوكيل التعديل عليه مباشرةً.

تطبيق قائمة مهام يعمل في Chrome

الإجراءات التي ستنفذّها

  • إنشاء واجهة محادثة أساسية باستخدام Flutter وFirebase AI Logic
  • دمج حزمة genui لإنشاء مساحات عرض مستندة إلى الذكاء الاصطناعي
  • إضافة شريط تقدّم للإشارة إلى أنّ التطبيق ينتظر ردًا من الوكيل
  • إنشاء مساحة عرض مُسمّاة وعرضها في موضع مخصّص في واجهة المستخدم
  • إنشاء مكوّن مخصّص لكتالوج GenUI يتيح لك التحكّم في طريقة عرض المهام

المتطلبات

هذا الدرس التطبيقي حول الترميز مخصّص لمطوّري 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 لتنزيل جميع الحِزم.

تفعيل واجهات برمجة التطبيقات وFirebase

لاستخدام حزمة firebase_ai، يجب أولاً تفعيل Firebase AI Logic في مشروعك.

  1. انتقِل إلى Firebase AI Logic في وحدة تحكّم Firebase.
  2. انقر على البدء لتشغيل سير العمل الإرشادي.
  3. اتّبِع التعليمات الظاهرة على الشاشة لإعداد مشروعك.

لمزيد من المعلومات، اطّلِع على التعليمات الخاصة بإضافة Firebase إلى تطبيق Flutter.

بعد تفعيل واجهات برمجة التطبيقات، عليك إعداد Firebase في تطبيق Flutter باستخدام واجهة سطر الأوامر FlutterFire:

flutterfire configure

اختَر مشروعك على Firebase واتّبِع التعليمات لإعداده للأنظمة الأساسية المستهدَفة (مثل Android وiOS والويب). يمكن إكمال هذا الدرس التطبيقي حول الترميز باستخدام حزمة تطوير البرامج (SDK) من Flutter ومتصفّح 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 يعرض رسالة محادثة واحدة. سيتم استخدامها لاحقًا في هذا الدرس التطبيقي حول الترميز لعرض الرسائل منك ومن الوكيل، ولكنها في الغالب مجرد أداة 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 التي أنشأتها سابقًا.

في ما يلي بعض الأمور التي يجب الاطّلاع عليها قبل المتابعة:

  • يتم إعداد الاتصال بـ Firebase AI Logic باستخدام الطريقة initState.
  • يوفّر التطبيق TextField وزرًا لإرسال الرسائل إلى الوكيل.
  • الطريقة _addMessage هي المكان الذي يتم فيه إرسال رسالة المستخدم إلى الوكيل.
  • قائمة _items هي المكان الذي يتم فيه تخزين سجلّ المحادثات.
  • يتم عرض الرسائل في ListView باستخدام أداة MessageBubble.

اختبار التطبيق

بعد الانتهاء من إعداد الرمز البرمجي وحفظه، يمكنك الآن تشغيل التطبيق واختباره.

flutter run -d chrome

جرِّب الدردشة مع الوكيل بشأن بعض المهام التي تريد إنجازها اليوم. مع أنّ واجهة المستخدِم المستندة إلى النصوص فقط يمكنها إنجاز المهمة، يمكن أن تسهّل GenUI التجربة وتسرّعها.

4. دمج حزمة GenUI

حان الوقت الآن للانتقال من النص العادي إلى واجهة المستخدم التوليدية. ستستبدل حلقة المراسلة الأساسية في Firebase بكائنات GenUI Conversation وCatalog وSurfaceController. ويسمح ذلك لنموذج الذكاء الاصطناعي بإنشاء عناصر واجهة مستخدم Flutter فعلية ضمن سلسلة المحادثات.

إصدار من التطبيق يتضمّن واجهة مستخدم من إنشاء الذكاء الاصطناعي التوليدي

توفر حزمة genui خمس فئات ستستخدمها خلال هذا الدرس التطبيقي حول الترميز:

  • تربط واجهة مستخدم SurfaceController التي ينشئها النموذج بالشاشة.
  • A2uiTransportAdapter يربط طلبات GenUI الداخلية بأي نموذج لغوي خارجي.
  • تغلف حزمة Conversation وحدة التحكّم ومحوّل النقل بواجهة برمجة تطبيقات موحّدة واحدة لتطبيق 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 أسلوب "استخدام نموذجك الخاص"، ما يعني أنّه يمكنك التحكّم في نموذج اللغة الكبير الذي يوفّر لك التجربة. في هذه الحالة، أنت تستخدم Firebase AI Logic، ولكن تم تصميم الحزمة للعمل مع مجموعة متنوعة من البرامج والجهات المقدّمة للخدمة.

تستتبع هذه الحرية بعض المسؤولية الإضافية، إذ عليك أخذ الرسائل التي تنشئها حزمة genui وإرسالها إلى الوكيل الذي اخترته، كما عليك أخذ ردود الوكيل وإرسالها مرة أخرى إلى genui.

لإجراء ذلك، عليك تحديد طريقة _sendAndReceive المشار إليها في الرمز البرمجي للخطوة السابقة. أضِف هذا الرمز إلى MyHomePageState:

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

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

    if (buffer.isEmpty) {
      return;
    }

    final text = buffer.toString();

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

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

سيتم استدعاء هذه الطريقة من خلال حزمة genui عندما تحتاج إلى إرسال رسالة إلى الوكيل. تؤدي المكالمة إلى addChunk في نهاية الطريقة إلى إعادة ردّ الوكيل إلى حزمة genui، ما يسمح لها بمعالجة الردّ وإنشاء واجهة مستخدم.

أخيرًا، استبدِل طريقة _addMessage الحالية بالكامل بهذا الإصدار الجديد، وذلك لتوجيه الرسائل إلى Conversation بدلاً من Firebase مباشرةً:

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

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

    _textController.clear();

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

    _scrollToBottom();

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

هذا كل شيء! حاوِل تشغيل التطبيق مرة أخرى. بالإضافة إلى الرسائل النصية، سترى الوكيل ينشئ مساحات عرض لواجهة المستخدم، مثل الأزرار وعناصر واجهة المستخدم النصية وغير ذلك.

يمكنك حتى أن تطلب من الوكيل عرض واجهة المستخدم بطريقة معيّنة. على سبيل المثال، جرِّب رسالة مثل "أريد عرض مهامي في عمود، مع زر لوضع علامة الاكتمال على كل مهمة".

5- إضافة حالة انتظار

تتم عملية إنشاء المحتوى باستخدام النموذج اللغوي الكبير بشكل غير متزامن. أثناء انتظار الردّ، يجب أن توقف واجهة المحادثة أزرار الإدخال وتعرض مؤشرًا لتقدّم العملية ليعرف المستخدم أنّ 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();
      },
    ),
  ],
),

6. الاحتفاظ بواجهة GenUI

حتى الآن، تم عرض قائمة المهام في بث المحادثة القابل للتمرير، مع إضافة كل رسالة أو سطح جديد إلى القائمة عند وصوله. في الخطوة التالية، ستتعرّف على كيفية تسمية مساحة عرض وعرضها في موقع جغرافي محدّد ضمن واجهة المستخدم.

أولاً، في أعلى main.dart، قبل void main()، عرِّف ثابتًا لاستخدامه كمعرّف مساحة عرض:

const taskDisplaySurfaceId = 'task_display';

ثانيًا، عدِّل عبارة switch في أداة معالجة Conversation للتأكّد من عدم إضافة أي مساحة عرض تحمل هذا المعرّف إلى _items:

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

بعد ذلك، افتح بنية تخطيط شجرة عناصر واجهة المستخدم لإنشاء مساحة للعنصر المثبَّت فوق سجلّ المحادثة مباشرةً. أضِف هذين التطبيقَين المصغّرَين كعنصرَين ثانويَين أولَين للعنصر الرئيسي Column:

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

حتى الآن، كان بإمكان الوكيل إنشاء أسطح واستخدامها كما يراه مناسبًا. لتقديم تعليمات أكثر تحديدًا، عليك إعادة النظر في طلب النظام. أضِف قسم ## USER INTERFACE التالي إلى نهاية الطلب المخزّن في الثابت systemInstruction:

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

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

من المهم تقديم تعليمات واضحة للوكيل بشأن وقت وكيفية استخدام عناصر واجهة المستخدم. من خلال إخبار الوكيل باستخدام عنصر فهرس ومعرّف سطح معيّنَين (وإعادة استخدام مثيل واحد)، يمكنك المساعدة في التأكّد من أنّه ينشئ الواجهة التي تريد رؤيتها.

لا يزال هناك المزيد من العمل الذي يجب إنجازه، ولكن يمكنك محاولة تشغيل تطبيقك مرة أخرى لترى كيف ينشئ الوكيل مساحة عرض المهام في أعلى واجهة المستخدم.

7. إنشاء أداة الكتالوج المخصّصة

في هذه المرحلة، لا يتوفّر عنصر الكتالوج TaskDisplay. في الخطوات القليلة التالية، ستعمل على إصلاح ذلك من خلال إنشاء مخطط بيانات وفئة لتحليل هذا المخطط وعنصر واجهة مستخدم وعنصر الكتالوج الذي يجمع كل شيء معًا.

أولاً، أنشِئ ملفًا باسم task_display.dart وأضِف عمليات الاستيراد التالية:

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

إنشاء مخطط البيانات

بعد ذلك، حدِّد مخطط البيانات الذي سيوفّره الوكيل عندما يريد إنشاء عرض للمهام. تستخدم العملية بعض أدوات الإنشاء المتقدّمة من حزمة json_schema_builder، ولكنّك في الأساس تحدّد مخطط JSON المستخدَم في الرسائل المرسَلة إلى الوكيل والمستلَمة منه.

ابدأ بـ S.object أساسي يشير إلى اسم المكوّن:

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

بعد ذلك، أضِف title وtasks وname وisCompleted وcompleteAction إلى سمات المخطط.

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

اطّلِع على السمة completeAction. يتم إنشاؤه باستخدام A2uiSchemas.action، وهو أداة إنشاء لسمة مخطط تمثّل إجراء A2UI. من خلال إضافة إجراء إلى المخطط، يخبر التطبيق في الأساس الوكيل: "عندما تسند إليّ مهمة، قدِّم أيضًا اسمًا وبيانات وصفية لإجراء يمكنني استخدامه لإخبارك بأنّ المهمة قد اكتملت". في وقت لاحق، سيستدعي التطبيق هذا الإجراء عندما ينقر المستخدم على مربّع اختيار.

بعد ذلك، أضِف حقول required إلى المخطط. توجّه هذه التعليمات البرنامج إلى ملء خصائص معيّنة في كل مرة. في هذه الحالة، يجب توفير كل سمة.

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

إنشاء فئات تحليل البيانات

عند إنشاء مثيلات لهذا المكوّن، سيرسل البرنامج بيانات تطابق المخطط. أضِف فئتَين لتحليل ملف JSON الوارد إلى عناصر Dart ذات أنواع محددة. لاحظ كيف يتعامل _TaskDisplayData مع البنية الجذرية، بينما يفوّض تحليل المصفوفة الداخلية إلى _TaskData.

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

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

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

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

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

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

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

إذا سبق لك إنشاء تطبيقات باستخدام Flutter، من المحتمل أن تكون هذه الفئات مشابهة للفئات التي أنشأتها. تقبل هذه الدوال JsonMap وتعرض عنصرًا مكتوبًا بشكل صارم يحتوي على بيانات تم تحليلها من JSON.

ألقِ نظرة على الحقلَين actionName وactionContext في _TaskData. يتم استخراجها من السمة completeAction في JSON، وتحتوي على اسم الإجراء وسياق بياناته (مرجع إلى موقع الإجراء في نموذج بيانات GenUI). سيتم استخدام هذه البيانات لاحقًا لإنشاء UserActionEvent.

نموذج البيانات هو متجر مركزي يمكن ملاحظته لجميع حالات واجهة المستخدم الديناميكية، ويتم الاحتفاظ به من خلال مكتبة genui. عندما ينشئ الوكيل أحد مكوّنات واجهة المستخدم من الكتالوج، ينشئ أيضًا عنصر بيانات يتطابق مع مخطط المكوِّن. يتم تخزين عنصر البيانات هذا في نموذج البيانات في العميل، حتى يمكن استخدامه لإنشاء أدوات ويتم الرجوع إليه في الرسائل اللاحقة إلى الوكيل (مثل completeAction الذي ستتم ربطه بأداة).

إضافة التطبيق المصغّر

الآن، أنشئ أداة لعرض القائمة. يجب أن تقبل هذه الدالة مثيلاً لفئة _TaskDisplayData ودالة ردّ اتصال يتم استدعاؤها عند اكتمال إحدى المهام.

class _TaskDisplay extends StatelessWidget {
  final _TaskDisplayData data;
  final void Function(_TaskData) onCompleteTask;

  const _TaskDisplay({required this.data, required this.onCompleteTask});

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            data.title,
            style: Theme.of(context).textTheme.titleLarge,
          ),
        ),
        ...data.tasks.map(
          (task) => CheckboxListTile(
            title: Text(
              task.name,
              style: TextStyle(
                decoration: task.isCompleted
                    ? TextDecoration.lineThrough
                    : TextDecoration.none,
              ),
            ),
            value: task.isCompleted,
            onChanged: task.isCompleted
                ? null
                : (val) {
                    if (val == true) {
                      onCompleteTask(task);
                    }
                  },
          ),
        ),
      ],
    );
  }
}

إنشاء CatalogItem

بعد إنشاء المخطط والمحلّل والتطبيق المصغّر، يمكنك الآن إنشاء CatalogItem لربطها كلها معًا.

في أسفل task_display.dart، أنشئ taskDisplay كمتغيّر على أعلى مستوى، واستخدِم _TaskDisplayData لتحليل JSON الوارد، وأنشئ مثيلاً للأداة _TaskDisplay.

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

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

تنفيذ onCompleteTask

لكي تعمل الأداة، يجب أن تتواصل مع الوكيل عند إكمال مهمة. استبدِل العنصر النائب الفارغ onCompleteTask بالرمز التالي لإنشاء حدث وإرساله باستخدام completeAction من بيانات المهمة.

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

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

تسجيل عنصر في الكتالوج

أخيرًا، افتح main.dart واستورِد الملف الجديد وسجِّله مع عناصر الفهرس الأخرى.

أضِف عملية الاستيراد هذه إلى أعلى lib/main.dart:

import 'task_display.dart';

استبدِل catalog = BasicCatalogItems.asCatalog(); في الدالة initState() بما يلي:

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

لقد أنهيت عملك! أعِد تشغيل التطبيق سريعًا للاطّلاع على التغييرات.

8. تجربة طرق مختلفة للتفاعل مع الوكيل

تطبيق قائمة مهام يعمل في Chrome

بعد إضافة الأداة الجديدة إلى "الكتالوج" وتوفير مساحة لها في واجهة مستخدم التطبيق، حان الوقت للاستمتاع بالعمل مع الوكيل. من المزايا الرئيسية لواجهة GenUI أنّها توفّر طريقتَين للتفاعل مع بياناتك: من خلال واجهة تطبيق مثل الأزرار ومربّعات الاختيار، ومن خلال وكيل يفهم اللغة الطبيعية ويمكنه التفكير في البيانات. ننصحك بتجربة كليهما.

  • استخدِم حقل النص لوصف ثلاث أو أربع مهام، وشاهِدها تظهر في القائمة.
  • استخدِم مربّع اختيار لوضع علامة على مهمة باعتبارها مكتملة أو غير مكتملة.
  • أنشئ قائمة تضم 5 أو 6 مهام، ثم اطلب من الوكيل إزالة المهام التي تتطلّب منك القيادة إلى مكان ما.
  • اطلب من المساعد إنشاء قائمة متكررة بالمهام كعناصر فردية ("أريد شراء بطاقة تهنئة بالعيد لأمي وأبي وجدتي. أنشئ مهامًا منفصلة لتلك المهام").
  • اطلب من الوكيل وضع علامة على جميع المهام بأنّها مكتملة أو غير مكتملة، أو وضع علامة على أول مهمتَين أو ثلاث مهام.

9- تهانينا

تهانينا! لقد أنشأت تطبيقًا لتتبُّع المهام يستند إلى الذكاء الاصطناعي باستخدام Generative UI وFlutter.

ما تعلّمته

  • التفاعل مع النماذج الأساسية من Google باستخدام حزمة تطوير البرامج (SDK) لمنصة Flutter Firebase
  • عرض مساحات تفاعلية من إنشاء Gemini باستخدام GenUI
  • تثبيت المساحات في التنسيقات باستخدام معرّفات العرض الثابت المحدّدة مسبقًا
  • تصميم مخططات مخصّصة وفهارس أدوات لتوفير حلقات تفاعلية قوية

المستندات المرجعية