Skip to content

Commit 4134cb3

Browse files
committed
feat(genui): refactor PromptBuilder to use spec schemas and async API
- Refactor `PromptBuilder` to load `server_to_client.json` and `common_types.json` as assets and use `$refs` in the generated prompt. - Make `PromptBuilder` creation asynchronous to support asset loading. - Fix examples (`composer`, `simple_chat`, `travel_app`) to use the new async API. - Add mock asset handlers in tests to support loading schemas. - Update golden files for prompt builder tests. Resolves #873 and #874.
1 parent a24693d commit 4134cb3

17 files changed

Lines changed: 3947 additions & 1088 deletions

examples/composer/lib/create_tab.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class _CreateTabState extends State<CreateTab> {
7878
transport: transport,
7979
);
8080

81-
final promptBuilder = PromptBuilder.chat(
81+
final promptBuilder = await PromptBuilder.createChat(
8282
catalog: catalog,
8383
systemPromptFragments: [
8484
'You are a UI generator. The user will describe a UI they want. '

examples/simple_chat/lib/chat_session.dart

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,20 @@ final Catalog _customCatalog = _basicCatalog.copyWith(
5656
newItems: [climbingLocationItem],
5757
);
5858

59-
PromptBuilder _promptBuilderFor(Catalog catalog) => PromptBuilder.chat(
60-
catalog: catalog,
61-
systemPromptFragments: [
62-
Prompts.summary,
63-
PromptFragments.acknowledgeUser(),
64-
PromptFragments.requireAtLeastOneSubmitElement(
65-
prefix: PromptBuilder.defaultImportancePrefix,
66-
),
67-
PromptFragments.uiGenerationRestriction(
68-
prefix: PromptBuilder.defaultImportancePrefix,
69-
),
70-
],
71-
);
59+
Future<PromptBuilder> _promptBuilderFor(Catalog catalog) async =>
60+
await PromptBuilder.createChat(
61+
catalog: catalog,
62+
systemPromptFragments: [
63+
Prompts.summary,
64+
PromptFragments.acknowledgeUser(),
65+
PromptFragments.requireAtLeastOneSubmitElement(
66+
prefix: PromptBuilder.defaultImportancePrefix,
67+
),
68+
PromptFragments.uiGenerationRestriction(
69+
prefix: PromptBuilder.defaultImportancePrefix,
70+
),
71+
],
72+
);
7273

7374
sealed class ChatSession extends ChangeNotifier {
7475
ChatSession._();
@@ -188,7 +189,7 @@ class A2uiChatSession extends ChatSession {
188189
late final StreamSubscription<ChatMessage> _submitSub;
189190
late final StreamSubscription<SurfaceUpdate> _surfaceSub;
190191

191-
void _init() {
192+
Future<void> _init() async {
192193
_messageSub = _transport.incomingMessages.listen(
193194
_surfaceController.handleMessage,
194195
);
@@ -198,9 +199,8 @@ class A2uiChatSession extends ChatSession {
198199
);
199200
_surfaceSub = _surfaceController.surfaceUpdates.listen(_onSurfaceUpdate);
200201

201-
_transport.addSystemMessage(
202-
_promptBuilderFor(_catalog).systemPromptJoined(),
203-
);
202+
final PromptBuilder pb = await _promptBuilderFor(_catalog);
203+
_transport.addSystemMessage(pb.systemPromptJoined());
204204
}
205205

206206
void _onSurfaceUpdate(SurfaceUpdate update) {

examples/travel_app/lib/src/ai_client/google_generative_ai_client.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ class GoogleGenerativeAiClient implements AiClient {
471471
var toolUsageCycle = 0;
472472
const maxToolUsageCycles = 40; // Safety break for tool loops
473473

474-
final promptBuilder = PromptBuilder.custom(
474+
final PromptBuilder promptBuilder = await PromptBuilder.createCustom(
475475
catalog: catalog,
476476
systemPromptFragments: systemInstruction,
477477
allowedOperations: SurfaceOperations.createAndUpdate(dataModel: true),

examples/travel_app/test/ai_client/google_generative_ai_client_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:convert';
6+
import 'dart:io';
7+
import 'dart:typed_data';
8+
59
import 'package:flutter_test/flutter_test.dart';
610
import 'package:genui/genui.dart';
711
import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart'
@@ -10,6 +14,41 @@ import 'package:travel_app/src/ai_client/google_generative_ai_client.dart';
1014
import 'package:travel_app/src/ai_client/google_generative_service_interface.dart';
1115

1216
void main() {
17+
TestWidgetsFlutterBinding.ensureInitialized();
18+
19+
setUpAll(() {
20+
// Mock asset loading because PromptBuilder loads schemas from assets,
21+
// and Flutter tests do not load package assets automatically.
22+
// This handler intercepts requests for assets and loads them directly
23+
// from the local file system.
24+
// It handles different CWDs (running from package root or example
25+
// directory).
26+
final String cwd = Directory.current.path;
27+
String packageRoot;
28+
if (cwd.endsWith('packages/genui')) {
29+
packageRoot = cwd;
30+
} else if (cwd.contains('examples/')) {
31+
packageRoot =
32+
'${cwd.substring(0, cwd.indexOf('examples/'))}packages/genui';
33+
} else {
34+
packageRoot = '$cwd/packages/genui';
35+
}
36+
37+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
38+
.setMockMessageHandler('flutter/assets', (ByteData? message) async {
39+
final String key = utf8.decode(message!.buffer.asUint8List());
40+
var relativePath = key;
41+
if (key.startsWith('packages/genui/')) {
42+
relativePath = key.substring('packages/genui/'.length);
43+
}
44+
final file = File('$packageRoot/$relativePath');
45+
if (file.existsSync()) {
46+
return ByteData.view(utf8.encode(file.readAsStringSync()).buffer);
47+
}
48+
return null;
49+
});
50+
});
51+
1352
group('GoogleGenerativeAiClient', () {
1453
late FakeGoogleGenerativeService fakeService;
1554
late GoogleGenerativeAiClient client;

packages/genui/lib/src/facade/prompt_builder.dart

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import 'dart:convert';
66

77
import 'package:flutter/material.dart';
8+
import 'package:flutter/services.dart';
89

9-
import '../model/a2ui_message.dart';
1010
import '../model/catalog.dart';
1111
import '../primitives/simple_items.dart';
1212

@@ -78,38 +78,54 @@ abstract class PromptBuilder {
7878
/// The builder will generate a prompt for a chat session,
7979
/// that instructs to create new surfaces for each response
8080
/// and restrict surface deletion and updates.
81-
factory PromptBuilder.chat({
81+
static Future<PromptBuilder> createChat({
8282
required Catalog catalog,
8383
Iterable<String> systemPromptFragments = const [],
8484
String importancePrefix = defaultImportancePrefix,
8585
JsonMap? clientDataModel,
86-
}) {
86+
}) async {
87+
final String commonTypes = await rootBundle.loadString(
88+
'packages/genui/submodules/a2ui/specification/v0_9/json/common_types.json',
89+
);
90+
final String serverToClient = await rootBundle.loadString(
91+
'packages/genui/submodules/a2ui/specification/v0_9/json/server_to_client.json',
92+
);
8793
return _BasicPromptBuilder(
8894
catalog: catalog,
8995
systemPromptFragments: systemPromptFragments,
9096
allowedOperations: SurfaceOperations.createOnly(dataModel: false),
9197
importancePrefix: importancePrefix,
9298
clientDataModel: clientDataModel,
9399
technicalPossibilities: const TechnicalPossibilities(),
100+
commonTypesSchema: commonTypes,
101+
serverToClientSchema: serverToClient,
94102
);
95103
}
96104

97-
factory PromptBuilder.custom({
105+
static Future<PromptBuilder> createCustom({
98106
required Catalog catalog,
99107
required SurfaceOperations allowedOperations,
100108
Iterable<String> systemPromptFragments = const [],
101109
String importancePrefix = defaultImportancePrefix,
102110
TechnicalPossibilities technicalPossibilities =
103111
const TechnicalPossibilities(),
104112
JsonMap? clientDataModel,
105-
}) {
113+
}) async {
114+
final String commonTypes = await rootBundle.loadString(
115+
'packages/genui/submodules/a2ui/specification/v0_9/json/common_types.json',
116+
);
117+
final String serverToClient = await rootBundle.loadString(
118+
'packages/genui/submodules/a2ui/specification/v0_9/json/server_to_client.json',
119+
);
106120
return _BasicPromptBuilder(
107121
catalog: catalog,
108122
systemPromptFragments: systemPromptFragments,
109123
allowedOperations: allowedOperations,
110124
importancePrefix: importancePrefix,
111125
clientDataModel: clientDataModel,
112126
technicalPossibilities: technicalPossibilities,
127+
commonTypesSchema: commonTypes,
128+
serverToClientSchema: serverToClient,
113129
);
114130
}
115131

@@ -332,9 +348,13 @@ final class _BasicPromptBuilder extends PromptBuilder {
332348
required this.importancePrefix,
333349
required this.clientDataModel,
334350
required this.technicalPossibilities,
351+
required this.commonTypesSchema,
352+
required this.serverToClientSchema,
335353
}) : super._();
336354

337355
final Catalog catalog;
356+
final String commonTypesSchema;
357+
final String serverToClientSchema;
338358

339359
final SurfaceOperations allowedOperations;
340360

@@ -359,36 +379,84 @@ final class _BasicPromptBuilder extends PromptBuilder {
359379

360380
@override
361381
Iterable<String> systemPrompt() {
362-
final String a2uiSchema = A2uiMessage.a2uiMessageSchema(
363-
catalog,
364-
).toJson(indent: ' ');
382+
final String catalogSchema = _generateCatalogSchema(catalog);
365383

366384
final fragments = <String>[
367385
...systemPromptFragments,
368386
'Use the provided tools to respond to user using rich UI elements.',
369387
...technicalPossibilities.systemPromptFragment(),
370388
...catalog.systemPromptFragments,
371389
...allowedOperations.systemPromptFragments,
372-
_fenced(a2uiSchema, sectionName: 'A2UI JSON SCHEMA'),
373-
if (catalog.functions.isNotEmpty)
374-
_fenced(
375-
const JsonEncoder.withIndent(' ').convert([
376-
for (final func in catalog.functions)
377-
{
378-
'name': func.name,
379-
'description': func.description,
380-
'parameters': func.argumentSchema.value,
381-
'returnType': func.returnType.value,
382-
},
383-
]),
384-
sectionName: 'AVAILABLE FUNCTIONS',
385-
),
390+
_fenced(commonTypesSchema, sectionName: 'COMMON TYPES'),
391+
_fenced(catalogSchema, sectionName: 'CATALOG SCHEMA'),
392+
_fenced(serverToClientSchema, sectionName: 'MESSAGE SCHEMA'),
386393
?_encodedDataModel(clientDataModel),
387394
];
388395

389396
return _fragmentsToPrompt(fragments);
390397
}
391398

399+
String _generateCatalogSchema(Catalog catalog) {
400+
final Map<String, dynamic> components = {
401+
for (final item in catalog.items)
402+
item.name: {
403+
'type': 'object',
404+
'allOf': [
405+
{r'$ref': r'common_types.json#/$defs/ComponentCommon'},
406+
{r'$ref': r'#/$defs/CatalogComponentCommon'},
407+
{
408+
'type': 'object',
409+
'properties': {
410+
'component': {'const': item.name},
411+
...item.dataSchema.value['properties'] as Map<String, dynamic>,
412+
},
413+
'required': [
414+
'component',
415+
...?item.dataSchema.value['required'] as List?,
416+
],
417+
},
418+
],
419+
'unevaluatedProperties': false,
420+
},
421+
};
422+
423+
final Map<String, dynamic> functions = {
424+
for (final func in catalog.functions)
425+
func.name: {
426+
'description': func.description,
427+
'parameters': func.argumentSchema.value,
428+
'returnType': func.returnType.value,
429+
},
430+
};
431+
432+
final Map<String, dynamic> catalogJson = {
433+
r'$schema': 'https://json-schema.org/draft/2020-12/schema',
434+
r'$id': 'https://a2ui.org/specification/v0_9/catalog.json',
435+
'title': 'A2UI Catalog',
436+
'description': 'Custom catalog of A2UI components and functions.',
437+
if (catalog.catalogId != null) 'catalogId': catalog.catalogId,
438+
'components': components,
439+
if (functions.isNotEmpty) 'functions': functions,
440+
r'$defs': {
441+
'CatalogComponentCommon': {
442+
'type': 'object',
443+
'properties': {
444+
'id': {
445+
'type': 'string',
446+
'description':
447+
'A unique identifier for this component instance within '
448+
'the surface. This ID is used to refer to the component '
449+
'in layout children arrays or event handlers.',
450+
},
451+
},
452+
'required': ['id'],
453+
},
454+
},
455+
};
456+
457+
return const JsonEncoder.withIndent(' ').convert(catalogJson);
458+
}
459+
392460
static String? _encodedDataModel(JsonMap? clientDataModel) {
393461
if (clientDataModel == null) return null;
394462
final String encodedModel = const JsonEncoder.withIndent(

packages/genui/pubspec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ dev_dependencies:
3838
sdk: flutter
3939
network_image_mock: ^2.1.1
4040
test: ^1.26.2
41+
42+
flutter:
43+
assets:
44+
- submodules/a2ui/specification/v0_9/json/common_types.json
45+
- submodules/a2ui/specification/v0_9/json/server_to_client.json

0 commit comments

Comments
 (0)