diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index 4328ba2a9..09b8b8d8d 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -40,7 +40,7 @@ class _SamplesViewState extends State { @override void initState() { super.initState(); - _genUiManager = GenUiManager(catalog: widget.catalog); + _genUiManager = GenUiManager(catalogs: [widget.catalog]); _loadSamples(); _setupSurfaceListener(); } @@ -109,7 +109,7 @@ class _SamplesViewState extends State { }); // Re-create GenUiManager to ensure a clean state for the new sample. _genUiManager.dispose(); - _genUiManager = GenUiManager(catalog: widget.catalog); + _genUiManager = GenUiManager(catalogs: [widget.catalog]); _setupSurfaceListener(); try { diff --git a/examples/custom_backend/lib/main.dart b/examples/custom_backend/lib/main.dart index f68386932..59bab3b3c 100644 --- a/examples/custom_backend/lib/main.dart +++ b/examples/custom_backend/lib/main.dart @@ -77,7 +77,7 @@ class _IntegrationTesterState extends State<_IntegrationTester> { final _controller = TextEditingController(text: requestText); final _protocol = Backend(uiSchema); - late final GenUiManager _genUi = GenUiManager(catalog: _catalog); + late final GenUiManager _genUi = GenUiManager(catalogs: [_catalog]); String? _selectedResponse; bool _isLoading = false; String? _errorMessage; diff --git a/examples/simple_chat/lib/main.dart b/examples/simple_chat/lib/main.dart index 616ae1669..b127c7469 100644 --- a/examples/simple_chat/lib/main.dart +++ b/examples/simple_chat/lib/main.dart @@ -76,7 +76,7 @@ class _ChatScreenState extends State { void initState() { super.initState(); final Catalog catalog = CoreCatalogItems.asCatalog(); - _genUiManager = GenUiManager(catalog: catalog); + _genUiManager = GenUiManager(catalogs: [catalog]); final systemInstruction = '''You are a helpful assistant who chats with a user, diff --git a/examples/travel_app/lib/src/catalog.dart b/examples/travel_app/lib/src/catalog.dart index 924b0bbd9..987ca7e13 100644 --- a/examples/travel_app/lib/src/catalog.dart +++ b/examples/travel_app/lib/src/catalog.dart @@ -24,35 +24,20 @@ import 'catalog/travel_carousel.dart'; /// for a travel planning experience, such as [travelCarousel], [itinerary], /// and [inputGroup]. The AI selects from these components to build a dynamic /// and interactive UI in response to user prompts. -final Catalog travelAppCatalog = CoreCatalogItems.asCatalog() - .copyWithout([ - CoreCatalogItems.audioPlayer, - CoreCatalogItems.card, - CoreCatalogItems.checkBox, - CoreCatalogItems.dateTimeInput, - CoreCatalogItems.divider, - CoreCatalogItems.textField, - CoreCatalogItems.list, - CoreCatalogItems.modal, - CoreCatalogItems.multipleChoice, - CoreCatalogItems.slider, - CoreCatalogItems.tabs, - CoreCatalogItems.video, - CoreCatalogItems.icon, - CoreCatalogItems.row, - CoreCatalogItems.image, - ]) - .copyWith([ - CoreCatalogItems.imageFixedSize, - checkboxFilterChipsInput, - dateInputChip, - informationCard, - inputGroup, - itinerary, - listingsBooker, - optionsFilterChipInput, - tabbedSections, - textInputChip, - trailhead, - travelCarousel, - ]); +final Catalog travelAppCatalog = Catalog([ + CoreCatalogItems.button, + CoreCatalogItems.column, + CoreCatalogItems.text, + CoreCatalogItems.imageFixedSize, + checkboxFilterChipsInput, + dateInputChip, + informationCard, + inputGroup, + itinerary, + listingsBooker, + optionsFilterChipInput, + tabbedSections, + textInputChip, + trailhead, + travelCarousel, +], catalogId: 'example.com:travel_v0'); diff --git a/examples/travel_app/lib/src/travel_planner_page.dart b/examples/travel_app/lib/src/travel_planner_page.dart index 2995c4a14..e8b86fdda 100644 --- a/examples/travel_app/lib/src/travel_planner_page.dart +++ b/examples/travel_app/lib/src/travel_planner_page.dart @@ -63,16 +63,7 @@ class _TravelPlannerPageState extends State @override void initState() { super.initState(); - final genUiManager = GenUiManager( - catalog: travelAppCatalog, - configuration: const GenUiConfiguration( - actions: ActionsConfig( - allowCreate: true, - allowUpdate: true, - allowDelete: true, - ), - ), - ); + final genUiManager = GenUiManager(catalogs: [travelAppCatalog]); _userMessageSubscription = genUiManager.onSubmit.listen( _handleUserMessageFromUi, ); diff --git a/examples/travel_app/test/widgets/conversation_test.dart b/examples/travel_app/test/widgets/conversation_test.dart index 6ca04aca7..c0d554c17 100644 --- a/examples/travel_app/test/widgets/conversation_test.dart +++ b/examples/travel_app/test/widgets/conversation_test.dart @@ -12,7 +12,7 @@ void main() { late GenUiManager manager; setUp(() { - manager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + manager = GenUiManager(catalogs: [CoreCatalogItems.asCatalog()]); }); testWidgets('renders a list of messages', (WidgetTester tester) async { diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index 067e16898..dddbdba16 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -67,7 +67,7 @@ class AiClientState { class Ai extends _$Ai { @override Future build() async { - final genUiManager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + final genUiManager = GenUiManager(catalogs: [CoreCatalogItems.asCatalog()]); final A2uiAgentConnector connector = await ref.watch( a2uiAgentConnectorProvider.future, ); diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 1c34efe8e..cacb066d1 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.5.2 (in progress) +- **Feature**: `GenUiManager` now supports multiple catalogs by accepting an `Iterable` in its constructor. +- **Feature**: `catalogId` property added to `UiDefinition` to specify which catalog a UI surface should use. +- **Refactor**: Moved `standardCatalogId` constant from `core_catalog.dart` to `primitives/constants.dart` for better organization and accessibility. + ## 0.5.1 - Homepage URL was updated. diff --git a/packages/genui/README.md b/packages/genui/README.md index cecee14d1..260d90021 100644 --- a/packages/genui/README.md +++ b/packages/genui/README.md @@ -164,7 +164,7 @@ provider. // Create a GenUiManager with a widget catalog. // The CoreCatalogItems contain basic widgets for text, markdown, and images. - _genUiManager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + _genUiManager = GenUiManager(catalogs: [CoreCatalogItems.asCatalog()]); // Create a ContentGenerator to communicate with the LLM. // Provide system instructions and the tools from the GenUiManager. @@ -378,7 +378,7 @@ Include your catalog items when instantiating `GenUiManager`. ```dart _genUiManager = GenUiManager( - catalog: CoreCatalogItems.asCatalog().copyWith([riddleCard]), + catalogs: [CoreCatalogItems.asCatalog().copyWith([riddleCard])], ); ``` diff --git a/packages/genui/lib/genui.dart b/packages/genui/lib/genui.dart index c027eee7e..85cf9d43e 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -22,6 +22,7 @@ export 'src/core/widgets/chat_primitives.dart'; export 'src/development_utilities/catalog_view.dart'; export 'src/facade/direct_call_integration/model.dart'; export 'src/facade/direct_call_integration/utils.dart'; +export 'src/model/a2ui_client_capabilities.dart'; export 'src/model/a2ui_message.dart'; export 'src/model/a2ui_schemas.dart'; export 'src/model/catalog.dart'; diff --git a/packages/genui/lib/src/catalog/core_catalog.dart b/packages/genui/lib/src/catalog/core_catalog.dart index 5ad80a80a..66481ad64 100644 --- a/packages/genui/lib/src/catalog/core_catalog.dart +++ b/packages/genui/lib/src/catalog/core_catalog.dart @@ -4,6 +4,7 @@ import '../model/catalog.dart'; import '../model/catalog_item.dart'; +import '../primitives/constants.dart'; 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; @@ -129,6 +130,6 @@ class CoreCatalogItems { text, textField, video, - ]); + ], catalogId: standardCatalogId); } } diff --git a/packages/genui/lib/src/content_generator.dart b/packages/genui/lib/src/content_generator.dart index e69946bf0..c46728ae6 100644 --- a/packages/genui/lib/src/content_generator.dart +++ b/packages/genui/lib/src/content_generator.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'model/a2ui_client_capabilities.dart'; import 'model/a2ui_message.dart'; import 'model/chat_message.dart'; @@ -49,6 +50,7 @@ abstract interface class ContentGenerator { Future sendRequest( ChatMessage message, { Iterable? history, + A2UiClientCapabilities? clientCapabilities, }); /// Disposes of the resources used by this generator. diff --git a/packages/genui/lib/src/conversation/gen_ui_conversation.dart b/packages/genui/lib/src/conversation/gen_ui_conversation.dart index 50deac99c..a0ab320df 100644 --- a/packages/genui/lib/src/conversation/gen_ui_conversation.dart +++ b/packages/genui/lib/src/conversation/gen_ui_conversation.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import '../content_generator.dart'; import '../core/genui_manager.dart'; +import '../model/a2ui_client_capabilities.dart'; import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/ui_models.dart'; @@ -149,7 +150,18 @@ class GenUiConversation { if (message is! UserUiInteractionMessage) { _conversation.value = [...history, message]; } - return contentGenerator.sendRequest(message, history: history); + final clientCapabilities = A2UiClientCapabilities( + supportedCatalogIds: genUiManager.catalogs + .map((c) => c.catalogId) + .where((id) => id != null) + .cast() + .toList(), + ); + return contentGenerator.sendRequest( + message, + history: history, + clientCapabilities: clientCapabilities, + ); } void _handleTextResponse(String text) { diff --git a/packages/genui/lib/src/core/genui_manager.dart b/packages/genui/lib/src/core/genui_manager.dart index daabf84b9..9a0adcd07 100644 --- a/packages/genui/lib/src/core/genui_manager.dart +++ b/packages/genui/lib/src/core/genui_manager.dart @@ -64,8 +64,8 @@ abstract interface class GenUiHost { /// Returns a [ValueNotifier] for the surface with the given [surfaceId]. ValueNotifier getSurfaceNotifier(String surfaceId); - /// The catalog of UI components available to the AI. - Catalog get catalog; + /// The catalogs of UI components available to the AI. + Iterable get catalogs; /// A map of data models for storing the UI state of each surface. Map get dataModels; @@ -85,16 +85,17 @@ abstract interface class GenUiHost { /// `beginRendering`) that the AI uses to manipulate the UI. It exposes a stream /// of `GenUiUpdate` events so that the application can react to changes. class GenUiManager implements GenUiHost { - /// Creates a new [GenUiManager]. - /// - /// The [catalog] defines the set of widgets available to the AI. + /// Creates a new [GenUiManager] with a list of supported widget catalogs. GenUiManager({ - required this.catalog, + required this.catalogs, this.configuration = const GenUiConfiguration(), }); final GenUiConfiguration configuration; + @override + final Iterable catalogs; + final _surfaces = >{}; final _surfaceUpdates = StreamController.broadcast(); final _onSubmit = StreamController.broadcast(); @@ -129,9 +130,6 @@ class GenUiManager implements GenUiHost { _onSubmit.add(UserUiInteractionMessage.text(eventJsonString)); } - @override - final Catalog catalog; - @override ValueNotifier getSurfaceNotifier(String surfaceId) { if (!_surfaces.containsKey(surfaceId)) { @@ -192,6 +190,7 @@ class GenUiManager implements GenUiHost { notifier.value ?? UiDefinition(surfaceId: message.surfaceId); final UiDefinition newUiDefinition = uiDefinition.copyWith( rootComponentId: message.root, + catalogId: message.catalogId, ); notifier.value = newUiDefinition; genUiLogger.info('Started rendering ${message.surfaceId}'); diff --git a/packages/genui/lib/src/core/genui_surface.dart b/packages/genui/lib/src/core/genui_surface.dart index 10984c726..8d8cdc566 100644 --- a/packages/genui/lib/src/core/genui_surface.dart +++ b/packages/genui/lib/src/core/genui_surface.dart @@ -2,13 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../core/genui_manager.dart'; +import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/data_model.dart'; import '../model/tools.dart'; import '../model/ui_models.dart'; +import '../primitives/constants.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; @@ -58,8 +61,15 @@ class _GenUiSurfaceState extends State { genUiLogger.warning('Surface ${widget.surfaceId} has no widgets.'); return const SizedBox.shrink(); } + + final Catalog? catalog = _findCatalogForDefinition(definition); + if (catalog == null) { + return Container(); + } + return _buildWidget( definition, + catalog, rootId, DataContext(widget.host.dataModelForSurface(widget.surfaceId), '/'), ); @@ -73,6 +83,7 @@ class _GenUiSurfaceState extends State { /// and constructs the corresponding Flutter widget. Widget _buildWidget( UiDefinition definition, + Catalog catalog, String widgetId, DataContext dataContext, ) { @@ -84,12 +95,17 @@ class _GenUiSurfaceState extends State { final JsonMap widgetData = data.componentProperties; genUiLogger.finest('Building widget $widgetId'); - return widget.host.catalog.buildWidget( + return catalog.buildWidget( CatalogItemContext( id: widgetId, data: widgetData, buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidget(definition, childId, childDataContext ?? dataContext), + _buildWidget( + definition, + catalog, + childId, + childDataContext ?? dataContext, + ), dispatchEvent: _dispatchEvent, buildContext: context, dataContext: dataContext, @@ -106,6 +122,16 @@ class _GenUiSurfaceState extends State { .getSurfaceNotifier(widget.surfaceId) .value; if (definition == null) return; + + final Catalog? catalog = _findCatalogForDefinition(definition); + if (catalog == null) { + genUiLogger.severe( + 'Cannot show modal for surface "${widget.surfaceId}" because ' + 'a catalog was not found.', + ); + return; + } + final modalId = event.context['modalId'] as String; final Component? modalComponent = definition.components[modalId]; if (modalComponent == null) return; @@ -116,6 +142,7 @@ class _GenUiSurfaceState extends State { context: context, builder: (context) => _buildWidget( definition, + catalog, contentChildId, DataContext(widget.host.dataModelForSurface(widget.surfaceId), '/'), ), @@ -133,4 +160,20 @@ class _GenUiSurfaceState extends State { : UiEvent.fromMap(eventMap); widget.host.handleUiEvent(newEvent); } + + Catalog? _findCatalogForDefinition(UiDefinition definition) { + final String catalogId = definition.catalogId ?? standardCatalogId; + final Catalog? catalog = widget.host.catalogs.firstWhereOrNull( + (c) => c.catalogId == catalogId, + ); + + if (catalog == null) { + genUiLogger.severe( + 'Catalog with id "$catalogId" not found for surface ' + '"${widget.surfaceId}". Ensure the catalog is provided to ' + 'GenUiManager.', + ); + } + return catalog; + } } diff --git a/packages/genui/lib/src/core/ui_tools.dart b/packages/genui/lib/src/core/ui_tools.dart index 69740f365..25560e0a5 100644 --- a/packages/genui/lib/src/core/ui_tools.dart +++ b/packages/genui/lib/src/core/ui_tools.dart @@ -88,36 +88,28 @@ class DeleteSurfaceTool extends AiTool { /// 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}) + BeginRenderingTool({required this.handleMessage, this.catalogId}) : super( name: 'beginRendering', description: 'Signals the client to begin rendering a surface with a ' 'root component.', - parameters: S.object( - properties: { - surfaceIdKey: S.string( - description: - 'The unique identifier for the UI surface to render.', - ), - 'root': S.string( - description: - 'The ID of the root widget. This ID must correspond to ' - 'the ID of one of the widgets in the `components` list.', - ), - }, - required: [surfaceIdKey, 'root'], - ), + parameters: A2uiSchemas.beginRenderingSchemaNoCatalogId(), ); /// The callback to invoke when signaling to begin rendering. 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)); + handleMessage( + BeginRendering(surfaceId: surfaceId, root: root, catalogId: catalogId), + ); return { 'status': 'Surface $surfaceId rendered and waiting for user input.', }; diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 8ad9d9508..e96486469 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -53,7 +53,7 @@ class _DebugCatalogViewState extends State { void initState() { super.initState(); - _genUi = GenUiManager(catalog: widget.catalog); + _genUi = GenUiManager(catalogs: [widget.catalog]); if (widget.onSubmit != null) { _subscription = _genUi.onSubmit.listen(widget.onSubmit); } else { diff --git a/packages/genui/lib/src/model/a2ui_client_capabilities.dart b/packages/genui/lib/src/model/a2ui_client_capabilities.dart new file mode 100644 index 000000000..8ec6c5ed6 --- /dev/null +++ b/packages/genui/lib/src/model/a2ui_client_capabilities.dart @@ -0,0 +1,40 @@ +// 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 '../primitives/simple_items.dart'; + +/// Describes the client's UI rendering capabilities to the server. +/// +/// This class represents the `a2uiClientCapabilities` object that is sent +/// from the client to the server with each message to inform the server about +/// the component catalogs the client supports. +class A2UiClientCapabilities { + /// Creates a new [A2UiClientCapabilities] instance. + const A2UiClientCapabilities({ + required this.supportedCatalogIds, + this.inlineCatalogs, + }); + + /// A list of identifiers for all pre-defined catalogs the client supports. + /// + /// The client MUST always include the standard catalog ID here if it + /// supports it. + final List supportedCatalogIds; + + /// An array of full Catalog Definition Documents. + /// + /// This allows a client to provide custom, on-the-fly catalogs. This should + /// only be provided if the server has advertised + /// `acceptsInlineCatalogs: true`. This is not yet implemented. + final List? inlineCatalogs; + + /// Serializes this object to a JSON-compatible map. + JsonMap toJson() { + final JsonMap json = {'supportedCatalogIds': supportedCatalogIds}; + if (inlineCatalogs != null) { + json['inlineCatalogs'] = inlineCatalogs; + } + return json; + } +} diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index a9208bbb7..c026fc532 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -113,6 +113,7 @@ final class BeginRendering extends A2uiMessage { required this.surfaceId, required this.root, this.styles, + this.catalogId, }); /// Creates a [BeginRendering] message from a JSON map. @@ -121,6 +122,7 @@ final class BeginRendering extends A2uiMessage { surfaceId: json[surfaceIdKey] as String, root: json['root'] as String, styles: json['styles'] as JsonMap?, + catalogId: json['catalogId'] as String?, ); } @@ -132,6 +134,9 @@ final class BeginRendering extends A2uiMessage { /// The styles to apply to the UI. final JsonMap? styles; + + /// The ID of the catalog to use for rendering this surface. + final String? catalogId; } /// An A2UI message that deletes a surface. diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index f5571d954..6e35030d0 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -134,6 +134,35 @@ 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( + 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.', + ), + }, + ), + }, + 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( properties: { surfaceIdKey: S.string( description: 'The surface ID of the surface to render.', diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 8838d75b0..d16c88de2 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -23,34 +23,38 @@ import 'data_model.dart'; @immutable class Catalog { /// Creates a new catalog with the given list of items. - const Catalog(this.items); + const Catalog(this.items, {this.catalogId}); /// The list of [CatalogItem]s available in this catalog. final Iterable items; + /// A string that uniquely identifies this catalog. It is recommended to use + /// a reverse-domain name notation, e.g. 'com.example.my_catalog'. + final String? catalogId; + /// Returns a new [Catalog] containing the items from both this catalog and /// the provided [items]. /// /// If an item with the same name already exists in the catalog, it will be /// replaced with the new item. - Catalog copyWith(List newItems) { + Catalog copyWith(List newItems, {String? catalogId}) { final Map itemsByName = { for (final item in items) item.name: item, }; itemsByName.addAll({for (final item in newItems) item.name: item}); - return Catalog(itemsByName.values); + return Catalog(itemsByName.values, catalogId: catalogId ?? this.catalogId); } /// Returns a new [Catalog] instance containing the items from this catalog /// with the specified items removed. - Catalog copyWithout(Iterable itemNames) { + Catalog copyWithout(Iterable itemNames, {String? catalogId}) { final Set namesToRemove = itemNames .map((item) => item.name) .toSet(); final List updatedItems = items .where((item) => !namesToRemove.contains(item.name)) .toList(); - return Catalog(updatedItems); + return Catalog(updatedItems, catalogId: catalogId ?? this.catalogId); } /// Builds a Flutter widget from a JSON-like data structure. diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 16c2cca25..8c5e4c137 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -84,6 +84,9 @@ class UiDefinition { /// The ID of the root widget in the UI tree. final String? rootComponentId; + /// The ID of the catalog to use for rendering this surface. + final String? catalogId; + /// A map of all widget definitions in the UI, keyed by their ID. Map get components => UnmodifiableMapView(_components); final Map _components; @@ -95,6 +98,7 @@ class UiDefinition { UiDefinition({ required this.surfaceId, this.rootComponentId, + this.catalogId, Map components = const {}, this.styles, }) : _components = components; @@ -102,12 +106,14 @@ class UiDefinition { /// Creates a copy of this [UiDefinition] with the given fields replaced. UiDefinition copyWith({ 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, ); diff --git a/packages/genui/lib/src/primitives/constants.dart b/packages/genui/lib/src/primitives/constants.dart new file mode 100644 index 000000000..c826615b6 --- /dev/null +++ b/packages/genui/lib/src/primitives/constants.dart @@ -0,0 +1,6 @@ +// 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. + +/// The catalog ID for the standard catalog. +const String standardCatalogId = 'a2ui.org:standard_catalog_0_8_0'; diff --git a/packages/genui/lib/test/fake_content_generator.dart b/packages/genui/lib/test/fake_content_generator.dart index 97b4e1b03..bde6dc8f3 100644 --- a/packages/genui/lib/test/fake_content_generator.dart +++ b/packages/genui/lib/test/fake_content_generator.dart @@ -31,6 +31,9 @@ class FakeContentGenerator implements ContentGenerator { /// The last history passed to [sendRequest]. Iterable? lastHistory; + /// The last client capabilities passed to [sendRequest]. + A2UiClientCapabilities? lastClientCapabilities; + @override Stream get a2uiMessageStream => _a2uiMessageController.stream; @@ -55,12 +58,14 @@ class FakeContentGenerator implements ContentGenerator { Future sendRequest( ChatMessage message, { Iterable? history, + A2UiClientCapabilities? clientCapabilities, }) async { _isProcessing.value = true; try { sendRequestCallCount++; lastMessage = message; lastHistory = history; + lastClientCapabilities = clientCapabilities; if (sendRequestCompleter != null) { await sendRequestCompleter!.future; } diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 26cff1ba0..db072e80d 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -12,7 +12,12 @@ void main() { ) async { ChatMessage? message; final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.button, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.button, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); manager.onSubmit.listen((event) => message = event); @@ -40,7 +45,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'button'), + const BeginRendering( + surfaceId: surfaceId, + root: 'button', + catalogId: 'test_catalog', + ), ); 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 2c3eca5bc..6ad0c916c 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -9,7 +9,12 @@ import 'package:genui/genui.dart'; void main() { testWidgets('Card widget renders child', (WidgetTester tester) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.card, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.card, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -33,7 +38,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'card'), + const BeginRendering( + surfaceId: surfaceId, + root: 'card', + catalogId: 'test_catalog', + ), ); 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 e891d0bfe..4bfb07ec5 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -11,7 +11,9 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.checkBox]), + catalogs: [ + Catalog([CoreCatalogItems.checkBox], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -30,7 +32,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'checkbox'), + const BeginRendering( + surfaceId: surfaceId, + root: 'checkbox', + catalogId: 'test_catalog', + ), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), true); diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index a23e54cd8..0c0a2b938 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -9,7 +9,12 @@ import 'package:genui/genui.dart'; void main() { testWidgets('Column widget renders children', (WidgetTester tester) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.column, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.column, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -45,7 +50,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'column'), + const BeginRendering( + surfaceId: surfaceId, + root: 'column', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( @@ -64,7 +73,12 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.column, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.column, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -110,7 +124,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'column'), + const BeginRendering( + surfaceId: surfaceId, + root: 'column', + catalogId: 'test_catalog', + ), ); 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 05e3e1e08..5ae2294ec 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 @@ -11,7 +11,12 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.dateTimeInput]), + catalogs: [ + Catalog([ + CoreCatalogItems.dateTimeInput, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -29,7 +34,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'datetime'), + const BeginRendering( + surfaceId: surfaceId, + root: 'datetime', + catalogId: 'test_catalog', + ), ); 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 78ede0e94..0a2d381fb 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -9,7 +9,9 @@ import 'package:genui/genui.dart'; void main() { testWidgets('Divider widget renders', (WidgetTester tester) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.divider]), + catalogs: [ + Catalog([CoreCatalogItems.divider], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -23,7 +25,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'divider'), + const BeginRendering( + surfaceId: surfaceId, + root: 'divider', + catalogId: 'test_catalog', + ), ); 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 8e6673619..41d60e2b4 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -11,7 +11,9 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.icon]), + catalogs: [ + Catalog([CoreCatalogItems.icon], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -29,7 +31,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'icon'), + const BeginRendering( + surfaceId: surfaceId, + root: 'icon', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( @@ -47,7 +53,9 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.icon]), + catalogs: [ + Catalog([CoreCatalogItems.icon], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -72,7 +80,11 @@ void main() { ), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'icon'), + const BeginRendering( + surfaceId: surfaceId, + root: 'icon', + catalogId: 'test_catalog', + ), ); 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 5b7fa650c..93fea445f 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -9,7 +9,12 @@ import 'package:genui/genui.dart'; void main() { testWidgets('List widget renders children', (WidgetTester tester) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.list, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.list, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -45,7 +50,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'list'), + const BeginRendering( + surfaceId: surfaceId, + root: 'list', + catalogId: 'test_catalog', + ), ); 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 dfe1b4254..de6d44d87 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -11,11 +11,13 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([ - CoreCatalogItems.modal, - CoreCatalogItems.button, - CoreCatalogItems.text, - ]), + catalogs: [ + Catalog([ + CoreCatalogItems.modal, + CoreCatalogItems.button, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -64,7 +66,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'modal'), + const BeginRendering( + surfaceId: surfaceId, + root: 'modal', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart index e5f1d1170..823116a77 100644 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart @@ -11,7 +11,12 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.multipleChoice]), + catalogs: [ + Catalog([ + CoreCatalogItems.multipleChoice, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -39,7 +44,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'multiple_choice'), + const BeginRendering( + surfaceId: surfaceId, + root: 'multiple_choice', + catalogId: 'test_catalog', + ), ); manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ '1', diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index a5543506e..cc5a05fc8 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -9,7 +9,12 @@ import 'package:genui/genui.dart'; void main() { testWidgets('Row widget renders children', (WidgetTester tester) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.row, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.row, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -45,7 +50,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'row'), + const BeginRendering( + surfaceId: surfaceId, + root: 'row', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( @@ -64,7 +73,12 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.row, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.row, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -110,7 +124,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'row'), + const BeginRendering( + surfaceId: surfaceId, + root: 'row', + catalogId: 'test_catalog', + ), ); 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 5a28fc0ab..d0a03f9d9 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -11,7 +11,9 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.slider]), + catalogs: [ + Catalog([CoreCatalogItems.slider], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -29,7 +31,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'slider'), + const BeginRendering( + surfaceId: surfaceId, + root: 'slider', + catalogId: 'test_catalog', + ), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), 0.5); diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 966ef4f2a..c1c6a77a0 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -11,7 +11,12 @@ void main() { WidgetTester tester, ) async { final manager = GenUiManager( - catalog: Catalog([CoreCatalogItems.tabs, CoreCatalogItems.text]), + catalogs: [ + Catalog([ + CoreCatalogItems.tabs, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'), + ], configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; @@ -54,7 +59,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'tabs'), + const BeginRendering( + surfaceId: surfaceId, + root: 'tabs', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 7b4041977..8b8a06cac 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -21,7 +21,7 @@ void main() { message = null; manager?.dispose(); manager = GenUiManager( - catalog: testCatalog, + catalogs: [testCatalog], configuration: const GenUiConfiguration(), ); manager!.onSubmit.listen((event) => message = event); @@ -30,7 +30,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager!.handleMessage( - BeginRendering(surfaceId: surfaceId, root: rootId), + BeginRendering( + surfaceId: surfaceId, + root: rootId, + catalogId: testCatalog.catalogId, + ), ); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index be04c9c29..05469ad6a 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -10,6 +10,13 @@ import 'package:logging/logging.dart'; void main() { group('Catalog', () { + test('has a catalogId', () { + final catalog = Catalog([ + CoreCatalogItems.text, + ], catalogId: 'test_catalog'); + expect(catalog.catalogId, 'test_catalog'); + }); + testWidgets('buildWidget finds and builds the correct widget', ( WidgetTester tester, ) async { diff --git a/packages/genui/test/core/genui_manager_test.dart b/packages/genui/test/core/genui_manager_test.dart index 2e8c078c3..aaac785be 100644 --- a/packages/genui/test/core/genui_manager_test.dart +++ b/packages/genui/test/core/genui_manager_test.dart @@ -4,7 +4,7 @@ import 'dart:convert'; -import 'package:flutter/src/foundation/change_notifier.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; @@ -14,7 +14,7 @@ void main() { setUp(() { manager = GenUiManager( - catalog: CoreCatalogItems.asCatalog(), + catalogs: [CoreCatalogItems.asCatalog()], configuration: const GenUiConfiguration( actions: ActionsConfig( allowCreate: true, @@ -29,6 +29,15 @@ void main() { manager.dispose(); }); + test('can be initialized with multiple catalogs', () { + final catalog1 = const Catalog([], catalogId: 'cat1'); + final catalog2 = const Catalog([], catalogId: 'cat2'); + final multiManager = GenUiManager(catalogs: [catalog1, catalog2]); + expect(multiManager.catalogs, contains(catalog1)); + expect(multiManager.catalogs, contains(catalog2)); + expect(multiManager.catalogs.length, 2); + }); + test('handleMessage adds a new surface and fires SurfaceAdded with ' 'definition', () async { const surfaceId = 's1'; @@ -51,7 +60,11 @@ void main() { final Future futureUpdated = manager.surfaceUpdates.first; manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const BeginRendering( + surfaceId: surfaceId, + root: 'root', + catalogId: 'test_catalog', + ), ); final GenUiUpdate updatedUpdate = await futureUpdated; @@ -61,8 +74,10 @@ void main() { (updatedUpdate as SurfaceUpdated).definition; expect(definition, isNotNull); expect(definition.rootComponentId, 'root'); + expect(definition.catalogId, 'test_catalog'); expect(manager.surfaces[surfaceId]!.value, isNotNull); expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); + expect(manager.surfaces[surfaceId]!.value!.catalogId, 'test_catalog'); }); test( diff --git a/packages/genui/test/core/ui_tools_test.dart b/packages/genui/test/core/ui_tools_test.dart index 9a9ca8d9a..910387e6a 100644 --- a/packages/genui/test/core/ui_tools_test.dart +++ b/packages/genui/test/core/ui_tools_test.dart @@ -31,7 +31,7 @@ void main() { }, dataSchema: Schema.object(properties: {}), ), - ]), + ], catalogId: 'test_catalog'), configuration: const GenUiConfiguration(), ); @@ -90,7 +90,10 @@ void main() { messages.add(message); } - final tool = BeginRenderingTool(handleMessage: fakeHandleMessage); + final tool = BeginRenderingTool( + handleMessage: fakeHandleMessage, + catalogId: 'test_catalog', + ); final Map args = { surfaceIdKey: 'testSurface', @@ -104,6 +107,7 @@ void main() { final beginRendering = messages[0] as BeginRendering; expect(beginRendering.surfaceId, 'testSurface'); expect(beginRendering.root, 'rootWidget'); + expect(beginRendering.catalogId, 'test_catalog'); }); }); } diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6efad683f..41ec58acb 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -5,17 +5,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import 'package:logging/logging.dart'; void main() { - final testCatalog = Catalog([CoreCatalogItems.button, CoreCatalogItems.text]); + late GenUiManager manager; + final testCatalog = Catalog([ + CoreCatalogItems.button, + CoreCatalogItems.text, + ], catalogId: 'test_catalog'); + + setUp(() { + manager = GenUiManager( + catalogs: [testCatalog], + configuration: const GenUiConfiguration(), + ); + }); testWidgets('SurfaceWidget builds a widget from a definition', ( WidgetTester tester, ) async { - final manager = GenUiManager( - catalog: testCatalog, - configuration: const GenUiConfiguration(), - ); const surfaceId = 'testSurface'; final components = [ const Component( @@ -40,7 +48,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const BeginRendering( + surfaceId: surfaceId, + root: 'root', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( @@ -54,10 +66,6 @@ void main() { }); testWidgets('SurfaceWidget handles events', (WidgetTester tester) async { - final manager = GenUiManager( - catalog: testCatalog, - configuration: const GenUiConfiguration(), - ); const surfaceId = 'testSurface'; final components = [ const Component( @@ -82,7 +90,11 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const BeginRendering( + surfaceId: surfaceId, + root: 'root', + catalogId: 'test_catalog', + ), ); await tester.pumpWidget( @@ -93,4 +105,57 @@ void main() { 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'}, + }, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + // Request a catalogId that doesn't exist in the manager. + manager.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: manager, 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 11d119ba2..c7603f23a 100644 --- a/packages/genui/test/model/ui_definition_test.dart +++ b/packages/genui/test/model/ui_definition_test.dart @@ -11,6 +11,7 @@ void main() { final definition = UiDefinition( surfaceId: 'testSurface', rootComponentId: 'root', + catalogId: 'test_catalog', components: { 'root': const Component( id: 'root', diff --git a/packages/genui/test/ui_tools_test.dart b/packages/genui/test/ui_tools_test.dart index 754acd90e..76e05e468 100644 --- a/packages/genui/test/ui_tools_test.dart +++ b/packages/genui/test/ui_tools_test.dart @@ -13,7 +13,7 @@ void main() { setUp(() { catalog = CoreCatalogItems.asCatalog(); genUiManager = GenUiManager( - catalog: catalog, + catalogs: [catalog], configuration: const GenUiConfiguration( actions: ActionsConfig( allowCreate: true, @@ -71,6 +71,7 @@ void main() { test('BeginRenderingTool sends BeginRendering message', () async { final tool = BeginRenderingTool( handleMessage: genUiManager.handleMessage, + catalogId: 'test_catalog', ); final Map args = { @@ -105,6 +106,11 @@ void main() { (e) => e.definition.rootComponentId, 'rootComponentId', 'root', + ) + .having( + (e) => e.definition.catalogId, + 'catalogId', + 'test_catalog', ), ), ); diff --git a/packages/genui_a2ui/example/lib/main.dart b/packages/genui_a2ui/example/lib/main.dart index cc1fe0e98..2fadaee8b 100644 --- a/packages/genui_a2ui/example/lib/main.dart +++ b/packages/genui_a2ui/example/lib/main.dart @@ -42,7 +42,7 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final TextEditingController _textController = TextEditingController(); final GenUiManager _genUiManager = GenUiManager( - catalog: CoreCatalogItems.asCatalog(), + catalogs: [CoreCatalogItems.asCatalog()], ); late final A2uiContentGenerator _contentGenerator; late final GenUiConversation _genUiConversation; diff --git a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart index 48754846a..68a4b779b 100644 --- a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart +++ b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart @@ -80,7 +80,10 @@ class A2uiAgentConnector { /// Connects to the agent and sends a message. /// /// Returns the text response from the agent, if any. - Future connectAndSend(genui.ChatMessage chatMessage) async { + Future connectAndSend( + genui.ChatMessage chatMessage, { + genui.A2UiClientCapabilities? clientCapabilities, + }) async { final List parts = (chatMessage is genui.UserMessage) ? chatMessage.parts : (chatMessage is genui.UserUiInteractionMessage) @@ -128,7 +131,11 @@ 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/ext/a2a-ui/v0.1']; diff --git a/packages/genui_a2ui/lib/src/a2ui_content_generator.dart b/packages/genui_a2ui/lib/src/a2ui_content_generator.dart index 02cc8bc0c..fddbaa3de 100644 --- a/packages/genui_a2ui/lib/src/a2ui_content_generator.dart +++ b/packages/genui_a2ui/lib/src/a2ui_content_generator.dart @@ -54,6 +54,7 @@ class A2uiContentGenerator implements ContentGenerator { Future sendRequest( ChatMessage message, { Iterable? history, + A2UiClientCapabilities? clientCapabilities, }) async { _isProcessing.value = true; try { @@ -62,7 +63,10 @@ class A2uiContentGenerator implements ContentGenerator { 'A2uiContentGenerator is stateful and ignores history.', ); } - final String? responseText = await connector.connectAndSend(message); + final String? responseText = await connector.connectAndSend( + message, + clientCapabilities: clientCapabilities, + ); if (responseText != null && responseText.isNotEmpty) { _textResponseController.add(responseText); } diff --git a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart index a1380bc92..0ece08435 100644 --- a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart +++ b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart @@ -42,6 +42,26 @@ 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() diff --git a/packages/genui_a2ui/test/a2ui_content_generator_test.dart b/packages/genui_a2ui/test/a2ui_content_generator_test.dart index 90966355e..e531d8ddb 100644 --- a/packages/genui_a2ui/test/a2ui_content_generator_test.dart +++ b/packages/genui_a2ui/test/a2ui_content_generator_test.dart @@ -42,7 +42,12 @@ void main() { final userMessage = UserMessage([const TextPart('Hello')]); expect(contentGenerator.isProcessing.value, isFalse); - final Future future = contentGenerator.sendRequest(userMessage); + final Future future = contentGenerator.sendRequest( + userMessage, + clientCapabilities: const A2UiClientCapabilities( + supportedCatalogIds: ['test_catalog'], + ), + ); expect(contentGenerator.isProcessing.value, isTrue); await future; @@ -51,12 +56,31 @@ void main() { expect(fakeConnector.lastConnectAndSendChatMessage, userMessage); }); + test('sendRequest passes clientCapabilities to connector', () async { + final userMessage = UserMessage([const TextPart('Test')]); + const capabilities = A2UiClientCapabilities( + supportedCatalogIds: ['test_catalog'], + ); + + await contentGenerator.sendRequest( + userMessage, + clientCapabilities: capabilities, + ); + + expect(fakeConnector.lastClientCapabilities, capabilities); + }); + test('sendRequest adds response to textResponseStream', () async { final userMessage = UserMessage([const TextPart('Test')]); final completer = Completer(); contentGenerator.textResponseStream.listen(completer.complete); - await contentGenerator.sendRequest(userMessage); + await contentGenerator.sendRequest( + userMessage, + clientCapabilities: const A2UiClientCapabilities( + supportedCatalogIds: ['test_catalog'], + ), + ); expect(await completer.future, 'Fake AI Response'); }); diff --git a/packages/genui_a2ui/test/fakes.dart b/packages/genui_a2ui/test/fakes.dart index db002a1db..4e93a5d07 100644 --- a/packages/genui_a2ui/test/fakes.dart +++ b/packages/genui_a2ui/test/fakes.dart @@ -148,10 +148,15 @@ class FakeA2uiAgentConnector implements A2uiAgentConnector { late a2a.A2AClient client; genui.ChatMessage? lastConnectAndSendChatMessage; + genui.A2UiClientCapabilities? lastClientCapabilities; @override - Future connectAndSend(genui.ChatMessage chatMessage) async { + Future connectAndSend( + genui.ChatMessage chatMessage, { + genui.A2UiClientCapabilities? clientCapabilities, + }) async { lastConnectAndSendChatMessage = chatMessage; + lastClientCapabilities = clientCapabilities; // Simulate sending a message and receiving a response return Future.value('Fake AI Response'); } diff --git a/packages/genui_firebase_ai/README.md b/packages/genui_firebase_ai/README.md index 4d59d81bc..4186b235d 100644 --- a/packages/genui_firebase_ai/README.md +++ b/packages/genui_firebase_ai/README.md @@ -19,7 +19,7 @@ Then, you can create an instance of `FirebaseAiContentGenerator` and pass it to ```dart final catalog = CoreCatalogItems.asCatalog(); -final genUiManager = GenUiManager(catalog: catalog); +final genUiManager = GenUiManager(catalogs: [catalog]); // Example of a custom tool final myCustomTool = DynamicAiTool>( name: 'my_custom_action', 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 09463db31..acf02db3f 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 @@ -111,6 +111,7 @@ class FirebaseAiContentGenerator implements ContentGenerator { Future sendRequest( ChatMessage message, { Iterable? history, + A2UiClientCapabilities? clientCapabilities, }) async { _isProcessing.value = true; try { @@ -350,7 +351,10 @@ class FirebaseAiContentGenerator implements ContentGenerator { catalog: catalog, configuration: configuration, ), - BeginRenderingTool(handleMessage: _a2uiMessageController.add), + BeginRenderingTool( + handleMessage: _a2uiMessageController.add, + catalogId: catalog.catalogId, + ), ], if (configuration.actions.allowDelete) DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), 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 5a5683746..f6b177f36 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 @@ -113,6 +113,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { Future sendRequest( ChatMessage message, { Iterable? history, + A2UiClientCapabilities? clientCapabilities, }) async { _isProcessing.value = true; try { @@ -351,7 +352,10 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { catalog: catalog, configuration: configuration, ), - BeginRenderingTool(handleMessage: _a2uiMessageController.add), + BeginRenderingTool( + handleMessage: _a2uiMessageController.add, + catalogId: catalog.catalogId, + ), ], if (configuration.actions.allowDelete) DeleteSurfaceTool(handleMessage: _a2uiMessageController.add),