Skip to content

Commit bd4e527

Browse files
committed
Merge branch 'main' of github.com:flutter/genui
2 parents 1c00de0 + ec8bc0a commit bd4e527

File tree

11 files changed

+783
-328
lines changed

11 files changed

+783
-328
lines changed

pkgs/genui_client/lib/main.dart

Lines changed: 135 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import 'dart:async';
2-
1+
import 'package:collection/collection.dart';
32
import 'package:firebase_app_check/firebase_app_check.dart';
43
import 'package:firebase_core/firebase_core.dart';
54
import 'package:flutter/material.dart';
65

76
import 'firebase_options.dart';
87
import 'src/ai_client/ai_client.dart';
8+
import 'src/chat_message.dart';
99
import 'src/dynamic_ui.dart';
10+
import 'src/ui_models.dart';
1011
import 'src/ui_server.dart';
1112

1213
void main() async {
@@ -88,12 +89,11 @@ class GenUIHomePage extends StatefulWidget {
8889
}
8990

9091
class _GenUIHomePageState extends State<GenUIHomePage> {
91-
final _updateController = StreamController<Map<String, Object?>>.broadcast();
92-
Map<String, Object?>? _uiDefinition;
92+
final _chatHistory = <ChatMessage>[];
9393
String _connectionStatus = 'Initializing...';
94-
Key _uiKey = UniqueKey();
9594
final _promptController = TextEditingController();
9695
late final ServerConnection _serverConnection;
96+
final ScrollController _scrollController = ScrollController();
9797

9898
@override
9999
void initState() {
@@ -102,29 +102,58 @@ class _GenUIHomePageState extends State<GenUIHomePage> {
102102
onSetUi: (definition) {
103103
if (!mounted) return;
104104
setState(() {
105-
_uiDefinition = definition;
106-
_uiKey = UniqueKey();
105+
final surfaceId = definition['surfaceId'] as String?;
106+
_chatHistory.add(UiResponse(
107+
definition: definition,
108+
surfaceId: surfaceId,
109+
));
107110
});
111+
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
108112
},
109113
onUpdateUi: (updates) {
110114
if (!mounted) return;
111-
for (final update in updates) {
112-
_updateController.add(update);
113-
}
115+
setState(() {
116+
for (final update in updates) {
117+
final uiUpdate = UiDefinition.fromMap(update);
118+
final oldResponse =
119+
_chatHistory.whereType<UiResponse>().firstWhereOrNull(
120+
(response) => response.surfaceId == uiUpdate.surfaceId,
121+
);
122+
if (oldResponse != null) {
123+
final index = _chatHistory.indexOf(oldResponse);
124+
_chatHistory[index] =
125+
UiResponse(definition: update, surfaceId: uiUpdate.surfaceId);
126+
}
127+
}
128+
});
129+
},
130+
onDeleteUi: (surfaceId) {
131+
if (!mounted) return;
132+
setState(() {
133+
_chatHistory.removeWhere((message) =>
134+
message is UiResponse && message.surfaceId == surfaceId);
135+
});
136+
},
137+
onTextResponse: (text) {
138+
if (!mounted) return;
139+
setState(() {
140+
_chatHistory.add(TextResponse(text: text));
141+
});
114142
},
115143
onError: (message) {
116144
if (!mounted) return;
117145
setState(() {
146+
_chatHistory.add(SystemMessage(text: 'Error: $message'));
118147
_connectionStatus = 'Error: $message';
119-
_uiDefinition = null;
120148
});
121149
},
122150
onStatusUpdate: (status) {
123151
if (!mounted) return;
124152
setState(() {
125153
_connectionStatus = status;
126-
if (status != 'Server started.') {
127-
_uiDefinition = null;
154+
if (status == 'Server started.' && _chatHistory.isEmpty) {
155+
_chatHistory.add(const SystemMessage(
156+
text: 'I can create UIs. What should I make for you?'));
128157
}
129158
});
130159
},
@@ -136,11 +165,17 @@ class _GenUIHomePageState extends State<GenUIHomePage> {
136165
}
137166
}
138167

168+
void _scrollToBottom() {
169+
if (_scrollController.hasClients) {
170+
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
171+
}
172+
}
173+
139174
@override
140175
void dispose() {
141-
_updateController.close();
142176
_serverConnection.dispose();
143177
_promptController.dispose();
178+
_scrollController.dispose();
144179
super.dispose();
145180
}
146181

@@ -150,14 +185,20 @@ class _GenUIHomePageState extends State<GenUIHomePage> {
150185

151186
void _sendPrompt() {
152187
final prompt = _promptController.text;
153-
_serverConnection.sendPrompt(prompt);
154188
if (prompt.isNotEmpty) {
189+
setState(() {
190+
_chatHistory.add(UserPrompt(text: prompt));
191+
});
192+
_serverConnection.sendPrompt(prompt);
155193
_promptController.clear();
194+
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
156195
}
157196
}
158197

159198
@override
160199
Widget build(BuildContext context) {
200+
final showProgressIndicator = _connectionStatus == 'Generating UI...' ||
201+
_connectionStatus == 'Starting server...';
161202
return Scaffold(
162203
appBar: AppBar(
163204
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
@@ -168,6 +209,73 @@ class _GenUIHomePageState extends State<GenUIHomePage> {
168209
constraints: const BoxConstraints(maxWidth: 1000),
169210
child: Column(
170211
children: [
212+
Expanded(
213+
child: _chatHistory.isEmpty
214+
? Center(
215+
child: Column(
216+
mainAxisAlignment: MainAxisAlignment.center,
217+
children: [
218+
if (showProgressIndicator)
219+
const CircularProgressIndicator(),
220+
const SizedBox(height: 16),
221+
Text(_connectionStatus),
222+
],
223+
),
224+
)
225+
: ListView.builder(
226+
controller: _scrollController,
227+
itemCount: _chatHistory.length,
228+
itemBuilder: (context, index) {
229+
final message = _chatHistory[index];
230+
return switch (message) {
231+
SystemMessage() => Card(
232+
elevation: 2.0,
233+
margin: const EdgeInsets.symmetric(
234+
horizontal: 8.0, vertical: 4.0),
235+
child: ListTile(
236+
title: Text(message.text),
237+
leading: const Icon(Icons.smart_toy_outlined),
238+
),
239+
),
240+
TextResponse() => Card(
241+
elevation: 2.0,
242+
margin: const EdgeInsets.symmetric(
243+
horizontal: 8.0, vertical: 4.0),
244+
child: ListTile(
245+
title: Text(message.text),
246+
leading: const Icon(Icons.smart_toy_outlined),
247+
),
248+
),
249+
UserPrompt() => Card(
250+
elevation: 2.0,
251+
margin: const EdgeInsets.symmetric(
252+
horizontal: 8.0, vertical: 4.0),
253+
child: ListTile(
254+
title: Text(
255+
message.text,
256+
textAlign: TextAlign.right,
257+
),
258+
trailing: const Icon(Icons.person),
259+
),
260+
),
261+
UiResponse() => Card(
262+
elevation: 2.0,
263+
margin: const EdgeInsets.symmetric(
264+
horizontal: 8.0, vertical: 4.0),
265+
child: Padding(
266+
padding: const EdgeInsets.all(16.0),
267+
child: DynamicUi(
268+
key: message.uiKey,
269+
surfaceId: message.surfaceId,
270+
definition: message.definition,
271+
onEvent: _handleUiEvent,
272+
),
273+
),
274+
),
275+
};
276+
},
277+
),
278+
),
171279
Padding(
172280
padding: const EdgeInsets.all(8.0),
173281
child: Row(
@@ -188,29 +296,18 @@ class _GenUIHomePageState extends State<GenUIHomePage> {
188296
],
189297
),
190298
),
191-
Expanded(
192-
child: _uiDefinition == null
193-
? Center(
194-
child: Column(
195-
mainAxisAlignment: MainAxisAlignment.center,
196-
children: [
197-
if (_connectionStatus == 'Generating UI...')
198-
const CircularProgressIndicator(),
199-
const SizedBox(height: 16),
200-
Text(_connectionStatus),
201-
],
202-
),
203-
)
204-
: Padding(
205-
padding: const EdgeInsets.all(16.0),
206-
child: DynamicUi(
207-
key: _uiKey,
208-
definition: _uiDefinition!,
209-
updateStream: _updateController.stream,
210-
onEvent: _handleUiEvent,
211-
),
212-
),
213-
),
299+
if (showProgressIndicator)
300+
const Padding(
301+
padding: EdgeInsets.all(8.0),
302+
child: Row(
303+
mainAxisAlignment: MainAxisAlignment.center,
304+
children: [
305+
CircularProgressIndicator(),
306+
SizedBox(width: 16),
307+
Text('Generating UI...'),
308+
],
309+
),
310+
),
214311
],
215312
),
216313
),

pkgs/genui_client/lib/src/ai_client/ai_client.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,9 @@ class AiClient {
185185
/// schema.
186186
///
187187
/// This method orchestrates the interaction with the generative AI model. It
188-
/// sends the given [prompts] and an [outputSchema] that defines the expected
189-
/// structure of the AI's response.
188+
/// sends the given [conversation] and an [outputSchema] that defines the
189+
/// expected structure of the AI's response. The [conversation] is updated
190+
/// in place with the results of the tool-calling conversation.
190191
///
191192
/// The AI is configured to use "forced tool calling", meaning it's expected
192193
/// to respond by either:
@@ -198,19 +199,21 @@ class AiClient {
198199
/// 2. Calling a special internal tool (named by [outputToolName]) whose
199200
/// argument is the final structured data matching [outputSchema].
200201
///
201-
/// - [prompts]: A list of [Content] objects representing the input to the AI.
202+
/// - [conversation]: A list of [Content] objects representing the input to
203+
/// the AI. This list will be modified in place to include the tool calling
204+
/// conversation.
202205
/// - [outputSchema]: A [Schema] defining the structure of the desired output
203206
/// `T`.
204207
/// - [additionalTools]: A list of [AiTool]s to make available to the AI for
205208
/// this specific call, in addition to the default [tools].
206209
Future<T?> generateContent<T extends Object>(
207-
List<Content> prompts,
210+
List<Content> conversation,
208211
Schema outputSchema, {
209212
Iterable<AiTool> additionalTools = const [],
210213
Content? systemInstruction,
211214
}) async {
212215
return await _generateContentWithRetries(
213-
prompts.toList(), // Don't want to modify original prompts.
216+
conversation,
214217
outputSchema,
215218
[...tools, ...additionalTools],
216219
systemInstruction,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2025 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// A sealed class representing a message in the chat history.
8+
sealed class ChatMessage {
9+
const ChatMessage();
10+
}
11+
12+
/// A message representing a system message.
13+
class SystemMessage extends ChatMessage {
14+
/// Creates a [SystemMessage] with the given [text].
15+
const SystemMessage({required this.text});
16+
17+
/// The text of the system message.
18+
final String text;
19+
}
20+
21+
/// A message representing a user's text prompt.
22+
class UserPrompt extends ChatMessage {
23+
/// Creates a [UserPrompt] with the given [text].
24+
const UserPrompt({required this.text});
25+
26+
/// The text of the user's prompt.
27+
final String text;
28+
}
29+
30+
/// A message representing a text response from the AI.
31+
class TextResponse extends ChatMessage {
32+
/// Creates a [TextResponse] with the given [text].
33+
const TextResponse({required this.text});
34+
35+
/// The text of the AI's response.
36+
final String text;
37+
}
38+
39+
/// A message representing a UI response from the AI.
40+
class UiResponse extends ChatMessage {
41+
/// Creates a [UiResponse] with the given UI [definition].
42+
UiResponse({required this.definition, String? surfaceId})
43+
: uiKey = UniqueKey(),
44+
surfaceId = surfaceId ??
45+
ValueKey(DateTime.now().toIso8601String()).hashCode.toString();
46+
47+
/// The JSON definition of the UI.
48+
final Map<String, Object?> definition;
49+
50+
/// A unique key for the UI widget.
51+
final Key uiKey;
52+
53+
/// The unique ID for this UI surface.
54+
final String surfaceId;
55+
}

0 commit comments

Comments
 (0)