跳到主內容

Flutter GenUI SDK 入門

瞭解如何使用 Flutter GenUI SDK 並將其新增到現有的 Flutter 應用中。

本指南介紹瞭如何開始使用 Flutter GenUI SDK 及其一系列包。SDK 的關鍵元件在主要元件頁面中進行了描述。

請按照以下說明將 genui 新增到你的 Flutter 應用中。程式碼示例展示瞭如何在執行 flutter create 建立的全新應用中執行這些說明,但你也可以對現有的 Flutter 應用執行相同的步驟。

配置你的代理提供程式

#

genui 包可以連線到各種代理提供程式。可用的提供程式包括:

Firebase AI Logic

適用於生產環境應用,即所有與大語言模型(LLM)的互動都在你的 Flutter 客戶端中完成,無需後端伺服器。Firebase 還能簡化 AI 功能的安全釋出,因為它負責管理你的 Gemini API 金鑰。

GenUI A2UI

適用於代理執行在伺服器上的客戶端/伺服器架構。

自定義構建

你也可以構建自己的介面卡來連線到你首選的 LLM 提供程式。敬請期待我們和社群未來推出的更多功能。

要使用 Vertex AI for Firebase SDK 連線到 Gemini,請按照以下說明操作:

  1. 使用 Firebase 控制檯建立一個新的 Firebase 專案

  2. 為該專案啟用 Gemini API

  3. 按照 Firebase Flutter 設定指南的前三個步驟,將 Firebase 新增到你的應用中。

  4. 使用 dart pub add 在你的 pubspec.yaml 檔案中將 genuifirebase_vertex_ai 新增為依賴項。

    dart pub add genui firebase_vertex_ai
    
  5. 在應用的 main 方法中,確保初始化了小部件繫結,然後初始化 Firebase。

    dart
    import 'package:flutter/material.dart';
    import 'package:firebase_core/firebase_core.dart';
    import 'firebase_options.dart';
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform,
      );
      runApp(const MyApp());
    }
    
  6. 建立一個 Vertex AI for Firebase 生成式模型例項,並用你的 SurfaceControllerA2uiTransportAdapter 將其包裝起來。

    dart
    import 'package:genui/genui.dart';
    import 'package:firebase_vertex_ai/firebase_vertex_ai.dart';
    
    final catalog = Catalog(components: [
      // ...
    ]);
    final catalogs = [catalog];
    
    final surfaceController = SurfaceController(catalogs: catalogs);
    
    final promptBuilder = PromptBuilder.chat(
      catalog: catalog,
      systemPromptFragments: ['You are a helpful assistant.'],
    );
    
    final model = FirebaseVertexAI.instance.generativeModel(
      model: 'gemini-2.5-flash',
      systemInstruction: Content.system(promptBuilder.systemPromptJoined()),
    );
    
    // The Conversation wires transport -> controller internally.
    late final A2uiTransportAdapter transportAdapter;
    transportAdapter = A2uiTransportAdapter(onSend: (message) async {
      // final stream = model.generateContentStream(...);
      // await for (final chunk in stream) {
      //   transportAdapter.addChunk(chunk.text ?? '');
      // }
    });
    
    final conversation = Conversation(
      controller: surfaceController,
      transport: transportAdapter,
    );
    

這是用於 genuiA2UI 流式 UI 協議的整合包。該包允許 Flutter 應用連線到 Agent-to-Agent (A2UI) 伺服器,並使用 genui 框架渲染由 AI 代理生成的動態使用者介面。

該包中的主要元件包括:

  • A2uiAgentConnector:處理與 A2A 伺服器的底層 Web 套接字通訊,包括髮送訊息和解析流事件。
  • AgentCard:一個儲存有關已連線 AI 代理元資料的資料類。

請按照以下說明操作:

  1. 設定依賴項:使用 dart pub addgenuigenui_a2aa2a 新增為 pubspec.yaml 中的依賴項。

    dart pub add genui genui_a2a a2a
    
  2. 初始化 SurfaceController:使用你的小部件 Catalog(目錄)設定 SurfaceController

  3. 建立 A2uiTransportAdapter:例項化 A2uiTransportAdapter 以解析訊息。

  4. 建立 A2uiAgentConnector:例項化 A2uiAgentConnector,並提供 A2A 伺服器的 URI。

  5. 建立 Conversation:將介面卡和控制器傳遞給 Conversation

  6. 使用 Surface 進行渲染:在 UI 中使用 Surface 小部件來顯示代理生成的內容。

  7. 傳送訊息:使用 connector.connectAndSendConversation.sendMessage 將使用者輸入傳送給代理生成的內容。

    dart
    import 'package:flutter/material.dart';
    import 'package:genui/genui.dart';
    import 'package:genui_a2a/genui_a2a.dart';
    import 'package:logging/logging.dart';
    
    void main() {
      // Setup logging.
      Logger.root.level = Level.ALL;
      Logger.root.onRecord.listen((record) {
        print('${record.level.name}: ${record.time}: ${record.message}');
        if (record.error != null) {
          print(record.error);
        }
        if (record.stackTrace != null) {
          print(record.stackTrace);
        }
      });
    
      runApp(const GenUIExampleApp());
    }
    
    class GenUIExampleApp extends StatelessWidget {
      const GenUIExampleApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'A2UI Example',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const ChatScreen(),
        );
      }
    }
    
    class ChatScreen extends StatefulWidget {
      const ChatScreen({super.key});
    
      @override
      State<ChatScreen> createState() => _ChatScreenState();
    }
    
    class _ChatScreenState extends State<ChatScreen> {
      final TextEditingController _textController = TextEditingController();
      final SurfaceController _surfaceController =
          SurfaceController(catalogs: [BasicCatalogItems.asCatalog()]);
      late final A2uiTransportAdapter _transportAdapter;
      late final Conversation _uiAgent;
      late final A2uiAgentConnector _connector;
      final List<ChatMessage> _messages = [];
    
      @override
      void initState() {
        super.initState();
    
        // The Conversation wires transport -> controller internally.
        _transportAdapter = A2uiTransportAdapter(onSend: (message) async {
          // Implement sending to LLM if needed, or handled by connector
        });
    
        _connector = A2uiAgentConnector(
          // TODO: Replace with your A2A server URL.
          url: Uri.parse('https://:8080'),
        );
        _uiAgent = Conversation(
          controller: _surfaceController,
          transport: _transportAdapter,
        );
    
        // Listen for messages from the remote agent.
        _connector.stream.listen(_surfaceController.handleMessage);
    
      }
    
      @override
      void dispose() {
        _textController.dispose();
        _uiAgent.dispose();
        _transportAdapter.dispose();
        _surfaceController.dispose();
        _connector.dispose();
        super.dispose();
      }
    
      void _handleSubmitted(String text) async {
        if (text.isEmpty) return;
        _textController.clear();
        final message = ChatMessage.user(TextPart(text));
        setState(() {
          _messages.insert(0, message);
        });
    
        final responseText = await _connector.connectAndSend(
            message,
            clientCapabilities: A2uiClientCapabilities(supportedProtocols: ['a2ui/0.9.0'])
        );
    
        // Handling response depends on your app's logic
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('A2UI Example'),
          ),
          body: Column(
            children: <Widget>[
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.all(8.0),
                  reverse: true,
                  itemBuilder: (_, int index) =>
                      _buildMessage(_messages[index]),
                  itemCount: _messages.length,
                ),
              ),
              const Divider(height: 1.0),
              Container(
                decoration: BoxDecoration(color: Theme.of(context).cardColor),
                child: _buildTextComposer(),
              ),
              // Surface for the main AI-generated UI:
              SizedBox(
                height: 300,
                child: Surface(
                  surfaceController: _surfaceController,
                  surfaceId: 'main_surface',
                ),
              ),
            ],
          ),
        );
      }
    
      Widget _buildMessage(ChatMessage message) {
        return Container(
          margin: const EdgeInsets.symmetric(vertical: 10.0),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: CircleAvatar(child: Text(message.role == Role.user ? 'U' : 'A')),
              ),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(message.role == Role.user ? 'User' : 'Agent',
                        style: const TextStyle(fontWeight: FontWeight.bold)),
                    Container(
                      margin: const EdgeInsets.only(top: 5.0),
                      child: Text(message.parts.whereType<TextPart>().map((e) => e.text).join('\n')),
                    ),
                  ],
                ),
              ),
            ],
          ),
        );
      }
    
      Widget _buildTextComposer() {
        return IconTheme(
          data: IconThemeData(color: Theme.of(context).colorScheme.secondary),
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 8.0),
            child: Row(
              children: <Widget>[
                Flexible(
                  child: TextField(
                    controller: _textController,
                    onSubmitted: _handleSubmitted,
                    decoration:
                        const InputDecoration.collapsed(hintText: 'Send a message'),
                  ),
                ),
                Container(
                  margin: const EdgeInsets.symmetric(horizontal: 4.0),
                  child: IconButton(
                    icon: const Icon(Icons.send),
                    onPressed: () => _handleSubmitted(_textController.text),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    

pub.dev 上的 example 目錄包含一個完整的應用程式,展示瞭如何使用此包。

要將 genui 與其他代理提供程式一起使用,請按照該提供程式的 SDK 文件來實現連線,並將結果流傳輸到 A2uiTransportAdapter 中。

建立與代理的連線

#

如果你為 iOS 或 macOS 構建 Flutter 專案,請將此鍵新增到你的 {ios,macos}/Runner/*.entitlements 檔案中,以啟用出站網路請求。

xml
<dict>
...
<key>com.apple.security.network.client</key>
<true/>
</dict>

接下來,使用以下說明將你的應用連線到你選擇的代理提供程式。

  1. 建立一個 SurfaceController,併為其提供你希望代理可用的小部件目錄。建立一個 A2uiTransportAdapter 以解析訊息並進行連線。

  2. 建立一個 PromptBuilder,併為其提供系統指令和工具(你希望代理能夠呼叫的函式)。你應該始終包含 SurfaceController 提供的工具,但也歡迎包含其他工具。將其新增到你的 LLM 系統提示詞中。

  3. 使用 SurfaceControllerA2uiTransportAdapter 的例項建立一個 Conversation。你的應用主要將與此物件進行互動以完成任務。

    例如

    dart
    class _MyHomePageState extends State<MyHomePage> {
      late final SurfaceController _surfaceController;
      late final A2uiTransportAdapter _transportAdapter;
      late final Conversation _conversation;
    
      @override
      void initState() {
        super.initState();
    
        // Create a SurfaceController with a widget catalog.
        // The BasicCatalogItems contain basic widgets for text, markdown, and images.
        _surfaceController = SurfaceController(catalogs: [BasicCatalogItems.asCatalog()]);
    
        // The Conversation wires transport -> controller internally.
        _transportAdapter = A2uiTransportAdapter(onSend: (message) async {
          // Implement sending to LLM and pipe chunks back.
        });
    
        final catalog = BasicCatalogItems.asCatalog();
        final promptBuilder = PromptBuilder.chat(
          catalog: catalog,
          systemPromptFragments: [
            '''
            You are an expert in creating funny riddles. Every time I give you a word,
            you should generate UI that displays one new riddle related to that word.
            Each riddle should have both a question and an answer.
            '''
          ],
        );
    
        // ... initialize your LLM Client of choice using promptBuilder.systemPromptJoined()
    
        // Create the Conversation to orchestrate everything.
        _conversation = Conversation(
          controller: _surfaceController,
          transport: _transportAdapter,
        );
    
        // Listen for surface lifecycle events:
        _conversation.events.listen((event) {
          if (event is ConversationSurfaceAdded) {
            _onSurfaceAdded(event);
          } else if (event is ConversationSurfaceRemoved) {
            _onSurfaceDeleted(event);
          }
        });
      }
    
      @override
      void dispose() {
        _textController.dispose();
        _conversation.dispose();
        _transportAdapter.dispose();
    
        super.dispose();
      }
    }
    

傳送訊息並顯示代理的響應

#

使用 Conversation 類中的 sendRequest 方法,或者透過直接流式傳輸到你的 LLM 客戶端並將結果流推送到介面卡(使用 _transportAdapter.addChunk)來向代理傳送請求。

接收並顯示生成的 UI:

  1. 監聽 Conversation 中的 events 流,以跟蹤 UI 介面生成時的新增和移除。這些事件包含每個介面的 surface ID

  2. 使用上一步接收到的 surface ID 為每個活躍的介面構建一個 Surface 小部件。

    例如

    dart
    class _MyHomePageState extends State<MyHomePage> {
      // ...
    
      final _textController = TextEditingController();
      final _surfaceIds = <String>[];
    
      // Send a request containing the user's [text] to the agent.
      void _sendMessage(String text) async {
        if (text.trim().isEmpty) return;
        // await _conversation.sendRequest(ChatMessage.user(TextPart(text)));
      }
    
      // Invoked by the events stream listener when a new
      // UI surface is generated. Here, the ID is stored so the
      // build method can create a Surface to display it.
      void _onSurfaceAdded(ConversationSurfaceAdded update) {
        setState(() {
          _surfaceIds.add(update.surfaceId);
        });
      }
    
      // Invoked by the events stream listener when a UI surface is removed.
      void _onSurfaceDeleted(ConversationSurfaceRemoved update) {
        setState(() {
          _surfaceIds.remove(update.surfaceId);
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: Text(widget.title),
          ),
          body: Column(
            children: [
              Expanded(
                child: ListView.builder(
                  itemCount: _surfaceIds.length,
                  itemBuilder: (context, index) {
                    // For each surface, create a Surface to display it.
                    final id = _surfaceIds[index];
                    return Surface(surfaceContext: _surfaceController.contextFor(id));
                  },
                ),
              ),
              SafeArea(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
                  child: Row(
                    children: [
                      Expanded(
                        child: TextField(
                          controller: _textController,
                          decoration: const InputDecoration(
                            hintText: 'Enter a message',
                          ),
                        ),
                      ),
                      const SizedBox(width: 16),
                      ElevatedButton(
                        onPressed: () {
                          // Send the user's text to the agent.
                          _sendMessage(_textController.text);
                          _textController.clear();
                        },
                        child: const Text('Send'),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }
    

將你自己的小部件新增到目錄中

#

為了方便起見,你可以使用提供的核心小部件目錄。然而,大多數生產應用會希望定義自定義的小部件目錄。

要新增你自己的小部件,請按照以下說明進行操作。

  1. 依賴 json_schema_builder 包:

    使用 dart pub addjson_schema_builder 新增為 pubspec.yaml 中的依賴項。

    dart pub add json_schema_builder
    
  2. 建立新小部件的 schema:

    每個目錄項都需要一個 schema 來定義填充它所需的資料。使用 json_schema_builder 包為新小部件定義一個 schema。

    dart
    import 'package:json_schema_builder/json_schema_builder.dart';
    import 'package:flutter/material.dart';
    import 'package:genui/genui.dart';
    
    final _schema = S.object(
      properties: {
        'question': S.string(description: 'The question part of a riddle.'),
        'answer': S.string(description: 'The answer part of a riddle.'),
      },
      required: ['question', 'answer'],
    );
    
  3. 建立 CatalogItem

    每個 CatalogItem 代表代理可以生成的某種型別的小部件。為此,它結合了名稱、schema 以及生成組成已生成 UI 的小部件的構建器函式。

    dart
    final riddleCard = CatalogItem(
      name: 'RiddleCard',
      dataSchema: _schema,
      widgetBuilder:
          ({
            required data,
            required id,
            required buildChild,
            required dispatchEvent,
            required context,
            required dataContext,
          }) {
            final json = data as Map<String, Object?>;
            final question = json['question'] as String;
            final answer = json['answer'] as String;
    
            return Container(
              constraints: const BoxConstraints(maxWidth: 400),
              decoration: BoxDecoration(border: Border.all()),
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(question, style: Theme.of(context).textTheme.headlineMedium),
                  const SizedBox(height: 8.0),
                  Text(answer, style: Theme.of(context).textTheme.headlineSmall),
                ],
              ),
            );
          },
    );
    
  4. CatalogItem 新增到目錄中:

    在例項化 SurfaceController 時包含你的目錄項。

    dart
    _surfaceController = SurfaceController(
      catalogs: [BasicCatalogItems.asCatalog().copyWith([riddleCard])],
    );
    
  5. 更新系統指令以使用新小部件:

    為了確保代理知道使用你的新小部件,請在系統指令中說明使用方式和時機。執行此操作時,提供 CatalogItem 中的名稱。

    dart
    final promptBuilder = PromptBuilder.chat(
      catalog: catalog,
      systemPromptFragments: [
        '''
        You are an expert in creating funny riddles. Every time I give you a word,
        generate a RiddleCard that displays one new riddle related to that word.
        Each riddle should have both a question and an answer.
        '''
      ],
    );
    
    // Pass promptBuilder.systemPromptJoined() to your LLM Config
    

資料模型與資料繫結

#

genui 的一個核心概念是 DataModel,這是一個用於所有動態 UI 狀態的集中式、可觀察的儲存。與其讓每個小部件自行管理狀態,不如將其狀態儲存在 DataModel 中。

小部件繫結到該模型中的資料。當模型中的資料發生變化時,只有依賴於該特定資料的小部件才會重新構建。這是透過傳遞給每個小部件構建器函式的 DataContext 物件來實現的。

繫結到資料模型

#

要將小部件屬性繫結到資料模型,請在 AI 傳送的資料中指定一個特殊的 JSON 物件。此物件可以包含標準 JSON 原語(用於靜態值)或帶有 path 屬性的物件(以繫結到資料模型中的值)。

例如,要在 Text 小部件中顯示使用者的名字,AI 將生成:

json
{
  "component": "Text",
  "text": "Welcome to GenUI",
  "variant": "h1"
}

Image

#
json
{
  "component": "Image",
  "url": "https://example.com/image.png",
  "variant": "mediumFeature"
}

更新資料模型

#

輸入小部件(如 TextField)直接更新 DataModel。當用戶在繫結到 /user/name 的文字欄位中輸入內容時,DataModel 會更新,任何繫結到同一路徑的其他小部件將自動重新構建以顯示新值。

這種響應式資料流簡化了狀態管理,並在使用者、UI 和 AI 之間建立了強大、高頻寬的互動迴圈。

下一步

#

檢視 genui 倉庫中包含的示例旅遊應用示例展示瞭如何定義代理可用於生成領域特定 UI 的自定義小部件目錄。

如果有什麼不清楚或缺失的內容,請建立一個 issue

系統指令

#

genui 包為 LLM 提供了一套可用於生成 UI 的工具。為了讓 LLM 使用這些工具,透過 PromptBuilder 提供的系統指令必須明確告知它這樣做。

這就是為什麼之前的示例包含了一條針對代理的系統指令,其中寫道:“每當我給你一個詞,你應該生成一個 UI,其...”

dart
final promptBuilder = PromptBuilder.chat(
  catalog: catalog,
  instructions: '''
    You are an expert in creating funny riddles.
    Every time I give you a word, you should generate UI that
    displays one new riddle related to that word.
    Each riddle should have both a question and an answer.
    ''',
);

故障排除/常見問題解答

#

如何配置日誌記錄?

#

要觀察你的應用與代理之間的通訊,請在 main 方法中啟用日誌記錄。

dart
import 'package:logging/logging.dart';
import 'package:genui/genui.dart';

final logger = configureGenUiLogging(level: Level.ALL);

void main() async {
  logger.onRecord.listen((record) {
    debugPrint('${record.loggerName}: ${record.message}');
  });

  // Additional initialization of bindings and Firebase.
}

我收到了關於 macOS/iOS 最低版本的錯誤。

#

Firebase 對 Apple 平臺有最低版本要求,該要求可能高於 Flutter 的預設值。請檢查你的 Podfile (iOS) 和 CMakeLists.txt (macOS) 以確保你的目標版本符合或超過 Firebase 的要求。