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

1. مقدمة

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

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

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

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

المتطلبات

هذا الدرس التطبيقي حول الترميز مخصّص لمطوّري Flutter ذوي الخبرة المتوسطة.

2. قبل البدء

إعداد مشروع Flutter

  1. إذا لم يسبق لك تثبيت حزمة تطوير البرامج (SDK) من Flutter على جهازك، عليك إجراء ذلك.
  2. افتح نافذة الأوامر الطرفية ونفِّذ الأمر flutter create لإنشاء مشروع جديد:
    flutter create intro_to_genui
    cd intro_to_genui
    
  3. أضِف الاعتمادات اللازمة إلى مشروع Flutter:
    flutter pub add firebase_core firebase_ai genui json_schema_builder
    
    يجب أن يبدو قسم dependencies النهائي على النحو التالي (قد تختلف أرقام الإصدارات قليلاً):
    dependencies:
      flutter:
        sdk: flutter
    
      cupertino_icons: ^1.0.8
      firebase_core: ^4.9.0
      firebase_ai: ^3.12.1
      genui: ^0.9.0
      json_schema_builder: ^0.1.3
    
  4. نفِّذ الأمر flutter pub get لتنزيل جميع الحِزم.

إعداد مشروع Firebase

  1. ثبِّت Firebase CLI إذا لم يسبق لك إجراء ذلك.
  2. سجِّل الدخول إلى Firebase باستخدام حساب Google الخاص بك:
    firebase login
    
  3. ثبِّت أداة سطر الأوامر FlutterFire CLI:
    dart pub global activate flutterfire_cli
    
  4. من دليل مشروع Flutter، نفِّذ الأمر التالي لإعداد مشروع Flutter لاستخدام Firebase:
    flutterfire configure
    
    سيؤدي هذا الأمر إلى ما يلي:
    1. سؤالك عمّا إذا كنت تريد استخدام مشروع حالي على Firebase أو إنشاء مشروع جديد على Firebase اختَر إنشاء مشروع جديد على Firebase.
    2. سيُطلب منك تحديد النظام الأساسي (iOS أو Android أو الويب) الذي تريد استهدافه لتطبيق Flutter. اختَر الويب في الوقت الحالي.

ينشئ الأمر flutterfire configure تلقائيًا مشروعًا على Firebase وتطبيقًا جديدًا على Firebase Web في مشروع Firebase هذا. ينشئ الأمر بعد ذلك ملف إعداد Firebase (firebase_options.dart) ويضيفه تلقائيًا إلى الدليل lib/ في مشروع Flutter.

يُرجى العِلم أنّ تطبيق هذا الدرس التطبيقي حول الترميز لا يعمل إلا مع حزمة تطوير البرامج (SDK) من Flutter وChrome المثبَّتَين على جهازك (أي أنّه يتم إنشاؤه كتطبيق الويب). بما أنّ هذا التطبيق مصمّم باستخدام Flutter، سيعمل أيضًا على منصات أخرى. لذا، في نهاية الدرس التطبيقي حول الترميز، جرِّب إعادة تشغيل flutterfire configure لإضافة إمكانية التوافق مع iOS أو Android أو نظام أساسي آخر، ثم إعادة إنشاء التطبيق على هذا النظام الأساسي.

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

إعداد Firebase AI Logic

  1. سجِّل الدخول إلى وحدة تحكُّم Firebase. استخدِم حساب Google نفسه الذي استخدمته لتسجيل الدخول إلى Firebase CLI.
  2. اختَر مشروع Firebase الذي أنشأته للتو باستخدام FlutterFire CLI.
  3. من قائمة التنقّل اليمنى، اختَر خدمات الذكاء الاصطناعي > منطق الذكاء الاصطناعي.
  4. انقر على البدء لبدء سير العمل الإرشادي.
  5. اختَر البدء باستخدام Gemini Developer API، واتّبِع التعليمات الظاهرة على الشاشة لإعداد Firebase AI Logic.

لديك حاليًا مكوّنات FlutterFire الإضافية المطلوبة لاستخدام Firebase AI Logic من قسم "إعداد مشروع Flutter". أنت على استعداد لبدء ترميز تطبيقك في الخطوة التالية.

3- إنشاء واجهة محادثة أساسية

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

إصدار مستند إلى النص من التطبيق

إنشاء أداة فقاعة الرسائل

لعرض الرسائل النصية من المستخدم والموظف، يحتاج تطبيقك إلى أداة. أنشئ ملفًا جديدًا باسم lib/message_bubble.dart وأضِف الفئة التالية:

import 'package:flutter/material.dart';

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

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

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

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

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

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

MessageBubble هو StatelessWidget يعرض رسالة محادثة واحدة. سيتم استخدامها لاحقًا في هذا الدرس التطبيقي حول الترميز لعرض الرسائل منك ومن الوكيل، ولكنّها في الغالب مجرّد أداة Text رائعة.

تنفيذ واجهة مستخدم Chat في main.dart

استبدِل محتوى lib/main.dart بالكامل بتنفيذ برنامج الدردشة الآلي الكامل هذا:

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:intro_to_genui/message_bubble.dart';
import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Just Today',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

sealed class ConversationItem {}

class TextItem extends ConversationItem {
  final String text;
  final bool isUser;
  TextItem({required this.text, this.isUser = false});
}

class _MyHomePageState extends State<MyHomePage> {
  final List<ConversationItem> _items = [];
  final _textController = TextEditingController();
  final _scrollController = ScrollController();
  late final ChatSession _chatSession;

  @override
  void initState() {
    super.initState();
    final model = FirebaseAI.googleAI().generativeModel(
      model: 'gemini-3.5-flash',
    );
    _chatSession = model.startChat();
    _chatSession.sendMessage(Content.text(systemInstruction));
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

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

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

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

    _scrollToBottom();

    final response = await _chatSession.sendMessage(Content.text(text));

    if (response.text?.isNotEmpty ?? false) {
      setState(() {
        _items.add(TextItem(text: response.text!, isUser: false));
      });
      _scrollToBottom();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Just Today'),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              children: [
                for (final item in _items)
                  switch (item) {
                    TextItem() => MessageBubble(
                          text: item.text,
                          isUser: item.isUser,
                        ),
                  },
              ],
            ),
          ),
          SafeArea(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      onSubmitted: (_) => _addMessage(),
                      decoration: const InputDecoration(
                        hintText: 'Enter a message',
                      ),
                    ),
                  ),
                  const SizedBox(width: 8),
                  ElevatedButton(
                    onPressed: _addMessage,
                    child: const Text('Send'),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

const systemInstruction = '''
  ## PERSONA
  You are an expert task planner.

  ## GOAL
  Work with me to produce a list of tasks that I should do today, and then track
  the completion status of each one.

  ## RULES
  Talk with me only about tasks that I should do today.
  Do not engage in conversation about any other topic.
  Do not offer suggestions unless I ask for them.
  Do not offer encouragement unless I ask for it.
  Do not offer advice unless I ask for it.
  Do not offer opinions unless I ask for them.

  ## PROCESS
  ### Planning
  *   Ask me for information about tasks that I should do today.
  *   Synthesize a list of tasks from that information.
  *   Ask clarifying questions if you need to.
  *   When you have a list of tasks that you think I should do today, present it
    to me for review.
  *   Respond to my suggestions for changes, if I have any, until I accept the
    list.

  ### Tracking
  *   Once the list is accepted, ask me to let you know when individual tasks are
    complete.
  *   If I tell you a task is complete, mark it as complete.
  *   Once all tasks are complete, send a message acknowledging that, and then
    end the conversation.
''';

يؤدي ملف main.dart الذي نسخته ولصقته إلى إعداد ChatSession أساسي باستخدام Firebase AI Logic والطلب في systemInstruction. تدير هذه الأداة أدوار المحادثة من خلال الاحتفاظ بقائمة بعناصر TextItem وعرضها بجانب طلبات بحث المستخدمين باستخدام أداة MessageBubble التي أنشأتها سابقًا.

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

  • يتم إعداد الاتصال بـ 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});
}

تهيئة الوحدات الأساسية لواجهة مستخدِم الذكاء الاصطناعي التوليدي

في أعلى 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
  • تثبيت المساحات في التنسيقات باستخدام معرّفات العرض الثابت المحدّدة مسبقًا
  • تصميم مخططات مخصّصة وفهارس أدوات لتوفير حلقات تفاعلية قوية

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