跳转至正文

GenUI SDK 入门

了解如何使用 GenUI SDK for Flutter,并将其添加到你现有的 Flutter 应用。

本指南说明如何开始使用 GenUI SDK for Flutter 及其系列 package。 SDK 的关键组件见 主要组件 页面。

按以下说明将 genui 添加到你的 Flutter 应用。代码示例展示如何在运行 flutter create 创建的全新应用上操作,现有 Flutter 应用也可遵循相同步骤。

配置智能体 (agent) 提供方

#

genui package 可连接多种智能体提供方,包括:

Firebase AI Logic
适用于与 LLM 的交互全部在 Flutter 客户端、无需服务器的生产应用。 Firebase 还便于安全交付 AI 功能,因为它负责管理 Gemini API 密钥。

GenUI A2UI
适用于智能体运行在服务器上的客户端/服务器架构。

自行构建
你也可以构建自己的适配器以连接首选 LLM 提供方。我们与社区很快会有更多方案。

使用 Vertex AI for Firebase SDK 连接 Gemini,请按以下说明操作:

  1. 使用 Firebase Console 创建新的 Firebase 项目

  2. 为该项目 启用 Gemini API

  3. Firebase Flutter 设置指南 的前三步将 Firebase 添加到应用。

  4. 使用 dart pub addpubspec.yaml 中添加 genuifirebase_vertex_ai 依赖:

    dart pub add genui firebase_vertex_ai
    
  5. 在应用的 main 方法中,确保初始化 widget 绑定,然后初始化 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 协议 整合的 package,使 Flutter 应用可连接 Agent-to-Agent (A2UI) 服务器,并用 genui 框架渲染 AI 智能体生成的动态用户界面。

该 package 的主要组件包括:

  • A2uiAgentConnector:处理与 A2A 服务器的底层 WebSocket 通信,包括发送消息与解析流事件。

  • AgentCard:保存已连接 AI 智能体元数据的数据类。

请按照以下说明操作:

  1. 设置依赖:使用 dart pub addpubspec.yaml 文件中添加 genuigenui_a2aa2a 依赖。

    dart pub add genui genui_a2a a2a
    
  2. 初始化 SurfaceController:用你的 widget Catalog 设置 SurfaceController

  3. 创建 A2uiTransportAdapter:实例化 A2uiTransportAdapter 以解析消息。

  4. 创建 A2uiAgentConnector:实例化 A2uiAgentConnector,并提供 A2A 服务器 URI。

  5. 创建 Conversation:将适配器与控制器传给 Conversation

  6. 使用 Surface 渲染:在 UI 中使用 Surface widget 显示智能体生成的内容。

  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('http://localhost: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 目录包含一个完整应用,演示如何使用该 package。

如果需要将 genui 与其他 agent 提供商配合使用,请按照该提供商的 SDK 文档实现连接,并将结果流式传输到 A2uiTransportAdapter 中。

创建与智能体的连接

#

若你为 iOS 或 macOS 构建 Flutter 项目,在 {ios,macos}/Runner/*.entitlements 文件中添加以下键以启用出站网络请求:

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

接下来,按以下说明将应用连接到所选智能体提供方。

  1. 创建 SurfaceController,提供你希望向智能体开放的 widget catalog,并创建 A2uiTransportAdapter 解析消息并连接。

  2. 创建 PromptBuilder,提供系统指令与工具(你希望智能体能调用的函数)。应始终包含 SurfaceController 提供的工具,也可添加其他工具,并写入 LLM 系统提示词。

  3. 使用 SurfaceControllerA2uiTransportAdapter 实例创建 Conversation;应用主要通过该对象完成工作。

    For example:

    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 widget。

    For example:

    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'),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }
    

将自定义 widget 加入 catalog

#

为方便起见,可使用提供的核心 widget catalog。但多数生产应用会希望定义自定义 widget catalog。

要添加自定义 widget,请按以下说明操作。

  1. 依赖 json_schema_builder package

    使用 dart pub addpubspec.yaml 文件中添加 json_schema_builder 依赖:

    dart pub add json_schema_builder
    
  2. 创建新 widget 的 schema

    每个 catalog item 都需要一个 schema,用于定义填充它所需的数据。使用 json_schema_builder package 为新 widget 定义一个 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 表示智能体允许生成的一类 widget。为此,它组合名称、schema,以及生成构成生成式 UI 的 widget 的 builder 函数。

    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 添加到 catalog

    实例化 SurfaceController 时包含你的 catalog item。

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

    为确保智能体知道要使用你的新 widget,请在系统指令中说明如何以及何时使用。说明时提供 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 状态的集中、可观察存储。各 widget 不各自管理状态,而是将状态存入 DataModel

widget 绑定 到该模型中的数据;模型中的数据变化时,仅依赖该数据的 widget 会重建。这通过传给各 widget builder 函数的 DataContext 对象实现。

绑定到数据模型

#

要将 widget 属性绑定到数据模型,在 AI 发送的数据中指定特殊 JSON 对象。该对象可含标准 JSON 基元(静态值),或含 path 属性的对象(绑定到数据模型中的值)。

例如,要在 Text widget 中显示用户名,AI 会生成:

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

图像

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

更新数据模型

#

输入类 widget(如 TextField)直接更新 DataModel。用户在绑定到 /user/name 的文本字段中输入时,DataModel 更新,绑定同一路径的其他 widget 会自动重建以显示新值。

这种响应式数据流简化状态管理,并在用户、UI 与 AI 之间形成强大的高带宽交互循环。

后续

#

查看 genui 仓库中的 示例旅行应用 展示如何定义智能体可用于生成领域特定 UI 的自定义 widget catalog。

若有不清楚或缺失之处,请 提交 issue

系统指令

#

genui package 为 LLM 提供一组可用于生成 UI 的工具。要让 LLM 使用这些工具,通过 PromptBuilder 提供的系统指令必须明确告知其这样做。

因此 先前的示例 为智能体包含系统指令,其中有「Every time I give you a word, you should generate UI that...」这样的表述:

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 要求。