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,請按照以下說明操作:
-
使用 Firebase 控制檯建立一個新的 Firebase 專案。
-
為該專案啟用 Gemini API。
-
按照 Firebase Flutter 設定指南的前三個步驟,將 Firebase 新增到你的應用中。
-
使用
dart pub add在你的pubspec.yaml檔案中將genui和firebase_vertex_ai新增為依賴項。dart pub add genui firebase_vertex_ai -
在應用的
main方法中,確保初始化了小部件繫結,然後初始化 Firebase。dartimport '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()); } -
建立一個 Vertex AI for Firebase 生成式模型例項,並用你的
SurfaceController和A2uiTransportAdapter將其包裝起來。dartimport '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, );
這是用於 genui 和 A2UI 流式 UI 協議的整合包。該包允許 Flutter 應用連線到 Agent-to-Agent (A2UI) 伺服器,並使用 genui 框架渲染由 AI 代理生成的動態使用者介面。
該包中的主要元件包括:
-
A2uiAgentConnector:處理與 A2A 伺服器的底層 Web 套接字通訊,包括髮送訊息和解析流事件。 -
AgentCard:一個儲存有關已連線 AI 代理元資料的資料類。
請按照以下說明操作:
-
設定依賴項:使用
dart pub add將genui、genui_a2a和a2a新增為pubspec.yaml中的依賴項。dart pub add genui genui_a2a a2a -
初始化
SurfaceController:使用你的小部件Catalog(目錄)設定SurfaceController。 -
建立
A2uiTransportAdapter:例項化A2uiTransportAdapter以解析訊息。 -
建立
A2uiAgentConnector:例項化A2uiAgentConnector,並提供 A2A 伺服器的 URI。 -
建立
Conversation:將介面卡和控制器傳遞給Conversation。 -
使用
Surface進行渲染:在 UI 中使用Surface小部件來顯示代理生成的內容。 -
傳送訊息:使用
connector.connectAndSend或Conversation.sendMessage將使用者輸入傳送給代理生成的內容。dartimport '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 檔案中,以啟用出站網路請求。
<dict>
...
<key>com.apple.security.network.client</key>
<true/>
</dict>
接下來,使用以下說明將你的應用連線到你選擇的代理提供程式。
-
建立一個
SurfaceController,併為其提供你希望代理可用的小部件目錄。建立一個A2uiTransportAdapter以解析訊息並進行連線。 -
建立一個
PromptBuilder,併為其提供系統指令和工具(你希望代理能夠呼叫的函式)。你應該始終包含SurfaceController提供的工具,但也歡迎包含其他工具。將其新增到你的 LLM 系統提示詞中。 -
使用
SurfaceController和A2uiTransportAdapter的例項建立一個Conversation。你的應用主要將與此物件進行互動以完成任務。例如
dartclass _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:
-
監聽
Conversation中的events流,以跟蹤 UI 介面生成時的新增和移除。這些事件包含每個介面的 surface ID。 -
使用上一步接收到的 surface ID 為每個活躍的介面構建一個
Surface小部件。例如
dartclass _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'), ), ], ), ), ), ], ), ); } }
將你自己的小部件新增到目錄中
#為了方便起見,你可以使用提供的核心小部件目錄。然而,大多數生產應用會希望定義自定義的小部件目錄。
要新增你自己的小部件,請按照以下說明進行操作。
-
依賴
json_schema_builder包:使用
dart pub add將json_schema_builder新增為pubspec.yaml中的依賴項。dart pub add json_schema_builder -
建立新小部件的 schema:
每個目錄項都需要一個 schema 來定義填充它所需的資料。使用
json_schema_builder包為新小部件定義一個 schema。dartimport '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'], ); -
建立
CatalogItem:每個
CatalogItem代表代理可以生成的某種型別的小部件。為此,它結合了名稱、schema 以及生成組成已生成 UI 的小部件的構建器函式。dartfinal 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), ], ), ); }, ); -
將
CatalogItem新增到目錄中:在例項化
SurfaceController時包含你的目錄項。dart_surfaceController = SurfaceController( catalogs: [BasicCatalogItems.asCatalog().copyWith([riddleCard])], ); -
更新系統指令以使用新小部件:
為了確保代理知道使用你的新小部件,請在系統指令中說明使用方式和時機。執行此操作時,提供
CatalogItem中的名稱。dartfinal 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 將生成:
{
"component": "Text",
"text": "Welcome to GenUI",
"variant": "h1"
}
Image
#{
"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,其...”
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 方法中啟用日誌記錄。
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 的要求。