diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index ddb318537..991ea8a74 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'dart:convert'; + import 'package:args/args.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; +import 'package:logging/logging.dart'; import 'samples_view.dart'; @@ -22,14 +24,14 @@ void main(List args) { samplesDir = fs.directory(results['samples'] as String); } else { final Directory current = fs.currentDirectory; - final Directory defaultSamples = fs - .directory(current.path) - .childDirectory('samples'); + final Directory defaultSamples = fs.directory('${current.path}/samples'); if (defaultSamples.existsSync()) { samplesDir = defaultSamples; } } + configureGenUiLogging(level: Level.ALL); + runApp(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); } @@ -48,7 +50,26 @@ class CatalogGalleryApp extends StatefulWidget { } class _CatalogGalleryAppState extends State { - final Catalog catalog = CoreCatalogItems.asCatalog(); + final Catalog catalog = Catalog([ + CoreCatalogItems.audioPlayer, + CoreCatalogItems.button, + CoreCatalogItems.card, + CoreCatalogItems.checkBox, + CoreCatalogItems.column, + CoreCatalogItems.dateTimeInput, + CoreCatalogItems.divider, + CoreCatalogItems.icon, + CoreCatalogItems.image, + CoreCatalogItems.list, + CoreCatalogItems.modal, + CoreCatalogItems.choicePicker, + CoreCatalogItems.row, + CoreCatalogItems.slider, + CoreCatalogItems.tabs, + CoreCatalogItems.text, + CoreCatalogItems.textField, + CoreCatalogItems.video, + ], catalogId: 'default'); @override Widget build(BuildContext context) { diff --git a/examples/catalog_gallery/lib/sample_parser.dart b/examples/catalog_gallery/lib/sample_parser.dart index 7f8a2b530..4417936f6 100644 --- a/examples/catalog_gallery/lib/sample_parser.dart +++ b/examples/catalog_gallery/lib/sample_parser.dart @@ -28,6 +28,9 @@ class SampleParser { static Sample parseString(String content) { final List lines = const LineSplitter().convert(content); + if (lines.firstOrNull == '---') { + lines.removeAt(0); + } final int separatorIndex = lines.indexOf('---'); if (separatorIndex == -1) { @@ -49,11 +52,16 @@ class SampleParser { .convert(jsonlBody) .where((line) => line.trim().isNotEmpty) .map((line) { - final dynamic json = jsonDecode(line); - if (json is Map) { - return A2uiMessage.fromJson(json); + try { + final dynamic json = jsonDecode(line); + if (json is Map) { + return A2uiMessage.fromJson(json); + } + throw FormatException('Invalid JSON line: $line'); + } on FormatException catch (e) { + print('Error parsing line: $line, error: $e'); + rethrow; } - throw FormatException('Invalid JSON line: $line'); }), ); diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index 446c0e71a..3d8ed5c11 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -68,23 +68,12 @@ class _SamplesViewState extends State { }); } } else if (update is SurfaceRemoved) { - if (_surfaceIds.contains(update.surfaceId)) { - setState(() { - final int removeIndex = _surfaceIds.indexOf(update.surfaceId); - _surfaceIds.removeAt(removeIndex); - if (_surfaceIds.isEmpty) { - _currentSurfaceIndex = 0; - } else { - if (_currentSurfaceIndex >= removeIndex && - _currentSurfaceIndex > 0) { - _currentSurfaceIndex--; - } - if (_currentSurfaceIndex >= _surfaceIds.length) { - _currentSurfaceIndex = _surfaceIds.length - 1; - } - } - }); - } + setState(() { + _surfaceIds.remove(update.surfaceId); + if (_currentSurfaceIndex >= _surfaceIds.length) { + _currentSurfaceIndex = 0; + } + }); } }); } @@ -99,6 +88,7 @@ class _SamplesViewState extends State { .toList(); setState(() { _sampleFiles = files; + _sampleFiles.sort((a, b) => a.path.compareTo(b.path)); }); } diff --git a/examples/catalog_gallery/pubspec.yaml b/examples/catalog_gallery/pubspec.yaml index 2b9381583..79fc70b05 100644 --- a/examples/catalog_gallery/pubspec.yaml +++ b/examples/catalog_gallery/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: sdk: flutter genui: ^0.5.1 json_schema_builder: ^0.1.3 + logging: ^1.3.0 yaml: ^3.1.3 dev_dependencies: diff --git a/examples/catalog_gallery/samples/hello_world.sample b/examples/catalog_gallery/samples/hello_world.sample index 16cd345cb..a4f03565c 100644 --- a/examples/catalog_gallery/samples/hello_world.sample +++ b/examples/catalog_gallery/samples/hello_world.sample @@ -1,5 +1,6 @@ +--- name: Test Sample description: This is a test sample to verify the parser. --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello World"}}}}]}} -{"beginRendering": {"surfaceId": "default", "root": "text1"}} +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "root", "props": { "component": "Text", "text": {"literalString": "Hello World"}}}]}} +{"createSurface": {"surfaceId": "default"}} diff --git a/examples/catalog_gallery/test/sample_parser_test.dart b/examples/catalog_gallery/test/sample_parser_test.dart index da3ea0e9a..b755e7ad2 100644 --- a/examples/catalog_gallery/test/sample_parser_test.dart +++ b/examples/catalog_gallery/test/sample_parser_test.dart @@ -12,8 +12,8 @@ void main() { name: Test Sample description: A test description --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} -{"beginRendering": {"surfaceId": "default", "root": "text1"}} +{"updateComponents": {"surfaceId": "default", "components": [{"id": "text1", "component": "Text", "text": {"literalString": "Hello"}}]}} +{"createSurface": {"surfaceId": "default", "catalogId": "standard"}} '''; final Sample sample = SampleParser.parseString(sampleContent); @@ -23,17 +23,16 @@ description: A test description final List messages = await sample.messages.toList(); expect(messages.length, 2); - expect(messages.first, isA()); - expect(messages.last, isA()); + expect(messages.first, isA()); + expect(messages.last, isA()); - final update = messages.first as SurfaceUpdate; + final update = messages.first as UpdateComponents; expect(update.surfaceId, 'default'); expect(update.components.length, 1); expect(update.components.first.type, 'Text'); - final begin = messages.last as BeginRendering; + final begin = messages.last as CreateSurface; expect(begin.surfaceId, 'default'); - expect(begin.root, 'text1'); }); test('SampleParser throws on missing separator', () { diff --git a/examples/catalog_gallery/test/widget_test.dart b/examples/catalog_gallery/test/widget_test.dart index 5117c55d3..964a544df 100644 --- a/examples/catalog_gallery/test/widget_test.dart +++ b/examples/catalog_gallery/test/widget_test.dart @@ -7,6 +7,7 @@ import 'package:file/memory.dart'; import 'package:file/src/interface/directory.dart'; import 'package:file/src/interface/file.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -25,9 +26,10 @@ void main() { final File sampleFile = samplesDir.childFile('test.sample'); sampleFile.writeAsStringSync(''' name: Test Sample -description: A test description +description: This is a test sample to verify the parser. --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello World"}}}}]}} +{"beginRendering": {"surfaceId": "default", "root": "text1"}} '''); await tester.pumpWidget(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); @@ -41,7 +43,45 @@ description: A test description await tester.tap(find.text('Samples')); await tester.pumpAndSettle(); + // Verify that the sample file is listed. // Verify that the sample file is listed. expect(find.text('test'), findsOneWidget); }); + + testWidgets('Loads sample with CreateSurface before SurfaceUpdate', ( + WidgetTester tester, + ) async { + final fs = MemoryFileSystem(); + final Directory samplesDir = fs.directory('/samples')..createSync(); + final File sampleFile = samplesDir.childFile('ordered.sample'); + sampleFile.writeAsStringSync(''' +name: Ordered Sample +description: Testing order. +--- +{"createSurface": {"surfaceId": "s1", "catalogId": "default"}} +{"updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": {"literalString": "Ordered Success"}}]}} +'''); + + await tester.pumpWidget(Container()); // Clear previous widget tree + await tester.pumpAndSettle(); + await tester.pumpWidget( + CatalogGalleryApp(key: UniqueKey(), samplesDir: samplesDir, fs: fs), + ); + await tester.pumpAndSettle(); + + // Tap on the Samples tab to load the view + await tester.tap(find.text('Samples')); + await tester.pumpAndSettle(); + + // Verify sample is listed + expect(find.text('ordered'), findsOneWidget); + + // Tap on sample + await tester.tap(find.text('ordered')); + await tester.pumpAndSettle(); + + // Verify surface is created and content is shown + expect(find.text('s1'), findsOneWidget); // Surface tab + expect(find.text('Ordered Success'), findsOneWidget); // Content + }); } diff --git a/examples/custom_backend/assets/data/saved-response-1.json b/examples/custom_backend/assets/data/saved-response-1.json index da064b09b..05c69e617 100644 --- a/examples/custom_backend/assets/data/saved-response-1.json +++ b/examples/custom_backend/assets/data/saved-response-1.json @@ -9,62 +9,50 @@ "args": { "components": [ { - "component": { - "Heading": { - "level": "3", - "text": { - "literalString": "Here are some of the things I can help you with:" - } - } + "component": "Heading", + "level": "3", + "text": { + "literalString": "Here are some of the things I can help you with:" }, "id": "heading_1" }, { "id": "multiple_choice_1", - "component": { - "MultipleChoice": { - "options": [ - { - "label": { - "literalString": "Show a cat fact" - }, - "value": "Show a cat fact" - }, - { - "label": { - "literalString": "Show a dog fact" - }, - "value": "Show a dog fact" - }, - { - "value": "Help me generate UI", - "label": { - "literalString": "Help me generate UI" - } - }, - { - "label": { - "literalString": "Answer a question" - }, - "value": "Answer a question" - } - ], - "selections": { - "path": "selection" + "component": "ChoicePicker", + "options": [ + { + "label": { + "literalString": "Show a cat fact" + }, + "value": "Show a cat fact" + }, + { + "label": { + "literalString": "Show a dog fact" + }, + "value": "Show a dog fact" + }, + { + "value": "Help me generate UI", + "label": { + "literalString": "Help me generate UI" } + }, + { + "label": { + "literalString": "Answer a question" + }, + "value": "Answer a question" } + ], + "value": { + "path": "selection" } }, { - "component": { - "Column": { - "children": { - "explicitList": [ - "heading_1", - "multiple_choice_1" - ] - } - } + "component": "Column", + "children": { + "explicitList": ["heading_1", "multiple_choice_1"] }, "id": "root" } diff --git a/examples/custom_backend/assets/data/saved-response-2.json b/examples/custom_backend/assets/data/saved-response-2.json index abcfb8acd..badd312eb 100644 --- a/examples/custom_backend/assets/data/saved-response-2.json +++ b/examples/custom_backend/assets/data/saved-response-2.json @@ -10,57 +10,45 @@ "surfaceId": "helpOptions", "components": [ { - "component": { - "Column": { - "children": { - "explicitList": [ - "heading", - "options" - ] - } - } + "component": "Column", + "children": { + "explicitList": ["heading", "options"] }, "id": "root" }, { - "component": { - "Heading": { - "level": "2", - "text": { - "literalString": "What can I help you with?" - } - } + "component": "Heading", + "level": "2", + "text": { + "literalString": "What can I help you with?" }, "id": "heading" }, { - "component": { - "MultipleChoice": { - "maxAllowedSelections": 1, - "options": [ - { - "value": "answer_questions", - "label": { - "literalString": "Answer questions" - } - }, - { - "value": "generate_ui", - "label": { - "literalString": "Generate UI" - } - }, - { - "label": { - "literalString": "Write code" - }, - "value": "write_code" - } - ], - "selections": { - "path": "selection" + "component": "ChoicePicker", + "usageHint": 1, + "options": [ + { + "value": "answer_questions", + "label": { + "literalString": "Answer questions" + } + }, + { + "value": "generate_ui", + "label": { + "literalString": "Generate UI" } + }, + { + "label": { + "literalString": "Write code" + }, + "value": "write_code" } + ], + "value": { + "path": "selection" }, "id": "options" } diff --git a/examples/custom_backend/assets/data/saved-response-3.json b/examples/custom_backend/assets/data/saved-response-3.json index f56a70aae..5a3f61820 100644 --- a/examples/custom_backend/assets/data/saved-response-3.json +++ b/examples/custom_backend/assets/data/saved-response-3.json @@ -9,57 +9,45 @@ "args": { "components": [ { - "component": { - "Column": { - "children": { - "explicitList": [ - "heading_1", - "multiple_choice_1" - ] - } - } + "component": "Column", + "children": { + "explicitList": ["heading_1", "multiple_choice_1"] }, "id": "root" }, { - "component": { - "Heading": { - "level": "1", - "text": { - "literalString": "How can I help you?" - } - } + "component": "Heading", + "level": "1", + "text": { + "literalString": "How can I help you?" }, "id": "heading_1" }, { - "component": { - "MultipleChoice": { - "selections": { - "path": "component.multiple_choice_1.selections" + "component": "ChoicePicker", + "value": { + "path": "component.multiple_choice_1.value" + }, + "options": [ + { + "label": { + "literalString": "Generate UI" + }, + "value": "generate_ui" + }, + { + "label": { + "literalString": "Answer questions" }, - "options": [ - { - "label": { - "literalString": "Generate UI" - }, - "value": "generate_ui" - }, - { - "label": { - "literalString": "Answer questions" - }, - "value": "answer_questions" - }, - { - "value": "summarize_text", - "label": { - "literalString": "Summarize text" - } - } - ] + "value": "answer_questions" + }, + { + "value": "summarize_text", + "label": { + "literalString": "Summarize text" + } } - }, + ], "id": "multiple_choice_1" } ], diff --git a/examples/custom_backend/test/backend_api_test.dart b/examples/custom_backend/test/backend_api_test.dart index bee3e8989..3c8a49c82 100644 --- a/examples/custom_backend/test/backend_api_test.dart +++ b/examples/custom_backend/test/backend_api_test.dart @@ -32,8 +32,8 @@ void main() { ); expect(result, isNotNull); expect(result!.messages.length, 2); - expect(result.messages[0], isA()); - expect(result.messages[1], isA()); + expect(result.messages[0], isA()); + expect(result.messages[1], isA()); }, retry: 3, timeout: const Timeout(Duration(minutes: 2)), diff --git a/examples/travel_app/.metadata b/examples/travel_app/.metadata index 5492cf186..298107264 100644 --- a/examples/travel_app/.metadata +++ b/examples/travel_app/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: linux - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: macos + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 # User provided section diff --git a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart index 741eae783..4a430d719 100644 --- a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart +++ b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart @@ -80,22 +80,19 @@ final checkboxFilterChipsInput = CatalogItem( [ { "id": "root", - "component": { - "CheckboxFilterChipsInput": { - "chipLabel": "Amenities", - "options": [ - "Wifi", - "Gym", - "Pool", - "Parking" - ], - "selectedOptions": { - "literalArray": [ - "Wifi", - "Gym" - ] - } - } + "component": "CheckboxFilterChipsInput", + "chipLabel": "Amenities", + "options": [ + "Wifi", + "Gym", + "Pool", + "Parking" + ], + "selectedOptions": { + "literalArray": [ + "Wifi", + "Gym" + ] } } ] diff --git a/examples/travel_app/lib/src/catalog/date_input_chip.dart b/examples/travel_app/lib/src/catalog/date_input_chip.dart index b0eaf3143..d93c2bec9 100644 --- a/examples/travel_app/lib/src/catalog/date_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/date_input_chip.dart @@ -116,14 +116,11 @@ final dateInputChip = CatalogItem( [ { "id": "root", - "component": { - "DateInputChip": { - "value": { - "literalString": "1871-07-22" - }, - "label": "Your birth date" - } - } + "component": "DateInputChip", + "value": { + "literalString": "1871-07-22" + }, + "label": "Your birth date" } ] ''', diff --git a/examples/travel_app/lib/src/catalog/information_card.dart b/examples/travel_app/lib/src/catalog/information_card.dart index 380270569..8120b3f7b 100644 --- a/examples/travel_app/lib/src/catalog/information_card.dart +++ b/examples/travel_app/lib/src/catalog/information_card.dart @@ -54,29 +54,23 @@ final informationCard = CatalogItem( [ { "id": "root", - "component": { - "InformationCard": { - "title": { - "literalString": "Beautiful Scenery" - }, - "subtitle": { - "literalString": "A stunning view" - }, - "body": { - "literalString": "This is a beautiful place to visit in the summer." - }, - "imageChildId": "image1" - } - } + "component": "InformationCard", + "title": { + "literalString": "Beautiful Scenery" + }, + "subtitle": { + "literalString": "A stunning view" + }, + "body": { + "literalString": "This is a beautiful place to visit in the summer." + }, + "imageChildId": "image1" }, { "id": "image1", - "component": { - "Image": { - "url": { - "literalString": "assets/travel_images/canyonlands_national_park_utah.jpg" - } - } + "component": "Image", + "url": { + "literalString": "assets/travel_images/canyonlands_national_park_utah.jpg" } } ] diff --git a/examples/travel_app/lib/src/catalog/input_group.dart b/examples/travel_app/lib/src/catalog/input_group.dart index ed16a7c95..976353630 100644 --- a/examples/travel_app/lib/src/catalog/input_group.dart +++ b/examples/travel_app/lib/src/catalog/input_group.dart @@ -58,60 +58,45 @@ final inputGroup = CatalogItem( [ { "id": "root", - "component": { - "InputGroup": { - "submitLabel": { - "literalString": "Submit" - }, - "children": [ - "check_in", - "check_out", - "text_input1", - "text_input2" - ], - "action": { - "name": "submit_form" - } - } + "component": "InputGroup", + "submitLabel": { + "literalString": "Submit" + }, + "children": [ + "check_in", + "check_out", + "text_input1", + "text_input2" + ], + "action": { + "name": "submit_form" } }, { "id": "check_in", - "component": { - "DateInputChip": { - "value": { - "literalString": "2026-07-22" - }, - "label": "Check-in date" - } - } + "component": "DateInputChip", + "value": { + "literalString": "2026-07-22" + }, + "label": "Check-in date" }, { "id": "check_out", - "component": { - "DateInputChip": { - "label": "Check-out date" - } - } + "component": "DateInputChip", + "label": "Check-out date" }, { "id": "text_input1", - "component": { - "TextInputChip": { - "value": { - "literalString": "John Doe" - }, - "label": "Enter your name" - } - } + "component": "TextInputChip", + "value": { + "literalString": "John Doe" + }, + "label": "Enter your name" }, { "id": "text_input2", - "component": { - "TextInputChip": { - "label": "Enter your friend's name" - } - } + "component": "TextInputChip", + "label": "Enter your friend's name" } ] ''', @@ -129,8 +114,8 @@ final inputGroup = CatalogItem( final List children = inputGroupData.children; final JsonMap actionData = inputGroupData.action; final name = actionData['name'] as String; - final List contextDefinition = - (actionData['context'] as List?) ?? []; + final Map contextDefinition = + (actionData['context'] as Map?) ?? {}; return Card( color: Theme.of(itemContext.buildContext).colorScheme.primaryContainer, diff --git a/examples/travel_app/lib/src/catalog/itinerary.dart b/examples/travel_app/lib/src/catalog/itinerary.dart index d28b4eb51..e8cbac31c 100644 --- a/examples/travel_app/lib/src/catalog/itinerary.dart +++ b/examples/travel_app/lib/src/catalog/itinerary.dart @@ -155,65 +155,56 @@ final itinerary = CatalogItem( [ { "id": "root", - "component": { - "Itinerary": { + "component": "Itinerary", + "title": { + "literalString": "My Awesome Trip" + }, + "subheading": { + "literalString": "A 3-day adventure" + }, + "imageChildId": "image1", + "days": [ + { "title": { - "literalString": "My Awesome Trip" + "literalString": "Day 1" + }, + "subtitle": { + "literalString": "Arrival and Exploration" }, - "subheading": { - "literalString": "A 3-day adventure" + "description": { + "literalString": "Welcome to the city!" }, - "imageChildId": "image1", - "days": [ + "imageChildId": "image2", + "entries": [ { "title": { - "literalString": "Day 1" + "literalString": "Check-in to Hotel" }, - "subtitle": { - "literalString": "Arrival and Exploration" + "bodyText": { + "literalString": "Check-in to your hotel and relax." }, - "description": { - "literalString": "Welcome to the city!" + "time": { + "literalString": "3:00 PM" }, - "imageChildId": "image2", - "entries": [ - { - "title": { - "literalString": "Check-in to Hotel" - }, - "bodyText": { - "literalString": "Check-in to your hotel and relax." - }, - "time": { - "literalString": "3:00 PM" - }, - "type": "accommodation", - "status": "noBookingRequired" - } - ] + "type": "accommodation", + "status": "noBookingRequired" } ] } - } + ] }, { "id": "image1", - "component": { - "Image": { - "url": { - "literalString": "assets/travel_images/canyonlands_national_park_utah.jpg" - } - } + "component": "Image", + "url": { + "literalString": "assets/travel_images/canyonlands_national_park_utah.jpg" } }, { "id": "image2", - "component": { - "Image": { - "url": { - "literalString": "assets/travel_images/brooklyn_bridge_new_york.jpg" - } - } + "component": "Image", + "url": { + "literalString": "assets/travel_images/brooklyn_bridge_new_york.jpg" } } ] @@ -550,9 +541,10 @@ class _ItineraryEntry extends StatelessWidget { return; } final actionName = actionData['name'] as String; - final List contextDefinition = - (actionData['context'] as List?) ?? - []; + final Map contextDefinition = + (actionData['context'] + as Map?) ?? + {}; final JsonMap resolvedContext = resolveContext( dataContext, contextDefinition, diff --git a/examples/travel_app/lib/src/catalog/listings_booker.dart b/examples/travel_app/lib/src/catalog/listings_booker.dart index 457c74a16..b92ebd54b 100644 --- a/examples/travel_app/lib/src/catalog/listings_booker.dart +++ b/examples/travel_app/lib/src/catalog/listings_booker.dart @@ -99,12 +99,9 @@ final listingsBooker = CatalogItem( return jsonEncode([ { 'id': 'root', - 'component': { - 'ListingsBooker': { - 'listingSelectionIds': [listingSelectionId1, listingSelectionId2], - 'itineraryName': {'literalString': 'Dart and Flutter deep dive'}, - }, - }, + 'component': 'ListingsBooker', + 'listingSelectionIds': [listingSelectionId1, listingSelectionId2], + 'itineraryName': {'literalString': 'Dart and Flutter deep dive'}, }, ]); }, @@ -333,9 +330,10 @@ class _ListingsBookerState extends State<_ListingsBooker> { return; } final actionName = actionData['name'] as String; - final List contextDefinition = - (actionData['context'] as List?) ?? - []; + final Map contextDefinition = + (actionData['context'] + as Map?) ?? + {}; final JsonMap resolvedContext = resolveContext( widget.dataContext, contextDefinition, diff --git a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart index 50c5398cc..b1b46fd89 100644 --- a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart +++ b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart @@ -76,18 +76,15 @@ final optionsFilterChipInput = CatalogItem( [ { "id": "root", - "component": { - "OptionsFilterChipInput": { - "chipLabel": "Budget", - "options": [ - "\$", - "\$\$", - "\$\$\$" - ], - "value": { - "literalString": "\$\$" - } - } + "component": "OptionsFilterChipInput", + "chipLabel": "Budget", + "options": [ + "\$", + "\$\$", + "\$\$\$" + ], + "value": { + "literalString": "\$\$" } } ] diff --git a/examples/travel_app/lib/src/catalog/tabbed_sections.dart b/examples/travel_app/lib/src/catalog/tabbed_sections.dart index f2eed8fc0..a67ae0e90 100644 --- a/examples/travel_app/lib/src/catalog/tabbed_sections.dart +++ b/examples/travel_app/lib/src/catalog/tabbed_sections.dart @@ -60,43 +60,34 @@ final tabbedSections = CatalogItem( [ { "id": "root", - "component": { - "TabbedSections": { - "sections": [ - { - "title": { - "literalString": "Tab 1" - }, - "child": "tab1_content" - }, - { - "title": { - "literalString": "Tab 2" - }, - "child": "tab2_content" - } - ] + "component": "TabbedSections", + "sections": [ + { + "title": { + "literalString": "Tab 1" + }, + "child": "tab1_content" + }, + { + "title": { + "literalString": "Tab 2" + }, + "child": "tab2_content" } - } + ] }, { "id": "tab1_content", - "component": { - "Text": { - "text": { - "literalString": "This is the content of Tab 1." - } - } + "component": "Text", + "text": { + "literalString": "This is the content of Tab 1." } }, { "id": "tab2_content", - "component": { - "Text": { - "text": { - "literalString": "This is the content of Tab 2." - } - } + "component": "Text", + "text": { + "literalString": "This is the content of Tab 2." } } ] diff --git a/examples/travel_app/lib/src/catalog/text_input_chip.dart b/examples/travel_app/lib/src/catalog/text_input_chip.dart index 8c663f1c6..1a6701a3d 100644 --- a/examples/travel_app/lib/src/catalog/text_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/text_input_chip.dart @@ -46,14 +46,11 @@ final textInputChip = CatalogItem( [ { "id": "root", - "component": { - "TextInputChip": { - "value": { - "literalString": "John Doe" - }, - "label": "Enter your name" - } - } + "component": "TextInputChip", + "value": { + "literalString": "John Doe" + }, + "label": "Enter your name" } ] ''', @@ -61,12 +58,9 @@ final textInputChip = CatalogItem( [ { "id": "root", - "component": { - "TextInputChip": { - "label": "Enter your password", - "obscured": true - } - } + "component": "TextInputChip", + "label": "Enter your password", + "obscured": true } ] ''', diff --git a/examples/travel_app/lib/src/catalog/trailhead.dart b/examples/travel_app/lib/src/catalog/trailhead.dart index d2ed234b9..32b7980a8 100644 --- a/examples/travel_app/lib/src/catalog/trailhead.dart +++ b/examples/travel_app/lib/src/catalog/trailhead.dart @@ -48,23 +48,20 @@ final trailhead = CatalogItem( [ { "id": "root", - "component": { - "Trailhead": { - "topics": [ - { - "literalString": "Topic 1" - }, - { - "literalString": "Topic 2" - }, - { - "literalString": "Topic 3" - } - ], - "action": { - "name": "select_topic" - } + "component": "Trailhead", + "topics": [ + { + "literalString": "Topic 1" + }, + { + "literalString": "Topic 2" + }, + { + "literalString": "Topic 3" } + ], + "action": { + "name": "select_topic" } } ] @@ -121,8 +118,8 @@ class _Trailhead extends StatelessWidget { label: Text(topic), onPressed: () { final name = action['name'] as String; - final List contextDefinition = - (action['context'] as List?) ?? []; + final Map contextDefinition = + (action['context'] as Map?) ?? {}; final JsonMap resolvedContext = resolveContext( dataContext, contextDefinition, diff --git a/examples/travel_app/lib/src/catalog/travel_carousel.dart b/examples/travel_app/lib/src/catalog/travel_carousel.dart index 9701b13a2..ea4f89b61 100644 --- a/examples/travel_app/lib/src/catalog/travel_carousel.dart +++ b/examples/travel_app/lib/src/catalog/travel_carousel.dart @@ -236,8 +236,8 @@ class _TravelCarouselItem extends StatelessWidget { child: InkWell( onTap: () { final name = data.action['name'] as String; - final List contextDefinition = - (data.action['context'] as List?) ?? []; + final Map contextDefinition = + (data.action['context'] as Map?) ?? {}; final JsonMap resolvedContext = resolveContext( dataContext, contextDefinition, @@ -302,42 +302,33 @@ String _hotelExample() { return jsonEncode([ { 'id': 'root', - 'component': { - 'TravelCarousel': { - 'items': [ - { - 'description': {'literalString': hotel1.description}, - 'imageChildId': 'image_1', - 'listingSelectionId': '12345', - 'action': {'name': 'selectHotel'}, - }, - { - 'description': {'literalString': hotel2.description}, - 'imageChildId': 'image_2', - 'listingSelectionId': '12346', - 'action': {'name': 'selectHotel'}, - }, - ], + 'component': 'TravelCarousel', + 'items': [ + { + 'description': {'literalString': hotel1.description}, + 'imageChildId': 'image_1', + 'listingSelectionId': '12345', + 'action': {'name': 'selectHotel'}, }, - }, + { + 'description': {'literalString': hotel2.description}, + 'imageChildId': 'image_2', + 'listingSelectionId': '12346', + 'action': {'name': 'selectHotel'}, + }, + ], }, { 'id': 'image_1', - 'component': { - 'Image': { - 'fit': 'cover', - 'url': {'literalString': hotel1.images[0]}, - }, - }, + 'component': 'Image', + 'fit': 'cover', + 'url': {'literalString': hotel1.images[0]}, }, { 'id': 'image_2', - 'component': { - 'Image': { - 'fit': 'cover', - 'url': {'literalString': hotel2.images[0]}, - }, - }, + 'component': 'Image', + 'fit': 'cover', + 'url': {'literalString': hotel2.images[0]}, }, ]); } @@ -346,114 +337,93 @@ String _inspirationExample() => ''' [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": ["inspiration_title", "inspiration_carousel"] - } - } + "component": "Column", + "children": { + "explicitList": ["inspiration_title", "inspiration_carousel"] } }, { "id": "inspiration_title", - "component": { - "Text": { - "text": { - "literalString": "Let's plan your dream trip to Greece! What kind of experience are you looking for?" - } - } + "component": "Text", + "text": { + "literalString": "Let's plan your dream trip to Greece! What kind of experience are you looking for?" } }, { "id": "inspiration_carousel", - "component": { - "TravelCarousel": { - "items": [ - { - "description": { - "literalString": "Relaxing Beach Holiday" - }, - "imageChildId": "santorini_beach_image", - "listingSelectionId": "12345", - "action": { - "name": "selectExperience" - } - }, - { - "imageChildId": "akrotiri_fresco_image", - "description": { - "literalString": "Cultural Exploration" - }, - "listingSelectionId": "12346", - "action": { - "name": "selectExperience" - } - }, - { - "imageChildId": "santorini_caldera_image", - "description": { - "literalString": "Adventure & Outdoors" - }, - "listingSelectionId": "12347", - "action": { - "name": "selectExperience" - } - }, - { - "description": { - "literalString": "Foodie Tour" - }, - "imageChildId": "greece_food_image", - "action": { - "name": "selectExperience" - } - } - ] + "component": "TravelCarousel", + "items": [ + { + "description": { + "literalString": "Relaxing Beach Holiday" + }, + "imageChildId": "santorini_beach_image", + "listingSelectionId": "12345", + "action": { + "name": "selectExperience" + } + }, + { + "imageChildId": "akrotiri_fresco_image", + "description": { + "literalString": "Cultural Exploration" + }, + "listingSelectionId": "12346", + "action": { + "name": "selectExperience" + } + }, + { + "imageChildId": "santorini_caldera_image", + "description": { + "literalString": "Adventure & Outdoors" + }, + "listingSelectionId": "12347", + "action": { + "name": "selectExperience" + } + }, + { + "description": { + "literalString": "Foodie Tour" + }, + "imageChildId": "greece_food_image", + "action": { + "name": "selectExperience" + } } - } + ] }, { "id": "santorini_beach_image", - "component": { - "Image": { - "fit": "cover", - "url": { - "literalString": "assets/travel_images/santorini_panorama.jpg" - } - } + "component": "Image", + "fit": "cover", + "url": { + "literalString": "assets/travel_images/santorini_panorama.jpg" } }, { "id": "akrotiri_fresco_image", - "component": { - "Image": { - "fit": "cover", - "url": { - "literalString": "assets/travel_images/akrotiri_spring_fresco_santorini.jpg" - } - } + "component": "Image", + "fit": "cover", + "url": { + "literalString": "assets/travel_images/akrotiri_spring_fresco_santorini.jpg" } }, { "id": "santorini_caldera_image", - "component": { - "Image": { - "url": { - "literalString": "assets/travel_images/santorini_from_space.jpg" - }, - "fit": "cover" - } - } + "component": "Image", + "url": { + "literalString": "assets/travel_images/santorini_from_space.jpg" + }, + "fit": "cover" }, { "id": "greece_food_image", - "component": { - "Image": { - "fit": "cover", - "url": { - "literalString": "assets/travel_images/saffron_gatherers_fresco_santorini.jpg" - } - } + "component": "Image", + "fit": "cover", + "url": { + "literalString": "assets/travel_images/saffron_gatherers_fresco_santorini.jpg" } } ] diff --git a/examples/travel_app/lib/src/travel_planner_page.dart b/examples/travel_app/lib/src/travel_planner_page.dart index e6ad0a8ae..f0e3ac099 100644 --- a/examples/travel_app/lib/src/travel_planner_page.dart +++ b/examples/travel_app/lib/src/travel_planner_page.dart @@ -346,11 +346,9 @@ the user can return to the main booking flow once they have done some research. ## Controlling the UI -Use the provided tools to build and manage the user interface in response to the -user's requests. To display or update a UI, you must first call the -`surfaceUpdate` tool to define all the necessary components. After defining the -components, you must call the `beginRendering` tool to specify the root -component that should be displayed. +To display or update a UI, you must output a `surfaceUpdate` message in JSONL format. +The `surfaceUpdate` message must define all necessary components and specify the root +component in the `components` list. The root component must have the ID "root". - Adding surfaces: Most of the time, you should only add new surfaces to the conversation. This is less confusing for the user, because they can easily @@ -361,9 +359,6 @@ component that should be displayed. user because it avoids confusing the conversation with many versions of the same itinerary etc. -Once you add or update a surface and are waiting for user input, the -conversation turn is complete, and you should call the provideFinalOutput tool. - If you are displaying more than one component, you should use a `Column` widget as the root and add the other components as children. @@ -426,110 +421,14 @@ ${_imagesJson ?? ''} ## Example -Here is an example of the arguments to the `surfaceUpdate` tool. Note that the -`root` widget ID must be present in the `widgets` list, and it should contain -the other widgets. - -```json -{ - "surfaceId": "mexico_trip_planner", - "definition": { - "root": "root_column", - "widgets": [ - { - "id": "root_column", - "widget": { - "Column": { - "children": ["trip_title", "itinerary"] - } - } - }, - { - "id": "trip_title", - "widget": { - "Text": { - "text": "Trip to Mexico City" - } - } - }, - { - "id": "itinerary", - "widget": { - "ItineraryWithDetails": { - "title": "Mexico City Adventure", - "subheading": "3-day Itinerary", - "imageChildId": "mexico_city_image", - "child": "itinerary_details" - } - } - }, - { - "id": "mexico_city_image", - "widget": { - "Image": { - "location": "assets/travel_images/mexico_city.jpg" - } - } - }, - { - "id": "itinerary_details", - "widget": { - "Column": { - "children": ["day1"] - } - } - }, - { - "id": "day1", - "widget": { - "ItineraryDay": { - "title": "Day 1", - "subtitle": "Arrival and Exploration", - "description": "Your first day in Mexico City will be focused on settling in and exploring the historic center.", - "imageChildId": "day1_image", - "children": ["day1_entry1", "day1_entry2"] - } - } - }, - { - "id": "day1_image", - "widget": { - "Image": { - "location": "assets/travel_images/mexico_city.jpg" - } - } - }, - { - "id": "day1_entry1", - "widget": { - "ItineraryEntry": { - "type": "transport", - "title": "Arrival at MEX Airport", - "time": "2:00 PM", - "bodyText": "Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage.", - "status": "noBookingRequired" - } - } - }, - { - "id": "day1_entry2", - "widget": { - "ItineraryEntry": { - "type": "activity", - "title": "Explore the Zocalo", - "subtitle": "Historic Center", - "time": "4:00 PM - 6:00 PM", - "address": "Plaza de la Constitución S/N, Centro Histórico, Ciudad de México", - "bodyText": "Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace.", - "status": "noBookingRequired" - } - } - } - ] - } -} +Here is an example of a `surfaceUpdate` message. Note that the `root` component +ID must be present in the `components` list, and it should contain the other +components. + +```jsonl +{"surfaceUpdate":{"surfaceId":"mexico_trip_planner","components":[{"id":"root","props":{"component":"Column","children":{"explicitList":["trip_title","itinerary"]}}},{"id":"trip_title","props":{"component":"Text","text":{"literalString":"Trip to Mexico City"}}},{"id":"itinerary","props":{"component":"Itinerary","title":{"literalString":"Mexico City Adventure"},"subheading":{"literalString":"3-day Itinerary"},"imageChildId":"mexico_city_image","days":[{"title":{"literalString":"Day 1"},"subtitle":{"literalString":"Arrival and Exploration"},"description":{"literalString":"Your first day in Mexico City will be focused on settling in and exploring the historic center."},"imageChildId":"day1_image","entries":[{"type":"transport","title":{"literalString":"Arrival at MEX Airport"},"time":{"literalString":"2:00 PM"},"bodyText":{"literalString":"Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage."},"status":"noBookingRequired"},{"type":"activity","title":{"literalString":"Explore the Zocalo"},"subtitle":{"literalString":"Historic Center"},"time":{"literalString":"4:00 PM - 6:00 PM"},"address":{"literalString":"Plaza de la Constitución S/N, Centro Histórico, Ciudad de México"},"bodyText":{"literalString":"Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace."},"status":"noBookingRequired"}]}]}},{"id":"mexico_city_image","props":{"component":"Image","url":{"literalString":"assets/travel_images/mexico_city.jpg"}}},{"id":"day1_image","props":{"component":"Image","url":{"literalString":"assets/travel_images/mexico_city.jpg"}}}]}} ``` -When updating or showing UIs, **ALWAYS** use the surfaceUpdate tool to supply +When updating or showing UIs, **ALWAYS** send a `surfaceUpdate` message to supply them. Prefer to collect and show information by creating a UI for it. '''; diff --git a/examples/travel_app/macos/.gitignore b/examples/travel_app/macos/.gitignore index 8effa017e..746adbb6b 100644 --- a/examples/travel_app/macos/.gitignore +++ b/examples/travel_app/macos/.gitignore @@ -1,7 +1,5 @@ # Flutter-related **/Flutter/ephemeral/ -**/Podfile -**/Podfile.lock **/Pods/ # Xcode-related diff --git a/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj b/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj index d95b8795b..545b04294 100644 --- a/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj +++ b/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj @@ -21,15 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 14D3C6AA03A2F53BEAA1E023 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 63482C6A6C08BB9F81EB622A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5B76D750F31DB184023289C /* Pods_Runner.framework */; }; - 92C895485B5D6EAE40BD982F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */; }; + 734E853C59BD3038134FBAE7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */; }; + 950994DFE82E78DF50C13CCF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,12 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* genui_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = genui_client.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* travel_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = travel_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -80,16 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 75E4CFAB98E85B3E246F3185 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 774839EDC278525C3B970ACB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 54EF4ABFFBBD4ED2E6E8C8E2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8401E357C5F002F289CB4C77 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - B5B76D750F31DB184023289C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + B5905576485F075C2C41063C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + FBE1367E274CF2427DB0841C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 92C895485B5D6EAE40BD982F /* Pods_RunnerTests.framework in Frameworks */, + 950994DFE82E78DF50C13CCF /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -105,7 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 63482C6A6C08BB9F81EB622A /* Pods_Runner.framework in Frameworks */, + 734E853C59BD3038134FBAE7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -139,15 +137,14 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - EC60867614928B307CB4BFFC /* Pods */, - F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */, + 9DC24E8C54350212698238F5 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* genui_client.app */, + 33CC10ED2044A3C60003C045 /* travel_app.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -188,27 +185,27 @@ path = Runner; sourceTree = ""; }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { + 9DC24E8C54350212698238F5 /* Pods */ = { isa = PBXGroup; children = ( - B5B76D750F31DB184023289C /* Pods_Runner.framework */, - 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */, + FBE1367E274CF2427DB0841C /* Pods-Runner.debug.xcconfig */, + B5905576485F075C2C41063C /* Pods-Runner.release.xcconfig */, + 54EF4ABFFBBD4ED2E6E8C8E2 /* Pods-Runner.profile.xcconfig */, + 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */, + 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */, + 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */, ); - name = Frameworks; + name = Pods; + path = Pods; sourceTree = ""; }; - EC60867614928B307CB4BFFC /* Pods */ = { + D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 75E4CFAB98E85B3E246F3185 /* Pods-Runner.debug.xcconfig */, - 8401E357C5F002F289CB4C77 /* Pods-Runner.release.xcconfig */, - 774839EDC278525C3B970ACB /* Pods-Runner.profile.xcconfig */, - 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */, - 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */, - 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */, + 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */, + 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */, ); - name = Pods; - path = Pods; + name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ @@ -218,7 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 3C4AA3BB3F3CD21560A46BB8 /* [CP] Check Pods Manifest.lock */, + 309CCC2F67C53A49EBA4B5E5 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -237,13 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 703477750A6EA69C0FBAFEB2 /* [CP] Check Pods Manifest.lock */, + AA7676B8E6E029E44ABAE708 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 997FC1770EA917D1C5764C28 /* [CP] Embed Pods Frameworks */, + 5C48C64FE089AB0E5D33E410 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -252,7 +249,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* genui_client.app */; + productReference = 33CC10ED2044A3C60003C045 /* travel_app.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -320,13 +317,34 @@ files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - 14D3C6AA03A2F53BEAA1E023 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 309CCC2F67C53A49EBA4B5E5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -365,29 +383,24 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 3C4AA3BB3F3CD21560A46BB8 /* [CP] Check Pods Manifest.lock */ = { + 5C48C64FE089AB0E5D33E410 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 703477750A6EA69C0FBAFEB2 /* [CP] Check Pods Manifest.lock */ = { + AA7676B8E6E029E44ABAE708 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -409,23 +422,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 997FC1770EA917D1C5764C28 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -477,46 +473,46 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Debug; }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Release; }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Profile; }; diff --git a/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b90d0dbaa..af141e5f7 100644 --- a/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig b/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig index c3c2aacec..69237d8c7 100644 --- a/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig +++ b/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = genui_client +PRODUCT_NAME = travel_app // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 dev.flutter.genui. +PRODUCT_COPYRIGHT = Copyright © 2025 dev.flutter.genui. All rights reserved. diff --git a/examples/travel_app/test/itinerary_test.dart b/examples/travel_app/test/itinerary_test.dart index 1fb08512d..775ed3534 100644 --- a/examples/travel_app/test/itinerary_test.dart +++ b/examples/travel_app/test/itinerary_test.dart @@ -38,7 +38,7 @@ void main() { 'status': 'choiceRequired', 'choiceRequiredAction': { 'name': 'testAction', - 'context': [], + 'context': {}, }, }, ], diff --git a/examples/travel_app/test/widgets/conversation_test.dart b/examples/travel_app/test/widgets/conversation_test.dart index 1b37a4dff..39e5debc0 100644 --- a/examples/travel_app/test/widgets/conversation_test.dart +++ b/examples/travel_app/test/widgets/conversation_test.dart @@ -26,19 +26,18 @@ void main() { ]; final components = [ const Component( - id: 'r1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hi there!'}, - }, + id: 'root', + props: { + 'component': 'Text', + 'text': {'literalString': 'Hi there!'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'r1'), + UpdateComponents(surfaceId: surfaceId, components: components), ); await tester.pumpWidget( @@ -79,18 +78,17 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { - 'Text': { - 'text': {'literalString': 'UI Content'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'UI Content'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + UpdateComponents(surfaceId: surfaceId, components: components), ); await tester.pumpWidget( MaterialApp( diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index d5db9e968..6a511d658 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -86,12 +86,16 @@ class Ai extends _$Ai { contentGenerator.a2uiMessageStream.listen((message) { switch (message) { - case BeginRendering(): - surfaceUpdateController.add(message.surfaceId); - case SurfaceUpdate(): - case DataModelUpdate(): + case CreateSurface(:final surfaceId): + case UpdateComponents(:final surfaceId): + surfaceUpdateController.add(surfaceId); + case UpdateDataModel(:final surfaceId): + surfaceUpdateController.add(surfaceId); case SurfaceDeletion(): - // We only navigate on BeginRendering. + // We only navigate on BeginRendering. + break; + case ErrorMessage(:final code, :final message): + appLogger.severe('Received A2UI Error: $code: $message'); } }); diff --git a/packages/genui/lib/genui.dart b/packages/genui/lib/genui.dart index bbe293e8a..d7496b92b 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -30,5 +30,6 @@ export 'src/model/chat_message.dart'; export 'src/model/data_model.dart'; export 'src/model/tools.dart'; export 'src/model/ui_models.dart'; +export 'src/primitives/constants.dart'; export 'src/primitives/logging.dart'; export 'src/primitives/simple_items.dart'; diff --git a/packages/genui/lib/src/catalog/core_catalog.dart b/packages/genui/lib/src/catalog/core_catalog.dart index 66481ad64..a4d6d874f 100644 --- a/packages/genui/lib/src/catalog/core_catalog.dart +++ b/packages/genui/lib/src/catalog/core_catalog.dart @@ -9,6 +9,7 @@ import 'core_widgets/audio_player.dart' as audio_player_item; import 'core_widgets/button.dart' as button_item; import 'core_widgets/card.dart' as card_item; import 'core_widgets/check_box.dart' as check_box_item; +import 'core_widgets/choice_picker.dart' as choice_picker_item; import 'core_widgets/column.dart' as column_item; import 'core_widgets/date_time_input.dart' as date_time_input_item; import 'core_widgets/divider.dart' as divider_item; @@ -16,7 +17,6 @@ import 'core_widgets/icon.dart' as icon_item; import 'core_widgets/image.dart' as image_item; import 'core_widgets/list.dart' as list_item; import 'core_widgets/modal.dart' as modal_item; -import 'core_widgets/multiple_choice.dart' as multiple_choice_item; import 'core_widgets/row.dart' as row_item; import 'core_widgets/slider.dart' as slider_item; import 'core_widgets/tabs.dart' as tabs_item; @@ -85,7 +85,7 @@ class CoreCatalogItems { /// Represents a widget allowing the user to select one or more options from a /// list. - static final CatalogItem multipleChoice = multiple_choice_item.multipleChoice; + static final CatalogItem choicePicker = choice_picker_item.choicePicker; /// Represents a layout widget that arranges its children in a horizontal /// sequence. @@ -123,7 +123,7 @@ class CoreCatalogItems { image, list, modal, - multipleChoice, + choicePicker, row, slider, tabs, diff --git a/packages/genui/lib/src/catalog/core_widgets/audio_player.dart b/packages/genui/lib/src/catalog/core_widgets/audio_player.dart index 9d58af8ae..3a5979960 100644 --- a/packages/genui/lib/src/catalog/core_widgets/audio_player.dart +++ b/packages/genui/lib/src/catalog/core_widgets/audio_player.dart @@ -39,12 +39,9 @@ final audioPlayer = CatalogItem( [ { "id": "root", - "component": { - "AudioPlayer": { - "url": { - "literalString": "https://example.com/audio.mp3" - } - } + "component": "AudioPlayer", + "url": { + "literalString": "https://example.com/audio.mp3" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/button.dart b/packages/genui/lib/src/catalog/core_widgets/button.dart index 6de2f2857..2eba23606 100644 --- a/packages/genui/lib/src/catalog/core_widgets/button.dart +++ b/packages/genui/lib/src/catalog/core_widgets/button.dart @@ -65,8 +65,8 @@ final button = CatalogItem( final Widget child = itemContext.buildChild(buttonData.child); final JsonMap actionData = buttonData.action; final actionName = actionData['name'] as String; - final List contextDefinition = - (actionData['context'] as List?) ?? []; + final Map contextDefinition = + (actionData['context'] as Map?) ?? {}; genUiLogger.info('Building Button with child: ${buttonData.child}'); final ColorScheme colorScheme = Theme.of( @@ -109,23 +109,17 @@ final button = CatalogItem( [ { "id": "root", - "component": { - "Button": { - "child": "text", - "action": { - "name": "button_pressed" - } - } + "component": "Button", + "child": "text", + "action": { + "name": "button_pressed" } }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "Hello World" - } - } + "component": "Text", + "text": { + "literalString": "Hello World" } } ] @@ -134,55 +128,40 @@ final button = CatalogItem( [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": ["primaryButton", "secondaryButton"] - } - } + "component": "Column", + "children": { + "explicitList": ["primaryButton", "secondaryButton"] } }, { "id": "primaryButton", - "component": { - "Button": { - "child": "primaryText", - "primary": true, - "action": { - "name": "primary_pressed" - } - } + "component": "Button", + "child": "primaryText", + "primary": true, + "action": { + "name": "primary_pressed" } }, { "id": "secondaryButton", - "component": { - "Button": { - "child": "secondaryText", - "action": { - "name": "secondary_pressed" - } - } + "component": "Button", + "child": "secondaryText", + "action": { + "name": "secondary_pressed" } }, { "id": "primaryText", - "component": { - "Text": { - "text": { - "literalString": "Primary Button" - } - } + "component": "Text", + "text": { + "literalString": "Primary Button" } }, { "id": "secondaryText", - "component": { - "Text": { - "text": { - "literalString": "Secondary Button" - } - } + "component": "Text", + "text": { + "literalString": "Secondary Button" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/card.dart b/packages/genui/lib/src/catalog/core_widgets/card.dart index 9320d9b21..cf22a3fad 100644 --- a/packages/genui/lib/src/catalog/core_widgets/card.dart +++ b/packages/genui/lib/src/catalog/core_widgets/card.dart @@ -48,20 +48,14 @@ final card = CatalogItem( [ { "id": "root", - "component": { - "Card": { - "child": "text" - } - } + "component": "Card", + "child": "text" }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "This is a card." - } - } + "component": "Text", + "text": { + "literalString": "This is a card." } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/check_box.dart b/packages/genui/lib/src/catalog/core_widgets/check_box.dart index 17e896e90..0e54270ef 100644 --- a/packages/genui/lib/src/catalog/core_widgets/check_box.dart +++ b/packages/genui/lib/src/catalog/core_widgets/check_box.dart @@ -76,16 +76,13 @@ final checkBox = CatalogItem( [ { "id": "root", - "component": { - "CheckBox": { - "label": { - "literalString": "Check me" - }, - "value": { - "path": "/myValue", - "literalBoolean": true - } - } + "component": "CheckBox", + "label": { + "literalString": "Check me" + }, + "value": { + "path": "/myValue", + "literalBoolean": true } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/choice_picker.dart b/packages/genui/lib/src/catalog/core_widgets/choice_picker.dart new file mode 100644 index 000000000..9cf930ee4 --- /dev/null +++ b/packages/genui/lib/src/catalog/core_widgets/choice_picker.dart @@ -0,0 +1,246 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../../core/widget_utilities.dart'; +import '../../model/a2ui_schemas.dart'; +import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; +import '../../primitives/simple_items.dart'; + +final _schema = S.object( + properties: { + 'value': A2uiSchemas.stringArrayReference(), + 'options': A2uiSchemas.objectArrayReference(), + 'usageHint': S.string( + description: 'Hint for how the choice picker should be displayed.', + enumValues: ['multipleSelection', 'mutuallyExclusive'], + ), + }, + required: ['value', 'options'], +); + +extension type _ChoicePickerData.fromMap(JsonMap _json) { + factory _ChoicePickerData({ + required JsonMap value, + required JsonMap options, + String? usageHint, + }) => _ChoicePickerData.fromMap({ + 'value': value, + 'options': options, + 'usageHint': usageHint, + }); + + JsonMap get value => _json['value'] as JsonMap; + JsonMap get options => _json['options'] as JsonMap; + String? get usageHint => _json['usageHint'] as String?; +} + +/// A catalog item representing a choice picker widget. +/// +/// This widget displays a list of options, each with a checkbox or radio +/// button. +/// +/// The `value` parameter, which should be a data model path, is updated to +/// reflect the list of *values* of the currently selected options. +/// +/// ## Parameters: +/// +/// - `value`: A list of the values of the selected options. +/// - `options`: A list of options to display, each with a `label` and a +/// `value`. +/// - `usageHint`: Hints at how the picker should behave. 'mutuallyExclusive' +/// implies single selection (radio buttons), while 'multipleSelection' +/// implies multiple selection (checkboxes). Defaults to 'multipleSelection'. +final choicePicker = CatalogItem( + name: 'ChoicePicker', + dataSchema: _schema, + widgetBuilder: (itemContext) { + final choicePickerData = _ChoicePickerData.fromMap( + itemContext.data as JsonMap, + ); + final ValueNotifier?> selectionsNotifier = itemContext + .dataContext + .subscribeToObjectArray(choicePickerData.value); + final ValueNotifier?> optionsNotifier = itemContext + .dataContext + .subscribeToObjectArray(choicePickerData.options); + + return ValueListenableBuilder?>( + valueListenable: selectionsNotifier, + builder: (context, selections, child) { + return ValueListenableBuilder?>( + valueListenable: optionsNotifier, + builder: (context, options, child) { + if (options == null) { + return const SizedBox.shrink(); + } + return Column( + children: options.map((optionObj) { + final option = optionObj as JsonMap; + final Object? labelObj = option['label']; + final ValueNotifier labelNotifier; + if (labelObj is String) { + labelNotifier = ValueNotifier(labelObj); + } else { + labelNotifier = itemContext.dataContext.subscribeToString( + labelObj as JsonMap?, + ); + } + final value = option['value'] as String; + return ValueListenableBuilder( + valueListenable: labelNotifier, + builder: (context, label, child) { + if (choicePickerData.usageHint == 'mutuallyExclusive') { + final Object? groupValue = selections?.isNotEmpty == true + ? selections!.first + : null; + return RadioListTile( + controlAffinity: ListTileControlAffinity.leading, + dense: true, + title: Text( + label ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + value: value, + // ignore: deprecated_member_use + groupValue: groupValue is String ? groupValue : null, + // ignore: deprecated_member_use + onChanged: (newValue) { + final path = + choicePickerData.value['path'] as String?; + if (path == null || newValue == null) { + return; + } + itemContext.dataContext.update(DataPath(path), [ + newValue, + ]); + }, + ); + } else { + return CheckboxListTile( + title: Text(label ?? ''), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: selections?.contains(value) ?? false, + onChanged: (newValue) { + final path = + choicePickerData.value['path'] as String?; + if (path == null) { + return; + } + final List newSelections = + selections?.map((e) => e.toString()).toList() ?? + []; + if (newValue ?? false) { + if (!newSelections.contains(value)) { + newSelections.add(value); + } + } else { + newSelections.remove(value); + } + itemContext.dataContext.update( + DataPath(path), + newSelections, + ); + }, + ); + } + }, + ); + }).toList(), + ); + }, + ); + }, + ); + }, + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": "Column", + "children": { + "explicitList": [ + "heading1", + "singleChoice", + "heading2", + "multiChoice" + ] + } + }, + { + "id": "heading1", + "component": "Text", + "text": { + "literalString": "Single Selection (mutuallyExclusive)" + } + }, + { + "id": "singleChoice", + "component": "ChoicePicker", + "value": { + "path": "/singleSelection" + }, + "usageHint": "mutuallyExclusive", + "options": { + "literalArray": [ + { + "label": { + "literalString": "Option A" + }, + "value": "A" + }, + { + "label": { + "literalString": "Option B" + }, + "value": "B" + } + ] + } + }, + { + "id": "heading2", + "component": "Text", + "text": { + "literalString": "Multiple Selections" + } + }, + { + "id": "multiChoice", + "component": "ChoicePicker", + "value": { + "path": "/multiSelection" + }, + "options": { + "literalArray": [ + { + "label": { + "literalString": "Option X" + }, + "value": "X" + }, + { + "label": { + "literalString": "Option Y" + }, + "value": "Y" + }, + { + "label": { + "literalString": "Option Z" + }, + "value": "Z" + } + ] + } + } + ] + ''', + ], +); diff --git a/packages/genui/lib/src/catalog/core_widgets/column.dart b/packages/genui/lib/src/catalog/core_widgets/column.dart index 47922a387..946a58fe4 100644 --- a/packages/genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/genui/lib/src/catalog/core_widgets/column.dart @@ -130,6 +130,9 @@ final column = CatalogItem( ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { + if (list is! List) { + return const SizedBox.shrink(); + } return Column( mainAxisAlignment: _parseMainAxisAlignment(columnData.distribution), crossAxisAlignment: _parseCrossAxisAlignment(columnData.alignment), @@ -155,57 +158,42 @@ final column = CatalogItem( [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": [ - "advice_text", - "advice_options", - "submit_button" - ] - } - } + "component": "Column", + "children": { + "explicitList": [ + "advice_text", + "advice_options", + "submit_button" + ] } }, { "id": "advice_text", - "component": { - "Text": { - "text": { - "literalString": "What kind of advice are you looking for?" - } - } + "component": "Text", + "text": { + "literalString": "What kind of advice are you looking for?" } }, { "id": "advice_options", - "component": { - "Text": { - "text": { - "literalString": "Some advice options." - } - } + "component": "Text", + "text": { + "literalString": "Some advice options." } }, { "id": "submit_button", - "component": { - "Button": { - "child": "submit_button_text", - "action": { - "name": "submit" - } - } + "component": "Button", + "child": "submit_button_text", + "action": { + "name": "submit" } }, { "id": "submit_button_text", - "component": { - "Text": { - "text": { - "literalString": "Submit" - } - } + "component": "Text", + "text": { + "literalString": "Submit" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart index 56ab1f4f3..bb12c5070 100644 --- a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart +++ b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart @@ -112,12 +112,9 @@ final dateTimeInput = CatalogItem( [ { "id": "root", - "component": { - "DateTimeInput": { - "value": { - "path": "/myDateTime" - } - } + "component": "DateTimeInput", + "value": { + "path": "/myDateTime" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/divider.dart b/packages/genui/lib/src/catalog/core_widgets/divider.dart index 02e69426d..54ef97350 100644 --- a/packages/genui/lib/src/catalog/core_widgets/divider.dart +++ b/packages/genui/lib/src/catalog/core_widgets/divider.dart @@ -44,9 +44,7 @@ final divider = CatalogItem( [ { "id": "root", - "component": { - "Divider": {} - } + "component": "Divider" } ] ''', diff --git a/packages/genui/lib/src/catalog/core_widgets/icon.dart b/packages/genui/lib/src/catalog/core_widgets/icon.dart index 3d882ddaf..ca8180992 100644 --- a/packages/genui/lib/src/catalog/core_widgets/icon.dart +++ b/packages/genui/lib/src/catalog/core_widgets/icon.dart @@ -139,12 +139,9 @@ final icon = CatalogItem( [ { "id": "root", - "component": { - "Icon": { - "name": { - "literalString": "add" - } - } + "component": "Icon", + "name": { + "literalString": "add" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/image.dart b/packages/genui/lib/src/catalog/core_widgets/image.dart index 079814bbd..c0ba66e2e 100644 --- a/packages/genui/lib/src/catalog/core_widgets/image.dart +++ b/packages/genui/lib/src/catalog/core_widgets/image.dart @@ -72,14 +72,11 @@ CatalogItem _imageCatalogItem({ [ { "id": "root", - "component": { - "Image": { - "url": { - "literalString": "https://storage.googleapis.com/cms-storage-bucket/lockup_flutter_horizontal.c823e53b3a1a7b0d36a9.png" - }, - "usageHint": "mediumFeature" - } - } + "component": "Image", + "url": { + "literalString": "https://storage.googleapis.com/cms-storage-bucket/lockup_flutter_horizontal.c823e53b3a1a7b0d36a9.png" + }, + "usageHint": "mediumFeature" } ] ''', diff --git a/packages/genui/lib/src/catalog/core_widgets/list.dart b/packages/genui/lib/src/catalog/core_widgets/list.dart index 42e9c08e8..be0cdeb32 100644 --- a/packages/genui/lib/src/catalog/core_widgets/list.dart +++ b/packages/genui/lib/src/catalog/core_widgets/list.dart @@ -69,7 +69,10 @@ final list = CatalogItem( ); }, templateListWidgetBuilder: - (context, Map data, componentId, dataBinding) { + (context, Object? data, componentId, dataBinding) { + if (data is! Map) { + return const SizedBox.shrink(); + } final List values = data.values.toList(); final List keys = data.keys.toList(); return ListView.builder( @@ -90,35 +93,26 @@ final list = CatalogItem( [ { "id": "root", - "component": { - "List": { - "children": { - "explicitList": [ - "text1", - "text2" - ] - } - } + "component": "List", + "children": { + "explicitList": [ + "text1", + "text2" + ] } }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "First" - } - } + "component": "Text", + "text": { + "literalString": "First" } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "Second" - } - } + "component": "Text", + "text": { + "literalString": "Second" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/modal.dart b/packages/genui/lib/src/catalog/core_widgets/modal.dart index a0023a75c..8197cc5e1 100644 --- a/packages/genui/lib/src/catalog/core_widgets/modal.dart +++ b/packages/genui/lib/src/catalog/core_widgets/modal.dart @@ -59,50 +59,35 @@ final modal = CatalogItem( [ { "id": "root", - "component": { - "Modal": { - "entryPointChild": "button", - "contentChild": "text" - } - } + "component": "Modal", + "entryPointChild": "button", + "contentChild": "text" }, { "id": "button", - "component": { - "Button": { - "child": "button_text", - "action": { - "name": "showModal", - "context": [ - { - "key": "modalId", - "value": { - "literalString": "root" - } - } - ] + "component": "Button", + "child": "button_text", + "action": { + "name": "showModal", + "context": { + "modalId": { + "literalString": "root" } } } }, { "id": "button_text", - "component": { - "Text": { - "text": { - "literalString": "Open Modal" - } - } + "component": "Text", + "text": { + "literalString": "Open Modal" } }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "This is a modal." - } - } + "component": "Text", + "text": { + "literalString": "This is a modal." } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart b/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart deleted file mode 100644 index 1e88f7053..000000000 --- a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:json_schema_builder/json_schema_builder.dart'; - -import '../../core/widget_utilities.dart'; -import '../../model/a2ui_schemas.dart'; -import '../../model/catalog_item.dart'; -import '../../model/data_model.dart'; -import '../../primitives/simple_items.dart'; - -final _schema = S.object( - properties: { - 'selections': A2uiSchemas.stringArrayReference(), - 'options': S.list( - items: S.object( - properties: { - 'label': A2uiSchemas.stringReference(), - 'value': S.string(), - }, - required: ['label', 'value'], - ), - ), - 'maxAllowedSelections': S.integer(), - }, - required: ['selections', 'options'], -); - -extension type _MultipleChoiceData.fromMap(JsonMap _json) { - factory _MultipleChoiceData({ - required JsonMap selections, - required List options, - int? maxAllowedSelections, - }) => _MultipleChoiceData.fromMap({ - 'selections': selections, - 'options': options, - 'maxAllowedSelections': maxAllowedSelections, - }); - - JsonMap get selections => _json['selections'] as JsonMap; - List get options => (_json['options'] as List).cast(); - int? get maxAllowedSelections => _json['maxAllowedSelections'] as int?; -} - -/// A catalog item representing a multiple choice selection widget. -/// -/// This widget displays a list of options, each with a checkbox. The -/// `selections` parameter, which should be a data model path, is updated to -/// reflect the list of *values* of the currently selected options. -/// -/// ## Parameters: -/// -/// - `selections`: A list of the values of the selected options. -/// - `options`: A list of options to display, each with a `label` and a -/// `value`. -/// - `maxAllowedSelections`: The maximum number of options that can be -/// selected. -final multipleChoice = CatalogItem( - name: 'MultipleChoice', - dataSchema: _schema, - widgetBuilder: (itemContext) { - final multipleChoiceData = _MultipleChoiceData.fromMap( - itemContext.data as JsonMap, - ); - final ValueNotifier?> selectionsNotifier = itemContext - .dataContext - .subscribeToObjectArray(multipleChoiceData.selections); - - return ValueListenableBuilder?>( - valueListenable: selectionsNotifier, - builder: (context, selections, child) { - return Column( - children: multipleChoiceData.options.map((option) { - final ValueNotifier labelNotifier = itemContext.dataContext - .subscribeToString(option['label'] as JsonMap); - final value = option['value'] as String; - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { - if (multipleChoiceData.maxAllowedSelections == 1) { - final Object? groupValue = selections?.isNotEmpty == true - ? selections!.first - : null; - return RadioListTile( - controlAffinity: ListTileControlAffinity.leading, - dense: true, - title: Text( - label ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ), - value: value, - // ignore: deprecated_member_use - groupValue: groupValue is String ? groupValue : null, - // ignore: deprecated_member_use - onChanged: (newValue) { - final path = - multipleChoiceData.selections['path'] as String?; - if (path == null || newValue == null) { - return; - } - itemContext.dataContext.update(DataPath(path), [ - newValue, - ]); - }, - ); - } else { - return CheckboxListTile( - title: Text(label ?? ''), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: selections?.contains(value) ?? false, - onChanged: (newValue) { - final path = - multipleChoiceData.selections['path'] as String?; - if (path == null) { - return; - } - final List newSelections = - selections?.map((e) => e.toString()).toList() ?? - []; - if (newValue ?? false) { - if (multipleChoiceData.maxAllowedSelections == null || - newSelections.length < - multipleChoiceData.maxAllowedSelections!) { - newSelections.add(value); - } - } else { - newSelections.remove(value); - } - itemContext.dataContext.update( - DataPath(path), - newSelections, - ); - }, - ); - } - }, - ); - }).toList(), - ); - }, - ); - }, - exampleData: [ - () => ''' - [ - { - "id": "root", - "component": { - "Column": { - "children": { - "explicitList": [ - "heading1", - "singleChoice", - "heading2", - "multiChoice" - ] - } - } - } - }, - { - "id": "heading1", - "component": { - "Text": { - "text": { - "literalString": "Single Selection (maxAllowedSelections: 1)" - } - } - } - }, - { - "id": "singleChoice", - "component": { - "MultipleChoice": { - "selections": { - "path": "/singleSelection" - }, - "maxAllowedSelections": 1, - "options": [ - { - "label": { - "literalString": "Option A" - }, - "value": "A" - }, - { - "label": { - "literalString": "Option B" - }, - "value": "B" - } - ] - } - } - }, - { - "id": "heading2", - "component": { - "Text": { - "text": { - "literalString": "Multiple Selections (unlimited)" - } - } - } - }, - { - "id": "multiChoice", - "component": { - "MultipleChoice": { - "selections": { - "path": "/multiSelection" - }, - "options": [ - { - "label": { - "literalString": "Option X" - }, - "value": "X" - }, - { - "label": { - "literalString": "Option Y" - }, - "value": "Y" - }, - { - "label": { - "literalString": "Option Z" - }, - "value": "Z" - } - ] - } - } - } - ] - ''', - ], -); diff --git a/packages/genui/lib/src/catalog/core_widgets/row.dart b/packages/genui/lib/src/catalog/core_widgets/row.dart index b9beb9b30..f6f48a654 100644 --- a/packages/genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/genui/lib/src/catalog/core_widgets/row.dart @@ -130,6 +130,9 @@ final row = CatalogItem( ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { + if (list is! List) { + return const SizedBox.shrink(); + } return Row( mainAxisAlignment: _parseMainAxisAlignment(rowData.distribution), crossAxisAlignment: _parseCrossAxisAlignment(rowData.alignment), @@ -155,35 +158,26 @@ final row = CatalogItem( [ { "id": "root", - "component": { - "Row": { - "children": { - "explicitList": [ - "text1", - "text2" - ] - } - } + "component": "Row", + "children": { + "explicitList": [ + "text1", + "text2" + ] } }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "First" - } - } + "component": "Text", + "text": { + "literalString": "First" } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "Second" - } - } + "component": "Text", + "text": { + "literalString": "Second" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/slider.dart b/packages/genui/lib/src/catalog/core_widgets/slider.dart index c4a1f5c2a..1517365ed 100644 --- a/packages/genui/lib/src/catalog/core_widgets/slider.dart +++ b/packages/genui/lib/src/catalog/core_widgets/slider.dart @@ -14,26 +14,19 @@ import '../../primitives/simple_items.dart'; final _schema = S.object( properties: { 'value': A2uiSchemas.numberReference(), - 'minValue': S.number(), - 'maxValue': S.number(), + 'min': A2uiSchemas.numberReference(), + 'max': A2uiSchemas.numberReference(), }, required: ['value'], ); extension type _SliderData.fromMap(JsonMap _json) { - factory _SliderData({ - required JsonMap value, - double? minValue, - double? maxValue, - }) => _SliderData.fromMap({ - 'value': value, - 'minValue': minValue, - 'maxValue': maxValue, - }); + factory _SliderData({required JsonMap value, JsonMap? min, JsonMap? max}) => + _SliderData.fromMap({'value': value, 'min': min, 'max': max}); JsonMap get value => _json['value'] as JsonMap; - double get minValue => (_json['minValue'] as num?)?.toDouble() ?? 0.0; - double get maxValue => (_json['maxValue'] as num?)?.toDouble() ?? 1.0; + JsonMap? get min => _json['min'] as JsonMap?; + JsonMap? get max => _json['max'] as JsonMap?; } /// A catalog item representing a Material Design slider. @@ -45,8 +38,8 @@ extension type _SliderData.fromMap(JsonMap _json) { /// ## Parameters: /// /// - `value`: The current value of the slider. -/// - `minValue`: The minimum value of the slider. Defaults to 0.0. -/// - `maxValue`: The maximum value of the slider. Defaults to 1.0. +/// - `min`: The minimum value of the slider. Defaults to 0.0. +/// - `max`: The maximum value of the slider. Defaults to 1.0. final slider = CatalogItem( name: 'Slider', dataSchema: _schema, @@ -54,10 +47,29 @@ final slider = CatalogItem( final sliderData = _SliderData.fromMap(itemContext.data as JsonMap); final ValueNotifier valueNotifier = itemContext.dataContext .subscribeToValue(sliderData.value, 'literalNumber'); + final ValueNotifier minNotifier = itemContext.dataContext + .subscribeToValue( + sliderData.min ?? {'literalNumber': 0.0}, + 'literalNumber', + ); + final ValueNotifier maxNotifier = itemContext.dataContext + .subscribeToValue( + sliderData.max ?? {'literalNumber': 1.0}, + 'literalNumber', + ); + + return ListenableBuilder( + listenable: Listenable.merge([valueNotifier, minNotifier, maxNotifier]), + builder: (context, child) { + final double min = (minNotifier.value ?? 0.0).toDouble(); + final double max = (maxNotifier.value ?? 1.0).toDouble(); + // Ensure min < max to avoid errors + final effectiveMin = min; + final double effectiveMax = max > min ? max : min + 1.0; + + final double val = (valueNotifier.value ?? effectiveMin).toDouble(); + final double effectiveVal = val.clamp(effectiveMin, effectiveMax); - return ValueListenableBuilder( - valueListenable: valueNotifier, - builder: (context, value, child) { return Padding( padding: const EdgeInsetsDirectional.only(end: 16.0), child: Row( @@ -65,11 +77,12 @@ final slider = CatalogItem( children: [ Expanded( child: Slider( - value: (value ?? sliderData.minValue).toDouble(), - min: sliderData.minValue, - max: sliderData.maxValue, - divisions: (sliderData.maxValue - sliderData.minValue) - .toInt(), + value: effectiveVal, + min: effectiveMin, + max: effectiveMax, + divisions: (effectiveMax - effectiveMin) > 0 + ? (effectiveMax - effectiveMin).toInt() + : 1, onChanged: (newValue) { final path = sliderData.value['path'] as String?; if (path != null) { @@ -78,10 +91,7 @@ final slider = CatalogItem( }, ), ), - Text( - value?.toStringAsFixed(0) ?? - sliderData.minValue.toStringAsFixed(0), - ), + Text(effectiveVal.toStringAsFixed(0)), ], ), ); @@ -93,15 +103,12 @@ final slider = CatalogItem( [ { "id": "root", - "component": { - "Slider": { - "minValue": 0, - "maxValue": 10, - "value": { - "path": "/myValue", - "literalNumber": 5 - } - } + "component": "Slider", + "min": {"literalNumber": 0}, + "max": {"literalNumber": 10}, + "value": { + "path": "/myValue", + "literalNumber": 5 } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/tabs.dart b/packages/genui/lib/src/catalog/core_widgets/tabs.dart index 57294d40d..ff4c02b31 100644 --- a/packages/genui/lib/src/catalog/core_widgets/tabs.dart +++ b/packages/genui/lib/src/catalog/core_widgets/tabs.dart @@ -88,43 +88,34 @@ final tabs = CatalogItem( [ { "id": "root", - "component": { - "Tabs": { - "tabItems": [ - { - "title": { - "literalString": "Overview" - }, - "child": "text1" - }, - { - "title": { - "literalString": "Details" - }, - "child": "text2" - } - ] + "component": "Tabs", + "tabItems": [ + { + "title": { + "literalString": "Overview" + }, + "child": "text1" + }, + { + "title": { + "literalString": "Details" + }, + "child": "text2" } - } + ] }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "This is a short summary of the item." - } - } + "component": "Text", + "text": { + "literalString": "This is a short summary of the item." } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "This is a much longer, more detailed description of the item, providing in-depth information and context. It can span multiple lines and include rich formatting if needed." - } - } + "component": "Text", + "text": { + "literalString": "This is a much longer, more detailed description of the item, providing in-depth information and context. It can span multiple lines and include rich formatting if needed." } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/text.dart b/packages/genui/lib/src/catalog/core_widgets/text.dart index 6709fa02b..5f254ce94 100644 --- a/packages/genui/lib/src/catalog/core_widgets/text.dart +++ b/packages/genui/lib/src/catalog/core_widgets/text.dart @@ -50,14 +50,11 @@ final text = CatalogItem( [ { "id": "root", - "component": { - "Text": { - "text": { - "literalString": "Hello World" - }, - "usageHint": "h1" - } - } + "component": "Text", + "text": { + "literalString": "Hello World" + }, + "usageHint": "h1" } ] ''', diff --git a/packages/genui/lib/src/catalog/core_widgets/text_field.dart b/packages/genui/lib/src/catalog/core_widgets/text_field.dart index f92bd2b39..5bb17a3cd 100644 --- a/packages/genui/lib/src/catalog/core_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/core_widgets/text_field.dart @@ -18,7 +18,7 @@ final _schema = S.object( description: 'The initial value of the text field.', ), 'label': A2uiSchemas.stringReference(), - 'textFieldType': S.string( + 'usageHint': S.string( enumValues: ['shortText', 'longText', 'number', 'date', 'obscured'], ), 'validationRegexp': S.string(), @@ -30,20 +30,20 @@ extension type _TextFieldData.fromMap(JsonMap _json) { factory _TextFieldData({ JsonMap? text, JsonMap? label, - String? textFieldType, + String? usageHint, String? validationRegexp, JsonMap? onSubmittedAction, }) => _TextFieldData.fromMap({ 'text': text, 'label': label, - 'textFieldType': textFieldType, + 'usageHint': usageHint, 'validationRegexp': validationRegexp, 'onSubmittedAction': onSubmittedAction, }); JsonMap? get text => _json['text'] as JsonMap?; JsonMap? get label => _json['label'] as JsonMap?; - String? get textFieldType => _json['textFieldType'] as String?; + String? get usageHint => _json['usageHint'] as String?; String? get validationRegexp => _json['validationRegexp'] as String?; JsonMap? get onSubmittedAction => _json['onSubmittedAction'] as JsonMap?; } @@ -52,7 +52,7 @@ class _TextField extends StatefulWidget { const _TextField({ required this.initialValue, this.label, - this.textFieldType, + this.usageHint, this.validationRegexp, required this.onChanged, required this.onSubmitted, @@ -60,7 +60,7 @@ class _TextField extends StatefulWidget { final String initialValue; final String? label; - final String? textFieldType; + final String? usageHint; final String? validationRegexp; final void Function(String) onChanged; final void Function(String) onSubmitted; @@ -97,8 +97,8 @@ class _TextFieldState extends State<_TextField> { return TextField( controller: _controller, decoration: InputDecoration(labelText: widget.label), - obscureText: widget.textFieldType == 'obscured', - keyboardType: switch (widget.textFieldType) { + obscureText: widget.usageHint == 'obscured', + keyboardType: switch (widget.usageHint) { 'number' => TextInputType.number, 'longText' => TextInputType.multiline, 'date' => TextInputType.datetime, @@ -120,7 +120,7 @@ class _TextFieldState extends State<_TextField> { /// /// - `text`: The initial value of the text field. /// - `label`: The text to display as the label for the text field. -/// - `textFieldType`: The type of text field. Can be `shortText`, `longText`, +/// - `usageHint`: The type of text field. Can be `shortText`, `longText`, /// `number`, `date`, or `obscured`. /// - `validationRegexp`: A regular expression to validate the input. /// - `onSubmittedAction`: The action to perform when the user submits the @@ -133,15 +133,12 @@ final textField = CatalogItem( [ { "id": "root", - "component": { - "TextField": { - "text": { - "literalString": "Hello World" - }, - "label": { - "literalString": "Greeting" - } - } + "component": "TextField", + "text": { + "literalString": "Hello World" + }, + "label": { + "literalString": "Greeting" } } ] @@ -150,17 +147,14 @@ final textField = CatalogItem( [ { "id": "root", - "component": { - "TextField": { - "text": { - "literalString": "password123" - }, - "label": { - "literalString": "Password" - }, - "textFieldType": "obscured" - } - } + "component": "TextField", + "text": { + "literalString": "password123" + }, + "label": { + "literalString": "Password" + }, + "usageHint": "obscured" } ] ''', @@ -183,7 +177,7 @@ final textField = CatalogItem( return _TextField( initialValue: currentValue ?? '', label: label, - textFieldType: textFieldData.textFieldType, + usageHint: textFieldData.usageHint, validationRegexp: textFieldData.validationRegexp, onChanged: (newValue) { if (path != null) { @@ -196,8 +190,8 @@ final textField = CatalogItem( return; } final actionName = actionData['name'] as String; - final List contextDefinition = - (actionData['context'] as List?) ?? []; + final Map contextDefinition = + (actionData['context'] as Map?) ?? {}; final JsonMap resolvedContext = resolveContext( itemContext.dataContext, contextDefinition, diff --git a/packages/genui/lib/src/catalog/core_widgets/video.dart b/packages/genui/lib/src/catalog/core_widgets/video.dart index b75a4c5c8..f39cf7c05 100644 --- a/packages/genui/lib/src/catalog/core_widgets/video.dart +++ b/packages/genui/lib/src/catalog/core_widgets/video.dart @@ -39,12 +39,9 @@ final video = CatalogItem( [ { "id": "root", - "component": { - "Video": { - "url": { - "literalString": "https://example.com/video.mp4" - } - } + "component": "Video", + "url": { + "literalString": "https://example.com/video.mp4" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart index 5b18ce40e..7b1a0ad8c 100644 --- a/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -17,7 +17,7 @@ import '../../primitives/simple_items.dart'; typedef TemplateListWidgetBuilder = Widget Function( BuildContext context, - Map data, + Object? data, String componentId, String dataBinding, ); @@ -101,9 +101,9 @@ class ComponentChildrenBuilder extends StatelessWidget { genUiLogger.finest( 'Widget $componentId subscribing to ${dataContext.path}', ); - final ValueNotifier?> dataNotifier = dataContext - .subscribe>(DataPath(dataBinding)); - return ValueListenableBuilder?>( + final ValueNotifier dataNotifier = dataContext + .subscribe(DataPath(dataBinding)); + return ValueListenableBuilder( valueListenable: dataNotifier, builder: (context, data, child) { genUiLogger.info( diff --git a/packages/genui/lib/src/core/a2ui_message_processor.dart b/packages/genui/lib/src/core/a2ui_message_processor.dart index 8547bfc93..269e3e121 100644 --- a/packages/genui/lib/src/core/a2ui_message_processor.dart +++ b/packages/genui/lib/src/core/a2ui_message_processor.dart @@ -81,8 +81,8 @@ abstract interface class GenUiHost { /// /// This class is the core state manager for the dynamic UI. It maintains a map /// of all active UI "surfaces", where each surface is represented by a -/// `UiDefinition`. It provides the tools (`surfaceUpdate`, `deleteSurface`, -/// `beginRendering`) that the AI uses to manipulate the UI. It exposes a stream +/// `UiDefinition`. It provides the tools (`updateComponents`, `deleteSurface`, +/// `createSurface`) that the AI uses to manipulate the UI. It exposes a stream /// of `GenUiUpdate` events so that the application can react to changes. class A2uiMessageProcessor implements GenUiHost { /// Creates a new [A2uiMessageProcessor] with a list of supported widget @@ -151,7 +151,7 @@ class A2uiMessageProcessor implements GenUiHost { /// Handles an [A2uiMessage] and updates the UI accordingly. void handleMessage(A2uiMessage message) { switch (message) { - case SurfaceUpdate(): + case UpdateComponents(): final String surfaceId = message.surfaceId; final ValueNotifier notifier = getSurfaceNotifier( surfaceId, @@ -168,8 +168,10 @@ class A2uiMessageProcessor implements GenUiHost { uiDefinition = uiDefinition.copyWith(components: newComponents); notifier.value = uiDefinition; - // Notify UI ONLY if rendering has begun (i.e., rootComponentId is set) - if (uiDefinition.rootComponentId != null) { + // Notify UI ONLY if rendering has begun (i.e., rootComponentId is set + // or 'root' exists) + if (uiDefinition.rootComponentId != null || + uiDefinition.components.containsKey('root')) { genUiLogger.info('Updating surface $surfaceId'); _surfaceUpdates.add(SurfaceUpdated(surfaceId, uiDefinition)); } else { @@ -177,33 +179,38 @@ class A2uiMessageProcessor implements GenUiHost { 'Caching components for surface $surfaceId (pre-rendering)', ); } - case BeginRendering(): + case CreateSurface(): final String surfaceId = message.surfaceId; dataModelForSurface(surfaceId); final ValueNotifier notifier = getSurfaceNotifier( surfaceId, ); - - // Update the definition with the root component + final isNew = notifier.value == null; final UiDefinition uiDefinition = notifier.value ?? UiDefinition(surfaceId: surfaceId); final UiDefinition newUiDefinition = uiDefinition.copyWith( - rootComponentId: message.root, catalogId: message.catalogId, ); notifier.value = newUiDefinition; - - genUiLogger.info('Creating and rendering surface $surfaceId'); - _surfaceUpdates.add(SurfaceAdded(surfaceId, newUiDefinition)); - case DataModelUpdate(): + genUiLogger.info('Created surface ${message.surfaceId}'); + if (isNew) { + _surfaceUpdates.add(SurfaceAdded(message.surfaceId, newUiDefinition)); + } else { + _surfaceUpdates.add( + SurfaceUpdated(message.surfaceId, newUiDefinition), + ); + } + case UpdateDataModel(): final String path = message.path ?? '/'; genUiLogger.info( 'Updating data model for surface ${message.surfaceId} at path ' '$path with contents:\n' - '${const JsonEncoder.withIndent(' ').convert(message.contents)}', + '${const JsonEncoder.withIndent(' ').convert(message.value)}', ); final DataModel dataModel = dataModelForSurface(message.surfaceId); - dataModel.update(DataPath(path), message.contents); + // TODO: Handle 'op' (add/replace/remove) + // For now, assuming replace/add behavior similar to old update + dataModel.update(DataPath(path), message.value); // Notify UI of an update if the surface is already rendering final ValueNotifier notifier = getSurfaceNotifier( @@ -224,6 +231,8 @@ class A2uiMessageProcessor implements GenUiHost { _dataModels.remove(surfaceId); _surfaceUpdates.add(SurfaceRemoved(surfaceId)); } + case ErrorMessage(:final code, :final message): + genUiLogger.severe('Received A2UI Error: $code: $message'); } } } diff --git a/packages/genui/lib/src/core/genui_surface.dart b/packages/genui/lib/src/core/genui_surface.dart index e7b6685c5..5a2ca8180 100644 --- a/packages/genui/lib/src/core/genui_surface.dart +++ b/packages/genui/lib/src/core/genui_surface.dart @@ -56,8 +56,8 @@ class _GenUiSurfaceState extends State { return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); } - final String? rootId = definition.rootComponentId; - if (rootId == null || definition.components.isEmpty) { + final String rootId = definition.rootComponentId ?? 'root'; + if (definition.components.isEmpty) { genUiLogger.warning('Surface ${widget.surfaceId} has no widgets.'); return const SizedBox.shrink(); } @@ -93,7 +93,7 @@ class _GenUiSurfaceState extends State { return Placeholder(child: Text('Widget with id: $widgetId not found.')); } - final JsonMap widgetData = data.componentProperties; + final JsonMap widgetData = data.props; genUiLogger.finest('Building widget $widgetId'); return catalog.buildWidget( CatalogItemContext( @@ -135,9 +135,7 @@ class _GenUiSurfaceState extends State { final modalId = event.context['modalId'] as String; final Component? modalComponent = definition.components[modalId]; if (modalComponent == null) return; - final contentChildId = - (modalComponent.componentProperties['Modal'] as Map)['contentChild'] - as String; + final contentChildId = modalComponent.props['contentChild'] as String; showModalBottomSheet( context: context, builder: (context) => _buildWidget( diff --git a/packages/genui/lib/src/core/ui_tools.dart b/packages/genui/lib/src/core/ui_tools.dart index 6612ee155..e7779fc04 100644 --- a/packages/genui/lib/src/core/ui_tools.dart +++ b/packages/genui/lib/src/core/ui_tools.dart @@ -15,13 +15,13 @@ import '../primitives/simple_items.dart'; /// /// This tool allows the AI to create a new UI surface or update an existing /// one with a new definition. -class SurfaceUpdateTool extends AiTool { - /// Creates an [SurfaceUpdateTool]. - SurfaceUpdateTool({required this.handleMessage, required Catalog catalog}) +class UpdateComponentsTool extends AiTool { + /// Creates an [UpdateComponentsTool]. + UpdateComponentsTool({required this.handleMessage, required Catalog catalog}) : super( - name: 'surfaceUpdate', + name: 'updateComponents', description: 'Updates a surface with a new set of components.', - parameters: A2uiSchemas.surfaceUpdateSchema(catalog), + parameters: A2uiSchemas.updateComponentsSchema(catalog), ); /// The callback to invoke when adding or updating a surface. @@ -31,13 +31,11 @@ class SurfaceUpdateTool extends AiTool { Future invoke(JsonMap args) async { final surfaceId = args[surfaceIdKey] as String; final List components = (args['components'] as List).map((e) { - final component = e as JsonMap; - return Component( - id: component['id'] as String, - componentProperties: component['component'] as JsonMap, - ); + return Component.fromJson(e as JsonMap); }).toList(); - handleMessage(SurfaceUpdate(surfaceId: surfaceId, components: components)); + handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); return { surfaceIdKey: surfaceId, 'status': 'UI Surface $surfaceId updated.', @@ -76,35 +74,26 @@ class DeleteSurfaceTool extends AiTool { } } -/// An [AiTool] for signaling the client to begin rendering. +/// An [AiTool] for signaling the client to create a surface. /// -/// This tool allows the AI to specify the root component of a UI surface. -class BeginRenderingTool extends AiTool { - /// Creates a [BeginRenderingTool]. - BeginRenderingTool({required this.handleMessage, this.catalogId}) +/// This tool allows the AI to initialize a UI surface. +class CreateSurfaceTool extends AiTool { + /// Creates a [CreateSurfaceTool]. + CreateSurfaceTool({required this.handleMessage}) : super( - name: 'beginRendering', - description: - 'Signals the client to begin rendering a surface with a ' - 'root component.', - parameters: A2uiSchemas.beginRenderingSchemaNoCatalogId(), + name: 'createSurface', + description: 'Signals the client to create a surface.', + parameters: A2uiSchemas.createSurfaceSchema(), ); - /// The callback to invoke when signaling to begin rendering. + /// The callback to invoke when signaling to create a surface. final void Function(A2uiMessage message) handleMessage; - /// The ID of the catalog to use for rendering this surface. - final String? catalogId; - @override Future invoke(JsonMap args) async { final surfaceId = args[surfaceIdKey] as String; - final root = args['root'] as String; - handleMessage( - BeginRendering(surfaceId: surfaceId, root: root, catalogId: catalogId), - ); - return { - 'status': 'Surface $surfaceId rendered and waiting for user input.', - }; + final catalogId = args['catalogId'] as String; + handleMessage(CreateSurface(surfaceId: surfaceId, catalogId: catalogId)); + return {'status': 'Surface $surfaceId created.'}; } } diff --git a/packages/genui/lib/src/core/widget_utilities.dart b/packages/genui/lib/src/core/widget_utilities.dart index 70b9ac25e..915781ac9 100644 --- a/packages/genui/lib/src/core/widget_utilities.dart +++ b/packages/genui/lib/src/core/widget_utilities.dart @@ -85,13 +85,12 @@ extension DataContextExtensions on DataContext { /// JsonMap resolveContext( DataContext dataContext, - List contextDefinitions, + Map contextDefinitions, ) { final resolved = {}; - for (final contextEntry in contextDefinitions) { - final entry = contextEntry as JsonMap; - final key = entry['key']! as String; - final value = entry['value'] as JsonMap; + for (final MapEntry entry in contextDefinitions.entries) { + final String key = entry.key; + final value = entry.value as JsonMap; if (value.containsKey('path')) { resolved[key] = dataContext.getValue(DataPath(value['path'] as String)); } else if (value.containsKey('literalString')) { diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 699f5ea37..afe38907a 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -15,6 +15,7 @@ import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/chat_message.dart'; import '../model/ui_models.dart'; +import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; /// A widget that displays a GenUI catalog widgets. @@ -85,10 +86,10 @@ class _DebugCatalogViewState extends State { } _a2uiMessageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); _a2uiMessageProcessor.handleMessage( - BeginRendering(surfaceId: surfaceId, root: rootComponent.id), + CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); surfaceIds.add(surfaceId); } diff --git a/packages/genui/lib/src/facade/direct_call_integration/utils.dart b/packages/genui/lib/src/facade/direct_call_integration/utils.dart index 5a5f979c9..907ed64aa 100644 --- a/packages/genui/lib/src/facade/direct_call_integration/utils.dart +++ b/packages/genui/lib/src/facade/direct_call_integration/utils.dart @@ -6,6 +6,7 @@ import '../../model/a2ui_message.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog.dart'; import '../../model/tools.dart'; +import '../../primitives/constants.dart'; import '../../primitives/simple_items.dart'; import 'model.dart'; @@ -37,7 +38,7 @@ GenUiFunctionDeclaration catalogToFunctionDeclaration( return GenUiFunctionDeclaration( description: toolDescription, name: toolName, - parameters: A2uiSchemas.surfaceUpdateSchema(catalog), + parameters: A2uiSchemas.updateComponentsSchema(catalog), ); } @@ -45,18 +46,18 @@ GenUiFunctionDeclaration catalogToFunctionDeclaration( ParsedToolCall parseToolCall(ToolCall toolCall, String toolName) { assert(toolCall.name == toolName); - final Map messageJson = {'surfaceUpdate': toolCall.args}; + final Map messageJson = {'updateComponents': toolCall.args}; final surfaceUpdateMessage = A2uiMessage.fromJson(messageJson); final surfaceId = (toolCall.args as JsonMap)[surfaceIdKey] as String; - final beginRenderingMessage = BeginRendering( + final createSurfaceMessage = CreateSurface( surfaceId: surfaceId, - root: 'root', + catalogId: standardCatalogId, ); return ParsedToolCall( - messages: [surfaceUpdateMessage, beginRenderingMessage], + messages: [surfaceUpdateMessage, createSurfaceMessage], surfaceId: surfaceId, ); } @@ -67,11 +68,11 @@ ToolCall catalogExampleToToolCall( String toolName, String surfaceId, ) { - final messageJson = {'surfaceUpdate': example}; + final messageJson = {'updateComponents': example}; final surfaceUpdateMessage = A2uiMessage.fromJson(messageJson); return ToolCall( name: toolName, - args: {surfaceIdKey: surfaceId, 'surfaceUpdate': surfaceUpdateMessage}, + args: {surfaceIdKey: surfaceId, 'updateComponents': surfaceUpdateMessage}, ); } diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index c026fc532..1eea7b017 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -17,18 +17,21 @@ sealed class A2uiMessage { /// Creates an [A2uiMessage] from a JSON map. factory A2uiMessage.fromJson(JsonMap json) { - if (json.containsKey('surfaceUpdate')) { - return SurfaceUpdate.fromJson(json['surfaceUpdate'] as JsonMap); + if (json.containsKey('updateComponents')) { + return UpdateComponents.fromJson(json['updateComponents'] as JsonMap); } - if (json.containsKey('dataModelUpdate')) { - return DataModelUpdate.fromJson(json['dataModelUpdate'] as JsonMap); + if (json.containsKey('updateDataModel')) { + return UpdateDataModel.fromJson(json['updateDataModel'] as JsonMap); } - if (json.containsKey('beginRendering')) { - return BeginRendering.fromJson(json['beginRendering'] as JsonMap); + if (json.containsKey('createSurface')) { + return CreateSurface.fromJson(json['createSurface'] as JsonMap); } if (json.containsKey('deleteSurface')) { return SurfaceDeletion.fromJson(json['deleteSurface'] as JsonMap); } + if (json.containsKey('error')) { + return ErrorMessage.fromJson(json['error'] as JsonMap); + } throw ArgumentError('Unknown A2UI message type: $json'); } @@ -37,25 +40,26 @@ sealed class A2uiMessage { return S.object( title: 'A2UI Message Schema', description: - """Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.""", + """Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'createSurface', 'updateComponents', 'updateDataModel', or 'deleteSurface'.""", properties: { - 'surfaceUpdate': A2uiSchemas.surfaceUpdateSchema(catalog), - 'dataModelUpdate': A2uiSchemas.dataModelUpdateSchema(), - 'beginRendering': A2uiSchemas.beginRenderingSchema(), + 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), + 'updateDataModel': A2uiSchemas.updateDataModelSchema(), + 'createSurface': A2uiSchemas.createSurfaceSchema(), 'deleteSurface': A2uiSchemas.surfaceDeletionSchema(), + 'error': A2uiSchemas.errorSchema(), }, ); } } /// An A2UI message that updates a surface with new components. -final class SurfaceUpdate extends A2uiMessage { - /// Creates a [SurfaceUpdate] message. - const SurfaceUpdate({required this.surfaceId, required this.components}); +final class UpdateComponents extends A2uiMessage { + /// Creates a [UpdateComponents] message. + const UpdateComponents({required this.surfaceId, required this.components}); - /// Creates a [SurfaceUpdate] message from a JSON map. - factory SurfaceUpdate.fromJson(JsonMap json) { - return SurfaceUpdate( + /// Creates a [UpdateComponents] message from a JSON map. + factory UpdateComponents.fromJson(JsonMap json) { + return UpdateComponents( surfaceId: json[surfaceIdKey] as String, components: (json['components'] as List) .map((e) => Component.fromJson(e as JsonMap)) @@ -79,20 +83,22 @@ final class SurfaceUpdate extends A2uiMessage { } /// An A2UI message that updates the data model. -final class DataModelUpdate extends A2uiMessage { - /// Creates a [DataModelUpdate] message. - const DataModelUpdate({ +final class UpdateDataModel extends A2uiMessage { + /// Creates a [UpdateDataModel] message. + const UpdateDataModel({ required this.surfaceId, this.path, - required this.contents, + this.op = 'replace', + required this.value, }); - /// Creates a [DataModelUpdate] message from a JSON map. - factory DataModelUpdate.fromJson(JsonMap json) { - return DataModelUpdate( + /// Creates a [UpdateDataModel] message from a JSON map. + factory UpdateDataModel.fromJson(JsonMap json) { + return UpdateDataModel( surfaceId: json[surfaceIdKey] as String, path: json['path'] as String?, - contents: json['contents'] as Object, + op: json['op'] as String? ?? 'replace', + value: json['value'] as Object, ); } @@ -102,41 +108,31 @@ final class DataModelUpdate extends A2uiMessage { /// The path in the data model to update. final String? path; - /// The new contents to write to the data model. - final Object contents; + /// The operation to perform (add, replace, remove). + final String op; + + /// The new value to write to the data model. + final Object value; } /// An A2UI message that signals the client to begin rendering. -final class BeginRendering extends A2uiMessage { - /// Creates a [BeginRendering] message. - const BeginRendering({ - required this.surfaceId, - required this.root, - this.styles, - this.catalogId, - }); +final class CreateSurface extends A2uiMessage { + /// Creates a [CreateSurface] message. + const CreateSurface({required this.surfaceId, required this.catalogId}); - /// Creates a [BeginRendering] message from a JSON map. - factory BeginRendering.fromJson(JsonMap json) { - return BeginRendering( + /// Creates a [CreateSurface] message from a JSON map. + factory CreateSurface.fromJson(JsonMap json) { + return CreateSurface( surfaceId: json[surfaceIdKey] as String, - root: json['root'] as String, - styles: json['styles'] as JsonMap?, - catalogId: json['catalogId'] as String?, + catalogId: json['catalogId'] as String, ); } /// The ID of the surface that this message applies to. final String surfaceId; - /// The ID of the root component. - final String root; - - /// The styles to apply to the UI. - final JsonMap? styles; - - /// The ID of the catalog to use for rendering this surface. - final String? catalogId; + /// The catalog ID used for this surface. + final String catalogId; } /// An A2UI message that deletes a surface. @@ -152,3 +148,36 @@ final class SurfaceDeletion extends A2uiMessage { /// The ID of the surface that this message applies to. final String surfaceId; } + +/// An A2UI message that reports an error. +final class ErrorMessage extends A2uiMessage { + /// Creates a [ErrorMessage] message. + const ErrorMessage({ + required this.code, + required this.message, + this.surfaceId, + this.path, + }); + + /// Creates a [ErrorMessage] message from a JSON map. + factory ErrorMessage.fromJson(JsonMap json) { + return ErrorMessage( + code: json['code'] as String, + message: json['message'] as String, + surfaceId: json['surfaceId'] as String?, + path: json['path'] as String?, + ); + } + + /// The error code. + final String code; + + /// The error message. + final String message; + + /// The ID of the surface that this error applies to. + final String? surfaceId; + + /// The path in the data model that this error applies to. + final String? path; +} diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index 6e35030d0..33b830264 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -83,35 +83,11 @@ class A2uiSchemas { description: description, properties: { 'name': S.string(), - 'context': S.list( + 'context': S.object( description: - 'A list of name-value pairs to be sent with the action to include ' + 'A map of name-value pairs to be sent with the action to include ' 'data associated with the action, e.g. values that are submitted.', - items: S.object( - properties: { - 'key': S.string(), - 'value': S.object( - properties: { - 'path': S.string( - description: - 'A path in the data model which should be bound to an ' - 'input element, e.g. a string reference for a text ' - 'field, or number reference for a slider.', - ), - 'literalString': S.string( - description: 'A literal string relevant to the action', - ), - 'literalNumber': S.number( - description: 'A literal number relevant to the action', - ), - 'literalBoolean': S.boolean( - description: 'A literal boolean relevant to the action', - ), - }, - ), - }, - required: ['key', 'value'], - ), + additionalProperties: true, ), }, required: ['name'], @@ -131,58 +107,31 @@ class A2uiSchemas { }, ); - /// Schema for a beginRendering message, which provides the root widget ID for - /// the given surface so that the surface can be rendered. - static Schema beginRenderingSchema() => S.object( + /// Schema for a value that can be either a literal array of objects (maps) + /// or a data-bound path to an array of objects in the DataModel. If both + /// path and literalArray are provided, the value at the path will be + /// initialized with the literalArray. + static Schema objectArrayReference({String? description}) => S.object( + description: description, properties: { - surfaceIdKey: S.string( - description: 'The surface ID of the surface to render.', - ), - 'root': S.string( - description: - 'The root widget ID for the surface. ' - 'All components must be descendents of this root in order to be ' - 'displayed.', - ), - 'catalogId': S.string( - description: - 'The identifier of the component catalog to use for this surface.', - ), - 'styles': S.object( - properties: { - 'font': S.string(description: 'The base font for this surface'), - 'primaryColor': S.string( - description: 'The seed color for the theme of this surface.', - ), - }, + 'path': S.string( + description: 'A relative or absolute path in the data model.', ), + 'literalArray': S.list(items: S.object(additionalProperties: true)), }, - required: [surfaceIdKey, 'root'], ); - /// Schema for a beginRendering message, which provides the root widget ID for - /// the given surface so that the surface can be rendered. - static Schema beginRenderingSchemaNoCatalogId() => S.object( + /// Schema for a createSurface message, which initializes a surface. + static Schema createSurfaceSchema() => S.object( properties: { surfaceIdKey: S.string( - description: 'The surface ID of the surface to render.', - ), - 'root': S.string( - description: - 'The root widget ID for the surface. ' - 'All components must be descendents of this root in order to be ' - 'displayed.', + description: 'The surface ID of the surface to create.', ), - 'styles': S.object( - properties: { - 'font': S.string(description: 'The base font for this surface'), - 'primaryColor': S.string( - description: 'The seed color for the theme of this surface.', - ), - }, + 'catalogId': S.string( + description: 'The catalog ID to use for this surface.', ), }, - required: [surfaceIdKey, 'root'], + required: [surfaceIdKey, 'catalogId'], ); /// Schema for a `deleteSurface` message which will delete the given surface. @@ -191,22 +140,24 @@ class A2uiSchemas { required: [surfaceIdKey], ); - /// Schema for a `dataModelUpdate` message which will update the given path in + /// Schema for a `updateDataModel` message which will update the given path in /// the data model. If the path is omitted, the entire data model is replaced. - static Schema dataModelUpdateSchema() => S.object( + static Schema updateDataModelSchema() => S.object( properties: { surfaceIdKey: S.string(), 'path': S.string(), - 'contents': S.any( - description: 'The new contents to write to the data model.', + 'op': S.string( + description: 'The operation to perform (add, replace, remove).', + enumValues: ['add', 'replace', 'remove'], ), + 'value': S.any(description: 'The new value to write to the data model.'), }, - required: [surfaceIdKey, 'contents'], + required: [surfaceIdKey, 'value'], ); - /// Schema for a `surfaceUpdate` message which defines the components to be + /// Schema for a `updateComponents` message which defines the components to be /// rendered on a surface. - static Schema surfaceUpdateSchema(Catalog catalog) => S.object( + static Schema updateComponentsSchema(Catalog catalog) => S.object( properties: { surfaceIdKey: S.string( description: @@ -223,29 +174,42 @@ class A2uiSchemas { 'Represents a *single* component in a UI widget tree. ' 'This component could be one of many supported types.', properties: { - 'id': S.string(), + 'id': S.string( + description: + 'The unique identifier for this component. The root ' + "component of the surface MUST have the id 'root'.", + ), 'weight': S.integer( description: 'Optional layout weight for use in Row/Column children.', ), - 'component': S.object( - description: - '''A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Text'). The value is an object containing the properties for that specific component.''', - properties: { - for (var entry - in ((catalog.definition as ObjectSchema) - .properties!['components']! - as ObjectSchema) - .properties! - .entries) - entry.key: entry.value, - }, + 'component': S.string( + description: 'The type of the component.', + enumValues: + ((catalog.definition as ObjectSchema) + .properties!['components']! + as ObjectSchema) + .properties! + .keys + .toList(), ), }, required: ['id', 'component'], + additionalProperties: true, ), ), }, required: [surfaceIdKey, 'components'], ); + + /// Schema for an `error` message which reports an error. + static Schema errorSchema() => S.object( + properties: { + 'code': S.string(), + 'message': S.string(), + 'surfaceId': S.string(), + 'path': S.string(), + }, + required: ['code', 'message'], + ); } diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index d16c88de2..e1fbcf9b3 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -60,7 +60,7 @@ class Catalog { /// Builds a Flutter widget from a JSON-like data structure. Widget buildWidget(CatalogItemContext itemContext) { final widgetData = itemContext.data as JsonMap; - final String? widgetType = widgetData.keys.firstOrNull; + final widgetType = widgetData['component'] as String?; final CatalogItem? item = items.firstWhereOrNull( (item) => item.name == widgetType, ); @@ -72,7 +72,7 @@ class Catalog { genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); return item.widgetBuilder( CatalogItemContext( - data: JsonMap.from(widgetData[widgetType]! as Map), + data: widgetData, id: itemContext.id, buildChild: (String childId, [DataContext? childDataContext]) => itemContext.buildChild( diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 8c5e4c137..e5b45864a 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -91,16 +91,12 @@ class UiDefinition { Map get components => UnmodifiableMapView(_components); final Map _components; - /// (Future) The styles for this surface. - final JsonMap? styles; - /// Creates a [UiDefinition]. UiDefinition({ required this.surfaceId, this.rootComponentId, this.catalogId, Map components = const {}, - this.styles, }) : _components = components; /// Creates a copy of this [UiDefinition] with the given fields replaced. @@ -108,14 +104,12 @@ class UiDefinition { String? rootComponentId, String? catalogId, Map? components, - JsonMap? styles, }) { return UiDefinition( surfaceId: surfaceId, rootComponentId: rootComponentId ?? this.rootComponentId, catalogId: catalogId ?? this.catalogId, components: components ?? _components, - styles: styles ?? this.styles, ); } @@ -140,59 +134,43 @@ class UiDefinition { /// A component in the UI. final class Component { /// Creates a [Component]. - const Component({ - required this.id, - required this.componentProperties, - this.weight, - }); + const Component({required this.id, required this.props, this.weight}); /// Creates a [Component] from a JSON map. factory Component.fromJson(JsonMap json) { - if (json['component'] == null) { - throw ArgumentError('Component.fromJson: component property is null'); - } - return Component( - id: json['id'] as String, - componentProperties: json['component'] as JsonMap, - weight: json['weight'] as int?, - ); + final id = json['id'] as String; + final weight = json['weight'] as int?; + final Map props = Map.of(json); + props.remove('id'); + props.remove('weight'); + return Component(id: id, props: props, weight: weight); } /// The unique ID of the component. final String id; /// The properties of the component. - final JsonMap componentProperties; + final JsonMap props; /// The weight of the component, used for layout in Row/Column. final int? weight; /// Converts this object to a JSON map. JsonMap toJson() { - return { - 'id': id, - 'component': componentProperties, - if (weight != null) 'weight': weight, - }; + return {'id': id, if (weight != null) 'weight': weight, ...props}; } /// The type of the component. - String get type => componentProperties.keys.first; + String get type => props['component'] as String; @override bool operator ==(Object other) => other is Component && id == other.id && weight == other.weight && - const DeepCollectionEquality().equals( - componentProperties, - other.componentProperties, - ); + const DeepCollectionEquality().equals(props, other.props); @override - int get hashCode => Object.hash( - id, - weight, - const DeepCollectionEquality().hash(componentProperties), - ); + int get hashCode => + Object.hash(id, weight, const DeepCollectionEquality().hash(props)); } diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index c27be5e3a..1b405b539 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -48,7 +48,7 @@ Future> validateCatalogItemExamples( CatalogItem item, Catalog catalog, ) async { - final Schema schema = A2uiSchemas.surfaceUpdateSchema(catalog); + final Schema schema = A2uiSchemas.updateComponentsSchema(catalog); final errors = []; for (var i = 0; i < item.exampleData.length; i++) { @@ -76,7 +76,7 @@ Future> validateCatalogItemExamples( ); } - final surfaceUpdate = SurfaceUpdate( + final surfaceUpdate = UpdateComponents( surfaceId: 'test-surface', components: components, ); diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 2aabd076f..05f27f015 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -16,39 +16,33 @@ void main() { Catalog([ CoreCatalogItems.button, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); manager.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'button', - componentProperties: { - 'Button': { - 'child': 'button_text', - 'action': {'name': 'testAction'}, - }, + id: 'root', + props: { + 'component': 'Button', + 'child': 'button_text', + 'action': {'name': 'testAction'}, }, ), const Component( id: 'button_text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Click Me'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Click Me'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'button', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index 1647aafb5..140b3ca9a 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -13,35 +13,28 @@ void main() { Catalog([ CoreCatalogItems.card, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'card', - componentProperties: { - 'Card': {'child': 'text'}, - }, + id: 'root', + props: {'component': 'Card', 'child': 'text'}, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is a card.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is a card.'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'card', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/check_box_test.dart b/packages/genui/test/catalog/core_widgets/check_box_test.dart index 31155fc0e..97c681e40 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -12,30 +12,25 @@ void main() { ) async { final manager = A2uiMessageProcessor( catalogs: [ - Catalog([CoreCatalogItems.checkBox], catalogId: 'test_catalog'), + Catalog([CoreCatalogItems.checkBox], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'checkbox', - componentProperties: { - 'CheckBox': { - 'label': {'literalString': 'Check me'}, - 'value': {'path': '/myValue'}, - }, + id: 'root', + props: { + 'component': 'CheckBox', + 'label': {'literalString': 'Check me'}, + 'value': {'path': '/myValue'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'checkbox', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), true); diff --git a/packages/genui/test/catalog/core_widgets/choice_picker_test.dart b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart new file mode 100644 index 000000000..3a8539fac --- /dev/null +++ b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart @@ -0,0 +1,133 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + testWidgets('MultipleChoice widget renders and handles changes', ( + WidgetTester tester, + ) async { + final manager = A2uiMessageProcessor( + catalogs: [ + Catalog([ + CoreCatalogItems.choicePicker, + CoreCatalogItems.text, + ], catalogId: standardCatalogId), + ], + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'ChoicePicker', + 'value': {'path': '/mySelections'}, + 'options': { + 'literalArray': [ + { + 'label': {'literalString': 'Option 1'}, + 'value': '1', + }, + { + 'label': {'literalString': 'Option 2'}, + 'value': '2', + }, + ], + }, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ + '1', + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('Option 1'), findsOneWidget); + expect(find.text('Option 2'), findsOneWidget); + final CheckboxListTile checkbox1 = tester.widget( + find.byType(CheckboxListTile).first, + ); + expect(checkbox1.value, isTrue); + final CheckboxListTile checkbox2 = tester.widget( + find.byType(CheckboxListTile).last, + ); + expect(checkbox2.value, isFalse); + + await tester.tap(find.text('Option 2')); + expect( + manager + .dataModelForSurface(surfaceId) + .getValue>(DataPath('/mySelections')), + ['1', '2'], + ); + }); + + testWidgets( + 'MultipleChoice widget handles simple string labels from data model', + (WidgetTester tester) async { + final manager = A2uiMessageProcessor( + catalogs: [ + Catalog([ + CoreCatalogItems.choicePicker, + ], catalogId: standardCatalogId), + ], + ); + const surfaceId = 'testSurfaceSimple'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'ChoicePicker', + 'value': {'path': '/mySelections'}, + 'options': {'path': '/myOptions'}, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + manager.handleMessage( + const UpdateDataModel( + surfaceId: surfaceId, + value: { + 'mySelections': [], + 'myOptions': [ + {'label': 'Simple Option 1', 'value': 's1'}, + {'label': 'Simple Option 2', 'value': 's2'}, + ], + }, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('Simple Option 1'), findsOneWidget); + expect(find.text('Simple Option 2'), findsOneWidget); + }, + ); +} diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index 4f2cdd597..9db15822e 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -13,47 +13,40 @@ void main() { Catalog([ CoreCatalogItems.column, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'column', - componentProperties: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + id: 'root', + props: { + 'component': 'Column', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'column', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( @@ -76,57 +69,50 @@ void main() { Catalog([ CoreCatalogItems.column, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'column', - componentProperties: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2', 'text3'], - }, + id: 'root', + props: { + 'component': 'Column', + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, weight: 1, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, weight: 2, ), const Component( id: 'text3', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Third'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Third'}, + 'weight': 0, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'column', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart index 3bf2bc8a6..6b5c7f19e 100644 --- a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart +++ b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart @@ -15,29 +15,24 @@ void main() { Catalog([ CoreCatalogItems.dateTimeInput, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'datetime', - componentProperties: { - 'DateTimeInput': { - 'value': {'path': '/myDateTime'}, - }, + id: 'root', + props: { + 'component': 'DateTimeInput', + 'value': {'path': '/myDateTime'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'datetime', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager .dataModelForSurface(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index 811d20095..3f62ec424 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -10,25 +10,18 @@ void main() { testWidgets('Divider widget renders', (WidgetTester tester) async { final manager = A2uiMessageProcessor( catalogs: [ - Catalog([CoreCatalogItems.divider], catalogId: 'test_catalog'), + Catalog([CoreCatalogItems.divider], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ - const Component( - id: 'divider', - componentProperties: {'Divider': {}}, - ), + const Component(id: 'root', props: {'component': 'Divider'}), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'divider', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index 3e23a406f..c93ef4a1b 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -12,29 +12,24 @@ void main() { ) async { final manager = A2uiMessageProcessor( catalogs: [ - Catalog([CoreCatalogItems.icon], catalogId: 'test_catalog'), + Catalog([CoreCatalogItems.icon], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'icon', - componentProperties: { - 'Icon': { - 'name': {'literalString': 'add'}, - }, + id: 'root', + props: { + 'component': 'Icon', + 'name': {'literalString': 'add'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'icon', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( @@ -53,36 +48,31 @@ void main() { ) async { final manager = A2uiMessageProcessor( catalogs: [ - Catalog([CoreCatalogItems.icon], catalogId: 'test_catalog'), + Catalog([CoreCatalogItems.icon], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'icon', - componentProperties: { - 'Icon': { - 'name': {'path': '/iconName'}, - }, + id: 'root', + props: { + 'component': 'Icon', + 'name': {'path': '/iconName'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const DataModelUpdate( + const UpdateDataModel( surfaceId: 'testSurface', path: '/iconName', - contents: 'close', + value: 'close', ), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'icon', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 8d2b6e922..48c95a1eb 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -13,47 +13,40 @@ void main() { Catalog([ CoreCatalogItems.list, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'list', - componentProperties: { - 'List': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + id: 'root', + props: { + 'component': 'List', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'list', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index 1a70c6675..20c1c0fe7 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -16,60 +16,52 @@ void main() { CoreCatalogItems.modal, CoreCatalogItems.button, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'modal', - componentProperties: { - 'Modal': {'entryPointChild': 'button', 'contentChild': 'text'}, + id: 'root', + props: { + 'component': 'Modal', + 'entryPointChild': 'button', + 'contentChild': 'text', }, ), const Component( id: 'button', - componentProperties: { - 'Button': { - 'child': 'button_text', - 'action': { - 'name': 'showModal', - 'context': [ - { - 'key': 'modalId', - 'value': {'literalString': 'modal'}, - }, - ], + props: { + 'component': 'Button', + 'child': 'button_text', + 'action': { + 'name': 'showModal', + 'context': { + 'modalId': {'literalString': 'root'}, }, }, }, ), const Component( id: 'button_text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Open Modal'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Open Modal'}, }, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is a modal.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is a modal.'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'modal', - catalogId: 'test_catalog', - ), + UpdateComponents(surfaceId: surfaceId, components: components), ); await tester.pumpWidget( @@ -79,6 +71,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text('Open Modal'), findsOneWidget); expect(find.text('This is a modal.'), findsNothing); diff --git a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart deleted file mode 100644 index 07deb091b..000000000 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; - -void main() { - testWidgets('MultipleChoice widget renders and handles changes', ( - WidgetTester tester, - ) async { - final manager = A2uiMessageProcessor( - catalogs: [ - Catalog([ - CoreCatalogItems.multipleChoice, - CoreCatalogItems.text, - ], catalogId: 'test_catalog'), - ], - ); - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'multiple_choice', - componentProperties: { - 'MultipleChoice': { - 'selections': {'path': '/mySelections'}, - 'options': [ - { - 'label': {'literalString': 'Option 1'}, - 'value': '1', - }, - { - 'label': {'literalString': 'Option 2'}, - 'value': '2', - }, - ], - }, - }, - ), - ]; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'multiple_choice', - catalogId: 'test_catalog', - ), - ); - manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ - '1', - ]); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), - ), - ); - - expect(find.text('Option 1'), findsOneWidget); - expect(find.text('Option 2'), findsOneWidget); - final CheckboxListTile checkbox1 = tester.widget( - find.byType(CheckboxListTile).first, - ); - expect(checkbox1.value, isTrue); - final CheckboxListTile checkbox2 = tester.widget( - find.byType(CheckboxListTile).last, - ); - expect(checkbox2.value, isFalse); - - await tester.tap(find.text('Option 2')); - expect( - manager - .dataModelForSurface(surfaceId) - .getValue>(DataPath('/mySelections')), - ['1', '2'], - ); - }); -} diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index c43f28908..62a7858f6 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -13,47 +13,40 @@ void main() { Catalog([ CoreCatalogItems.row, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'row', - componentProperties: { - 'Row': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + id: 'root', + props: { + 'component': 'Row', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'row', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( @@ -76,57 +69,49 @@ void main() { Catalog([ CoreCatalogItems.row, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'row', - componentProperties: { - 'Row': { - 'children': { - 'explicitList': ['text1', 'text2', 'text3'], - }, + id: 'root', + props: { + 'component': 'Row', + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, weight: 1, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, weight: 2, ), const Component( id: 'text3', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Third'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Third'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'row', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index 43de16ebc..2c925cbea 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -12,29 +12,26 @@ void main() { ) async { final manager = A2uiMessageProcessor( catalogs: [ - Catalog([CoreCatalogItems.slider], catalogId: 'test_catalog'), + Catalog([CoreCatalogItems.slider], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'slider', - componentProperties: { - 'Slider': { - 'value': {'path': '/myValue'}, - }, + id: 'root', + props: { + 'component': 'Slider', + 'value': {'path': '/myValue'}, + 'min': {'literalNumber': 0.0}, + 'max': {'literalNumber': 1.0}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'slider', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), 0.5); @@ -57,4 +54,64 @@ void main() { greaterThan(0.5), ); }); + + testWidgets('Slider widget handles data-bound min/max values', ( + WidgetTester tester, + ) async { + final manager = A2uiMessageProcessor( + catalogs: [ + Catalog([CoreCatalogItems.slider], catalogId: standardCatalogId), + ], + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'Slider', + 'value': {'path': '/myValue'}, + 'min': {'path': '/myMin'}, + 'max': {'path': '/myMax'}, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + manager.handleMessage( + const UpdateDataModel( + surfaceId: surfaceId, + value: {'myValue': 5.0, 'myMin': 0.0, 'myMax': 10.0}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + final Slider slider = tester.widget(find.byType(Slider)); + expect(slider.value, 5.0); + expect(slider.min, 0.0); + expect(slider.max, 10.0); + + // Update min/max via data model + manager.handleMessage( + const UpdateDataModel( + surfaceId: surfaceId, + value: {'myMin': 2.0, 'myMax': 8.0}, + ), + ); + await tester.pumpAndSettle(); + + final Slider sliderUpdated = tester.widget(find.byType(Slider)); + expect(sliderUpdated.min, 2.0); + expect(sliderUpdated.max, 8.0); + }); } diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 11acefdde..f617f5c0a 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -15,54 +15,47 @@ void main() { Catalog([ CoreCatalogItems.tabs, CoreCatalogItems.text, - ], catalogId: 'test_catalog'), + ], catalogId: standardCatalogId), ], ); const surfaceId = 'testSurface'; final components = [ const Component( - id: 'tabs', - componentProperties: { - 'Tabs': { - 'tabItems': [ - { - 'title': {'literalString': 'Tab 1'}, - 'child': 'text1', - }, - { - 'title': {'literalString': 'Tab 2'}, - 'child': 'text2', - }, - ], - }, + id: 'root', + props: { + 'component': 'Tabs', + 'tabItems': [ + { + 'title': {'literalString': 'Tab 1'}, + 'child': 'text1', + }, + { + 'title': {'literalString': 'Tab 2'}, + 'child': 'text2', + }, + ], }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is the first tab.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is the first tab.'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is the second tab.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is the second tab.'}, }, ), ]; manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'tabs', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart new file mode 100644 index 000000000..e7f5ffefd --- /dev/null +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -0,0 +1,70 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + testWidgets('TextField renders and handles changes/submissions', ( + WidgetTester tester, + ) async { + ChatMessage? message; + final manager = A2uiMessageProcessor( + catalogs: [ + Catalog([CoreCatalogItems.textField], catalogId: standardCatalogId), + ], + ); + manager.onSubmit.listen((event) => message = event); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'TextField', + 'text': {'path': '/myValue'}, + 'label': {'literalString': 'My Label'}, + 'onSubmittedAction': {'name': 'submit'}, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + manager + .dataModelForSurface(surfaceId) + .update(DataPath('/myValue'), 'initial'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + await tester.pumpAndSettle(); + + final Finder textFieldFinder = find.byType(TextField); + expect(find.widgetWithText(TextField, 'initial'), findsOneWidget); + final TextField textField = tester.widget(textFieldFinder); + expect(textField.decoration?.labelText, 'My Label'); + + // Test onChanged + await tester.enterText(textFieldFinder, 'new value'); + expect( + manager + .dataModelForSurface(surfaceId) + .getValue(DataPath('/myValue')), + 'new value', + ); + + // Test onSubmitted + expect(message, null); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(message, isNotNull); + }); +} diff --git a/packages/genui/test/catalog/core_widgets/text_test.dart b/packages/genui/test/catalog/core_widgets/text_test.dart index c70416e2c..e855d85f5 100644 --- a/packages/genui/test/catalog/core_widgets/text_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_test.dart @@ -21,9 +21,10 @@ void main() { body: text.widgetBuilder( CatalogItemContext( data: { + 'component': 'Text', 'text': {'literalString': 'Hello World'}, }, - id: 'test_text', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, @@ -50,10 +51,11 @@ void main() { body: text.widgetBuilder( CatalogItemContext( data: { + 'component': 'Text', 'text': {'literalString': 'Heading 1'}, 'usageHint': 'h1', }, - id: 'test_text_h1', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, @@ -106,7 +108,7 @@ void main() { data: { 'text': {'literalString': 'Hello **Bold**'}, }, - id: 'test_text_markdown', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart deleted file mode 100644 index 14bbff411..000000000 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; - -void main() { - group('Core Widgets', () { - final Catalog testCatalog = CoreCatalogItems.asCatalog(); - - ChatMessage? message; - A2uiMessageProcessor? messageProcessor; - - Future pumpWidgetWithDefinition( - WidgetTester tester, - String rootId, - List components, - ) async { - message = null; - messageProcessor?.dispose(); - messageProcessor = A2uiMessageProcessor(catalogs: [testCatalog]); - messageProcessor!.onSubmit.listen((event) => message = event); - const surfaceId = 'testSurface'; - messageProcessor!.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - messageProcessor!.handleMessage( - BeginRendering( - surfaceId: surfaceId, - root: rootId, - catalogId: testCatalog.catalogId, - ), - ); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: GenUiSurface(host: messageProcessor!, surfaceId: surfaceId), - ), - ), - ); - } - - testWidgets('Button renders and handles taps', (WidgetTester tester) async { - final components = [ - const Component( - id: 'button', - componentProperties: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, - }, - ), - const Component( - id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Click Me'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'button', components); - - expect(find.text('Click Me'), findsOneWidget); - - expect(message, null); - await tester.tap(find.byType(ElevatedButton)); - expect(message, isNotNull); - }); - - testWidgets('Text renders from data model', (WidgetTester tester) async { - final components = [ - const Component( - id: 'text', - componentProperties: { - 'Text': { - 'text': {'path': '/myText'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'text', components); - messageProcessor! - .dataModelForSurface('testSurface') - .update(DataPath('/myText'), 'Hello from data model'); - await tester.pumpAndSettle(); - - expect(find.text('Hello from data model'), findsOneWidget); - }); - - testWidgets('Column renders children', (WidgetTester tester) async { - final components = [ - const Component( - id: 'col', - componentProperties: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, - }, - }, - ), - const Component( - id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, - }, - ), - const Component( - id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'col', components); - - expect(find.text('First'), findsOneWidget); - expect(find.text('Second'), findsOneWidget); - }); - - testWidgets('TextField renders and handles changes/submissions', ( - WidgetTester tester, - ) async { - final components = [ - const Component( - id: 'field', - componentProperties: { - 'TextField': { - 'text': {'path': '/myValue'}, - 'label': {'literalString': 'My Label'}, - 'onSubmittedAction': {'name': 'submit'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'field', components); - messageProcessor! - .dataModelForSurface('testSurface') - .update(DataPath('/myValue'), 'initial'); - await tester.pumpAndSettle(); - - final Finder textFieldFinder = find.byType(TextField); - expect(find.widgetWithText(TextField, 'initial'), findsOneWidget); - final TextField textField = tester.widget(textFieldFinder); - expect(textField.decoration?.labelText, 'My Label'); - - // Test onChanged - await tester.enterText(textFieldFinder, 'new value'); - expect( - messageProcessor! - .dataModelForSurface('testSurface') - .getValue(DataPath('/myValue')), - 'new value', - ); - - // Test onSubmitted - expect(message, null); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(message, isNotNull); - }); - }); -} diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index 05469ad6a..77c2bc538 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -21,11 +21,10 @@ void main() { WidgetTester tester, ) async { final catalog = Catalog([CoreCatalogItems.column, CoreCatalogItems.text]); - final widgetData = { - 'Column': { - 'children': { - 'explicitList': ['child1'], - }, + final Map widgetData = { + 'component': 'Column', + 'children': { + 'explicitList': ['child1'], }, }; @@ -63,9 +62,7 @@ void main() { final catalog = const Catalog([]); final Map data = { 'id': 'text1', - 'widget': { - 'unknown_widget': {'text': 'hello'}, - }, + 'widget': {'component': 'unknown_widget', 'text': 'hello'}, }; final Future logFuture = expectLater( diff --git a/packages/genui/test/core/a2ui_message_processor_test.dart b/packages/genui/test/core/a2ui_message_processor_test.dart index b60bab6ff..1c05c67b5 100644 --- a/packages/genui/test/core/a2ui_message_processor_test.dart +++ b/packages/genui/test/core/a2ui_message_processor_test.dart @@ -37,42 +37,81 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { - 'Text': {'text': 'Hello'}, - }, + props: {'component': 'Text', 'text': 'Hello'}, ), ]; messageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); - final Future futureUpdate = + final Future futureUpdated = messageProcessor.surfaceUpdates.first; messageProcessor.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'root', - catalogId: 'test_catalog', - ), + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); - final GenUiUpdate update = await futureUpdate; + final GenUiUpdate updatedUpdate = await futureUpdated; - expect(update, isA()); - expect(update.surfaceId, surfaceId); - final UiDefinition definition = (update as SurfaceAdded).definition; + expect(updatedUpdate, isA()); + expect(updatedUpdate.surfaceId, surfaceId); + final UiDefinition definition = + (updatedUpdate as SurfaceUpdated).definition; expect(definition, isNotNull); - expect(definition.rootComponentId, 'root'); - expect(definition.catalogId, 'test_catalog'); + // expect(definition.rootComponentId, 'root'); // CreateSurface no longer sets root expect(messageProcessor.surfaces[surfaceId]!.value, isNotNull); - expect( - messageProcessor.surfaces[surfaceId]!.value!.rootComponentId, - 'root', + // expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); + }); + + test( + 'handleMessage fires SurfaceAdded when CreateSurface is received for a ' + 'new surface', + () async { + const surfaceId = 'newSurface'; + final Future futureUpdate = + messageProcessor.surfaceUpdates.first; + messageProcessor.handleMessage( + const CreateSurface( + surfaceId: surfaceId, + catalogId: standardCatalogId, + ), + ); + final GenUiUpdate update = await futureUpdate; + + expect(update, isA()); + expect(update.surfaceId, surfaceId); + }, + ); + + test('handleMessage updates surface when UpdateComponents follows ' + 'CreateSurface', () async { + const surfaceId = 's2'; + // 1. CreateSurface + final Future futureCreate = + messageProcessor.surfaceUpdates.first; + messageProcessor.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), ); - expect( - messageProcessor.surfaces[surfaceId]!.value!.catalogId, - 'test_catalog', + final GenUiUpdate createUpdate = await futureCreate; + expect(createUpdate, isA()); + + // 2. UpdateComponents + final components = [ + const Component( + id: 'root', + props: {'component': 'Text', 'text': 'Updated'}, + ), + ]; + final Future futureUpdate = + messageProcessor.surfaceUpdates.first; + messageProcessor.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), ); + final GenUiUpdate update = await futureUpdate; + + expect(update, isA()); + expect(update.surfaceId, surfaceId); + final UiDefinition definition = (update as SurfaceUpdated).definition; + expect(definition.components['root'], components[0]); }); test( @@ -82,33 +121,32 @@ void main() { final oldComponents = [ const Component( id: 'root', - componentProperties: { - 'Text': {'text': 'Old'}, - }, + props: {'component': 'Text', 'text': 'Old'}, ), ]; final newComponents = [ const Component( id: 'root', - componentProperties: { - 'Text': {'text': 'New'}, - }, + props: {'component': 'Text', 'text': 'New'}, ), ]; final Future expectation = expectLater( messageProcessor.surfaceUpdates, - emitsInOrder([isA(), isA()]), + emitsInOrder([isA(), isA()]), ); messageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: oldComponents), + UpdateComponents(surfaceId: surfaceId, components: oldComponents), ); messageProcessor.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const CreateSurface( + surfaceId: surfaceId, + catalogId: standardCatalogId, + ), ); messageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: newComponents), + UpdateComponents(surfaceId: surfaceId, components: newComponents), ); await expectation; @@ -120,13 +158,11 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { - 'Text': {'text': 'Hello'}, - }, + props: {'component': 'Text', 'text': 'Hello'}, ), ]; messageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + UpdateComponents(surfaceId: surfaceId, components: components), ); final Future futureUpdate = diff --git a/packages/genui/test/core/genui_surface_test.dart b/packages/genui/test/core/genui_surface_test.dart new file mode 100644 index 000000000..6eebb84c5 --- /dev/null +++ b/packages/genui/test/core/genui_surface_test.dart @@ -0,0 +1,92 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + late A2uiMessageProcessor manager; + final testCatalog = Catalog([ + CoreCatalogItems.button, + CoreCatalogItems.text, + ], catalogId: standardCatalogId); + + setUp(() { + manager = A2uiMessageProcessor(catalogs: [testCatalog]); + }); + + testWidgets('SurfaceWidget builds a widget from a definition', ( + WidgetTester tester, + ) async { + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'Button', + 'child': 'text', + 'action': {'name': 'testAction'}, + }, + ), + const Component( + id: 'text', + props: { + 'component': 'Text', + 'text': {'literalString': 'Hello'}, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + + await tester.pumpWidget( + MaterialApp( + home: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ); + + expect(find.text('Hello'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('SurfaceWidget handles events', (WidgetTester tester) async { + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'Button', + 'child': 'text', + 'action': {'name': 'testAction'}, + }, + ), + const Component( + id: 'text', + props: { + 'component': 'Text', + 'text': {'literalString': 'Hello'}, + }, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: standardCatalogId), + ); + + await tester.pumpWidget( + MaterialApp( + home: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + }); +} diff --git a/packages/genui/test/core/ui_tools_test.dart b/packages/genui/test/core/ui_tools_test.dart index b3087a8eb..5d99a17bf 100644 --- a/packages/genui/test/core/ui_tools_test.dart +++ b/packages/genui/test/core/ui_tools_test.dart @@ -2,110 +2,97 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/core/ui_tools.dart'; -import 'package:genui/src/model/a2ui_message.dart'; -import 'package:genui/src/model/catalog.dart'; -import 'package:genui/src/model/catalog_item.dart'; -import 'package:genui/src/model/tools.dart'; -import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:genui/genui.dart'; void main() { - group('$SurfaceUpdateTool', () { - test('invoke calls handleMessage with correct arguments', () async { - final messages = []; + group('UI Tools', () { + late A2uiMessageProcessor genUiManager; + late Catalog catalog; - void fakeHandleMessage(A2uiMessage message) { - messages.add(message); - } + setUp(() { + catalog = CoreCatalogItems.asCatalog(); + genUiManager = A2uiMessageProcessor(catalogs: [catalog]); + }); - final tool = SurfaceUpdateTool( - handleMessage: fakeHandleMessage, - catalog: Catalog([ - CatalogItem( - name: 'Text', - widgetBuilder: (_) { - return const Text(''); - }, - dataSchema: Schema.object(properties: {}), - ), - ], catalogId: 'test_catalog'), + test('UpdateComponentsTool sends UpdateComponents message', () async { + final tool = UpdateComponentsTool( + handleMessage: genUiManager.handleMessage, + catalog: catalog, ); final Map args = { surfaceIdKey: 'testSurface', 'components': [ - { - 'id': 'rootWidget', - 'component': { - 'Text': {'text': 'Hello'}, - }, - }, + {'id': 'root', 'component': 'Text', 'text': 'Hello'}, ], }; - await tool.invoke(args); - - expect(messages.length, 1); - expect(messages[0], isA()); - final surfaceUpdate = messages[0] as SurfaceUpdate; - expect(surfaceUpdate.surfaceId, 'testSurface'); - expect(surfaceUpdate.components.length, 1); - expect(surfaceUpdate.components[0].id, 'rootWidget'); - expect(surfaceUpdate.components[0].componentProperties, { - 'Text': {'text': 'Hello'}, - }); - }); - }); - - group('DeleteSurfaceTool', () { - test('invoke calls handleMessage with correct arguments', () async { - final messages = []; - - void fakeHandleMessage(A2uiMessage message) { - messages.add(message); - } - - final tool = DeleteSurfaceTool(handleMessage: fakeHandleMessage); - - final Map args = {surfaceIdKey: 'testSurface'}; + final Future future = expectLater( + genUiManager.surfaceUpdates, + emits( + isA() + .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') + .having( + (e) => e.definition.components.length, + 'components.length', + 1, + ) + .having( + (e) => e.definition.components.values.first.id, + 'components.first.id', + 'root', + ), + ), + ); await tool.invoke(args); + genUiManager.handleMessage( + const CreateSurface( + surfaceId: 'testSurface', + catalogId: standardCatalogId, + ), + ); - expect(messages.length, 1); - expect(messages[0], isA()); - final deleteSurface = messages[0] as SurfaceDeletion; - expect(deleteSurface.surfaceId, 'testSurface'); + await future; }); - }); - - group('BeginRenderingTool', () { - test('invoke calls handleMessage with correct arguments', () async { - final messages = []; - void fakeHandleMessage(A2uiMessage message) { - messages.add(message); - } - - final tool = BeginRenderingTool( - handleMessage: fakeHandleMessage, - catalogId: 'test_catalog', - ); + test('CreateSurfaceTool sends CreateSurface message', () async { + final tool = CreateSurfaceTool(handleMessage: genUiManager.handleMessage); final Map args = { surfaceIdKey: 'testSurface', - 'root': 'rootWidget', + 'catalogId': standardCatalogId, }; + // First, add a component to the surface so that the root can be set. + genUiManager.handleMessage( + const UpdateComponents( + surfaceId: 'testSurface', + components: [ + Component( + id: 'root', + props: {'component': 'Text', 'text': 'Hello'}, + ), + ], + ), + ); + + // Use expectLater to wait for the stream to emit the correct event. + final Future future = expectLater( + genUiManager.surfaceUpdates, + emits( + isA().having( + (e) => e.surfaceId, + surfaceIdKey, + 'testSurface', + ), + ), + ); + await tool.invoke(args); - expect(messages.length, 1); - expect(messages[0], isA()); - final beginRendering = messages[0] as BeginRendering; - expect(beginRendering.surfaceId, 'testSurface'); - expect(beginRendering.root, 'rootWidget'); - expect(beginRendering.catalogId, 'test_catalog'); + await future; // Wait for the expectation to be met. }); }); } diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart deleted file mode 100644 index 3c8df88c3..000000000 --- a/packages/genui/test/genui_surface_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; -import 'package:logging/logging.dart'; - -void main() { - late A2uiMessageProcessor processor; - final testCatalog = Catalog([ - CoreCatalogItems.button, - CoreCatalogItems.text, - ], catalogId: 'test_catalog'); - - setUp(() { - processor = A2uiMessageProcessor(catalogs: [testCatalog]); - }); - - testWidgets('SurfaceWidget builds a widget from a definition', ( - WidgetTester tester, - ) async { - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - componentProperties: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, - }, - ), - const Component( - id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, - }, - ), - ]; - processor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - processor.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'root', - catalogId: 'test_catalog', - ), - ); - - await tester.pumpWidget( - MaterialApp( - home: GenUiSurface(host: processor, surfaceId: surfaceId), - ), - ); - - expect(find.text('Hello'), findsOneWidget); - expect(find.byType(ElevatedButton), findsOneWidget); - }); - - testWidgets('SurfaceWidget handles events', (WidgetTester tester) async { - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - componentProperties: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, - }, - ), - const Component( - id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, - }, - ), - ]; - processor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - processor.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'root', - catalogId: 'test_catalog', - ), - ); - - await tester.pumpWidget( - MaterialApp( - home: GenUiSurface(host: processor, surfaceId: surfaceId), - ), - ); - - await tester.tap(find.byType(ElevatedButton)); - }); - - testWidgets( - 'SurfaceWidget renders container and logs error on catalog miss', - (WidgetTester tester) async { - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, - }, - ), - ]; - processor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - // Request a catalogId that doesn't exist in the processor. - processor.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'root', - catalogId: 'non_existent_catalog', - ), - ); - - final logs = []; - genUiLogger.onRecord.listen(logs.add); - - await tester.pumpWidget( - MaterialApp( - home: GenUiSurface(host: processor, surfaceId: surfaceId), - ), - ); - - // Should build an empty container instead of the widget tree. - expect(find.byType(Container), findsOneWidget); - expect(find.byType(Text), findsNothing); - - // Should log a severe error. - expect( - logs.any( - (r) => - r.level == Level.SEVERE && - r.message.contains( - 'Catalog with id "non_existent_catalog" not found', - ), - ), - isTrue, - ); - }, - ); -} diff --git a/packages/genui/test/model/ui_definition_test.dart b/packages/genui/test/model/ui_definition_test.dart index c7603f23a..5e4480edd 100644 --- a/packages/genui/test/model/ui_definition_test.dart +++ b/packages/genui/test/model/ui_definition_test.dart @@ -15,9 +15,7 @@ void main() { components: { 'root': const Component( id: 'root', - componentProperties: { - 'Text': {'text': 'Hello'}, - }, + props: {'component': 'Text', 'text': 'Hello'}, ), }, ); @@ -27,12 +25,7 @@ void main() { expect(json[surfaceIdKey], 'testSurface'); expect(json['rootComponentId'], 'root'); expect(json['components'], { - 'root': { - 'id': 'root', - 'component': { - 'Text': {'text': 'Hello'}, - }, - }, + 'root': {'id': 'root', 'component': 'Text', 'text': 'Hello'}, }); }); }); diff --git a/packages/genui/test/ui_tools_test.dart b/packages/genui/test/ui_tools_test.dart deleted file mode 100644 index 2abe9365f..000000000 --- a/packages/genui/test/ui_tools_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; - -void main() { - group('UI Tools', () { - late A2uiMessageProcessor a2uiMessageProcessor; - late Catalog catalog; - - setUp(() { - catalog = CoreCatalogItems.asCatalog(); - a2uiMessageProcessor = A2uiMessageProcessor(catalogs: [catalog]); - }); - - test('SurfaceUpdateTool sends SurfaceUpdate message', () async { - final tool = SurfaceUpdateTool( - handleMessage: a2uiMessageProcessor.handleMessage, - catalog: catalog, - ); - - final Map args = { - surfaceIdKey: 'testSurface', - 'components': [ - { - 'id': 'root', - 'component': { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, - }, - }, - ], - }; - - final Future future = expectLater( - a2uiMessageProcessor.surfaceUpdates, - emits( - isA() - .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') - .having( - (e) => e.definition.components.length, - 'components.length', - 1, - ) - .having( - (e) => e.definition.components.values.first.id, - 'components.first.id', - 'root', - ), - ), - ); - - await tool.invoke(args); - a2uiMessageProcessor.handleMessage( - const BeginRendering(surfaceId: 'testSurface', root: 'root'), - ); - - await future; - }); - - test('BeginRenderingTool sends BeginRendering message', () async { - final tool = BeginRenderingTool( - handleMessage: a2uiMessageProcessor.handleMessage, - catalogId: 'test_catalog', - ); - - final Map args = { - surfaceIdKey: 'testSurface', - 'root': 'root', - }; - - // First, add a component to the surface so that the root can be set. - a2uiMessageProcessor.handleMessage( - const SurfaceUpdate( - surfaceId: 'testSurface', - components: [ - Component( - id: 'root', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, - }, - ), - ], - ), - ); - - // Use expectLater to wait for the stream to emit the correct event. - final Future future = expectLater( - a2uiMessageProcessor.surfaceUpdates, - emits( - isA() - .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') - .having( - (e) => e.definition.rootComponentId, - 'rootComponentId', - 'root', - ) - .having( - (e) => e.definition.catalogId, - 'catalogId', - 'test_catalog', - ), - ), - ); - - await tool.invoke(args); - - await future; // Wait for the expectation to be met. - }); - }); -} diff --git a/packages/genui/test/validation_test_utils.dart b/packages/genui/test/validation_test_utils.dart index 5858a5672..f567f0199 100644 --- a/packages/genui/test/validation_test_utils.dart +++ b/packages/genui/test/validation_test_utils.dart @@ -22,7 +22,7 @@ void validateCatalogExamples( ...catalog.items, ...additionalCatalogs.expand((c) => c.items), ]); - final Schema schema = A2uiSchemas.surfaceUpdateSchema(mergedCatalog); + final Schema schema = A2uiSchemas.updateComponentsSchema(mergedCatalog); for (final CatalogItem item in catalog.items) { group('CatalogItem ${item.name}', () { @@ -48,13 +48,13 @@ void validateCatalogExamples( reason: 'Example must have a component with id "root"', ); - final surfaceUpdate = SurfaceUpdate( + final updateComponents = UpdateComponents( surfaceId: 'test-surface', components: components, ); final List validationErrors = await schema.validate( - surfaceUpdate.toJson(), + updateComponents.toJson(), ); expect(validationErrors, isEmpty); }); diff --git a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart index 4a9df756a..d36a9623f 100644 --- a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart +++ b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart @@ -131,13 +131,8 @@ class A2uiAgentConnector { if (contextId != null) { message.contextId = contextId; } - if (clientCapabilities != null) { - message.metadata = { - 'a2uiClientCapabilities': clientCapabilities.toJson(), - }; - } final payload = A2AMessageSendParams()..message = message; - payload.extensions = ['https://a2ui.org/a2a-extension/a2ui/v0.8']; + payload.extensions = ['https://a2ui.org/ext/a2a-ui/v0.9']; _log.info('--- OUTGOING REQUEST ---'); _log.info('URL: ${url.toString()}'); @@ -223,10 +218,10 @@ class A2uiAgentConnector { } final Map clientEvent = { - 'actionName': event['action'], + 'action': event['action'], 'sourceComponentId': event['sourceComponentId'], 'timestamp': DateTime.now().toIso8601String(), - 'resolvedContext': event['context'], + 'context': event['context'], }; _log.finest('Sending client event: $clientEvent'); @@ -256,10 +251,11 @@ class A2uiAgentConnector { 'Processing a2ui messages from data part:\n' '${const JsonEncoder.withIndent(' ').convert(data)}', ); - if (data.containsKey('surfaceUpdate') || - data.containsKey('dataModelUpdate') || - data.containsKey('beginRendering') || - data.containsKey('deleteSurface')) { + if (data.containsKey('updateComponents') || + data.containsKey('updateDataModel') || + data.containsKey('createSurface') || + data.containsKey('deleteSurface') || + data.containsKey('error')) { if (!_controller.isClosed) { _log.finest( 'Adding message to stream: ' diff --git a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart index 0ece08435..c565f4cd5 100644 --- a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart +++ b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart @@ -42,26 +42,6 @@ void main() { expect(fakeClient.getAgentCardCalled, 1); }); - test('connectAndSend includes clientCapabilities in metadata', () async { - const capabilities = genui.A2UiClientCapabilities( - supportedCatalogIds: ['cat1', 'cat2'], - ); - fakeClient.sendMessageStreamHandler = (_) => const Stream.empty(); - - await connector.connectAndSend( - genui.UserMessage.text('Hi'), - clientCapabilities: capabilities, - ); - - expect(fakeClient.sendMessageStreamCalled, 1); - final a2a.A2AMessage sentMessage = - fakeClient.lastSendMessageParams!.message; - expect(sentMessage.metadata, isNotNull); - expect(sentMessage.metadata!['a2uiClientCapabilities'], { - 'supportedCatalogIds': ['cat1', 'cat2'], - }); - }); - test('connectAndSend processes stream and returns text response', () async { final responses = [ a2a.A2ASendStreamMessageSuccessResponse() @@ -73,14 +53,13 @@ void main() { ..parts = [ a2a.A2ADataPart() ..data = { - 'surfaceUpdate': { + 'updateComponents': { 'surfaceId': 's1', 'components': [ { 'id': 'c1', - 'component': { - 'Column': {'children': []}, - }, + 'component': 'Column', + 'children': [], }, ], }, @@ -111,7 +90,7 @@ void main() { expect(connector.contextId, 'context1'); expect(fakeClient.sendMessageStreamCalled, 1); expect(messages.length, 1); - expect(messages.first, isA()); + expect(messages.first, isA()); }); test('connectAndSend sends multiple text parts', () async { @@ -177,9 +156,9 @@ void main() { expect(sentMessage.contextId, 'context1'); final dataPart = sentMessage.parts!.first as a2a.A2ADataPart; final a2uiEvent = dataPart.data['a2uiEvent'] as Map; - expect(a2uiEvent['actionName'], 'testAction'); + expect(a2uiEvent['action'], 'testAction'); expect(a2uiEvent['sourceComponentId'], 'c1'); - expect(a2uiEvent['resolvedContext'], {'key': 'value'}); + expect(a2uiEvent['context'], {'key': 'value'}); }); test('sendEvent does nothing if taskId is null', () async { diff --git a/packages/genui_a2ui/test/a2ui_content_generator_test.dart b/packages/genui_a2ui/test/a2ui_content_generator_test.dart index e531d8ddb..4ec7fe09b 100644 --- a/packages/genui_a2ui/test/a2ui_content_generator_test.dart +++ b/packages/genui_a2ui/test/a2ui_content_generator_test.dart @@ -101,15 +101,10 @@ void main() { contentGenerator.a2uiMessageStream.listen(completer.complete); final testMessage = A2uiMessage.fromJson({ - 'surfaceUpdate': { + 'updateComponents': { 'surfaceId': 's1', 'components': [ - { - 'id': 'c1', - 'component': { - 'Column': {'children': []}, - }, - }, + {'id': 'c1', 'component': 'Column', 'children': []}, ], }, }); diff --git a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart index a19d1b5e5..5ea6478cd 100644 --- a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart +++ b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart @@ -7,10 +7,9 @@ import 'dart:convert'; import 'package:firebase_ai/firebase_ai.dart' hide TextPart; // ignore: implementation_imports -import 'package:firebase_ai/src/api.dart' show ModalityTokenCount; + import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' hide Part; -import 'package:json_schema_builder/json_schema_builder.dart' as dsb; import 'gemini_content_converter.dart'; import 'gemini_generative_model.dart'; @@ -38,7 +37,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { FirebaseAiContentGenerator({ required this.catalog, this.systemInstruction, - this.outputToolName = 'provideFinalOutput', this.modelCreator = defaultGenerativeModelFactory, this.additionalTools = const [], }); @@ -49,15 +47,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { /// The system instruction to use for the AI model. final String? systemInstruction; - /// The name of an internal pseudo-tool used to retrieve the final structured - /// output from the AI. - /// - /// This only needs to be provided in case of name collision with another - /// tool. - /// - /// Defaults to 'provideFinalOutput'. - final String outputToolName; - /// A function to use for creating the model itself. /// /// This factory function is responsible for instantiating the @@ -113,15 +102,7 @@ class FirebaseAiContentGenerator implements ContentGenerator { _isProcessing.value = true; try { final messages = [...?history, message]; - final Object? response = await _generate( - messages: messages, - // This turns on forced function calling. - outputSchema: dsb.S.object(properties: {'response': dsb.S.string()}), - ); - // Convert any response to a text response to the user. - if (response is Map && response.containsKey('response')) { - _textResponseController.add(response['response']! as String); - } + await _generate(messages: messages); } catch (e, st) { genUiLogger.severe('Error generating content', e, st); _errorController.add(ContentGeneratorError(e, st)); @@ -152,32 +133,12 @@ class FirebaseAiContentGenerator implements ContentGenerator { ({List? generativeAiTools, Set allowedFunctionNames}) _setupToolsAndFunctions({ - required bool isForcedToolCalling, required List availableTools, required GeminiSchemaAdapter adapter, - required dsb.Schema? outputSchema, }) { - genUiLogger.fine( - 'Setting up tools' - '${isForcedToolCalling ? ' with forced tool calling' : ''}', - ); - // Create an "output" tool that copies its args into the output. - final DynamicAiTool>? finalOutputAiTool = - isForcedToolCalling - ? DynamicAiTool>( - name: outputToolName, - description: - '''Returns the final output. Call this function when you are done with the current turn of the conversation. Do not call this if you need to use other tools first. You MUST call this tool when you are done.''', - // Wrap the outputSchema in an object so that the output schema - // isn't limited to objects. - parameters: dsb.S.object(properties: {'output': outputSchema!}), - invokeFunction: (args) async => args, // Invoke is a pass-through - ) - : null; + genUiLogger.fine('Setting up tools'); - final List> allTools = isForcedToolCalling - ? [...availableTools, finalOutputAiTool!] - : availableTools; + final allTools = availableTools; genUiLogger.fine( 'Available tools: ${allTools.map((t) => t.name).join(', ')}', ); @@ -261,14 +222,9 @@ class FirebaseAiContentGenerator implements ContentGenerator { ); } - Future< - ({List functionResponseParts, Object? capturedResult}) - > - _processFunctionCalls({ + Future> _processFunctionCalls({ required List functionCalls, - required bool isForcedToolCalling, required List availableTools, - Object? capturedResult, }) async { genUiLogger.fine( 'Processing ${functionCalls.length} function calls from model.', @@ -278,25 +234,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { genUiLogger.fine( 'Processing function call: ${call.name} with args: ${call.args}', ); - if (isForcedToolCalling && call.name == outputToolName) { - try { - capturedResult = call.args['output']; - genUiLogger.fine( - 'Captured final output from tool "$outputToolName".', - ); - } catch (exception, stack) { - genUiLogger.severe( - 'Unable to read output: $call [${call.args}]', - exception, - stack, - ); - } - genUiLogger.info( - '****** Gen UI Output ******.\n' - '${const JsonEncoder.withIndent(' ').convert(capturedResult)}', - ); - break; - } final AiTool aiTool = availableTools.firstWhere( (t) => t.name == call.name || t.fullName == call.name, @@ -326,32 +263,14 @@ class FirebaseAiContentGenerator implements ContentGenerator { 'Finished processing function calls. Returning ' '${functionResponseParts.length} responses.', ); - return ( - functionResponseParts: functionResponseParts, - capturedResult: capturedResult, - ); + return functionResponseParts; } - Future _generate({ - required Iterable messages, - dsb.Schema? outputSchema, - }) async { - final isForcedToolCalling = outputSchema != null; + Future _generate({required Iterable messages}) async { final converter = GeminiContentConverter(); final adapter = GeminiSchemaAdapter(); - final List> availableTools = [ - SurfaceUpdateTool( - handleMessage: _a2uiMessageController.add, - catalog: catalog, - ), - BeginRenderingTool( - handleMessage: _a2uiMessageController.add, - catalogId: catalog.catalogId, - ), - DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), - ...additionalTools, - ]; + final List> availableTools = [...additionalTools]; // A local copy of the incoming messages which is updated with tool results // as they are generated. @@ -363,37 +282,37 @@ class FirebaseAiContentGenerator implements ContentGenerator { :List? generativeAiTools, :Set allowedFunctionNames, ) = _setupToolsAndFunctions( - isForcedToolCalling: isForcedToolCalling, availableTools: availableTools, adapter: adapter, - outputSchema: outputSchema, ); var toolUsageCycle = 0; const maxToolUsageCycles = 40; // Safety break for tool loops - Object? capturedResult; + final String definition = const JsonEncoder.withIndent( + ' ', + ).convert(catalog.definition.toJson()); final GeminiGenerativeModelInterface model = modelCreator( configuration: this, - systemInstruction: systemInstruction == null - ? null - : Content.system(systemInstruction!), + systemInstruction: Content.system( + '${systemInstruction ?? ''}\n\n' + 'You have access to the following UI components:\n' + '$definition\n\n' + 'You must output your response as a stream of JSON objects, one per ' + 'line (JSONL). Each line can be either a plain text response or a ' + 'structured A2UI message (e.g., createSurface, surfaceUpdate). ' + 'Do not wrap the JSON objects in a list or any other structure. ' + 'Just output one JSON object per line.', + ), tools: generativeAiTools, - toolConfig: isForcedToolCalling - ? ToolConfig( - functionCallingConfig: FunctionCallingConfig.any( - allowedFunctionNames.toSet(), - ), - ) + toolConfig: generativeAiTools == null + ? null : ToolConfig(functionCallingConfig: FunctionCallingConfig.auto()), ); + toolLoop: while (toolUsageCycle < maxToolUsageCycles) { genUiLogger.fine('Starting tool usage cycle ${toolUsageCycle + 1}.'); - if (isForcedToolCalling && capturedResult != null) { - genUiLogger.fine('Captured result found, exiting tool usage loop.'); - break; - } toolUsageCycle++; final String concatenatedContents = mutableContent @@ -407,206 +326,103 @@ With functions: ''', ); final inferenceStartTime = DateTime.now(); - GenerateContentResponse response; - response = await model.generateContent(mutableContent); - genUiLogger.finest('Raw model response: ${_responseToString(response)}'); + // We use generateContentStream to handle streaming responses + final Stream responseStream = model + .generateContentStream(mutableContent); - final Duration elapsed = DateTime.now().difference(inferenceStartTime); + final currentLineBuffer = StringBuffer(); - if (response.usageMetadata != null) { - inputTokenUsage += response.usageMetadata!.promptTokenCount ?? 0; - outputTokenUsage += response.usageMetadata!.candidatesTokenCount ?? 0; - } - genUiLogger.info( - '****** Completed Inference ******\n' - 'Latency = ${elapsed.inMilliseconds}ms\n' - 'Output tokens = ${response.usageMetadata?.candidatesTokenCount ?? 0}\n' - 'Prompt tokens = ${response.usageMetadata?.promptTokenCount ?? 0}', - ); - - if (response.candidates.isEmpty) { - genUiLogger.warning( - 'Response has no candidates: ${response.promptFeedback}', - ); - return isForcedToolCalling ? null : ''; - } + await for (final GenerateContentResponse response in responseStream) { + if (response.candidates.isEmpty) { + continue; + } + final Candidate candidate = response.candidates.first; - final Candidate candidate = response.candidates.first; - final List functionCalls = candidate.content.parts - .whereType() - .toList(); + // Handle function calls if any (though we prefer JSONL now, tools + // might still be used for other things) + final List functionCalls = candidate.content.parts + .whereType() + .toList(); - if (functionCalls.isEmpty) { - genUiLogger.fine('Model response contained no function calls.'); - if (isForcedToolCalling) { - genUiLogger.warning( - 'Model did not call any function. FinishReason: ' - '${candidate.finishReason}. Text: "${candidate.text}" ', - ); - if (candidate.text != null && candidate.text!.trim().isNotEmpty) { - genUiLogger.warning( - 'Model returned direct text instead of a tool call. This might ' - 'be an error or unexpected AI behavior for forced tool calling.', - ); - } + if (functionCalls.isNotEmpty) { genUiLogger.fine( - 'Model returned text but no function calls with forced tool ' - 'calling, so returning null.', + 'Model response contained ${functionCalls.length} function calls.', ); - return null; - } else { - final String text = candidate.text ?? ''; mutableContent.add(candidate.content); - genUiLogger.fine('Returning text response: "$text"'); - _textResponseController.add(text); - return text; + final List functionResponseParts = + await _processFunctionCalls( + functionCalls: functionCalls, + availableTools: availableTools, + ); + + if (functionResponseParts.isNotEmpty) { + mutableContent.add( + Content.functionResponses(functionResponseParts), + ); + genUiLogger.fine( + 'Added tool response message with ' + '${functionResponseParts.length} parts to conversation.', + ); + // Continue the loop to send tool outputs back to the model + continue toolLoop; + } } - } - - genUiLogger.fine( - 'Model response contained ${functionCalls.length} function calls.', - ); - mutableContent.add(candidate.content); - genUiLogger.fine( - 'Added assistant message with ${candidate.content.parts.length} ' - 'parts to conversation.', - ); - final ({ - Object? capturedResult, - List functionResponseParts, - }) - result = await _processFunctionCalls( - functionCalls: functionCalls, - isForcedToolCalling: isForcedToolCalling, - availableTools: availableTools, - capturedResult: capturedResult, - ); - capturedResult = result.capturedResult; - final List functionResponseParts = - result.functionResponseParts; - - if (functionResponseParts.isNotEmpty) { - mutableContent.add(Content.functionResponses(functionResponseParts)); - genUiLogger.fine( - 'Added tool response message with ${functionResponseParts.length} ' - 'parts to conversation.', - ); + // Handle text content for JSONL parsing + final String? text = candidate.text; + if (text != null && text.isNotEmpty) { + for (var i = 0; i < text.length; i++) { + final String char = text[i]; + if (char == '\n') { + _processLine(currentLineBuffer.toString()); + currentLineBuffer.clear(); + } else { + currentLineBuffer.write(char); + } + } + } } - // If the model returned a text response, we assume it's the final - // response and we should stop the tool calling loop. - if (!isForcedToolCalling && - candidate.text != null && - candidate.text!.trim().isNotEmpty) { - genUiLogger.fine( - 'Model returned a text response of "${candidate.text!.trim()}". ' - 'Exiting tool loop.', - ); - _textResponseController.add(candidate.text!); - return candidate.text; + // Process any remaining content in the buffer + if (currentLineBuffer.isNotEmpty) { + _processLine(currentLineBuffer.toString()); } - } - if (isForcedToolCalling) { - if (toolUsageCycle >= maxToolUsageCycles) { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, - ); - } - genUiLogger.fine('Exited tool usage loop. Returning captured result.'); - return capturedResult; - } else { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, + final Duration elapsed = DateTime.now().difference(inferenceStartTime); + genUiLogger.info( + '****** Completed Inference ******\n' + 'Latency = ${elapsed.inMilliseconds}ms', ); - return ''; + + // If we reached here, it means the stream finished. + // If there were function calls, the loop would have continued via + // `continue`. If there were no function calls, we are done. + break; } } -} -String _usageMetadata(UsageMetadata? metadata) { - if (metadata == null) return ''; - final buffer = StringBuffer(); - buffer.writeln('UsageMetadata('); - buffer.writeln(' promptTokenCount: ${metadata.promptTokenCount},'); - buffer.writeln(' candidatesTokenCount: ${metadata.candidatesTokenCount},'); - buffer.writeln(' totalTokenCount: ${metadata.totalTokenCount},'); - buffer.writeln(' thoughtsTokenCount: ${metadata.thoughtsTokenCount},'); - buffer.writeln( - ' toolUsePromptTokenCount: ${metadata.toolUsePromptTokenCount},', - ); - buffer.writeln(' promptTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.promptTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' modality: ${detail.modality},'); - buffer.writeln(' tokenCount: ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(' candidatesTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.candidatesTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' ${detail.modality},'); - buffer.writeln(' ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(' toolUsePromptTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.toolUsePromptTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' ${detail.modality},'); - buffer.writeln(' ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); -} + void _processLine(String line) { + line = line.trim(); + if (line.isEmpty) return; -String _responseToString(GenerateContentResponse response) { - final buffer = StringBuffer(); - buffer.writeln('GenerateContentResponse('); - buffer.writeln(' usageMetadata: ${_usageMetadata(response.usageMetadata)},'); - buffer.writeln(' promptFeedback: ${response.promptFeedback},'); - buffer.writeln(' candidates: ['); - for (final Candidate candidate in response.candidates) { - buffer.writeln(' Candidate('); - buffer.writeln(' finishReason: ${candidate.finishReason},'); - buffer.writeln(' finishMessage: "${candidate.finishMessage}",'); - buffer.writeln(' content: Content('); - buffer.writeln(' role: "${candidate.content.role}",'); - buffer.writeln(' parts: ['); - for (final Part part in candidate.content.parts) { - if (part is TextPart) { - buffer.writeln( - ' TextPart(text: "${(part as TextPart).text}"),', - ); - } else if (part is FunctionCall) { - buffer.writeln(' FunctionCall('); - buffer.writeln(' name: "${part.name}",'); - final String indentedLines = (const JsonEncoder.withIndent( - ' ', - ).convert(part.args)).split('\n').join('\n '); - buffer.writeln(' args: $indentedLines,'); - buffer.writeln(' ),'); - } else { - buffer.writeln(' Unknown Part: ${part.runtimeType},'); + try { + final dynamic json = jsonDecode(line); + if (json is Map) { + // Check if it's an A2UI message + // We can try to parse it as an A2uiMessage, or check for specific keys + // Ideally A2uiMessage.fromJson would handle it or throw + try { + final message = A2uiMessage.fromJson(json); + _a2uiMessageController.add(message); + return; + } catch (_) { + // Not an A2UI message, treat as text/other JSON + } } + } catch (_) { + // Not JSON, treat as text } - buffer.writeln(' ],'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); + _textResponseController.add(line); } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); } diff --git a/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart b/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart index 1565d195b..91ac82c23 100644 --- a/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart +++ b/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart @@ -11,6 +11,11 @@ import 'package:firebase_ai/firebase_ai.dart'; abstract class GeminiGenerativeModelInterface { /// Generates content from the given [content]. Future generateContent(Iterable content); + + /// Generates a stream of content from the given [content]. + Stream generateContentStream( + Iterable content, + ); } /// A wrapper for the `firebase_ai` [GenerativeModel] that implements the @@ -29,4 +34,11 @@ class GeminiGenerativeModel implements GeminiGenerativeModelInterface { Future generateContent(Iterable content) { return _model.generateContent(content); } + + @override + Stream generateContentStream( + Iterable content, + ) { + return _model.generateContentStream(content); + } } diff --git a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart index d82b50aaa..39c758e51 100644 --- a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart +++ b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart @@ -21,11 +21,7 @@ void main() { return FakeGeminiGenerativeModel([ GenerateContentResponse([ Candidate( - Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Hello'}, - }), - ]), + Content.model([const TextPart('{"response": "Hello"}')]), [], null, FinishReason.stop, @@ -70,11 +66,7 @@ void main() { ], null), GenerateContentResponse([ Candidate( - Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Tool called'}, - }), - ]), + Content.model([const TextPart('Tool called')]), [], null, FinishReason.stop, @@ -101,11 +93,7 @@ void main() { return FakeGeminiGenerativeModel([ GenerateContentResponse([ Candidate( - Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Hello'}, - }), - ]), + Content.model([const TextPart('Hello')]), [], null, FinishReason.stop, @@ -134,6 +122,13 @@ class FakeGeminiGenerativeModel implements GeminiGenerativeModelInterface { @override Future generateContent(Iterable content) { - return Future.delayed(Duration.zero, () => responses[callCount++]); + throw UnimplementedError(); + } + + @override + Stream generateContentStream( + Iterable content, + ) async* { + yield responses[callCount++]; } } diff --git a/packages/genui_firebase_ai/test/test_infra/utils.dart b/packages/genui_firebase_ai/test/test_infra/utils.dart index 219537815..be4917732 100644 --- a/packages/genui_firebase_ai/test/test_infra/utils.dart +++ b/packages/genui_firebase_ai/test/test_infra/utils.dart @@ -37,4 +37,28 @@ class FakeGenerativeModel implements GeminiGenerativeModelInterface { 'No response or exception configured for FakeGenerativeModel', ); } + + @override + Stream generateContentStream( + Iterable content, + ) async* { + generateContentCallCount++; + if (exception != null) { + final Exception? e = exception; + exception = null; // Reset for next call + throw e!; + } + if (responses.isNotEmpty) { + final GenerateContentResponse response = responses.removeAt(0); + yield GenerateContentResponse(response.candidates, promptFeedback); + return; + } + if (response != null) { + yield GenerateContentResponse(response!.candidates, promptFeedback); + return; + } + throw StateError( + 'No response or exception configured for FakeGenerativeModel', + ); + } } diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index 78dd85fc8..135d705ab 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -10,7 +10,6 @@ import 'package:genui/genui.dart'; import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' as google_ai; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf; -import 'package:json_schema_builder/json_schema_builder.dart' as dsb; import 'google_content_converter.dart'; import 'google_generative_service_interface.dart'; @@ -32,7 +31,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { GoogleGenerativeAiContentGenerator({ required this.catalog, this.systemInstruction, - this.outputToolName = 'provideFinalOutput', this.serviceFactory = defaultGenerativeServiceFactory, this.additionalTools = const [], this.modelName = 'models/gemini-2.5-flash', @@ -45,15 +43,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { /// The system instruction to use for the AI model. final String? systemInstruction; - /// The name of an internal pseudo-tool used to retrieve the final structured - /// output from the AI. - /// - /// This only needs to be provided in case of name collision with another - /// tool. - /// - /// Defaults to 'provideFinalOutput'. - final String outputToolName; - /// A function to use for creating the service itself. /// /// This factory function is responsible for instantiating the @@ -115,15 +104,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { _isProcessing.value = true; try { final messages = [...?history, message]; - final response = await _generate( - messages: messages, - // This turns on forced function calling. - outputSchema: dsb.S.object(properties: {'response': dsb.S.string()}), - ); - // Convert any response to a text response to the user. - if (response is Map && response.containsKey('response')) { - _textResponseController.add(response['response']! as String); - } + await _generate(messages: messages); } catch (e, st) { genUiLogger.severe('Error generating content', e, st); _errorController.add(ContentGeneratorError(e, st)); @@ -147,31 +128,12 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { ({List? tools, Set allowedFunctionNames}) _setupToolsAndFunctions({ - required bool isForcedToolCalling, required List availableTools, required GoogleSchemaAdapter adapter, - required dsb.Schema? outputSchema, }) { - genUiLogger.fine( - 'Setting up tools' - '${isForcedToolCalling ? ' with forced tool calling' : ''}', - ); - // Create an "output" tool that copies its args into the output. - final finalOutputAiTool = isForcedToolCalling - ? DynamicAiTool>( - name: outputToolName, - description: - '''Returns the final output. Call this function when you are done with the current turn of the conversation. Do not call this if you need to use other tools first. You MUST call this tool when you are done.''', - // Wrap the outputSchema in an object so that the output schema - // isn't limited to objects. - parameters: dsb.S.object(properties: {'output': outputSchema!}), - invokeFunction: (args) async => args, // Invoke is a pass-through - ) - : null; + genUiLogger.fine('Setting up tools'); - final allTools = isForcedToolCalling - ? [...availableTools, finalOutputAiTool!] - : availableTools; + final allTools = availableTools; genUiLogger.fine( 'Available tools: ${allTools.map((t) => t.name).join(', ')}', ); @@ -249,12 +211,9 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { return (tools: tools, allowedFunctionNames: allowedFunctionNames); } - Future<({List functionResponseParts, Object? capturedResult})> - _processFunctionCalls({ + Future<({List functionResponseParts})> _processFunctionCalls({ required List functionCalls, - required bool isForcedToolCalling, required List availableTools, - Object? capturedResult, }) async { genUiLogger.fine( 'Processing ${functionCalls.length} function calls from model.', @@ -264,27 +223,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { genUiLogger.fine( 'Processing function call: ${call.name} with args: ${call.args}', ); - if (isForcedToolCalling && call.name == outputToolName) { - try { - // Convert Struct args to Map to extract output - final argsMap = call.args?.toJson() as Map?; - capturedResult = argsMap?['output']; - genUiLogger.fine( - 'Captured final output from tool "$outputToolName".', - ); - } catch (exception, stack) { - genUiLogger.severe( - 'Unable to read output: $call [${call.args}]', - exception, - stack, - ); - } - genUiLogger.info( - '****** Gen UI Output ******.\n' - '${const JsonEncoder.withIndent(' ').convert(capturedResult)}', - ); - break; - } final aiTool = availableTools.firstWhere( (t) => t.name == call.name || t.fullName == call.name, @@ -324,67 +262,52 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { 'Finished processing function calls. Returning ' '${functionResponseParts.length} responses.', ); - return ( - functionResponseParts: functionResponseParts, - capturedResult: capturedResult, - ); + return (functionResponseParts: functionResponseParts); } - Future _generate({ - required Iterable messages, - dsb.Schema? outputSchema, - }) async { - final isForcedToolCalling = outputSchema != null; + Future _generate({required Iterable messages}) async { final converter = GoogleContentConverter(); final adapter = GoogleSchemaAdapter(); final service = serviceFactory(configuration: this); try { - final availableTools = [ - SurfaceUpdateTool( - handleMessage: _a2uiMessageController.add, - catalog: catalog, - ), - BeginRenderingTool( - handleMessage: _a2uiMessageController.add, - catalogId: catalog.catalogId, - ), - DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), - ...additionalTools, - ]; - // A local copy of the incoming messages which is updated with // tool results // as they are generated. final content = converter.toGoogleAiContent(messages); final (:tools, :allowedFunctionNames) = _setupToolsAndFunctions( - isForcedToolCalling: isForcedToolCalling, - availableTools: availableTools, + availableTools: additionalTools, adapter: adapter, - outputSchema: outputSchema, ); var toolUsageCycle = 0; const maxToolUsageCycles = 40; // Safety break for tool loops - Object? capturedResult; // Build system instruction if provided - final systemInstructionContent = systemInstruction != null - ? [ - google_ai.Content( - parts: [google_ai.Part(text: systemInstruction)], - ), - ] - : []; + final definition = const JsonEncoder.withIndent( + ' ', + ).convert(catalog.definition.toJson()); + final effectiveSystemInstruction = + '${systemInstruction ?? ''}\n\n' + 'You have access to the following UI components:\n' + '$definition\n\n' + 'You must output your response as a stream of JSON objects, one per ' + 'line (JSONL). Each line can be either a plain text response or a ' + 'structured A2UI message (e.g., createSurface, surfaceUpdate). ' + 'Do not wrap the JSON objects in a list or any other structure. ' + 'Just output one JSON object per line.'; + + final systemInstructionContent = [ + google_ai.Content( + parts: [google_ai.Part(text: effectiveSystemInstruction)], + ), + ]; + toolLoop: while (toolUsageCycle < maxToolUsageCycles) { genUiLogger.fine('Starting tool usage cycle ${toolUsageCycle + 1}.'); - if (isForcedToolCalling && capturedResult != null) { - genUiLogger.fine('Captured result found, exiting tool usage loop.'); - break; - } toolUsageCycle++; final concatenatedContents = content @@ -398,227 +321,147 @@ With functions: ''', ); final inferenceStartTime = DateTime.now(); - google_ai.GenerateContentResponse response; - try { - final request = google_ai.GenerateContentRequest( - model: modelName, - contents: [...systemInstructionContent, ...content], - tools: tools, - toolConfig: isForcedToolCalling - ? google_ai.ToolConfig( - functionCallingConfig: google_ai.FunctionCallingConfig( - mode: google_ai.FunctionCallingConfig_Mode.any, - allowedFunctionNames: allowedFunctionNames.toList(), - ), - ) - : google_ai.ToolConfig( - functionCallingConfig: google_ai.FunctionCallingConfig( - mode: google_ai.FunctionCallingConfig_Mode.auto, - ), - ), - ); - response = await service.generateContent(request); - genUiLogger.finest( - 'Raw model response: ${_responseToString(response)}', - ); - } catch (e, st) { - genUiLogger.severe('Error from service.generateContent', e, st); - _errorController.add(ContentGeneratorError(e, st)); - rethrow; - } - final elapsed = DateTime.now().difference(inferenceStartTime); - if (response.usageMetadata != null) { - inputTokenUsage += (response.usageMetadata!.promptTokenCount ?? 0) - .toInt(); - outputTokenUsage += - (response.usageMetadata!.candidatesTokenCount ?? 0).toInt(); - } - genUiLogger.info( - '****** Completed Inference ******\n' - 'Latency = ${elapsed.inMilliseconds}ms\n' - 'Output tokens = ' - '${response.usageMetadata?.candidatesTokenCount ?? 0}\n' - 'Prompt tokens = ${response.usageMetadata?.promptTokenCount ?? 0}', + final request = google_ai.GenerateContentRequest( + model: modelName, + contents: [...systemInstructionContent, ...content], + tools: tools, + toolConfig: tools == null + ? null + : google_ai.ToolConfig( + functionCallingConfig: google_ai.FunctionCallingConfig( + mode: google_ai.FunctionCallingConfig_Mode.auto, + ), + ), ); - if (response.candidates == null || response.candidates!.isEmpty) { - genUiLogger.warning( - 'Response has no candidates: ${response.promptFeedback}', + final responseStream = service.streamGenerateContent(request); + + final currentLineBuffer = StringBuffer(); + + await for (final google_ai.GenerateContentResponse response + in responseStream) { + if (response.candidates == null || response.candidates!.isEmpty) { + continue; + } + + final candidate = response.candidates!.first; + genUiLogger.fine( + 'Received candidate: content=${candidate.content}, ' + 'finishReason=${candidate.finishReason}, ' + 'safetyRatings=${candidate.safetyRatings}', ); - return isForcedToolCalling ? null : ''; - } - final candidate = response.candidates!.first; - final functionCalls = []; - if (candidate.content?.parts != null) { - for (final part in candidate.content!.parts!) { - if (part.functionCall != null) { - functionCalls.add(part.functionCall!); + // Handle function calls + final functionCalls = []; + if (candidate.content?.parts != null) { + for (final part in candidate.content!.parts!) { + if (part.functionCall != null) { + functionCalls.add(part.functionCall!); + } } } - } - if (functionCalls.isEmpty) { - genUiLogger.fine('Model response contained no function calls.'); - if (isForcedToolCalling) { - genUiLogger.warning( - 'Model did not call any function. FinishReason: ' - '${candidate.finishReason}.', - ); - // Extract text from parts - String? text; - if (candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - text = textParts.join(''); - } - if (text != null && text.trim().isNotEmpty) { - genUiLogger.warning( - 'Model returned direct text instead of a tool call. ' - 'This might be an error or unexpected AI behavior for ' - 'forced tool calling.', - ); - } + if (functionCalls.isNotEmpty) { genUiLogger.fine( - 'Model returned text but no function calls with forced tool ' - 'calling, so returning null.', + 'Model response contained ${functionCalls.length} ' + 'function calls.', ); - return null; - } else { - // Extract text from parts - var text = ''; - if (candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - text = textParts.join(''); - } if (candidate.content != null) { content.add(candidate.content!); } - genUiLogger.fine('Returning text response: "$text"'); - _textResponseController.add(text); - return text; - } - } - genUiLogger.fine( - 'Model response contained ${functionCalls.length} function calls.', - ); - if (candidate.content != null) { - content.add(candidate.content!); - } - genUiLogger.fine( - 'Added assistant message with ' - '${candidate.content?.parts?.length ?? 0} ' - 'parts to conversation.', - ); - - final result = await _processFunctionCalls( - functionCalls: functionCalls, - isForcedToolCalling: isForcedToolCalling, - availableTools: availableTools, - capturedResult: capturedResult, - ); - capturedResult = result.capturedResult; - final functionResponseParts = result.functionResponseParts; + final result = await _processFunctionCalls( + functionCalls: functionCalls, + availableTools: additionalTools, + ); + final functionResponseParts = result.functionResponseParts; - if (functionResponseParts.isNotEmpty) { - content.add( - google_ai.Content(role: 'user', parts: functionResponseParts), - ); - genUiLogger.fine( - 'Added tool response message with ${functionResponseParts.length} ' - 'parts to conversation.', - ); - } + if (functionResponseParts.isNotEmpty) { + content.add( + google_ai.Content(role: 'user', parts: functionResponseParts), + ); + genUiLogger.fine( + 'Added tool response message with ' + '${functionResponseParts.length} parts to conversation.', + ); + continue toolLoop; + } + } - // If the model returned a text response, we assume it's the final - // response and we should stop the tool calling loop. - if (!isForcedToolCalling && candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - final text = textParts.join(''); - if (text.trim().isNotEmpty) { - genUiLogger.fine( - 'Model returned a text response of "${text.trim()}". ' - 'Exiting tool loop.', - ); - _textResponseController.add(text); - return text; + // Handle text content for JSONL parsing + if (candidate.content?.parts != null) { + for (final part in candidate.content!.parts!) { + final text = part.text; + if (text != null && text.isNotEmpty) { + genUiLogger.fine('Received text part: $text'); + for (var i = 0; i < text.length; i++) { + final char = text[i]; + if (char == '\n') { + _processLine(currentLineBuffer.toString()); + currentLineBuffer.clear(); + } else { + currentLineBuffer.write(char); + } + } + } + } } } - } - if (isForcedToolCalling) { - if (toolUsageCycle >= maxToolUsageCycles) { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, - ); + // Process any remaining content in the buffer + if (currentLineBuffer.isNotEmpty) { + _processLine(currentLineBuffer.toString()); } - genUiLogger.fine('Exited tool usage loop. Returning captured result.'); - return capturedResult; - } else { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, + + final elapsed = DateTime.now().difference(inferenceStartTime); + genUiLogger.info( + '****** Completed Inference ******\n' + 'Latency = ${elapsed.inMilliseconds}ms', ); - return ''; + + // If we reached here, it means the stream finished. + // If there were function calls, the loop would have continued via + // `continue toolLoop`. If there were no function calls, we are done. + break; } } finally { service.close(); } } -} -String _responseToString(google_ai.GenerateContentResponse response) { - final buffer = StringBuffer(); - buffer.writeln('GenerateContentResponse('); - buffer.writeln(' usageMetadata: ${response.usageMetadata},'); - buffer.writeln(' promptFeedback: ${response.promptFeedback},'); - buffer.writeln(' candidates: ['); - if (response.candidates != null) { - for (final candidate in response.candidates!) { - buffer.writeln(' Candidate('); - buffer.writeln(' finishReason: ${candidate.finishReason},'); - buffer.writeln(' finishMessage: "${candidate.finishMessage}",'); - buffer.writeln(' content: Content('); - buffer.writeln(' role: "${candidate.content?.role}",'); - buffer.writeln(' parts: ['); - if (candidate.content?.parts != null) { - for (final part in candidate.content!.parts!) { - if (part.text != null) { - buffer.writeln(' Part(text: "${part.text}"),'); - } else if (part.functionCall != null) { - buffer.writeln(' Part(functionCall:'); - buffer.writeln(' FunctionCall('); - buffer.writeln(' name: "${part.functionCall!.name}",'); - final indentedLines = (const JsonEncoder.withIndent(' ').convert( - part.functionCall!.args ?? {}, - )).split('\n').join('\n '); - buffer.writeln(' args: $indentedLines,'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); - } else { - buffer.writeln(' Unknown Part,'); - } + void _processLine(String line) { + line = line.trim(); + if (line.isEmpty) { + return; + } + + // If the line doesn't start with '{', it's not a JSONL object. + // However, we still want to emit it as text if it's not JSON. + // if (!line.startsWith('{')) { + // genUiLogger.fine('Ignored non-JSONL line: $line'); + // return; + // } + + genUiLogger.fine('Processing line: $line'); + + try { + final json = jsonDecode(line); + if (json is Map) { + try { + final message = A2uiMessage.fromJson(json); + genUiLogger.fine('Parsed A2UI message: $message'); + _a2uiMessageController.add(message); + return; + } catch (e) { + // Not an A2UI message, treat as text/other JSON + genUiLogger.fine('Failed to parse as A2UI message: $e'); } } - buffer.writeln(' ],'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); + } catch (e) { + // Not JSON, treat as text + genUiLogger.fine('Failed to parse as JSON: $e'); } + _textResponseController.add(line); } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); } diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart b/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart index 15d1b1311..cf98dd2e3 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart @@ -15,6 +15,11 @@ abstract class GoogleGenerativeServiceInterface { google_ai.GenerateContentRequest request, ); + /// Generates a stream of content from the given [request]. + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ); + /// Closes the service and releases any resources. void close(); } @@ -41,6 +46,13 @@ class GoogleGenerativeServiceWrapper return _service.generateContent(request); } + @override + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ) { + return _service.streamGenerateContent(request); + } + @override void close() { _service.close(); diff --git a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart index d0f2caff9..bf57f8266 100644 --- a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart +++ b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart @@ -25,31 +25,7 @@ void main() { expect(generator, isNotNull); expect(generator.catalog, catalog); expect(generator.modelName, 'models/gemini-2.5-flash'); - expect(generator.outputToolName, 'provideFinalOutput'); - }); - - test('constructor accepts custom model name', () { - final catalog = const genui.Catalog([]); - - final generator = GoogleGenerativeAiContentGenerator( - catalog: catalog, - modelName: 'models/gemini-2.5-pro', - apiKey: 'test-api-key', - ); - - expect(generator.modelName, 'models/gemini-2.5-pro'); - }); - - test('constructor accepts custom output tool name', () { - final catalog = const genui.Catalog([]); - - final generator = GoogleGenerativeAiContentGenerator( - catalog: catalog, - outputToolName: 'customOutput', - apiKey: 'test-api-key', - ); - - expect(generator.outputToolName, 'customOutput'); + expect(generator.modelName, 'models/gemini-2.5-flash'); }); test('constructor accepts system instruction', () { @@ -141,17 +117,7 @@ void main() { google_ai.Candidate( content: google_ai.Content( role: 'model', - parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '1', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Hello'}, - }), - ), - ), - ], + parts: [google_ai.Part(text: '{"response": "Hello"}')], ), finishReason: google_ai.Candidate_FinishReason.stop, ), @@ -170,79 +136,17 @@ void main() { expect(generator.isProcessing.value, isFalse); }); - // TODO(implementation): This test is timing out, needs investigation - test( - 'can call a tool and return a result', - () async { - final generator = GoogleGenerativeAiContentGenerator( - catalog: const genui.Catalog({}), - additionalTools: [ - genui.DynamicAiTool>( - name: 'testTool', - description: 'A test tool', - parameters: dsb.Schema.object(), - invokeFunction: (args) async => {'result': 'tool result'}, - ), - ], - serviceFactory: ({required configuration}) { - return FakeGoogleGenerativeService([ - google_ai.GenerateContentResponse( - candidates: [ - google_ai.Candidate( - content: google_ai.Content( - role: 'model', - parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '1', - name: 'testTool', - args: protobuf.Struct.fromJson({}), - ), - ), - ], - ), - finishReason: google_ai.Candidate_FinishReason.stop, - ), - ], - ), - google_ai.GenerateContentResponse( - candidates: [ - google_ai.Candidate( - content: google_ai.Content( - role: 'model', - parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '2', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Tool called'}, - }), - ), - ), - ], - ), - finishReason: google_ai.Candidate_FinishReason.stop, - ), - ], - ), - ]); - }, - ); - - final hi = genui.UserMessage([const genui.TextPart('Hi')]); - final completer = Completer(); - unawaited(generator.textResponseStream.first.then(completer.complete)); - await generator.sendRequest(hi); - final response = await completer.future; - expect(response, 'Tool called'); - }, - skip: 'Test is timing out, needs debugging', - ); - - test('returns a simple text response', () async { + test('can call a tool and return a result', () async { final generator = GoogleGenerativeAiContentGenerator( catalog: const genui.Catalog({}), + additionalTools: [ + genui.DynamicAiTool>( + name: 'testTool', + description: 'A test tool', + parameters: dsb.Schema.object(), + invokeFunction: (args) async => {'result': 'tool result'}, + ), + ], serviceFactory: ({required configuration}) { return FakeGoogleGenerativeService([ google_ai.GenerateContentResponse( @@ -254,10 +158,8 @@ void main() { google_ai.Part( functionCall: google_ai.FunctionCall( id: '1', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Hello'}, - }), + name: 'testTool', + args: protobuf.Struct.fromJson({}), ), ), ], @@ -266,6 +168,45 @@ void main() { ), ], ), + google_ai.GenerateContentResponse( + candidates: [ + google_ai.Candidate( + content: google_ai.Content( + role: 'model', + parts: [google_ai.Part(text: 'Tool called')], + ), + finishReason: google_ai.Candidate_FinishReason.stop, + ), + ], + ), + ]); + }, + ); + + final hi = genui.UserMessage([const genui.TextPart('Hi')]); + final completer = Completer(); + unawaited(generator.textResponseStream.first.then(completer.complete)); + await generator.sendRequest(hi); + final response = await completer.future; + expect(response, 'Tool called'); + }); + + test('returns a simple text response', () async { + final generator = GoogleGenerativeAiContentGenerator( + catalog: const genui.Catalog({}), + serviceFactory: ({required configuration}) { + return FakeGoogleGenerativeService([ + google_ai.GenerateContentResponse( + candidates: [ + google_ai.Candidate( + content: google_ai.Content( + role: 'model', + parts: [google_ai.Part(text: 'Hello')], + ), + finishReason: google_ai.Candidate_FinishReason.stop, + ), + ], + ), ]); }, ); @@ -293,6 +234,13 @@ class FakeGoogleGenerativeService implements GoogleGenerativeServiceInterface { return Future.delayed(Duration.zero, () => responses[callCount++]); } + @override + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ) async* { + yield responses[callCount++]; + } + @override void close() { // No-op for testing diff --git a/pubspec.lock b/pubspec.lock index 3ae4985e6..5a9a8727a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -751,6 +751,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -819,18 +827,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" mcp_dart: dependency: transitive description: @@ -1176,26 +1184,26 @@ packages: dependency: transitive description: name: test - sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.28.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.14" + version: "0.6.12" tuple: dependency: transitive description: