diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index 7bc26ea10..8a5f1285d 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -7,7 +7,7 @@ dev_tools/catalog_gallery: 52.91 dev_tools/composer: 20.49 packages/a2ui_core: 76.30 packages/genai_primitives: 100.00 -packages/genui: 79.71 +packages/genui: 79.38 packages/genui_a2a: 91.37 packages/json_schema_builder: 79.09 tool/e2e: 100.00 diff --git a/docs/usage/migration/migration_genui_onto_a2ui_core.md b/docs/usage/migration/migration_genui_onto_a2ui_core.md new file mode 100644 index 000000000..3110a2f33 --- /dev/null +++ b/docs/usage/migration/migration_genui_onto_a2ui_core.md @@ -0,0 +1,122 @@ +# Migration Guide: GenUI on `a2ui_core` + +This PR migrates `package:genui`'s runtime substrate onto the shared +`package:a2ui_core` implementation, but it intentionally keeps the existing +GenUI-facing API shape as a compatibility facade. + +The goal is to make this PR about the substrate swap (#811): shared protocol +parsing, shared surface/data-model state, and granular Flutter rebuilds. Public +API renames for closer web/core parity are deferred to a follow-up PR (#801). + +## Scope + +Substrate / state migration only. Deferred to follow-ups: + +- Catalog widget bodies stay on `CatalogItemContext`. A typed-props authoring + API (flutter/genui#801) is on hold until the upstream Node Layer + (A2UI#1282) settles. +- Action dispatch and `sendDataModel` synchronization still flow through the + existing GenUI path, not `core.SurfaceGroupModel.onAction` or + `MessageProcessor.getClientDataModel()`. +- `GenericBinder` is not exposed as a Flutter-side public API. +- `UpdateDataModelMessage.hasValue` is parsed and serialized losslessly + through the genui facade (with round-trip test coverage), but runtime + mutation still treats both `value: null` and an omitted `value` as "remove + the key" pending flutter/genui#938 (adds `DataModel.remove` and the + processor-level branch). +- `DataPath` no longer interprets RFC 6901 `~0` / `~1` escapes, matching the + TypeScript reference implementation. A2UI#1499 tracks the spec + clarification. + +## What stays source-compatible in this PR + +GenUI applications and catalog authors should continue to use the existing +`package:genui` API names: + +- `CreateSurface`, `UpdateComponents`, `UpdateDataModel`, `DeleteSurface`, and + `A2uiMessage.fromJson`. +- `DataPath`, `DataModel`, `InMemoryDataModel`, `DataContext.update`, + `getValue`, `subscribe`, and `bindExternalState`. +- `Component` and `SurfaceDefinition` snapshot/value objects. +- `SurfaceContext.definition`, `SurfaceUpdate.definition`, and `SurfaceController.store` / `DataModelStore`. +- `ActionDelegate.handleEvent`'s existing `SurfaceDefinition` callback shape. +- Catalog widget authoring through `CatalogItemContext.id`, `type`, `data`, + `surfaceId`, `getComponent`, `dataContext`, `buildChild`, `dispatchEvent`, + and `reportError`. +- `UiPart.create(definition: SurfaceDefinition(...))`. + +Most consumers should **not** need to add `a2ui_core` to application or example +packages. Catalog widget authors and apps that use the existing facade API stay +on GenUI types. + +The exception is integrators reaching for the live surface model. A few APIs +that exist for GenUI-internal use cross the boundary and expose +`a2ui_core.SurfaceModel`: `SurfaceAdded.surface`, `ComponentsUpdated.surface`, +`SurfaceRegistry.watchLiveSurface`, and `SurfaceRegistry.getLiveSurface`. +These are marked `@internal`; use `SurfaceUpdate.definition` or +`SurfaceRegistry.getSurface` / `watchSurface` (which still return +`SurfaceDefinition`) unless you specifically need live core access. + +## What changed internally + +The compatibility types above now delegate to, wrap, or snapshot from +`a2ui_core`: + +- `SurfaceController.handleMessage(...)` accepts GenUI facade messages, converts + them to core messages privately, and delegates state mutation to + `a2ui_core.MessageProcessor`. +- `InMemoryDataModel` wraps `a2ui_core.DataModel` while preserving the old + `DataPath`/`ValueListenable` API. +- GenUI's own `SurfaceController` + `Surface` path renders from the live core + `SurfaceModel`, so component updates can rebuild only the affected component + subtree. +- `SurfaceController.store` remains available as a compatibility facade; for + live surfaces it returns wrappers around the surface's core-backed data model. +- Custom/external `SurfaceContext` implementations can still provide only the + legacy `definition` snapshot path. +- `CatalogItemContext` is internally backed by `a2ui_core.ComponentContext`, but + the public authoring surface remains the GenUI shim getters and callbacks. + `getComponent()` returns a GenUI `Component?` snapshot. + +## Remaining behavior changes to watch for + +These are substrate behavior changes, not rename requirements: + +1. **`DataModel` is stricter.** Some writes that previously no-op'd now throw + core data errors, especially type-mismatched intermediate paths and very + large list indices. Sparse list writes are also core-style: skipped entries + are filled with `null`. +2. **Stored containers are mutable copies.** Incoming map/list values are copied + before storage so nested updates work even when callers pass const literals. +3. **Surfaces are live internally.** Public `SurfaceDefinition` snapshots remain + available, but GenUI's built-in renderer uses live component models for + granular rebuilds. +4. **Data reactivity is signal-backed internally.** Public `subscribe(...)` + still returns a `ValueListenable`, and `Bound*` widgets keep their existing + API. Internally, those listenables bridge to `preact_signals` from + `a2ui_core`. +5. **Protocol validation is stricter.** The core parser rejects malformed + messages more consistently, including missing/incorrect versions and + messages with more than one top-level action key. +6. **Duplicate `createSurface` for an active surface id is an error** instead + of silently reusing the existing surface. +7. **JSON Pointer `~0`/`~1` escapes are not interpreted.** Paths split on `/`, + matching the web core behavior. +8. **`SurfaceRegistry.updateSurface(...)` is removed.** Surface lifecycle + updates now flow through `SurfaceController.handleMessage`; the + definition-only push path is no longer supported. `addSurface` and + `notifyUpdated` exist on `SurfaceRegistry` but are marked `@internal`. + +## On the future of the compatibility facades + +The GenUI-named types in this release (`CreateSurface`, `InMemoryDataModel`, +`DataPath`, `SurfaceDefinition`, `Component`, `SurfaceContext.definition`, +`SurfaceController.store`, etc.) are kept as a compatibility API on top of +`a2ui_core`. They're stable; you can write new code against them today. + +A future PR may add `a2ui_core`-shaped aliases or replacements for closer +cross-language parity (`CreateSurfaceMessage`, string paths, `SurfaceModel`, +raw map component payloads, etc.). That PR is not scheduled, and the existing +GenUI names will not be removed without a separate deprecation cycle. Treat +the facade types in this release as the current public API, not as +short-lived shims. diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 4b51ff99e..6efaa5a07 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -2,4 +2,17 @@ ## 0.0.1-wip002 +- **Feature**: `UpdateDataModelMessage` carries a `hasValue` field so + parsers can distinguish `value: null` (set to null) from an absent + `value` key (remove the key) per the v0.9 protocol. Runtime mutation + still collapses both to "remove" pending flutter/genui#938. +- **Feature**: Re-export preact_signals `effect` and `Effect`. +- **Fix**: `DataModel.set` deep-copies map/list payloads so later nested + writes work even when callers pass const literals. +- **Behavior**: `DataPath` no longer interprets RFC 6901 `~0`/`~1` + escapes; paths split on `/` only, matching the TypeScript reference + implementation (see A2UI#1499 tracking spec clarification). + +## 0.0.1-dev002 + - Initial version. diff --git a/packages/a2ui_core/lib/src/core/data_model.dart b/packages/a2ui_core/lib/src/core/data_model.dart index 7dd5f9252..198aeb6cb 100644 --- a/packages/a2ui_core/lib/src/core/data_model.dart +++ b/packages/a2ui_core/lib/src/core/data_model.dart @@ -12,13 +12,29 @@ import '../primitives/reactivity.dart'; /// allocate a billion-element list. const int maxAutoVivifyIndex = 10000; +/// Returns a mutable copy of JSON-like maps/lists so later nested writes do +/// not fail when callers pass const or otherwise unmodifiable literals. +Object? _mutableJsonLike(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _mutableJsonLike(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _mutableJsonLike(item)]; + } + return value; +} + /// A standalone, observable data store representing the client-side state. /// It handles JSON Pointer path resolution and reactive signal management. class DataModel { Object? _data; final Map>> _signals = {}; - DataModel([Object? initialData]) : _data = initialData ?? {}; + DataModel([Object? initialData]) + : _data = _mutableJsonLike(initialData ?? {}); /// Synchronously gets data at a specific JSON pointer path. Object? get(String path) { @@ -49,7 +65,7 @@ class DataModel { batch(() { if (dataPath.isEmpty) { - _data = value; + _data = _mutableJsonLike(value); } else { _data ??= {}; Object? current = _data; @@ -102,7 +118,7 @@ class DataModel { if (value == null) { current.remove(lastSegment); } else { - current[lastSegment] = value; + current[lastSegment] = _mutableJsonLike(value); } } else if (current is List) { final int? index = int.tryParse(lastSegment); @@ -121,7 +137,7 @@ class DataModel { while (current.length <= index) { current.add(null); } - current[index] = value; + current[index] = _mutableJsonLike(value); } } diff --git a/packages/a2ui_core/lib/src/core/messages.dart b/packages/a2ui_core/lib/src/core/messages.dart index 923c52695..cdb8079f5 100644 --- a/packages/a2ui_core/lib/src/core/messages.dart +++ b/packages/a2ui_core/lib/src/core/messages.dart @@ -9,7 +9,12 @@ abstract class A2uiMessage { final String version; A2uiMessage({this.version = 'v0.9'}); - /// Deserializes a JSON envelope into a typed [A2uiMessage]. + /// Deserializes a JSON message into a typed [A2uiMessage]. + /// + /// Throws [A2uiValidationError] if the `version` field is missing or is + /// not exactly `'v0.9'`, or if the message does not contain exactly one + /// of `createSurface`, `updateComponents`, `updateDataModel`, + /// `deleteSurface`. factory A2uiMessage.fromJson(Map json) { final Object? rawVersion = json['version']; if (rawVersion is! String) { @@ -65,11 +70,18 @@ abstract class A2uiMessage { if (json.containsKey('updateDataModel')) { final body = json['updateDataModel'] as Map; - return UpdateDataModelMessage( + if (body.containsKey('value')) { + return UpdateDataModelMessage( + version: version, + surfaceId: body['surfaceId'] as String, + path: body['path'] as String?, + value: body['value'], + ); + } + return UpdateDataModelMessage.removeKey( version: version, surfaceId: body['surfaceId'] as String, path: body['path'] as String?, - value: body['value'], ); } @@ -137,17 +149,36 @@ class UpdateComponentsMessage extends A2uiMessage { } /// Updates the data model for an existing surface. +/// +/// The wire protocol distinguishes two intents: +/// +/// - `"value": ` (present, possibly `null`): set [path] to that value. +/// - omitted `value` key: remove the key at [path] (sparse-clear for lists). +/// +/// The default constructor builds the first; [UpdateDataModelMessage.removeKey] +/// builds the second. [hasValue] preserves the distinction across parse and +/// serialize. class UpdateDataModelMessage extends A2uiMessage { final String surfaceId; final String? path; final Object? value; + /// True if the message carries an explicit `value` on the wire. + final bool hasValue; + UpdateDataModelMessage({ super.version, required this.surfaceId, this.path, this.value, - }); + }) : hasValue = true; + + UpdateDataModelMessage.removeKey({ + super.version, + required this.surfaceId, + this.path, + }) : value = null, + hasValue = false; @override Map toJson() => { @@ -155,7 +186,7 @@ class UpdateDataModelMessage extends A2uiMessage { 'updateDataModel': { 'surfaceId': surfaceId, if (path != null) 'path': path, - if (value != null) 'value': value, + if (hasValue) 'value': value, }, }; } diff --git a/packages/a2ui_core/lib/src/primitives/data_path.dart b/packages/a2ui_core/lib/src/primitives/data_path.dart index bb5b3a269..4d8bd9cc7 100644 --- a/packages/a2ui_core/lib/src/primitives/data_path.dart +++ b/packages/a2ui_core/lib/src/primitives/data_path.dart @@ -4,7 +4,11 @@ import 'package:collection/collection.dart'; -/// A class for handling JSON Pointer (RFC 6901) paths. +/// A class for handling JSON Pointer-style paths. +/// +/// Splits paths on `/` only; does not implement RFC 6901 `~0`/`~1` +/// escaping for `~`/`/` within segments. This matches the web_core +/// reference implementation, despite the v0.9 spec citing RFC 6901. class DataPath { final List segments; @@ -29,9 +33,7 @@ class DataPath { return DataPath([]); } - final List segments = normalized.split('/').map((s) { - return s.replaceAll('~1', '/').replaceAll('~0', '~'); - }).toList(); + final List segments = normalized.split('/'); return DataPath(segments); } @@ -68,7 +70,7 @@ class DataPath { @override String toString() { if (segments.isEmpty) return '/'; - return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}'; + return '/${segments.join('/')}'; } @override diff --git a/packages/a2ui_core/lib/src/primitives/reactivity.dart b/packages/a2ui_core/lib/src/primitives/reactivity.dart index c867253ba..1b06466cf 100644 --- a/packages/a2ui_core/lib/src/primitives/reactivity.dart +++ b/packages/a2ui_core/lib/src/primitives/reactivity.dart @@ -10,4 +10,12 @@ library; export 'package:preact_signals/preact_signals.dart' - show Computed, Effect, ReadonlySignal, Signal, batch, computed, signal; + show + Computed, + Effect, + ReadonlySignal, + Signal, + batch, + computed, + effect, + signal; diff --git a/packages/a2ui_core/test/data_model_test.dart b/packages/a2ui_core/test/data_model_test.dart index 698a9896e..1012d0a19 100644 --- a/packages/a2ui_core/test/data_model_test.dart +++ b/packages/a2ui_core/test/data_model_test.dart @@ -145,6 +145,23 @@ void main() { expect(model.get('/'), isEmpty); }); + test('copies immutable containers before nested writes', () { + final model = DataModel(); + model.set('/', const { + 'experience': '2-5', + 'nested': {'count': 1}, + 'items': ['a'], + }); + + model.set('/experience', '5+'); + model.set('/nested/count', 2); + model.set('/items/1', 'b'); + + expect(model.get('/experience'), '5+'); + expect(model.get('/nested'), {'count': 2}); + expect(model.get('/items'), ['a', 'b']); + }); + test('rejects excessively large list indices to prevent OOM', () { final model = DataModel(); expect( diff --git a/packages/a2ui_core/test/data_path_test.dart b/packages/a2ui_core/test/data_path_test.dart index 0d2e232d8..c1fa7e1d4 100644 --- a/packages/a2ui_core/test/data_path_test.dart +++ b/packages/a2ui_core/test/data_path_test.dart @@ -19,9 +19,9 @@ void main() { expect(path.toString(), '/foo/bar'); }); - test('parses escaped segments', () { + test('preserves ~ characters literally (no RFC 6901 escaping)', () { final path = DataPath.parse('/foo~1bar/baz~0qux'); - expect(path.segments, ['foo/bar', 'baz~qux']); + expect(path.segments, ['foo~1bar', 'baz~0qux']); expect(path.toString(), '/foo~1bar/baz~0qux'); }); @@ -48,18 +48,5 @@ void main() { expect(DataPath.parse('/a/b'), equals(DataPath.parse('/a/b'))); expect(DataPath.parse('/a/b'), isNot(equals(DataPath.parse('/a/c')))); }); - - test('hashCode distinguishes segments from slashes in keys', () { - // Per RFC 6901 section 3, '~1' escapes a literal '/' within a key name. - // DataPath(['a', 'b']) represents JSON Pointer "/a/b" (two keys). - // DataPath(['a/b']) represents JSON Pointer "/a~1b" (one key: "a/b"). - // These are semantically different pointers and must have different - // hash codes for correctness in hash-based collections. - final twoSegments = DataPath(['a', 'b']); - final oneSegment = DataPath(['a/b']); - - expect(twoSegments, isNot(equals(oneSegment))); - expect(twoSegments.hashCode, isNot(equals(oneSegment.hashCode))); - }); }); } diff --git a/packages/a2ui_core/test/messages_test.dart b/packages/a2ui_core/test/messages_test.dart index d6438f57c..8c090c75c 100644 --- a/packages/a2ui_core/test/messages_test.dart +++ b/packages/a2ui_core/test/messages_test.dart @@ -82,6 +82,49 @@ void main() { final ud = msg as UpdateDataModelMessage; expect(ud.path, isNull); expect(ud.value, isNull); + expect(ud.hasValue, isFalse); + }); + + test('parses updateDataModel with explicit null value', () { + // Per the v0.9 spec, `value: null` and an absent `value` key carry + // different intent (set-to-null vs remove-key). They must round-trip + // distinctly so callers (and senders) preserve the distinction. + final msg = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, + }); + + final ud = msg as UpdateDataModelMessage; + expect(ud.value, isNull); + expect(ud.hasValue, isTrue); + }); + + test('round-trips an explicit-null updateDataModel value', () { + final original = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': '/x', 'value': null}, + }); + final Map json = original.toJson(); + final body = json['updateDataModel'] as Map; + expect(body.containsKey('value'), isTrue); + expect(body['value'], isNull); + + final reparsed = A2uiMessage.fromJson(json) as UpdateDataModelMessage; + expect(reparsed.hasValue, isTrue); + expect(reparsed.value, isNull); + }); + + test('round-trips an omitted updateDataModel value as omitted', () { + final omitted = UpdateDataModelMessage.removeKey( + surfaceId: 's1', + path: '/x', + ); + final body = omitted.toJson()['updateDataModel'] as Map; + expect(body.containsKey('value'), isFalse); + + final reparsed = + A2uiMessage.fromJson(omitted.toJson()) as UpdateDataModelMessage; + expect(reparsed.hasValue, isFalse); }); test('parses deleteSurface', () { diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 0ce448fd6..f9d89952b 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,6 +1,30 @@ # `genui` Changelog -## 0.9.2 +## 0.10.0 (in progress) + +- **Refactor**: Migrate the runtime substrate onto `package:a2ui_core`. + Public GenUI types are preserved as compatibility facades; see + [docs/usage/migration/migration_genui_onto_a2ui_core.md](../../docs/usage/migration/migration_genui_onto_a2ui_core.md). +- **Behavior**: `DataModel` writes are stricter (core data errors on + type-mismatched intermediate paths and excessively large list indices) + and sparse list writes now fill skipped entries with `null` instead of + silently dropping them. +- **Behavior**: A duplicate `createSurface` for an already-active surface + id is now an error. +- **Behavior**: JSON Pointer `~0`/`~1` escapes are not interpreted on + `DataPath`; paths split on `/`, matching the web reference implementation + (see A2UI#1499 tracking spec clarification). +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed. Surface + lifecycle now flows through `SurfaceController.handleMessage`; the + definition-only push path could not be preserved without diverging from + the live `a2ui_core` surface model. `SurfaceRegistry.addSurface` / + `notifyUpdated` exist as internal lifecycle hooks and are marked + `@internal`. +- **Internal**: The live `core.SurfaceModel` fields on `SurfaceAdded` / + `ComponentsUpdated` are marked `@internal`. Most consumers should read + `SurfaceUpdate.definition` instead. + +## 0.9.1 - **Feature**: Updated example/README.md. diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index 5a63ff916..121d2c9e2 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -4,21 +4,62 @@ import '../model/data_model.dart'; -/// Manages the data models for surfaces. +/// A facade over per-surface data models managed by +/// `a2ui_core.SurfaceGroupModel`. +/// +/// Kept to preserve the legacy `SurfaceController.store` / +/// `store.getDataModel(surfaceId)` API. Pre-`createSurface`, returns a +/// standalone in-memory model; when `SurfaceController` later attaches a +/// live surface via [attachLive], any data written to the standalone model +/// is migrated into the live one and future [getDataModel] calls return the +/// live wrapper. +/// +/// Slated for removal alongside the rest of the GenUI->a2ui_core facade +/// renames. New code should read from `SurfaceController.registry +/// .getSurface(id)?.dataModel` directly. class DataModelStore { + /// Creates a [DataModelStore]. + DataModelStore({DataModel? Function(String surfaceId)? lookup}) + : _lookup = lookup; + + final DataModel? Function(String surfaceId)? _lookup; final Map _dataModels = {}; + final Map _liveDataModels = {}; final Set _attachedSurfaces = {}; /// Retrieves the data model for the given [surfaceId], creating it if it - /// does not exist. + /// does not exist. Already-attached live models are returned directly + /// without re-invoking [_lookup]. DataModel getDataModel(String surfaceId) { + final DataModel? cached = _liveDataModels[surfaceId]; + if (cached != null) return cached; + final DataModel? liveModel = _lookup?.call(surfaceId); + if (liveModel != null) { + _liveDataModels[surfaceId] = liveModel; + return liveModel; + } return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); } + /// Caches [liveModel] for [surfaceId] and migrates any pre-create + /// fallback data into it. Callers that had a reference to the fallback + /// model must refetch via [getDataModel]. + void attachLive(String surfaceId, DataModel liveModel) { + final DataModel? fallback = _dataModels.remove(surfaceId); + if (fallback != null) { + final Object? snapshot = fallback.getValue(DataPath.root); + liveModel.update(DataPath.root, snapshot); + fallback.dispose(); + } + _liveDataModels[surfaceId] = liveModel; + } + /// Removes the data model for the given [surfaceId] and detaches the surface. void removeDataModel(String surfaceId) { final DataModel? model = _dataModels.remove(surfaceId); model?.dispose(); + final DataModel? liveModel = _liveDataModels.remove(surfaceId); + liveModel?.dispose(); _attachedSurfaces.remove(surfaceId); } @@ -33,12 +74,18 @@ class DataModelStore { } /// An unmodifiable map of all registered data models. - Map get dataModels => Map.unmodifiable(_dataModels); + Map get dataModels => + Map.unmodifiable({..._dataModels, ..._liveDataModels}); /// Disposes of all data models in this store. void dispose() { for (final DataModel model in _dataModels.values) { model.dispose(); } + for (final DataModel model in _liveDataModels.values) { + model.dispose(); + } + _dataModels.clear(); + _liveDataModels.clear(); } } diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 69731d290..e03a47b61 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -16,8 +17,9 @@ import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/chat_message.dart'; import '../model/data_model.dart'; +import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; -import '../primitives/constants.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/logging.dart'; import 'data_model_store.dart'; @@ -25,50 +27,57 @@ import 'surface_registry.dart' as surface_reg; /// The runtime controller for the GenUI system. /// -/// Orchestrates the lifecycle of UI surfaces, manages communication with the -/// AI service, and handles data model updates. +/// Wraps [core.MessageProcessor] and adds Flutter-side concerns: pre-create +/// message buffering, catalog-schema validation, and a [SurfaceUpdate] +/// stream the Flutter facade subscribes to. interface class SurfaceController implements SurfaceHost, A2uiMessageSink { - /// Creates a [SurfaceController]. - /// - /// The [catalogs] parameter defines the set of component catalogs available - /// for use by surfaces managed by this controller. - /// - /// The [pendingUpdateTimeout] specifies how long to wait for a surface - /// creation message before discarding buffered updates for that surface. SurfaceController({ required this.catalogs, this.pendingUpdateTimeout = const Duration(minutes: 1), - }); + }) { + _processor = core.MessageProcessor( + // Growable: handleMessage injects stub catalogs for unknown catalogIds. + catalogs: catalogs.map((c) => c.coreCatalog).toList(), + ); + _processor.groupModel.onSurfaceCreated.addListener(_onCoreSurfaceCreated); + _processor.groupModel.onSurfaceDeleted.addListener(_onCoreSurfaceDeleted); + } /// The catalogs available to surfaces in this engine. final Iterable catalogs; - /// The timeout for pending updates waiting for a surface creation. + /// The timeout for buffered updates waiting for a surface creation. final Duration pendingUpdateTimeout; + late final core.MessageProcessor _processor; late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); - late final DataModelStore _store = DataModelStore(); + late final DataModelStore _store = DataModelStore( + lookup: (String surfaceId) { + final core.SurfaceModel? surface = _registry.getLiveSurface(surfaceId); + if (surface == null) return null; + return InMemoryDataModel.wrap(surface.dataModel); + }, + ); final _onSubmit = StreamController.broadcast(); - final _pendingUpdates = >{}; + final _pendingUpdates = >{}; final _pendingUpdateTimers = {}; - // Expose registry events as surface updates @override Stream get surfaceUpdates => _registry.events.map( (e) => switch (e) { - surface_reg.SurfaceAdded(:final surfaceId, :final definition) => - SurfaceAdded(surfaceId, definition), - surface_reg.SurfaceUpdated(:final surfaceId, :final definition) => - ComponentsUpdated(surfaceId, definition), + // Registry-emitted events always populate `surface` via + // `SurfaceAdded.fromCore` / `SurfaceUpdated.fromCore`. + surface_reg.SurfaceAdded(:final surfaceId, :final surface) => + SurfaceAdded.fromCore(surfaceId, surface!), + surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => + ComponentsUpdated.fromCore(surfaceId, surface!), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); /// A stream of messages to be submitted to the AI service. - /// - /// This includes user actions and validation errors. Stream get onSubmit => _onSubmit.stream; /// The IDs of the currently active surfaces. @@ -90,32 +99,157 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The store of data models managed by this controller. DataModelStore get store => _store; - /// Process an [message] from the AI service. - /// - /// Decodes the message and updates the state of the relevant surface, - /// provided the message passes validation. - /// - /// If validation fails, a [A2uiValidationException] is caught and logged, - /// and an error message is sent back via [onSubmit]. + /// Processes a message from the AI service. @override void handleMessage(A2uiMessage message) { genUiLogger.info( 'SurfaceController.handleMessage received: ${message.runtimeType}', ); + _handleCoreMessage(message.toCoreMessage()); + } + + void _handleCoreMessage(core.A2uiMessage coreMessage) { + // Reject an empty surfaceId on any message that carries one. CreateSurface + // would otherwise create a surface with id ""; updates and deletes would + // buffer under "" until they time out, since a surface "" can never exist. + final String? surfaceId = _surfaceIdOf(coreMessage); + if (surfaceId != null && surfaceId.isEmpty) { + reportError( + A2uiValidationException( + 'Surface ID cannot be empty', + surfaceId: '', + path: 'surfaceId', + ), + StackTrace.current, + ); + return; + } + + final String? bufferSurfaceId = _bufferSurfaceIdIfNoSurface(coreMessage); + if (bufferSurfaceId != null) { + _bufferMessage(bufferSurfaceId, coreMessage); + return; + } + + // Register an empty stub for unknown catalogIds. Mirrors the lenient + // pre-migration behavior tests and demos relied on. + if (coreMessage is core.CreateSurfaceMessage) { + final core.CreateSurfaceMessage createMessage = coreMessage; + if (!_processor.catalogs.any((c) => c.id == createMessage.catalogId)) { + _processor.catalogs.add( + core.Catalog( + id: createMessage.catalogId, + components: const [], + ), + ); + } + } try { - _handleMessageInternal(message); + _processor.processMessages([coreMessage]); + } on core.A2uiStateError catch (e) { + genUiLogger.warning('State error from MessageProcessor: ${e.message}'); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; + } on core.A2uiValidationError catch (e) { + genUiLogger.warning( + 'Validation error from MessageProcessor: ${e.message}', + ); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; } on A2uiValidationException catch (e) { genUiLogger.warning('Validation failed for surface ${e.surfaceId}: $e'); reportError(e, StackTrace.current); + return; } catch (exception, stackTrace) { genUiLogger.severe( - 'Error handling message: $message', + 'Error handling message: $coreMessage', exception, stackTrace, ); reportError(exception, stackTrace); + return; } + + if (coreMessage is core.UpdateComponentsMessage) { + final core.SurfaceModel? surface = _processor + .groupModel + .getSurface(coreMessage.surfaceId); + if (surface != null) { + _registry.notifyUpdated(surface); + // Validation does not roll back the mutation; we surface the error + // and let the caller decide. + try { + final Catalog? genuiCatalog = catalogs.firstWhereOrNull( + (c) => c.catalogId == surface.catalog.id, + ); + if (genuiCatalog != null) { + _validateComponents(coreMessage.surfaceId, surface, genuiCatalog); + } + } on A2uiValidationException catch (e) { + genUiLogger.warning( + 'Schema validation failed for surface ${e.surfaceId}: $e', + ); + reportError(e, StackTrace.current); + } + } + } + } + + /// If [message] targets a surface that does not yet exist, returns that + /// surfaceId so the caller can buffer the message. Otherwise returns null. + String? _bufferSurfaceIdIfNoSurface(core.A2uiMessage message) { + final String? targetId = switch (message) { + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + _ => null, + }; + if (targetId == null) return null; + if (_processor.groupModel.getSurface(targetId) != null) return null; + return targetId; + } + + String? _surfaceIdOf(core.A2uiMessage message) => switch (message) { + core.CreateSurfaceMessage(:final surfaceId) => surfaceId, + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + core.DeleteSurfaceMessage(:final surfaceId) => surfaceId, + _ => null, + }; + + void _onCoreSurfaceCreated(core.SurfaceModel surface) { + // Migrate pre-create fallback data into the live model BEFORE notifying + // registry listeners; otherwise a synchronous listener could call + // contextFor(...).dataModel and cache an empty live wrapper before the + // fallback's data is copied in. + _store.attachLive(surface.id, InMemoryDataModel.wrap(surface.dataModel)); + _registry.addSurface(surface); + final List? pending = _pendingUpdates.remove(surface.id); + _pendingUpdateTimers.remove(surface.id)?.cancel(); + if (pending != null) { + for (final core.A2uiMessage msg in pending) { + _handleCoreMessage(msg); + } + } + } + + void _onCoreSurfaceDeleted(String surfaceId) { + _pendingUpdates.remove(surfaceId); + _pendingUpdateTimers.remove(surfaceId)?.cancel(); + _store.removeDataModel(surfaceId); + _registry.removeSurface(surfaceId); } /// Reports an error to the AI service. @@ -149,103 +283,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - void _handleMessageInternal(A2uiMessage message) { - switch (message) { - case CreateSurface( - :final surfaceId, - :final catalogId, - :final theme, - :final sendDataModel, - ): - if (surfaceId.isEmpty) { - throw A2uiValidationException( - 'Surface ID cannot be empty', - surfaceId: surfaceId, - path: 'surfaceId', - ); - } - - final List? pending = _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - - _store.getDataModel(surfaceId); // Ensure model exists - - final SurfaceDefinition? existing = _registry.getSurface(surfaceId); - final SurfaceDefinition newDefinition = - (existing ?? SurfaceDefinition(surfaceId: surfaceId)).copyWith( - catalogId: catalogId, - theme: theme, - ); - - if (sendDataModel) { - _store.attachSurface(surfaceId); - } else { - _store.detachSurface(surfaceId); - } - - _registry.updateSurface( - surfaceId, - newDefinition, - isNew: existing == null, - ); - - final Catalog? catalog = _findCatalogForDefinition(newDefinition); - if (catalog != null) { - newDefinition.validate(catalog.definition); - } - - if (pending != null) { - for (final A2uiMessage msg in pending) { - _handleMessageInternal(msg); - } - } - - case UpdateComponents(:final surfaceId, :final components): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final SurfaceDefinition current = _registry.getSurface(surfaceId)!; - final Map newComponents = Map.of(current.components); - for (final component in components) { - newComponents[component.id] = component; - } - - _registry.updateSurface( - surfaceId, - current.copyWith(components: newComponents), - ); - - final SurfaceDefinition updatedDefinition = _registry.getSurface( - surfaceId, - )!; - final Catalog? catalog = _findCatalogForDefinition(updatedDefinition); - if (catalog != null) { - updatedDefinition.validate(catalog.definition); - } - - case UpdateDataModel(:final surfaceId, :final path, :final value): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final DataModel model = _store.getDataModel(surfaceId); - model.update(path, value); - - // Note: We don't trigger a surface update here to avoid full UI refreshes - // on data changes. Components should listen to the DataModel directly. - - case DeleteSurface(:final surfaceId): - _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - _registry.removeSurface(surfaceId); - _store.removeDataModel(surfaceId); - } - } - - void _bufferMessage(String surfaceId, A2uiMessage message) { + void _bufferMessage(String surfaceId, core.A2uiMessage message) { _pendingUpdates.putIfAbsent(surfaceId, () => []).add(message); if (!_pendingUpdateTimers.containsKey(surfaceId)) { _pendingUpdateTimers[surfaceId] = Timer(pendingUpdateTimeout, () { @@ -255,12 +293,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } - /// Handles a UI event from a surface. - /// - /// Converts the event into a [ChatMessage] and adds it to the [onSubmit] - /// stream. + /// Sends a [UserActionEvent] to [onSubmit] as a [ChatMessage]. No-op for + /// non-action [UiEvent]s. void handleUiEvent(UiEvent event) { - if (event is! UserActionEvent) return; + if (!event.isUserAction) return; _onSubmit.add( ChatMessage.user( '', @@ -273,22 +309,40 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - genUiLogger.fine( - 'Finding catalog for ${definition.catalogId} in ' - '${catalogs.map((c) => c.catalogId).toList()}', - ); - return catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == definition.catalogId, + Catalog? _findCatalogForSurface(String surfaceId) { + final core.SurfaceModel? surface = _registry + .getLiveSurface(surfaceId); + if (surface == null) return null; + return catalogs.firstWhereOrNull((c) => c.catalogId == surface.catalog.id); + } + + /// Validates the components currently in [surface] against [catalog]'s + /// schema. Throws [A2uiValidationException] on the first failing component. + void _validateComponents( + String surfaceId, + core.SurfaceModel surface, + Catalog catalog, + ) { + schema_validation.validateComponents( + surfaceId: surfaceId, + components: surface.componentsModel.all.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: catalog.definition, ); } /// Disposes of the controller and releases all resources. - /// - /// Closes the [onSubmit] stream and cancels any pending timers. void dispose() { - _registry.dispose(); + _processor.groupModel.onSurfaceCreated.removeListener( + _onCoreSurfaceCreated, + ); + _processor.groupModel.onSurfaceDeleted.removeListener( + _onCoreSurfaceDeleted, + ); + _processor.groupModel.dispose(); _store.dispose(); + _registry.dispose(); _onSubmit.close(); for (final Timer timer in _pendingUpdateTimers.values) { timer.cancel(); @@ -296,38 +350,31 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } -class _ControllerContext implements SurfaceContext { +class _ControllerContext implements LiveSurfaceContext { _ControllerContext(this._controller, this.surfaceId); final SurfaceController _controller; @override final String surfaceId; + @override + ValueListenable get surface => + _controller.registry.watchLiveSurface(surfaceId); + @override ValueListenable get definition => - _controller.registry.watchSurface(surfaceId); + _controller.registry.watchDefinition(surfaceId); @override DataModel get dataModel => _controller.store.getDataModel(surfaceId); @override - Catalog? get catalog { - final ValueListenable definitions = _controller.registry - .watchSurface(surfaceId); - final SurfaceDefinition? definition = definitions.value; - final String catalogId = definition?.catalogId ?? basicCatalogId; - return _controller.catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == catalogId, - ); - } + Catalog? get catalog => _controller._findCatalogForSurface(surfaceId); @override - void handleUiEvent(UiEvent event) { - _controller.handleUiEvent(event); - } + void handleUiEvent(UiEvent event) => _controller.handleUiEvent(event); @override - void reportError(Object error, StackTrace? stack) { - _controller.reportError(error, stack); - } + void reportError(Object error, StackTrace? stack) => + _controller.reportError(error, stack); } diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 72285889c..e255d757c 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -4,9 +4,10 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' hide Catalog, DataContext; import 'package:flutter/foundation.dart'; -import '../model/ui_models.dart'; +import '../model/ui_models.dart' as genui_model; import '../primitives/logging.dart'; /// Events emitted by the [SurfaceRegistry]. @@ -14,31 +15,69 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - /// Creates a [SurfaceAdded] event. - SurfaceAdded(this.surfaceId, this.definition); + /// Constructs from a [genui_model.SurfaceDefinition]. The live [surface] + /// is `null` when constructed via this path (intended for tests/mocks); + /// the [SurfaceRegistry] uses [SurfaceAdded.fromCore] internally so + /// real-world events have both fields populated. + SurfaceAdded(this.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceAdded.fromCore(this.surfaceId, SurfaceModel coreSurface) + : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), + surface = coreSurface; + final String surfaceId; - final SurfaceDefinition definition; + + /// Snapshot definition for this surface. + final genui_model.SurfaceDefinition definition; + + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by [SurfaceRegistry]. Intended for + /// GenUI internals. + @internal + final SurfaceModel? surface; } /// An event indicating that a surface has been removed. class SurfaceRemoved extends RegistryEvent { - /// Creates a [SurfaceRemoved] event. SurfaceRemoved(this.surfaceId); final String surfaceId; } -/// An event indicating that a surface has been updated. +/// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - /// Creates a [SurfaceUpdated] event. - SurfaceUpdated(this.surfaceId, this.definition); + /// Constructs from a [genui_model.SurfaceDefinition]. See [SurfaceAdded] + /// for the relationship between this constructor and + /// [SurfaceUpdated.fromCore]. + SurfaceUpdated(this.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceUpdated.fromCore(this.surfaceId, SurfaceModel coreSurface) + : definition = genui_model.SurfaceDefinition.fromCore(coreSurface), + surface = coreSurface; + final String surfaceId; - final SurfaceDefinition definition; + + /// Snapshot definition for this surface. + final genui_model.SurfaceDefinition definition; + + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by [SurfaceRegistry]. Intended for + /// GenUI internals. + @internal + final SurfaceModel? surface; } -/// Manages the lifecycle and storage of [SurfaceDefinition]s. +/// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly +/// [ValueListenable]s for them, plus a registry-event stream. class SurfaceRegistry { - final Map> _surfaces = {}; - // Track creation/update order for cleanup policies + final Map> _surfaces = {}; + final Map> + _definitions = {}; final List _surfaceOrder = []; final StreamController _eventController = StreamController.broadcast(); @@ -47,83 +86,127 @@ class SurfaceRegistry { Stream get events => _eventController.stream; /// The list of surface IDs in the order they were created or updated. - /// - /// This is used by cleanup strategies to determine which surfaces to remove. List get surfaceOrder => List.unmodifiable(_surfaceOrder); - /// Returns a [ValueListenable] that tracks the definition of the surface - /// with the given [surfaceId]. - /// - /// If the surface does not exist, a new notifier is created with a null - /// value. - ValueListenable watchSurface(String surfaceId) { + /// Returns a [ValueListenable] tracking the + /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. The value is + /// `null` until the surface is registered, and becomes `null` again when + /// it is removed. + ValueListenable watchSurface( + String surfaceId, + ) => watchDefinition(surfaceId); + + /// Returns a [ValueListenable] tracking the + /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. + ValueListenable watchDefinition( + String surfaceId, + ) { + return _definitions.putIfAbsent( + surfaceId, + () => ValueNotifier(null), + ); + } + + /// Returns a [ValueListenable] tracking the live core surface model for + /// [surfaceId]. Intended for GenUI internals. + @internal + ValueListenable watchLiveSurface(String surfaceId) { if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface $surfaceId'); - } else { - genUiLogger.fine('Fetching surface notifier for $surfaceId'); + genUiLogger.fine('Adding new surface watcher for $surfaceId'); } return _surfaces.putIfAbsent( surfaceId, - () => ValueNotifier(null), + () => ValueNotifier(null), ); } - /// Updates the definition of a surface. - /// - /// If [isNew] is true, a [SurfaceAdded] event is emitted. Otherwise, a - /// [SurfaceUpdated] event is emitted. - void updateSurface( - String surfaceId, - SurfaceDefinition definition, { - bool isNew = false, - }) { - final ValueNotifier notifier = _surfaces.putIfAbsent( - surfaceId, - () => ValueNotifier(null), + /// Registers a new surface, emitting a [SurfaceAdded] event. Intended + /// for GenUI internals; external callers should drive surface lifecycle + /// through `SurfaceController.handleMessage`. + @internal + void addSurface(SurfaceModel surface) { + final ValueNotifier?> notifier = _surfaces + .putIfAbsent(surface.id, () => ValueNotifier(null)); + notifier.value = surface; + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, ); - notifier.value = definition; + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + genUiLogger.info('Created new surface ${surface.id}'); + _eventController.add(SurfaceAdded.fromCore(surface.id, surface)); + } - _surfaceOrder.remove(surfaceId); - _surfaceOrder.add(surfaceId); - - if (isNew) { - genUiLogger.info('Created new surface $surfaceId'); - _eventController.add(SurfaceAdded(surfaceId, definition)); - } else { - // genUiLogger.info('Updated surface $surfaceId'); // Optional logging - _eventController.add(SurfaceUpdated(surfaceId, definition)); - } + /// Signals that the components of a surface have changed. Intended for + /// GenUI internals. + @internal + void notifyUpdated(SurfaceModel surface) { + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, + ); + _eventController.add(SurfaceUpdated.fromCore(surface.id, surface)); } - /// Removes a surface from the registry. + /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. /// - /// Emits a [SurfaceRemoved] event if the surface existed. + /// The per-id [ValueNotifier] is intentionally retained so widgets already + /// listening stay connected; a later re-create of the same id updates the + /// existing notifier. The [SurfaceModel] is owned and disposed by the + /// substrate's `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { - if (_surfaces.containsKey(surfaceId)) { - genUiLogger.info('Deleting surface $surfaceId'); - final ValueNotifier? notifier = _surfaces.remove( - surfaceId, - ); - notifier?.dispose(); - _surfaceOrder.remove(surfaceId); - _eventController.add(SurfaceRemoved(surfaceId)); - } + final ValueNotifier?>? notifier = + _surfaces[surfaceId]; + if (notifier == null || notifier.value == null) return; + genUiLogger.info('Deleting surface $surfaceId'); + notifier.value = null; + _definitions[surfaceId]?.value = null; + _surfaceOrder.remove(surfaceId); + _eventController.add(SurfaceRemoved(surfaceId)); } - /// Returns true if the registry contains a surface with the given + /// Returns true if the registry has a watcher (or live surface) for /// [surfaceId]. - bool hasSurface(String surfaceId) => _surfaces.containsKey(surfaceId); + bool hasSurface(String surfaceId) => _surfaces[surfaceId]?.value != null; - /// Returns the current definition of the surface with the given [surfaceId], - /// or null if it doesn't exist. - SurfaceDefinition? getSurface(String surfaceId) => - _surfaces[surfaceId]?.value; + /// Returns the current [genui_model.SurfaceDefinition] snapshot for the + /// given [surfaceId], or `null` if the surface does not exist. + genui_model.SurfaceDefinition? getSurface(String surfaceId) => + _definitions[surfaceId]?.value; - /// Disposes of the registry and all its resources. + /// Returns the live core surface model for [surfaceId], or `null` if the + /// surface does not exist. Intended for GenUI internals. + @internal + SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]?.value; + + /// Disposes of the registry and all per-surface notifiers. The underlying + /// [SurfaceModel]s are owned and disposed by the substrate's + /// `core.SurfaceGroupModel`, not by this registry. void dispose() { _eventController.close(); - for (final ValueNotifier notifier in _surfaces.values) { + for (final ValueNotifier?> notifier + in _surfaces.values) { + notifier.dispose(); + } + for (final ValueNotifier notifier + in _definitions.values) { notifier.dispose(); } + _surfaces.clear(); + _definitions.clear(); + _surfaceOrder.clear(); } } diff --git a/packages/genui/lib/src/functions/format_string.dart b/packages/genui/lib/src/functions/format_string.dart index c966ec01f..ccdd8aa2f 100644 --- a/packages/genui/lib/src/functions/format_string.dart +++ b/packages/genui/lib/src/functions/format_string.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; import 'package:stream_transform/stream_transform.dart'; import '../model/client_function.dart'; -import '../model/data_model.dart'; +import '../model/data_path.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; @@ -92,7 +92,7 @@ class ExpressionParser { /// any data path dependencies within the arguments will be added to the set. Stream evaluateFunctionCall( JsonMap callDefinition, { - Set? dependencies, + Set? dependencies, int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -184,7 +184,7 @@ class ExpressionParser { Stream _parseStringWithInterpolations( String input, - Set? dependencies, { + Set? dependencies, { int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -284,7 +284,7 @@ class ExpressionParser { Object? _evaluateExpression( String content, int depth, - Set? dependencies, + Set? dependencies, ) { if (depth > _maxRecursionDepth) { throw RecursionExpectedException( @@ -320,7 +320,7 @@ class ExpressionParser { Map _parseNamedArgs( String argsStr, int depth, - Set? dependencies, + Set? dependencies, ) { final args = {}; var i = 0; @@ -379,7 +379,7 @@ class ExpressionParser { String input, int start, int depth, - Set? dependencies, + Set? dependencies, ) { if (start >= input.length) return (null, start); @@ -440,10 +440,10 @@ class ExpressionParser { return (_resolvePath(token, dependencies), i); } - Stream _resolvePath(String pathStr, Set? dependencies) { + Stream _resolvePath(String pathStr, Set? dependencies) { pathStr = pathStr.trim(); if (dependencies != null) { - dependencies.add(context.resolvePath(DataPath(pathStr))); + dependencies.add(context.resolvePath(DataPath(pathStr)).toString()); return Stream.value(null); } return context.subscribeStream(DataPath(pathStr)); diff --git a/packages/genui/lib/src/interfaces.dart b/packages/genui/lib/src/interfaces.dart index 79e9c4a6f..fccbaeb5d 100644 --- a/packages/genui/lib/src/interfaces.dart +++ b/packages/genui/lib/src/interfaces.dart @@ -7,6 +7,6 @@ library; export 'interfaces/a2ui_message_sink.dart'; -export 'interfaces/surface_context.dart'; +export 'interfaces/surface_context.dart' hide LiveSurfaceContext; export 'interfaces/surface_host.dart'; export 'interfaces/transport.dart'; diff --git a/packages/genui/lib/src/interfaces/surface_context.dart b/packages/genui/lib/src/interfaces/surface_context.dart index dd76909be..19ca09411 100644 --- a/packages/genui/lib/src/interfaces/surface_context.dart +++ b/packages/genui/lib/src/interfaces/surface_context.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import '../model/catalog.dart'; @@ -9,13 +10,11 @@ import '../model/data_model.dart'; import '../model/ui_models.dart'; /// An interface for a specific UI surface context. -/// -/// This provides access to the state and definition of a single surface. abstract interface class SurfaceContext { /// The ID of the surface this context is bound to. String get surfaceId; - /// The current definition of the UI for this surface. + /// The current snapshot definition of the UI for this surface. ValueListenable get definition; /// The data model for this surface. @@ -30,3 +29,12 @@ abstract interface class SurfaceContext { /// Reports an error capable of being sent back to the AI. void reportError(Object error, StackTrace? stack); } + +/// GenUI-internal extension of [SurfaceContext] that exposes the live core +/// surface model so the renderer can subscribe to per-component updates for +/// granular rebuilds. External implementations only need to satisfy +/// [SurfaceContext]. +@internal +abstract interface class LiveSurfaceContext implements SurfaceContext { + ValueListenable get surface; +} diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 57ef8a169..69f1fc4c7 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -2,146 +2,127 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../primitives/logging.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/simple_items.dart'; import 'a2ui_schemas.dart'; import 'catalog.dart'; import 'data_model.dart'; import 'ui_models.dart'; -/// A sealed class representing a message in the A2UI stream. -sealed class A2uiMessage { - /// Creates an [A2uiMessage]. - const A2uiMessage(); +/// A source-compatible GenUI facade for A2UI protocol messages. +/// +/// The canonical parser and processor live in `a2ui_core`; these classes keep +/// the legacy GenUI names while converting to/from the core message types at +/// the renderer boundary. +abstract class A2uiMessage { + const A2uiMessage({this.version = 'v0.9'}); - /// Creates an [A2uiMessage] from a JSON map. factory A2uiMessage.fromJson(JsonMap json) { try { - final Object? version = json['version']; - if (version != 'v0.9') { - throw A2uiValidationException( - 'A2UI message must have version "v0.9"', - json: json, - ); - } - if (json case {'createSurface': JsonMap data}) { - try { - return CreateSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse CreateSurface message', - json: json, - cause: e, - ); - } - } - if (json case {'updateComponents': JsonMap data}) { - try { - return UpdateComponents.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateComponents message', - json: json, - cause: e, - ); - } - } - if (json case {'updateDataModel': JsonMap data}) { - try { - return UpdateDataModel.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateDataModel message', - json: json, - cause: e, - ); - } - } - if (json case {'deleteSurface': JsonMap data}) { - try { - return DeleteSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse DeleteSurface message', - json: json, - cause: e, - ); - } + return A2uiMessage.fromCore( + core.A2uiMessage.fromJson(Map.from(json)), + ); + } on core.A2uiValidationError catch (e) { + String message = e.message; + if (message.contains("'version'")) { + message = 'A2UI message must have version "v0.9"'; } - } on A2uiValidationException { - rethrow; - } catch (exception, stackTrace) { - genUiLogger.severe( - 'Failed to parse A2UI message from JSON: $json', - exception, - stackTrace, + throw A2uiValidationException(message, json: json, cause: e); + } catch (e) { + throw A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, ); - rethrow; } - throw A2uiValidationException( - 'Unknown A2UI message type: ${json.keys}', - json: json, - ); } - /// Returns the JSON schema for an A2UI message. - static Schema a2uiMessageSchema(Catalog catalog) { - return S.combined( - 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.', - oneOf: [ - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'createSurface': A2uiSchemas.createSurfaceSchema(), - }, - required: ['version', 'createSurface'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), - }, - required: ['version', 'updateComponents'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateDataModel': A2uiSchemas.updateDataModelSchema(), - }, - required: ['version', 'updateDataModel'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), - }, - required: ['version', 'deleteSurface'], - additionalProperties: false, - ), - ], - ); + /// Creates a facade message from a core message. + factory A2uiMessage.fromCore(core.A2uiMessage message) { + return switch (message) { + core.CreateSurfaceMessage() => CreateSurface.fromCore(message), + core.UpdateComponentsMessage() => UpdateComponents.fromCore(message), + core.UpdateDataModelMessage() => UpdateDataModel.fromCore(message), + core.DeleteSurfaceMessage() => DeleteSurface.fromCore(message), + _ => throw A2uiValidationException( + 'Unknown A2UI message type: ${message.runtimeType}', + ), + }; } + + /// Returns the JSON schema for an A2UI message. + static Schema a2uiMessageSchema(Catalog catalog) => + _buildA2uiMessageSchema(catalog); + + /// The protocol version. + final String version; + + /// Converts this facade message to the core substrate message. + core.A2uiMessage toCoreMessage(); + + Map toJson() => toCoreMessage().toJson(); +} + +/// Returns the JSON schema for an A2UI message, parameterized by [catalog]. +Schema a2uiMessageSchema(Catalog catalog) => _buildA2uiMessageSchema(catalog); + +Schema _buildA2uiMessageSchema(Catalog catalog) { + return S.combined( + 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.', + oneOf: [ + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'createSurface': A2uiSchemas.createSurfaceSchema(), + }, + required: ['version', 'createSurface'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), + }, + required: ['version', 'updateComponents'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateDataModel': A2uiSchemas.updateDataModelSchema(), + }, + required: ['version', 'updateDataModel'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), + }, + required: ['version', 'deleteSurface'], + additionalProperties: false, + ), + ], + ); } /// An A2UI message that signals the client to create and show a new surface. final class CreateSurface extends A2uiMessage { - /// Creates a [CreateSurface] message. const CreateSurface({ + super.version, required this.surfaceId, required this.catalogId, this.theme, this.sendDataModel = false, }); - /// Creates a [CreateSurface] message from a JSON map. + /// Creates a [CreateSurface] message from a JSON map body. factory CreateSurface.fromJson(JsonMap json) { return CreateSurface( surfaceId: json[surfaceIdKey] as String, @@ -151,6 +132,16 @@ final class CreateSurface extends A2uiMessage { ); } + factory CreateSurface.fromCore(core.CreateSurfaceMessage message) { + return CreateSurface( + version: message.version, + surfaceId: message.surfaceId, + catalogId: message.catalogId, + theme: message.theme == null ? null : JsonMap.from(message.theme!), + sendDataModel: message.sendDataModel, + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; @@ -163,22 +154,27 @@ final class CreateSurface extends A2uiMessage { /// If true, the client sends the full data model in A2A metadata. final bool sendDataModel; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'catalogId': catalogId, - 'theme': ?theme, - 'sendDataModel': sendDataModel, - }; + @override + core.CreateSurfaceMessage toCoreMessage() { + return core.CreateSurfaceMessage( + version: version, + surfaceId: surfaceId, + catalogId: catalogId, + theme: theme == null ? null : Map.from(theme!), + sendDataModel: sendDataModel, + ); + } } /// An A2UI message that updates a surface with new components. final class UpdateComponents extends A2uiMessage { - /// Creates a [UpdateComponents] message. - const UpdateComponents({required this.surfaceId, required this.components}); + const UpdateComponents({ + super.version, + required this.surfaceId, + required this.components, + }); - /// Creates a [UpdateComponents] message from a JSON map. + /// Creates an [UpdateComponents] message from a JSON map body. factory UpdateComponents.fromJson(JsonMap json) { return UpdateComponents( surfaceId: json[surfaceIdKey] as String, @@ -188,35 +184,80 @@ final class UpdateComponents extends A2uiMessage { ); } + factory UpdateComponents.fromCore(core.UpdateComponentsMessage message) { + return UpdateComponents( + version: message.version, + surfaceId: message.surfaceId, + components: message.components + .map((json) => Component.fromJson(JsonMap.from(json))) + .toList(), + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; /// The list of components to add or update. final List components; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'components': components.map((c) => c.toJson()).toList(), - }; + @override + core.UpdateComponentsMessage toCoreMessage() { + return core.UpdateComponentsMessage( + version: version, + surfaceId: surfaceId, + components: components.map((c) => c.toCoreJson()).toList(), + ); + } } /// An A2UI message that updates the data model. final class UpdateDataModel extends A2uiMessage { - /// Creates a [UpdateDataModel] message. + /// Creates an [UpdateDataModel] message that sets [path] to [value]. const UpdateDataModel({ + super.version, required this.surfaceId, this.path = DataPath.root, this.value, - }); + }) : hasValue = true; - /// Creates a [UpdateDataModel] message from a JSON map. + /// Creates an [UpdateDataModel] message that removes the key at [path]. + const UpdateDataModel.removeKey({ + super.version, + required this.surfaceId, + this.path = DataPath.root, + }) : value = null, + hasValue = false; + + /// Creates an [UpdateDataModel] message from a JSON map body. factory UpdateDataModel.fromJson(JsonMap json) { - return UpdateDataModel( + final path = DataPath(json['path'] as String? ?? '/'); + if (json.containsKey('value')) { + return UpdateDataModel( + surfaceId: json[surfaceIdKey] as String, + path: path, + value: json['value'], + ); + } + return UpdateDataModel.removeKey( surfaceId: json[surfaceIdKey] as String, - path: DataPath(json['path'] as String? ?? '/'), - value: json['value'], + path: path, + ); + } + + factory UpdateDataModel.fromCore(core.UpdateDataModelMessage message) { + final path = DataPath(message.path ?? '/'); + if (message.hasValue) { + return UpdateDataModel( + version: message.version, + surfaceId: message.surfaceId, + path: path, + value: message.value, + ); + } + return UpdateDataModel.removeKey( + version: message.version, + surfaceId: message.surfaceId, + path: path, ); } @@ -227,33 +268,50 @@ final class UpdateDataModel extends A2uiMessage { final DataPath path; /// The new value to write to the data model. - /// - /// If null (and the key is present in the JSON), it implies deletion of the - /// key at the path. final Object? value; - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'path': path.toString(), - 'value': ?value, - }; + /// Whether the wire JSON carries an explicit `value` key. + final bool hasValue; + + @override + core.UpdateDataModelMessage toCoreMessage() { + if (!hasValue) { + return core.UpdateDataModelMessage.removeKey( + version: version, + surfaceId: surfaceId, + path: path.toString(), + ); + } + return core.UpdateDataModelMessage( + version: version, + surfaceId: surfaceId, + path: path.toString(), + value: value, + ); + } } /// An A2UI message that deletes a surface. final class DeleteSurface extends A2uiMessage { - /// Creates a [DeleteSurface] message. - const DeleteSurface({required this.surfaceId}); + const DeleteSurface({super.version, required this.surfaceId}); - /// Creates a [DeleteSurface] message from a JSON map. + /// Creates a [DeleteSurface] message from a JSON map body. factory DeleteSurface.fromJson(JsonMap json) { return DeleteSurface(surfaceId: json[surfaceIdKey] as String); } + factory DeleteSurface.fromCore(core.DeleteSurfaceMessage message) { + return DeleteSurface( + version: message.version, + surfaceId: message.surfaceId, + ); + } + /// The ID of the surface that this message applies to. final String surfaceId; - /// Converts this message to a JSON map. - Map toJson() => {'version': 'v0.9', surfaceIdKey: surfaceId}; + @override + core.DeleteSurfaceMessage toCoreMessage() { + return core.DeleteSurfaceMessage(version: version, surfaceId: surfaceId); + } } diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 6aab4e2a6..afee37062 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; @@ -135,27 +136,17 @@ interface class Catalog { } genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); - return KeyedSubtree( - key: ValueKey(itemContext.id), - child: item.widgetBuilder( - CatalogItemContext( - data: itemContext.data, - id: itemContext.id, - type: widgetType, - buildChild: (String childId, [DataContext? childDataContext]) => - itemContext.buildChild( - childId, - childDataContext ?? itemContext.dataContext, - ), - dispatchEvent: itemContext.dispatchEvent, - buildContext: itemContext.buildContext, - dataContext: itemContext.dataContext, - getComponent: itemContext.getComponent, - getCatalogItem: (String type) => - items.firstWhereOrNull((item) => item.name == type), - surfaceId: itemContext.surfaceId, - reportError: itemContext.reportError, - ), + // No KeyedSubtree: per-id identity comes from the Surface widget's + // _ComponentBuilder wrapper. + return item.widgetBuilder( + itemContext.withOverrides( + buildChild: (String childId, [DataContext? childDataContext]) => + itemContext.buildChild( + childId, + childDataContext ?? itemContext.dataContext, + ), + getCatalogItem: (String type) => + items.firstWhereOrNull((item) => item.name == type), ), ); } @@ -262,3 +253,28 @@ class CatalogItemNotFoundException implements Exception { return buffer.toString(); } } + +class _CatalogItemComponentApi implements core.ComponentApi { + _CatalogItemComponentApi(this._item); + final CatalogItem _item; + + @override + String get name => _item.name; + + @override + Schema get schema => _item.dataSchema; +} + +/// Substrate-facing [core.Catalog] view of a genui [Catalog]. +extension CatalogCoreView on Catalog { + /// Returns a [core.Catalog] populated from this catalog's items, used when + /// constructing a [core.SurfaceModel] so substrate-side lookups see real + /// component metadata instead of an empty stub. + core.Catalog get coreCatalog => + core.Catalog( + id: catalogId ?? 'genui_inline_$hashCode', + components: items + .map(_CatalogItemComponentApi.new) + .toList(growable: false), + ); +} diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 8bcd0e62c..07a993043 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart' show internal; import 'data_model.dart'; import 'ui_models.dart'; @@ -18,7 +20,7 @@ typedef ChildBuilderCallback = /// A callback that builds an example of a catalog item. /// /// The returned string must be a valid JSON representation of a list of -/// [Component] objects. One of the components in the list must have the `id` +/// component objects. One of the components in the list must have the `id` /// 'root'. typedef ExampleBuilderCallback = String Function(); @@ -27,37 +29,80 @@ typedef CatalogWidgetBuilder = Widget Function(CatalogItemContext itemContext); /// Context provided to a [CatalogItem]'s widget builder. /// -/// This class encapsulates all the information and callbacks needed to build -/// a catalog widget, including access to the widget's data, its position in -/// the component tree, and mechanisms for building children and dispatching -/// events. +/// Backed by a substrate [core.ComponentContext] plus Flutter-specific +/// extras: [buildContext], [buildChild], [dispatchEvent], [dataContext], +/// [reportError]. final class CatalogItemContext { - /// Creates a [CatalogItemContext] with the required parameters. - /// - /// All parameters are required to ensure the widget builder has complete - /// context for rendering and interaction. + /// Creates a [CatalogItemContext] from raw fields. Synthesizes a + /// stand-alone substrate context internally. CatalogItemContext({ - required this.data, - required this.id, - required this.type, + required String id, + required String type, + required Map data, required this.buildChild, required this.dispatchEvent, required this.buildContext, required this.dataContext, - required this.getComponent, + required GetComponentCallback getComponent, required this.getCatalogItem, - required this.surfaceId, + required String surfaceId, required this.reportError, - }); + }) : _componentContext = _standaloneContext( + id: id, + type: type, + data: data, + surfaceId: surfaceId, + ), + _getComponentOverride = getComponent; - /// The parsed data for this component from the AI-generated definition. - final Object data; + /// Creates a [CatalogItemContext] from a substrate [core.ComponentContext]. + /// Renderer-internal; tests should use the public constructor or + /// [CatalogItemContext.forTesting]. + @internal + CatalogItemContext.fromCore({ + required core.ComponentContext componentContext, + required this.buildChild, + required this.dispatchEvent, + required this.buildContext, + required this.dataContext, + required this.getCatalogItem, + required this.reportError, + }) : _componentContext = componentContext, + _getComponentOverride = null; - /// The unique identifier for this component instance. - final String id; + /// Test-only convenience: builds a stand-alone substrate context so tests + /// don't need to wire up a full surface. + @visibleForTesting + factory CatalogItemContext.forTesting({ + required String id, + required String type, + required Map data, + required ChildBuilderCallback buildChild, + required DispatchEventCallback dispatchEvent, + required BuildContext buildContext, + required DataContext dataContext, + required CatalogItem? Function(String type) getCatalogItem, + required String surfaceId, + required void Function(Object error, StackTrace? stack) reportError, + }) { + return CatalogItemContext( + id: id, + type: type, + data: data, + buildChild: buildChild, + dispatchEvent: dispatchEvent, + buildContext: buildContext, + dataContext: dataContext, + getComponent: (_) => null, + getCatalogItem: getCatalogItem, + surfaceId: surfaceId, + reportError: reportError, + ); + } - /// The type of this component. - final String type; + final core.ComponentContext _componentContext; + + final GetComponentCallback? _getComponentOverride; /// Callback to build a child widget by its component ID. final ChildBuilderCallback buildChild; @@ -68,20 +113,91 @@ final class CatalogItemContext { /// The Flutter [BuildContext] for this widget. final BuildContext buildContext; - /// The [DataContext] for accessing and modifying the data model. + /// The [DataContext] for accessing the data model, dispatching catalog + /// functions, and subscribing to dynamic-value streams. final DataContext dataContext; - /// Callback to retrieve a component definition by its ID. - final GetComponentCallback getComponent; - /// Callback to retrieve a catalog item definition by its type name. final CatalogItem? Function(String type) getCatalogItem; - /// The ID of the surface this component belongs to. - final String surfaceId; - /// Callback to report an error that occurred within this component. final void Function(Object error, StackTrace? stack) reportError; + + CatalogItemContext._copy({ + required core.ComponentContext componentContext, + required GetComponentCallback? getComponentOverride, + required this.buildChild, + required this.dispatchEvent, + required this.buildContext, + required this.dataContext, + required this.getCatalogItem, + required this.reportError, + }) : _componentContext = componentContext, + _getComponentOverride = getComponentOverride; + + /// Returns a copy of this context with selected callbacks replaced. The + /// substrate context is preserved. + @internal + CatalogItemContext withOverrides({ + ChildBuilderCallback? buildChild, + DispatchEventCallback? dispatchEvent, + BuildContext? buildContext, + DataContext? dataContext, + CatalogItem? Function(String type)? getCatalogItem, + void Function(Object error, StackTrace? stack)? reportError, + }) { + return CatalogItemContext._copy( + componentContext: _componentContext, + getComponentOverride: _getComponentOverride, + buildChild: buildChild ?? this.buildChild, + dispatchEvent: dispatchEvent ?? this.dispatchEvent, + buildContext: buildContext ?? this.buildContext, + dataContext: dataContext ?? this.dataContext, + getCatalogItem: getCatalogItem ?? this.getCatalogItem, + reportError: reportError ?? this.reportError, + ); + } + + /// The parsed data for this component from the AI-generated definition. + Object get data => _componentContext.componentModel.properties; + + /// The unique identifier for this component instance. + String get id => _componentContext.componentModel.id; + + /// The type of this component. + String get type => _componentContext.componentModel.type; + + /// The ID of the surface this component belongs to. + String get surfaceId => _componentContext.surface.id; + + /// Retrieves a component on the surface by its ID, or `null` if absent. + Component? getComponent(String componentId) { + final Component? override = _getComponentOverride?.call(componentId); + if (override != null) return override; + final core.ComponentModel? component = _componentContext + .surface + .componentsModel + .get(componentId); + return component == null ? null : Component.fromCore(component); + } + + static core.ComponentContext _standaloneContext({ + required String id, + required String type, + required Map data, + required String surfaceId, + }) { + final surface = core.SurfaceModel( + surfaceId, + catalog: core.Catalog( + id: 'catalog', + components: const [], + ), + ); + final component = core.ComponentModel(id, type, data); + surface.componentsModel.addComponent(component); + return core.ComponentContext(surface, component); + } } /// Defines a UI layout type, its schema, and how to build its widget. @@ -145,7 +261,7 @@ final class CatalogItem { /// example usage of this widget. /// /// Each returned string must be a valid JSON representation of a list of - /// [Component] objects. For the example to be renderable, one of the + /// component objects. For the example to be renderable, one of the /// components in the list must have the `id` 'root', which will be used as /// the entry point for rendering. /// diff --git a/packages/genui/lib/src/model/client_function.dart b/packages/genui/lib/src/model/client_function.dart index d025cacf4..e2d9e1984 100644 --- a/packages/genui/lib/src/model/client_function.dart +++ b/packages/genui/lib/src/model/client_function.dart @@ -20,22 +20,22 @@ abstract interface class ExecutionContext { ClientFunction? getFunction(String name); /// Subscribes to a path, resolving it against the current context. - ValueListenable subscribe(DataPath path); + ValueListenable subscribe(Object path); /// Subscribes to a path and returns a [Stream]. - Stream subscribeStream(DataPath path); + Stream subscribeStream(Object path); /// Gets a value, resolving the path against the current context. - T? getValue(DataPath path); + T? getValue(Object path); /// Updates the data model, resolving the path against the current context. - void update(DataPath path, Object? contents); + void update(Object path, Object? contents); /// Creates a new, nested ExecutionContext for a child widget. - ExecutionContext nested(DataPath relativePath); + ExecutionContext nested(Object relativePath); /// Resolves a path against the current context's path. - DataPath resolvePath(DataPath pathToResolve); + DataPath resolvePath(Object pathToResolve); /// Resolves any dynamic values (bindings or function calls) in the given /// value. diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index cdd031110..70f622bfb 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -12,20 +12,149 @@ import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; import 'client_function.dart' as cf; - import 'data_path.dart'; export 'data_path.dart'; +/// Converts either a legacy [DataPath] or a string path into [DataPath]. +DataPath _toDataPath(Object path) => + path is DataPath ? path : DataPath('$path'); + +/// Exception thrown when a value in the [DataModel] is not of the expected +/// type. +class DataModelTypeException implements Exception { + /// Creates a [DataModelTypeException]. + DataModelTypeException({ + required this.path, + required this.expectedType, + required this.actualType, + }); + + /// The path where the type mismatch occurred. + final DataPath path; + + /// The expected type. + final Type expectedType; + + /// The actual type found. + final Type actualType; + + @override + String toString() { + return 'DataModelTypeException: Expected $expectedType at $path, ' + 'but found $actualType'; + } +} + +/// Manages the application's data model and provides a subscription-based +/// mechanism for reactive UI updates. +abstract interface class DataModel { + /// Updates the data model at a specific absolute path and notifies all + /// relevant subscribers. + void update(DataPath absolutePath, Object? contents); + + /// Subscribes to a specific absolute path in the data model. + ValueNotifier subscribe(DataPath absolutePath); + + /// Binds an external state [source] to a [path] in the DataModel. + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }); + + /// Disposes resources and bindings. + void dispose(); + + /// Retrieves a static, one-time value from the data model at the + /// specified absolute path without creating a subscription. + T? getValue(DataPath absolutePath); +} + +/// Standard in-memory implementation of [DataModel]. Facade over +/// `a2ui_core.DataModel`. +class InMemoryDataModel implements DataModel { + /// Creates an empty in-memory data model. + InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; + + /// Wraps an existing core data model. + @internal + InMemoryDataModel.wrap(core.DataModel coreDataModel) + : _core = coreDataModel, + _ownsCore = false; + + final core.DataModel _core; + final bool _ownsCore; + final List _externalSubscriptions = []; + + /// The wrapped core data model. Intended for GenUI internals only. + @internal + core.DataModel get coreDataModel => _core; + + @override + void update(DataPath absolutePath, Object? contents) { + _core.set(absolutePath.toString(), contents); + } + + @override + ValueNotifier subscribe(DataPath absolutePath) { + return _SignalNotifier(_core.watch(absolutePath.toString())); + } + + @override + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }) { + final VoidCallback cleanup = _bindExternalState( + dataModel: this, + path: path, + source: source, + twoWay: twoWay, + ); + _externalSubscriptions.add(cleanup); + return () { + cleanup(); + _externalSubscriptions.remove(cleanup); + }; + } + + @override + void dispose() { + for (final callback in List.of(_externalSubscriptions)) { + callback(); + } + _externalSubscriptions.clear(); + if (_ownsCore) { + _core.dispose(); + } + } + + @override + T? getValue(DataPath absolutePath) { + final Object? value = _core.get(absolutePath.toString()); + if (value != null && value is! T) { + throw DataModelTypeException( + path: absolutePath, + expectedType: T, + actualType: value.runtimeType, + ); + } + return value as T?; + } +} + /// A contextual view of the main DataModel, used by widgets to resolve /// relative and absolute paths. class DataContext implements cf.ExecutionContext { /// Creates a [DataContext] for the given [path]. DataContext( this._dataModel, - this.path, { + Object path, { Iterable? functions, - }) : _functions = { + }) : path = _toDataPath(path), + _functions = { if (functions != null) for (final f in functions) f.name: f, }; @@ -49,16 +178,16 @@ class DataContext implements cf.ExecutionContext { /// Subscribes to a path, resolving it against the current context. @override - ValueNotifier subscribe(DataPath path) { + ValueListenable subscribe(Object path) { final DataPath absolutePath = resolvePath(path); return _dataModel.subscribe(absolutePath); } /// Subscribes to a path and returns a [Stream]. @override - Stream subscribeStream(DataPath path) { + Stream subscribeStream(Object path) { late StreamController controller; - ValueNotifier? notifier; + ValueListenable? notifier; void listener() { if (!controller.isClosed) { @@ -73,8 +202,11 @@ class DataContext implements cf.ExecutionContext { notifier!.addListener(listener); }, onCancel: () { - notifier?.removeListener(listener); - notifier?.dispose(); + final currentNotifier = notifier; + currentNotifier?.removeListener(listener); + if (currentNotifier is ChangeNotifier) { + (currentNotifier as ChangeNotifier).dispose(); + } notifier = null; controller.close(); }, @@ -84,31 +216,27 @@ class DataContext implements cf.ExecutionContext { /// Gets a value, resolving the path against the current context. @override - T? getValue(DataPath path) => _dataModel.getValue(resolvePath(path)); + T? getValue(Object path) => _dataModel.getValue(resolvePath(path)); /// Updates the data model, resolving the path against the current context. @override - void update(DataPath path, Object? contents) => + void update(Object path, Object? contents) => _dataModel.update(resolvePath(path), contents); /// Creates a new, nested DataContext for a child widget. - /// - /// Used by list/template widgets to create a context for their children. @override - DataContext nested(DataPath relativePath) => + DataContext nested(Object relativePath) => DataContext._(_dataModel, resolvePath(relativePath), _functions); /// Resolves a path against the current context's path. @override - DataPath resolvePath(DataPath pathToResolve) => - pathToResolve.isAbsolute ? pathToResolve : path.join(pathToResolve); + DataPath resolvePath(Object pathToResolve) { + final DataPath path = _toDataPath(pathToResolve); + return path.isAbsolute ? path : this.path.join(path); + } /// Resolves any dynamic values (bindings or function calls) in the given /// value. - /// - /// String values are treated as literals (no interpolation). - /// Maps with a 'path' key are resolved to the value at that path. - /// Maps with a 'call' key are executed as functions. @override Stream resolve(Object? value) => _evaluateStream(value); @@ -137,7 +265,6 @@ class DataContext implements cf.ExecutionContext { return Stream.value(null); } - // Resolve arguments final Map args = {}; final Object? argsJson = callDefinition['args']; @@ -182,7 +309,6 @@ class DataContext implements cf.ExecutionContext { } /// Resolves a context map definition against a [DataContext]. -/// Future resolveContext( DataContext dataContext, JsonMap? contextDefinition, @@ -198,349 +324,85 @@ Future resolveContext( return resolved; } -/// Exception thrown when a value in the [DataModel] is not of the expected -/// type. -class DataModelTypeException implements Exception { - /// Creates a [DataModelTypeException]. - DataModelTypeException({ - required this.path, - required this.expectedType, - required this.actualType, - }); - - /// The path where the type mismatch occurred. - final DataPath path; - - /// The expected type. - final Type expectedType; - - /// The actual type found. - final Type actualType; - - @override - String toString() { - return 'DataModelTypeException: Expected $expectedType at $path, ' - 'but found $actualType'; +VoidCallback _bindExternalState({ + required DataModel dataModel, + required DataPath path, + required ValueListenable source, + bool twoWay = false, +}) { + dataModel.update(path, source.value); + + void onSourceChanged() { + final T newValue = source.value; + final T? currentValue = dataModel.getValue(path); + if (currentValue != newValue) { + dataModel.update(path, newValue); + } } -} -/// Manages the application's data model and provides a subscription-based -/// mechanism for reactive UI updates. -abstract interface class DataModel { - /// Updates the data model at a specific absolute path and notifies all - /// relevant subscribers. - /// - /// If [absolutePath] is root, the entire data model is replaced - /// (if contents is a Map). - void update(DataPath absolutePath, Object? contents); - - /// Subscribes to a specific absolute path in the data model. - ValueNotifier subscribe(DataPath absolutePath); - - /// Binds an external state [source] to a [path] in the DataModel. - /// - /// **Side Effect:** Calling this method immediately performs a synchronous - /// `update()` on the DataModel at the specified [path] using the current - /// value of the [source]. - /// - /// If [twoWay] is true, changes in the DataModel at [path] will also - /// update the [source] (assuming [source] is a [ValueNotifier]). - /// - /// Returns a function that disposes the binding. - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }); + source.addListener(onSourceChanged); - /// Disposes resources and bindings. - void dispose(); - - /// Retrieves a static, one-time value from the data model at the - /// specified absolute path without creating a subscription. - T? getValue(DataPath absolutePath); -} - -/// Standard in-memory implementation of [DataModel]. -class InMemoryDataModel implements DataModel { - JsonMap _data = {}; - final Map> _subscriptions = {}; - - final List _cleanupCallbacks = []; - - @override - void update(DataPath absolutePath, Object? contents) { - genUiLogger.info( - 'DataModel.update: path=$absolutePath, contents=' - '${const JsonEncoder.withIndent(' ').convert(contents)}', - ); - - if (absolutePath == DataPath.root) { - if (contents is Map) { - _data = Map.from(contents); - } else { - genUiLogger.warning( - 'DataModel.update: contents for root path is not a Map: $contents', - ); - if (contents == null) { - _data = {}; + VoidCallback? removeModelListener; + if (twoWay) { + if (source is! ValueNotifier) { + genUiLogger.warning( + 'bindExternalState: twoWay is true but source is not a ValueNotifier.', + ); + } else { + final ValueNotifier notifier = source; + final ValueListenable subscription = dataModel.subscribe(path); + + void onModelChanged() { + final T? modelValue = subscription.value; + if (modelValue != null && modelValue != notifier.value) { + notifier.value = modelValue; } } - _notifySubscribers(DataPath.root); - return; - } - _updateValue(_data, absolutePath.segments, contents); - _notifySubscribers(absolutePath); - } - - @override - ValueNotifier subscribe(DataPath absolutePath) { - genUiLogger.finer('DataModel.subscribe: path=$absolutePath'); - if (_subscriptions.containsKey(absolutePath)) { - final notifier = - _subscriptions[absolutePath]! as _RefCountedValueNotifier; - notifier.incrementRef(); - return notifier; + subscription.addListener(onModelChanged); + removeModelListener = () { + subscription.removeListener(onModelChanged); + final currentSubscription = subscription; + if (currentSubscription is ChangeNotifier) { + (currentSubscription as ChangeNotifier).dispose(); + } + }; } - - final T? initialValue = getValue(absolutePath); - final notifier = _RefCountedValueNotifier( - initialValue, - onDispose: () { - _subscriptions.remove(absolutePath); - }, - ); - _subscriptions[absolutePath] = notifier; - return notifier; } - final List _externalSubscriptions = []; - - @override - void Function() bindExternalState({ - required DataPath path, - required ValueListenable source, - bool twoWay = false, - }) { - update(path, source.value); - - void onSourceChanged() { - final T newValue = source.value; - final T? currentValue = getValue(path); - if (currentValue != newValue) { - update(path, newValue); - } - } + return () { + source.removeListener(onSourceChanged); + removeModelListener?.call(); + }; +} - source.addListener(onSourceChanged); - void removeSourceListener() => source.removeListener(onSourceChanged); - _externalSubscriptions.add(removeSourceListener); - - VoidCallback? removeModelListener; - if (twoWay) { - if (source is! ValueNotifier) { - genUiLogger.warning( - 'bindExternalState: twoWay is true but source is not a ' - 'ValueNotifier.', - ); +/// Bridges a preact_signals [core.ReadonlySignal] to a Flutter +/// [ValueNotifier]. +class _SignalNotifier extends ValueNotifier { + _SignalNotifier(this._signal) : super(_cast(_signal.peek())) { + _disposeEffect = core.effect(() { + final T? newValue = _cast(_signal.value); + if (newValue == value) { + notifyListeners(); } else { - final ValueNotifier notifier = source; - final ValueNotifier subscription = subscribe(path); - - void onModelChanged() { - final T? modelValue = subscription.value; - if (modelValue != null && modelValue != notifier.value) { - notifier.value = modelValue; - } - } - - subscription.addListener(onModelChanged); - removeModelListener = () { - subscription.removeListener(onModelChanged); - // When we are done with the subscription, we should dispose it to - // decrement ref count. - subscription.dispose(); - }; - _externalSubscriptions.add(removeModelListener); - } - } - - return () { - removeSourceListener(); - _externalSubscriptions.remove(removeSourceListener); - - if (removeModelListener != null) { - removeModelListener(); - _externalSubscriptions.remove(removeModelListener); + value = newValue; } - }; - } - - @override - void dispose() { - for (final VoidCallback callback in _cleanupCallbacks) { - callback(); - } - _cleanupCallbacks.clear(); - - for (final VoidCallback callback in _externalSubscriptions) { - callback(); - } - _externalSubscriptions.clear(); - - // The DataModel does not own the refcounts of the returned notifiers. - // They are owned by the subscribers who called subscribe(). - // We only need to clear our cache. Let the subscribers dispose them. - _subscriptions.clear(); + }); } - @override - T? getValue(DataPath absolutePath) { - if (absolutePath == DataPath.root) { - _checkType(_data, absolutePath); - return _data as T?; - } - final Object? value = _getValue(_data, absolutePath.segments); - _checkType(value, absolutePath); - return value as T?; - } + final core.ReadonlySignal _signal; + late final void Function() _disposeEffect; + bool _isDisposed = false; - void _checkType(Object? value, DataPath path) { - if (value != null && value is! T) { + static T? _cast(Object? v) { + if (v != null && v is! T) { throw DataModelTypeException( - path: path, + path: DataPath.root, expectedType: T, - actualType: value.runtimeType, + actualType: v.runtimeType, ); } - } - - Object? _getValue(Object? current, List segments) { - if (segments.isEmpty) { - return current; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - return _getValue(current[segment], remaining); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0 && index < current.length) { - return _getValue(current[index], remaining); - } - } - return null; - } - - void _updateValue(Object? current, List segments, Object? value) { - if (segments.isEmpty) { - return; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - if (remaining.isEmpty) { - if (value == null) { - current.remove(segment); - } else { - current[segment] = value; - } - return; - } - - Object? nextNode = current[segment]; - if (nextNode == null) { - if (value == null) { - return; - } - - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - nextNode = isNextSegmentListIndex ? [] : {}; - current[segment] = nextNode; - } - _updateValue(nextNode, remaining, value); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0) { - if (remaining.isEmpty) { - if (index < current.length) { - if (value == null) { - current[index] = value; - } else { - current[index] = value; - } - } else if (index == current.length) { - if (value != null) current.add(value); - } - } else { - if (index < current.length) { - _updateValue(current[index], remaining, value); - } else if (index == current.length) { - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - final Object newItem = isNextSegmentListIndex - ? [] - : {}; - current.add(newItem); - _updateValue(newItem, remaining, value); - } - } - } - } - } - - void _notifySubscribers(DataPath path) { - if (_subscriptions.containsKey(path)) { - _subscriptions[path]!.value = getValue(path); - } - - var parent = path; - while (!parent.isAbsolute || parent.segments.isNotEmpty) { - if (parent == DataPath.root) break; - if (!parent.isAbsolute && parent.segments.isEmpty) break; - parent = parent.dirname; - final _RefCountedValueNotifier? notifier = - _subscriptions[parent]; - if (notifier != null) { - final Object? newValue = getValue(parent); - if (newValue != notifier.value) { - notifier.value = newValue; - } else { - // _updateValue mutates containers in place, which means - // listeners on this ancestor won't get automatically notified by - // the `ValueNotifier.value` setter. - notifier.forceNotify(); - } - } - } - - for (final DataPath p in _subscriptions.keys.toList()) { - if (p.startsWith(path) && p != path) { - _subscriptions[p]!.value = getValue(p); - } - } - } -} - -class _RefCountedValueNotifier extends ValueNotifier { - _RefCountedValueNotifier(super.value, {this.onDispose}); - - final VoidCallback? onDispose; - int _refCount = 1; - bool _isDisposed = false; - - void incrementRef() { - _refCount++; - } - - void forceNotify() { - notifyListeners(); + return v as T?; } @override @@ -548,16 +410,13 @@ class _RefCountedValueNotifier extends ValueNotifier { if (_isDisposed) { genUiLogger.warning( 'Attempt to dispose of already disposed notifier', - '_RefCountedValueNotifier.dispose Error', + '_SignalNotifier.dispose Error', StackTrace.current, ); return; } - _refCount--; - if (_refCount <= 0) { - _isDisposed = true; - onDispose?.call(); - super.dispose(); - } + _isDisposed = true; + _disposeEffect(); + super.dispose(); } } diff --git a/packages/genui/lib/src/model/parts/ui.dart b/packages/genui/lib/src/model/parts/ui.dart index e291dc808..1b107487d 100644 --- a/packages/genui/lib/src/model/parts/ui.dart +++ b/packages/genui/lib/src/model/parts/ui.dart @@ -66,12 +66,11 @@ extension UiPartListExtension on Iterable { @immutable final class UiPart { /// Creates a [DataPart] compatible with GenUI. - static DataPart create({ - required SurfaceDefinition definition, - String? surfaceId, - }) { + static DataPart create({required Object definition, String? surfaceId}) { final Map json = { - _Json.definition: definition.toJson(), + _Json.definition: definition is SurfaceDefinition + ? definition.toJson() + : definition as JsonMap, _Json.surfaceId: surfaceId ?? generateId(), }; return DataPart( diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart new file mode 100644 index 000000000..70b2acf88 --- /dev/null +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -0,0 +1,174 @@ +// 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 'dart:convert'; + +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../primitives/a2ui_validation_exception.dart'; +import '../primitives/simple_items.dart'; + +/// Validates a set of A2UI components against a catalog [schema]. +/// +/// Throws [A2uiValidationException] on the first component that fails. State +/// is not rolled back. +void validateComponents({ + required String surfaceId, + required Iterable<({String id, String type, JsonMap json})> components, + required Schema schema, +}) { + final List> allowedSchemas = _extractAllowedSchemas( + jsonDecode(schema.toJson()) as Map, + ); + if (allowedSchemas.isEmpty) return; + + for (final component in components) { + var matched = false; + final errors = []; + + for (final s in allowedSchemas) { + if (_schemaMatchesType(s, component.type)) { + try { + _validateInstance( + component.json, + s, + '/components/${component.id}', + surfaceId, + ); + matched = true; + break; + } catch (e) { + errors.add(e.toString()); + } + } + } + + if (!matched) { + if (errors.isNotEmpty) { + throw A2uiValidationException( + 'Validation failed for component ${component.id} ' + '(${component.type}): ${errors.join("; ")}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + throw A2uiValidationException( + 'Unknown component type: ${component.type}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + } +} + +List> _extractAllowedSchemas( + Map schemaMap, +) { + if (schemaMap.containsKey('oneOf')) { + return (schemaMap['oneOf'] as List).cast>(); + } + if (schemaMap.containsKey('properties') && + (schemaMap['properties'] as Map).containsKey('components')) { + final componentsProp = + (schemaMap['properties'] as Map)['components'] as Map; + if (componentsProp.containsKey('items')) { + final items = componentsProp['items'] as Map; + if (items.containsKey('oneOf')) { + return (items['oneOf'] as List).cast>(); + } + return [items]; + } + if (componentsProp.containsKey('properties')) { + return (componentsProp['properties'] as Map).values + .cast>() + .toList(); + } + } + return const []; +} + +bool _schemaMatchesType(Map schema, String type) { + if (schema case { + 'properties': {'component': Map compProp}, + }) { + return switch (compProp) { + {'const': String constType} when constType == type => true, + {'enum': List enums} when enums.contains(type) => true, + _ => false, + }; + } + return false; +} + +void _validateInstance( + Object? instance, + Map schema, + String path, + String surfaceId, +) { + if (instance == null) return; + + if (schema case {'const': Object? constVal} when instance != constVal) { + throw A2uiValidationException( + 'Value mismatch. Expected $constVal, got $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case { + 'enum': List enums, + } when !enums.contains(instance)) { + throw A2uiValidationException( + 'Value not in enum: $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case {'required': List required} when instance is Map) { + for (final String key in required.cast()) { + if (!instance.containsKey(key)) { + throw A2uiValidationException( + 'Missing required property: $key', + surfaceId: surfaceId, + path: path, + ); + } + } + } + if (schema case { + 'properties': Map props, + } when instance is Map) { + for (final MapEntry entry in props.entries) { + final String key = entry.key; + final propSchema = entry.value as Map; + if (instance.containsKey(key)) { + _validateInstance(instance[key], propSchema, '$path/$key', surfaceId); + } + } + } + if (schema case { + 'items': Map itemsSchema, + } when instance is List) { + for (var i = 0; i < instance.length; i++) { + _validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId); + } + } + if (schema case {'oneOf': List oneOfs}) { + var oneMatched = false; + for (final Map s in oneOfs.cast>()) { + try { + _validateInstance(instance, s, path, surfaceId); + oneMatched = true; + break; + } catch (_) {} + } + if (!oneMatched) { + throw A2uiValidationException( + 'Value did not match any oneOf schema', + surfaceId: surfaceId, + path: path, + ); + } + } +} diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 3915740c3..e57145e14 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -4,11 +4,14 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart' show internal; import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; +import 'schema_validation.dart' as schema_validation; /// A callback that is called when events are sent. typedef SendEventsCallback = @@ -19,8 +22,8 @@ typedef DispatchEventCallback = void Function(UiEvent event); /// A data object that represents a user interaction event in the UI. /// -/// Used to send information from the app to the AI about user -/// actions, such as tapping a button or entering text. +/// Used to send information from the app to the AI about user actions, +/// such as tapping a button or entering text. extension type UiEvent.fromMap(JsonMap _json) { /// The ID of the surface that this event originated from. String get surfaceId => _json[surfaceIdKey] as String; @@ -32,20 +35,21 @@ extension type UiEvent.fromMap(JsonMap _json) { String get eventType => _json['eventType'] as String; /// The value associated with the event, if any. - /// - /// For example, the text in a `TextField`, or the value of a `Checkbox`. Object? get value => _json['value']; /// The timestamp of when the event occurred. DateTime get timestamp => DateTime.parse(_json['timestamp'] as String); + /// Whether this is a [UserActionEvent]. Extension types are erased to their + /// representation at runtime, so `event is UserActionEvent` is always true + /// for any [UiEvent]; the action's `name` key is the real discriminator. + bool get isUserAction => _json.containsKey('name'); + /// Converts this event to a map, suitable for JSON serialization. JsonMap toMap() => _json; } -/// A UI event that represents a user action. -/// -/// Triggers a submission to the AI, such as tapping a button. +/// A UI event that represents a user action; triggers a submission to the AI. extension type UserActionEvent.fromMap(JsonMap _json) implements UiEvent { /// Creates a [UserActionEvent] from a set of properties. UserActionEvent({ @@ -80,21 +84,9 @@ final class _Json { /// A data object that represents the entire UI definition. /// -/// The root object that defines a complete UI to be rendered. +/// Snapshot facade kept for public-API compatibility; mutation is owned by +/// `a2ui_core.SurfaceModel`. class SurfaceDefinition { - /// The ID of the surface that this UI belongs to. - final String surfaceId; - - /// 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; - - /// The theme for this surface. - final JsonMap? theme; - /// Creates a [SurfaceDefinition]. SurfaceDefinition({ required this.surfaceId, @@ -117,6 +109,32 @@ class SurfaceDefinition { ); } + /// Creates a snapshot from a live core surface model. + factory SurfaceDefinition.fromCore(core.SurfaceModel surface) { + return SurfaceDefinition( + surfaceId: surface.id, + catalogId: surface.catalog.id, + components: { + for (final core.ComponentModel component in surface.componentsModel.all) + component.id: Component.fromCore(component), + }, + theme: surface.theme.isEmpty ? null : JsonMap.from(surface.theme), + ); + } + + /// The ID of the surface that this UI belongs to. + final String surfaceId; + + /// 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; + + /// The theme for this surface. + final JsonMap? theme; + /// Creates a copy of this [SurfaceDefinition] with the given fields replaced. SurfaceDefinition copyWith({ String? catalogId, @@ -150,165 +168,14 @@ class SurfaceDefinition { } /// Validates the UI definition against a schema. - /// - /// Throws [A2uiValidationException] if validation fails. void validate(Schema schema) { - final String jsonOutput = schema.toJson(); - final schemaMap = jsonDecode(jsonOutput) as Map; - - List> allowedSchemas = []; - if (schemaMap.containsKey('oneOf')) { - allowedSchemas = (schemaMap['oneOf'] as List) - .cast>(); - } else if (schemaMap.containsKey('properties') && - (schemaMap['properties'] as Map).containsKey('components')) { - final componentsProp = - (schemaMap['properties'] as Map)['components'] - as Map; - if (componentsProp.containsKey('items')) { - final items = componentsProp['items'] as Map; - if (items.containsKey('oneOf')) { - allowedSchemas = (items['oneOf'] as List) - .cast>(); - } else { - allowedSchemas = [items]; - } - } else if (componentsProp.containsKey('properties')) { - allowedSchemas = (componentsProp['properties'] as Map).values - .cast>() - .toList(); - } - } - - if (allowedSchemas.isEmpty) { - return; - } - - for (final Component component in components.values) { - var matched = false; - List errors = []; - final JsonMap instanceJson = component.toJson(); - - for (final s in allowedSchemas) { - if (_schemaMatchesType(s, component.type)) { - try { - _validateInstance(instanceJson, s, '/components/${component.id}'); - matched = true; - break; - } catch (e) { - errors.add(e.toString()); - } - } - } - - if (!matched) { - if (errors.isNotEmpty) { - throw A2uiValidationException( - 'Validation failed for component ${component.id} ' - '(${component.type}): ${errors.join("; ")}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - throw A2uiValidationException( - 'Unknown component type: ${component.type}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - } - } - - bool _schemaMatchesType(Map schema, String type) { - if (schema case { - 'properties': {'component': Map compProp}, - }) { - return switch (compProp) { - {'const': String constType} when constType == type => true, - {'enum': List enums} when enums.contains(type) => true, - _ => false, - }; - } - return false; - } - - void _validateInstance( - Object? instance, - Map schema, - String path, - ) { - if (instance == null) { - return; - } - - if (schema case {'const': Object? constVal} when instance != constVal) { - throw A2uiValidationException( - 'Value mismatch. Expected $constVal, got $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case { - 'enum': List enums, - } when !enums.contains(instance)) { - throw A2uiValidationException( - 'Value not in enum: $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case {'required': List required} when instance is Map) { - for (final String key in required.cast()) { - if (!instance.containsKey(key)) { - throw A2uiValidationException( - 'Missing required property: $key', - surfaceId: surfaceId, - path: path, - ); - } - } - } - - if (schema case { - 'properties': Map props, - } when instance is Map) { - for (final MapEntry entry in props.entries) { - final String key = entry.key; - final propSchema = entry.value as Map; - if (instance.containsKey(key)) { - _validateInstance(instance[key], propSchema, '$path/$key'); - } - } - } - - if (schema case { - 'items': Map itemsSchema, - } when instance is List) { - for (var i = 0; i < instance.length; i++) { - _validateInstance(instance[i], itemsSchema, '$path/$i'); - } - } - - if (schema case {'oneOf': List oneOfs}) { - var oneMatched = false; - for (final Map s - in oneOfs.cast>()) { - try { - _validateInstance(instance, s, path); - oneMatched = true; - break; - } catch (_) {} - } - if (!oneMatched) { - throw A2uiValidationException( - 'Value did not match any oneOf schema', - surfaceId: surfaceId, - path: path, - ); - } - } + schema_validation.validateComponents( + surfaceId: surfaceId, + components: components.values.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: schema, + ); } } @@ -336,6 +203,15 @@ final class Component { return Component(id: id, type: rawType, properties: properties); } + /// Creates a snapshot from a live core component model. + factory Component.fromCore(core.ComponentModel component) { + return Component( + id: component.id, + type: component.type, + properties: JsonMap.from(component.properties), + ); + } + /// The unique ID of the component. final String id; @@ -350,6 +226,9 @@ final class Component { return {'id': id, 'component': type, ...properties}; } + /// Converts this snapshot to the core component wire JSON shape. + Map toCoreJson() => Map.from(toJson()); + @override bool operator ==(Object other) => other is Component && @@ -362,76 +241,62 @@ final class Component { Object.hash(id, type, const DeepCollectionEquality().hash(properties)); } -/// Exception thrown when validation fails. -class A2uiValidationException implements Exception { - /// Creates a [A2uiValidationException]. - A2uiValidationException( - this.message, { - this.surfaceId, - this.path, - this.json, - this.cause, - }); - - /// The error message. - final String message; - - /// The ID of the surface where the validation error occurred. - final String? surfaceId; - - /// The path in the data/component model where the error occurred. - final String? path; - - /// The JSON that caused the error. - final Object? json; - - /// The underlying cause of the error. - final Object? cause; - - @override - String toString() { - final buffer = StringBuffer('A2uiValidationException: $message'); - if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); - if (path != null) buffer.write(' (path: $path)'); - if (cause != null) buffer.write('\nCause: $cause'); - if (json != null) buffer.write('\nJSON: $json'); - return buffer.toString(); - } -} - -/// A sealed class representing an update to the UI managed by the system. -/// -/// Subclasses: [SurfaceAdded], [ComponentsUpdated], and [SurfaceRemoved]. +/// Surface lifecycle events emitted by `SurfaceController.surfaceUpdates`. sealed class SurfaceUpdate { - /// Creates a [SurfaceUpdate] for the given [surfaceId]. const SurfaceUpdate(this.surfaceId); - - /// The ID of the surface that was updated. final String surfaceId; } /// Fired when a new surface is created. final class SurfaceAdded extends SurfaceUpdate { - /// Creates a [SurfaceAdded] event for the given [surfaceId] and - /// [definition]. - const SurfaceAdded(super.surfaceId, this.definition); - - /// The definition of the new surface. + /// Constructs from a [SurfaceDefinition]. The live [surface] is `null` + /// when constructed via this path (intended for tests/mocks); + /// `SurfaceController` uses [SurfaceAdded.fromCore] internally so + /// real-world events have both fields populated. + const SurfaceAdded(super.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + SurfaceAdded.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : surface = coreSurface, + definition = SurfaceDefinition.fromCore(coreSurface); + + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by `SurfaceController`. Intended + /// for GenUI internals. + @internal + final core.SurfaceModel? surface; + + /// Snapshot definition for this surface. final SurfaceDefinition definition; } -/// Fired when an existing surface is modified. +/// Fired when an existing surface's component set is modified. final class ComponentsUpdated extends SurfaceUpdate { - /// Creates a [ComponentsUpdated] event for the given [surfaceId] and - /// [definition]. - const ComponentsUpdated(super.surfaceId, this.definition); - - /// The new definition of the surface. + /// Constructs from a [SurfaceDefinition]. See [SurfaceAdded] for the + /// relationship between this constructor and + /// [ComponentsUpdated.fromCore]. + const ComponentsUpdated(super.surfaceId, this.definition) : surface = null; + + /// Internal: constructs from a live core surface, populating both + /// [surface] and [definition]. + @internal + ComponentsUpdated.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : surface = coreSurface, + definition = SurfaceDefinition.fromCore(coreSurface); + + /// Live `a2ui_core` surface model. Null when constructed via the public + /// constructor; populated when emitted by `SurfaceController`. Intended + /// for GenUI internals. + @internal + final core.SurfaceModel? surface; + + /// Snapshot definition for this surface. final SurfaceDefinition definition; } /// Fired when a surface is deleted. final class SurfaceRemoved extends SurfaceUpdate { - /// Creates a [SurfaceRemoved] event for the given [surfaceId]. const SurfaceRemoved(super.surfaceId); } diff --git a/packages/genui/lib/src/primitives.dart b/packages/genui/lib/src/primitives.dart index b7a9faebe..5e71708f7 100644 --- a/packages/genui/lib/src/primitives.dart +++ b/packages/genui/lib/src/primitives.dart @@ -5,6 +5,7 @@ /// Low-level utilities used by the GenUI framework. library; +export 'primitives/a2ui_validation_exception.dart'; export 'primitives/cancellation.dart'; export 'primitives/constants.dart'; export 'primitives/logging.dart'; diff --git a/packages/genui/lib/src/primitives/a2ui_validation_exception.dart b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart new file mode 100644 index 000000000..c168d84e5 --- /dev/null +++ b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart @@ -0,0 +1,31 @@ +// 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. + +/// Exception thrown when an A2UI message fails parsing or validation. +class A2uiValidationException implements Exception { + /// Creates an [A2uiValidationException]. + A2uiValidationException( + this.message, { + this.surfaceId, + this.path, + this.json, + this.cause, + }); + + final String message; + final String? surfaceId; + final String? path; + final Object? json; + final Object? cause; + + @override + String toString() { + final buffer = StringBuffer('A2uiValidationException: $message'); + if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); + if (path != null) buffer.write(' (path: $path)'); + if (cause != null) buffer.write('\nCause: $cause'); + if (json != null) buffer.write('\nJSON: $json'); + return buffer.toString(); + } +} diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index e5d6ac3b3..14ce0ae3d 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -7,7 +7,7 @@ import 'dart:convert'; import '../model/a2ui_message.dart'; import '../model/generation_events.dart'; -import '../model/ui_models.dart'; +import '../primitives/a2ui_validation_exception.dart'; /// Transforms a stream of text chunks into a stream of logical /// [GenerationEvent]s. @@ -181,38 +181,54 @@ class _A2uiParserStream { } } + /// Top-level keys that mark a JSON payload as an attempted A2UI message. + /// If parsing fails on one of these, surface a validation error rather + /// than fall back to plain text. `version` is included so a + /// malformed-but-versioned payload still counts as an attempted message. + static const _a2uiMessageKeys = { + 'version', + 'createSurface', + 'updateComponents', + 'updateDataModel', + 'deleteSurface', + }; + + bool _looksLikeA2uiMessage(Map json) => + json.keys.any(_a2uiMessageKeys.contains); + void _emitMessage(Object json) { if (json is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - // Failed to parse A2UI message structure (e.g. invalid type - // discriminator) - _controller.add(TextEvent(jsonEncode(json))); - _wasLastEventA2ui = false; - } + _tryEmitOne(json); } else if (json is List) { for (final Object? item in json) { if (item is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(item))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - _controller.add(TextEvent(jsonEncode(item))); - _wasLastEventA2ui = false; - } + _tryEmitOne(item); } } } } + void _tryEmitOne(Map json) { + try { + _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); + _wasLastEventA2ui = true; + } catch (e) { + if (_looksLikeA2uiMessage(json)) { + _controller.addError( + A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, + ), + ); + } else { + // Not an A2UI message; emit as plain text. + _controller.add(TextEvent(jsonEncode(json))); + } + _wasLastEventA2ui = false; + } + } + _Match? _findMarkdownJson(String text) { final regex = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```'); final RegExpMatch? match = regex.firstMatch(text); diff --git a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart index a96bb777c..5eac93b30 100644 --- a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart +++ b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart @@ -4,11 +4,12 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../interfaces/transport.dart'; import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/generation_events.dart'; - import 'a2ui_parser_transformer.dart'; export '../model/generation_events.dart' @@ -56,8 +57,18 @@ class A2uiTransportAdapter implements Transport { } /// Feeds a raw A2UI message (e.g. from a tool output or separate channel). - void addMessage(A2uiMessage message) { - _messageStream.add(message); + void addMessage(Object message) { + if (message is A2uiMessage) { + _messageStream.add(message); + return; + } + if (message is core.A2uiMessage) { + _messageStream.add(A2uiMessage.fromCore(message)); + return; + } + throw ArgumentError( + 'Unsupported A2UI message type: ${message.runtimeType}', + ); } /// A stream of sanitizer text for the chat UI. diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index c1faff9d8..175b6dfa3 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -10,7 +11,6 @@ import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/data_model.dart'; import '../model/ui_models.dart'; - import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import 'fallback_widget.dart'; @@ -20,8 +20,10 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that renders a dynamic UI surface generated by the AI. /// -/// This widget connects to a [SurfaceContext] and renders the UI defined by the -/// [SurfaceDefinition] for the bound surface. +/// Drives off [SurfaceContext.definition] by default; when the context also +/// implements [LiveSurfaceContext] (GenUI's own controller does), the renderer +/// subscribes to per-component updates from the live core surface model for +/// granular rebuilds. class Surface extends StatefulWidget { /// Creates a [Surface]. const Surface({ @@ -45,60 +47,245 @@ class Surface extends StatefulWidget { } class _SurfaceState extends State { + core.SurfaceModel? _trackedSurface; + void Function(core.ComponentModel)? _onCreated; + void Function(String)? _onDeleted; + + LiveSurfaceContext? get _liveContext => + widget.surfaceContext is LiveSurfaceContext + ? widget.surfaceContext as LiveSurfaceContext + : null; + + @override + void initState() { + super.initState(); + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.addListener(_onSurfaceChanged); + _attachComponentsListeners(liveContext.surface.value); + } else { + widget.surfaceContext.definition.addListener(_onDefinitionChanged); + } + } + + @override + void didUpdateWidget(Surface oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.surfaceContext == widget.surfaceContext) return; + + if (oldWidget.surfaceContext is LiveSurfaceContext) { + (oldWidget.surfaceContext as LiveSurfaceContext).surface.removeListener( + _onSurfaceChanged, + ); + _detachComponentsListeners(); + } else { + oldWidget.surfaceContext.definition.removeListener(_onDefinitionChanged); + } + + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.addListener(_onSurfaceChanged); + _attachComponentsListeners(liveContext.surface.value); + } else { + widget.surfaceContext.definition.addListener(_onDefinitionChanged); + } + } + + @override + void dispose() { + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + liveContext.surface.removeListener(_onSurfaceChanged); + _detachComponentsListeners(); + } else { + widget.surfaceContext.definition.removeListener(_onDefinitionChanged); + } + super.dispose(); + } + + void _onDefinitionChanged() { + if (mounted) setState(() {}); + } + + void _onSurfaceChanged() { + final core.SurfaceModel? next = _liveContext?.surface.value; + if (!identical(next, _trackedSurface)) { + _detachComponentsListeners(); + _attachComponentsListeners(next); + } + if (mounted) setState(() {}); + } + + void _attachComponentsListeners(core.SurfaceModel? surface) { + _trackedSurface = surface; + if (surface == null) return; + _onCreated = (component) { + if (mounted) setState(() {}); + }; + _onDeleted = (id) { + if (mounted) setState(() {}); + }; + surface.componentsModel.onCreated.addListener(_onCreated!); + surface.componentsModel.onDeleted.addListener(_onDeleted!); + } + + void _detachComponentsListeners() { + final core.SurfaceModel? surface = _trackedSurface; + if (surface != null && _onCreated != null && _onDeleted != null) { + surface.componentsModel.onCreated.removeListener(_onCreated!); + surface.componentsModel.onDeleted.removeListener(_onDeleted!); + } + _onCreated = null; + _onDeleted = null; + _trackedSurface = null; + } + @override Widget build(BuildContext context) { - genUiLogger.fine( - 'Outer Building surface ${widget.surfaceContext.surfaceId}', + final LiveSurfaceContext? liveContext = _liveContext; + if (liveContext != null) { + return _buildLiveSurface(liveContext); + } + return _buildDefinitionSurface(widget.surfaceContext.definition.value); + } + + Widget _buildLiveSurface(LiveSurfaceContext surfaceContext) { + final core.SurfaceModel? surface = surfaceContext.surface.value; + genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); + + if (surface == null) { + genUiLogger.info( + 'Surface ${widget.surfaceContext.surfaceId} has no model yet.', + ); + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + + const rootId = 'root'; + if (surface.componentsModel.get(rootId) == null) { + genUiLogger.warning( + 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + ); + return const SizedBox.shrink(); + } + + final Catalog? catalog = widget.surfaceContext.catalog; + if (catalog == null) { + final error = Exception( + 'Catalog with id "${surface.catalog.id}" not found.', + ); + genUiLogger.severe(error.toString()); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _buildLiveWidget( + surface, + catalog, + rootId, + DataContext( + widget.surfaceContext.dataModel, + DataPath.root, + functions: catalog.functions, + ), + ); + } + + Widget _buildDefinitionSurface(SurfaceDefinition? definition) { + genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); + if (definition == null) { + genUiLogger.info( + 'Surface ${widget.surfaceContext.surfaceId} has no definition.', + ); + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + + const rootId = 'root'; + if (definition.components.isEmpty || + !definition.components.containsKey(rootId)) { + genUiLogger.warning( + 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + ); + return const SizedBox.shrink(); + } + + final Catalog? catalog = _findCatalogForDefinition(definition); + if (catalog == null) { + final error = Exception( + 'Catalog with id "${definition.catalogId}" not found.', + ); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _buildWidgetFromDefinition( + definition, + catalog, + rootId, + DataContext( + widget.surfaceContext.dataModel, + DataPath.root, + functions: catalog.functions, + ), ); - return ValueListenableBuilder( - valueListenable: widget.surfaceContext.definition, - builder: (context, definition, child) { - genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); - if (definition == null) { - genUiLogger.info( - 'Surface ${widget.surfaceContext.surfaceId} has no definition.', + } + + Widget _buildLiveWidget( + core.SurfaceModel surface, + Catalog catalog, + String widgetId, + DataContext dataContext, + ) { + final core.ComponentModel? data = surface.componentsModel.get(widgetId); + if (data == null) { + final error = Exception('Widget with id: $widgetId not found.'); + genUiLogger.severe(error.toString()); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _ComponentBuilder( + key: ValueKey(widgetId), + component: data, + builder: (BuildContext ctx) { + try { + genUiLogger.finest('Building widget $widgetId'); + final coreCtx = core.ComponentContext( + surface, + data, + basePath: dataContext.path.toString(), ); - return widget.defaultBuilder?.call(context) ?? - const SizedBox.shrink(); - } - // Implicit root is "root". - const rootId = 'root'; - if (definition.components.isEmpty || - !definition.components.containsKey(rootId)) { - genUiLogger.warning( - 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + return catalog.buildWidget( + CatalogItemContext.fromCore( + componentContext: coreCtx, + buildChild: (String childId, [DataContext? childDataContext]) => + _buildLiveWidget( + surface, + catalog, + childId, + childDataContext ?? dataContext, + ), + dispatchEvent: _dispatchEvent, + buildContext: ctx, + dataContext: dataContext, + getCatalogItem: (String type) => + catalog.items.firstWhereOrNull((item) => item.name == type), + reportError: widget.surfaceContext.reportError, + ), ); - return const SizedBox.shrink(); - } - - final Catalog? catalog = _findCatalogForDefinition(definition); - if (catalog == null) { - final error = Exception( - 'Catalog with id "${definition.catalogId}" not found.', + } catch (exception, stackTrace) { + genUiLogger.severe( + 'Error building widget $widgetId', + exception, + stackTrace, ); - widget.surfaceContext.reportError(error, StackTrace.current); - return FallbackWidget(error: error); + widget.surfaceContext.reportError(exception, stackTrace); + return FallbackWidget(error: exception, stackTrace: stackTrace); } - - return _buildWidget( - definition, - catalog, - rootId, - DataContext( - widget.surfaceContext.dataModel, - DataPath.root, - functions: catalog.functions, - ), - ); }, ); } - /// The main recursive build function. - /// It reads a widget definition and its current state from - /// `widget.definition` - /// and constructs the corresponding Flutter widget. - Widget _buildWidget( + Widget _buildWidgetFromDefinition( SurfaceDefinition definition, Catalog catalog, String widgetId, @@ -121,7 +308,7 @@ class _SurfaceState extends State { data: widgetData, type: data.type, buildChild: (String childId, [DataContext? childDataContext]) => - _buildWidget( + _buildWidgetFromDefinition( definition, catalog, childId, @@ -154,32 +341,28 @@ class _SurfaceState extends State { context, event, widget.surfaceContext, - _buildWidget, + _buildWidgetFromDefinition, )) { return; } - // The event comes in without a surfaceId, which we add here. final Map eventMap = { ...event.toMap(), surfaceIdKey: widget.surfaceContext.surfaceId, }; - final UiEvent newEvent = event is UserActionEvent + final UiEvent newEvent = event.isUserAction ? UserActionEvent.fromMap(eventMap) : UiEvent.fromMap(eventMap); widget.surfaceContext.handleUiEvent(newEvent); } Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - // The surfaceContext is responsible for resolving the catalog based on - // the current definition in the registry. final Catalog? catalog = widget.surfaceContext.catalog; - if (catalog == null) { genUiLogger.severe( 'Catalog with id "${definition.catalogId}" not found for surface ' - '"${widget.surfaceContext.surfaceId}". Ensure the catalog is provided ' - 'to A2uiMessageProcessor.', + '"${widget.surfaceContext.surfaceId}". Ensure the catalog is ' + 'provided to SurfaceController.', ); } return catalog; @@ -187,18 +370,10 @@ class _SurfaceState extends State { } /// A delegate for handling UI actions in [Surface]. -/// -/// Implement this interface to provide custom handling for specific actions, -/// such as showing modals or navigating. abstract interface class ActionDelegate { /// Handles a [UiEvent]. /// /// Returns `true` if the event was handled, `false` otherwise. - /// - /// The [context] is the build context of the [Surface]. - /// The [genUiContext] provides access to the surface state. - /// The [buildWidget] function allows building widgets from the definition, - /// useful for rendering content inside modals or dialogs. bool handleEvent( BuildContext context, UiEvent event, @@ -208,7 +383,7 @@ abstract interface class ActionDelegate { ); } -/// The default action delegate that handles standard actions like 'showModal'. +/// The default action delegate that handles no actions itself. class DefaultActionDelegate implements ActionDelegate { /// Creates a [DefaultActionDelegate]. const DefaultActionDelegate(); @@ -224,3 +399,49 @@ class DefaultActionDelegate implements ActionDelegate { return false; } } + +/// Subscribes to a single [core.ComponentModel.onUpdated] and rebuilds its +/// child subtree when that component's properties change. +class _ComponentBuilder extends StatefulWidget { + const _ComponentBuilder({ + super.key, + required this.component, + required this.builder, + }); + + final core.ComponentModel component; + final WidgetBuilder builder; + + @override + State<_ComponentBuilder> createState() => _ComponentBuilderState(); +} + +class _ComponentBuilderState extends State<_ComponentBuilder> { + @override + void initState() { + super.initState(); + widget.component.onUpdated.addListener(_onComponentUpdated); + } + + @override + void didUpdateWidget(_ComponentBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.component, widget.component)) { + oldWidget.component.onUpdated.removeListener(_onComponentUpdated); + widget.component.onUpdated.addListener(_onComponentUpdated); + } + } + + @override + void dispose() { + widget.component.onUpdated.removeListener(_onComponentUpdated); + super.dispose(); + } + + void _onComponentUpdated(core.ComponentModel _) { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => widget.builder(context); +} diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index e356a785c..d84950d86 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -14,10 +15,9 @@ export '../model/data_model.dart' show resolveContext; /// A builder widget that simplifies handling of nullable `ValueListenable`s. /// -/// This widget listens to a `ValueListenable` and rebuilds its child -/// whenever the value changes. If the value is `null`, it returns a -/// `SizedBox.shrink()`, effectively hiding the child. If the value is not -/// `null`, it calls the `builder` function with the non-nullable value. +/// Listens to a `ValueListenable` and rebuilds its child whenever the +/// value changes. Returns `SizedBox.shrink()` for null; otherwise calls +/// [builder] with the non-null value. class OptionalValueBuilder extends StatelessWidget { /// The `ValueListenable` to listen to. final ValueListenable listenable; @@ -47,8 +47,7 @@ class OptionalValueBuilder extends StatelessWidget { /// A widget that binds to a value in the [DataContext] and rebuilds when it /// changes. /// -/// This widget handles the lifecycle of the underlying [ValueNotifier], -/// ensuring it is disposed when the widget is unmounted. +/// Subclasses provide type-specific conversion via [BoundValueState.convert]. abstract class BoundValue extends StatefulWidget { /// Creates a [BoundValue]. const BoundValue({ @@ -71,14 +70,18 @@ abstract class BoundValue extends StatefulWidget { State> createState(); } -/// State class for [BoundValue]. +/// Backing state for [BoundValue]. Manages a preact_signals signal that +/// mirrors the resolved value. Function-call values (`{call: ...}`) are +/// driven by a [StreamSubscription] that pushes into the signal. abstract class BoundValueState> extends State { - ValueNotifier? _notifier; + late core.ReadonlySignal _signal; + StreamSubscription? _streamSub; + void Function()? _disposeBridge; @override void initState() { super.initState(); - _initNotifier(); + _setupSignal(); } @override @@ -86,40 +89,139 @@ abstract class BoundValueState> extends State { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value || widget.dataContext != oldWidget.dataContext) { - _disposeNotifier(); - _initNotifier(); + _disposeSignal(); + _setupSignal(); } } @override void dispose() { - _disposeNotifier(); + _disposeSignal(); super.dispose(); } - void _initNotifier() { - _notifier = createNotifier(); + void _setupSignal() { + final Object? raw = widget.value; + + if (raw is Map && raw.containsKey('path')) { + final path = DataPath(raw['path'] as String); + final ValueListenable source = widget.dataContext + .subscribe(path); + // Bridge the legacy ValueListenable facade to a signal so the + // signal-backed BoundValue implementation can stay granular. + final core.Signal bridge = core.signal(convert(source.value)); + + void listener() { + bridge.set(convert(source.value), force: true); + } + + source.addListener(listener); + _disposeBridge = () { + source.removeListener(listener); + final currentSource = source; + if (currentSource is ChangeNotifier) { + (currentSource as ChangeNotifier).dispose(); + } + }; + _signal = bridge; + } else if (raw is Map && raw.containsKey('call')) { + // Function-call resolution stays Stream-based for now; bridge to signal. + final core.Signal s = core.signal(null); + _streamSub = widget.dataContext + .resolve(raw) + .listen( + (Object? v) => s.value = convert(v), + onError: (Object error) { + genUiLogger.warning('Error in Bound stream', error); + }, + ); + _signal = s; + } else { + _signal = core.signal(convert(raw)); + } } - void _disposeNotifier() { - _notifier?.dispose(); - _notifier = null; + void _disposeSignal() { + _streamSub?.cancel(); + _streamSub = null; + _disposeBridge?.call(); + _disposeBridge = null; + // preact_signals don't require explicit disposal; subscriptions are torn + // down when the consuming Effect (in _SignalBuilder) is disposed. } - /// Subclasses implement this to create the specific notifier type. - ValueNotifier createNotifier(); + /// Converts a raw resolved value into the typed [T?]. + T? convert(Object? value); @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _notifier!, - builder: (context, value, child) { - return widget.builder(context, value); - }, + return _SignalBuilder( + signal: _signal, + builder: (ctx, value) => widget.builder(ctx, value), ); } } +/// Subscribes to a preact_signals [core.ReadonlySignal] and rebuilds when it +/// changes. Stand-in for `Watch` from a signals-Flutter package, since +/// `signals_flutter` is built on a different (incompatible) signals library. +class _SignalBuilder extends StatefulWidget { + const _SignalBuilder({required this.signal, required this.builder}); + + final core.ReadonlySignal signal; + final Widget Function(BuildContext context, T value) builder; + + @override + State<_SignalBuilder> createState() => _SignalBuilderState(); +} + +class _SignalBuilderState extends State<_SignalBuilder> { + late T _value; + void Function()? _disposeEffect; + bool _initialRun = true; + + @override + void initState() { + super.initState(); + _value = widget.signal.peek(); + _subscribe(); + } + + @override + void didUpdateWidget(_SignalBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.signal != oldWidget.signal) { + _disposeEffect?.call(); + _initialRun = true; + _value = widget.signal.peek(); + _subscribe(); + } + } + + void _subscribe() { + _disposeEffect = core.effect(() { + final T newValue = widget.signal.value; + // First run is the dependency-tracking pass; value already set from peek. + if (_initialRun) { + _initialRun = false; + return; + } + if (mounted) { + setState(() => _value = newValue); + } + }); + } + + @override + void dispose() { + _disposeEffect?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} + /// Binds to a [String] value. class BoundString extends BoundValue { /// Creates a [BoundString]. @@ -136,17 +238,7 @@ class BoundString extends BoundValue { class _BoundStringState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToStringNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) => v?.toString()), - ), - Object? value => ValueNotifier(value?.toString()), - }; - } + String? convert(Object? value) => value?.toString(); } /// Binds to a [bool] value. @@ -165,20 +257,14 @@ class BoundBool extends BoundValue { class _BoundBoolState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToBoolNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is bool) return v; - return v != null; - }), - ), - bool value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + bool? convert(Object? value) { + if (value is bool) return value; + if (value is String) { + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + } + if (value is num) return value != 0; + return null; } } @@ -198,21 +284,10 @@ class BoundNumber extends BoundValue { class _BoundNumberState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToNumberNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is num) return v; - if (v is String) return num.tryParse(v); - return null; - }), - ), - num value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + num? convert(Object? value) { + if (value is num) return value; + if (value is String) return num.tryParse(value); + return null; } } @@ -232,22 +307,9 @@ class BoundList extends BoundValue> { class _BoundListState extends BoundValueState, BoundList> { @override - ValueNotifier?> createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe>( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier?>( - widget.dataContext.resolve(widget.value).map((v) { - if (v is List) return v.cast(); - return null; - }), - ), - List value => ValueNotifier?>( - value.cast(), - ), - _ => ValueNotifier?>(null), - }; + List? convert(Object? value) { + if (value is List) return value.cast(); + return null; } } @@ -267,113 +329,5 @@ class BoundObject extends BoundValue { class _BoundObjectState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value), - ), - Object? value => ValueNotifier(value), - }; - } -} - -class _StreamToValueNotifier extends ValueNotifier { - _StreamToValueNotifier(Stream stream, [T? initialValue]) - : super(initialValue) { - _subscription = stream.listen( - (value) => this.value = value, - onError: (Object error) { - // We log the error but don't crash. - // ValueNotifier doesn't support error state. - genUiLogger.warning( - 'Error in stream subscription for ValueNotifier', - error, - ); - }, - ); - } - - StreamSubscription? _subscription; - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } -} - -class _ToStringNotifier extends ValueNotifier { - _ToStringNotifier(this._source) : super(_source.value?.toString()) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - void _update() { - super.value = _source.value?.toString(); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToBoolNotifier extends ValueNotifier { - _ToBoolNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static bool? _convert(Object? value) { - if (value is bool) return value; - if (value is String) { - if (value.toLowerCase() == 'true') return true; - if (value.toLowerCase() == 'false') return false; - } - if (value is num) return value != 0; - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToNumberNotifier extends ValueNotifier { - _ToNumberNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static num? _convert(Object? value) { - if (value is num) return value; - if (value is String) return num.tryParse(value); - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } + Object? convert(Object? value) => value; } diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index 1b405b539..4769c8402 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -4,13 +4,12 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../src/model/a2ui_message.dart'; import '../src/model/a2ui_schemas.dart'; import '../src/model/catalog.dart'; import '../src/model/catalog_item.dart' show CatalogItem; -import '../src/model/ui_models.dart'; import '../src/primitives/simple_items.dart'; /// A class to represent a validation error in a catalog item example. @@ -63,11 +62,12 @@ Future> validateCatalogItemExamples( continue; } - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) + final List> components = exampleData + .cast() + .map(Map.from) .toList(); - if (components.every((c) => c.id != 'root')) { + if (components.every((c) => c['id'] != 'root')) { errors.add( ExampleValidationError( i, @@ -76,13 +76,16 @@ Future> validateCatalogItemExamples( ); } - final surfaceUpdate = UpdateComponents( + final surfaceUpdate = core.UpdateComponentsMessage( surfaceId: 'test-surface', components: components, ); + // `a2ui_core.UpdateComponentsMessage.toJson()` produces + // `{'version': ..., 'updateComponents': {...}}`; the schema here + // validates the body shape only. final List validationErrors = await schema.validate( - surfaceUpdate.toJson(), + surfaceUpdate.toJson()['updateComponents'], ); if (validationErrors.isNotEmpty) { errors.add( diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 8580f3eb2..71506f532 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -16,6 +16,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-dev002 audioplayers: ^6.6.0 collection: ^1.19.1 flutter: diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 24c6a7fdb..d2c140a46 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -78,8 +78,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'root', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myText'), 'Hello from data model'); await tester.pumpAndSettle(); @@ -131,8 +132,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'field', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myValue'), 'initial'); await tester.pumpAndSettle(); @@ -144,8 +146,9 @@ void main() { // Test onChanged await tester.enterText(textFieldFinder, 'new value'); expect( - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .getValue(DataPath('/myValue')), 'new value', ); diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 88e0bc588..bf9ac6bc0 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -147,13 +148,114 @@ void main() { test('surface() creates a new ValueNotifier if one does not exist', () { final ValueListenable notifier1 = controller.registry - .watchSurface('s1'); + .watchDefinition('s1'); final ValueListenable notifier2 = controller.registry - .watchSurface('s1'); + .watchDefinition('s1'); expect(notifier1, same(notifier2)); expect(notifier1.value, isNull); }); + test('public SurfaceAdded / ComponentsUpdated constructors are ' + 'definition-based', () { + final def = SurfaceDefinition(surfaceId: 's1'); + final added = SurfaceAdded('s1', def); + expect(added.surfaceId, 's1'); + expect(added.definition, same(def)); + expect(added.surface, isNull); + + final updated = ComponentsUpdated('s1', def); + expect(updated.surfaceId, 's1'); + expect(updated.definition, same(def)); + expect(updated.surface, isNull); + }); + + test( + 'registry watchSurface/getSurface expose SurfaceDefinition snapshots', + () { + const surfaceId = 's1'; + final ValueListenable notifier = controller.registry + .watchSurface(surfaceId); + expect(notifier.value, isNull); + expect(controller.registry.getSurface(surfaceId), isNull); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final SurfaceDefinition? def = controller.registry.getSurface( + surfaceId, + ); + expect(def, isNotNull); + expect(def!.catalogId, 'test_catalog'); + expect(notifier.value, same(def)); + }, + ); + + test('store exposes the live surface data model facade', () { + const surfaceId = 's1'; + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + controller.handleMessage( + const UpdateDataModel( + surfaceId: surfaceId, + path: DataPath.root, + value: {'name': 'Alice'}, + ), + ); + + final DataModel model = controller.store.getDataModel(surfaceId); + expect(model.getValue(DataPath('/name')), 'Alice'); + expect(controller.store.dataModels[surfaceId], same(model)); + + controller.handleMessage( + UpdateDataModel( + surfaceId: surfaceId, + path: DataPath('/name'), + value: 'Bob', + ), + ); + expect(model.getValue(DataPath('/name')), 'Bob'); + }); + + test('contextFor(surfaceId).dataModel returns a writable model ' + 'before createSurface', () { + const surfaceId = 'pre_create'; + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(() => model.update(DataPath('/foo'), 'bar'), returnsNormally); + expect(model.getValue(DataPath('/foo')), 'bar'); + }); + + test('pre-create data written via contextFor is migrated into the live ' + 'surface model when createSurface arrives', () async { + const surfaceId = 'migrate_me'; + controller + .contextFor(surfaceId) + .dataModel + .update(DataPath('/name'), 'Alice'); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + // Post-create, contextFor.dataModel routes through the live core + // surface model, so reading back here proves the migration landed. + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath('/name')), 'Alice'); + }); + + test('pre-create root-null write is preserved across createSurface', () { + const surfaceId = 'null_root'; + controller.contextFor(surfaceId).dataModel.update(DataPath.root, null); + + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath.root), isNull); + }); + test('dispose() closes the updates stream', () async { var isClosed = false; controller.surfaceUpdates.listen( @@ -170,9 +272,6 @@ void main() { }); test('can handle UI event', () async { - controller.store - .getDataModel('testSurface') - .update(DataPath('/myValue'), 'testValue'); final Future future = controller.onSubmit.first; final now = DateTime.now(); final event = UserActionEvent( @@ -205,6 +304,23 @@ void main() { expect(part.interaction, expectedJson); }); + test('handleUiEvent ignores non-action UiEvents', () async { + var submitted = false; + final StreamSubscription sub = controller.onSubmit.listen( + (_) => submitted = true, + ); + final event = UiEvent.fromMap({ + 'widgetId': 'testWidget', + 'eventType': 'onChanged', + 'value': 'hello', + 'timestamp': DateTime.now().toIso8601String(), + }); + controller.handleUiEvent(event); + await Future.delayed(Duration.zero); + expect(submitted, isFalse); + await sub.cancel(); + }); + test( 'handleMessage reports validation error with correct structure', () async { @@ -229,6 +345,38 @@ void main() { }, ); + test('rejects empty surfaceId on non-create messages', () async { + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage(const UpdateDataModel(surfaceId: '', value: 1)); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['code'], 'VALIDATION_FAILED'); + expect(errorMap['surfaceId'], ''); + expect(errorMap['path'], 'surfaceId'); + }); + + test( + 'duplicate createSurface for an active surface reports an error', + () async { + controller.handleMessage( + const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage( + const CreateSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['surfaceId'], 's1'); + }, + ); + test('drops pending updates after timeout', () async { // Create controller with short timeout final shortTimeoutController = SurfaceController( @@ -277,7 +425,8 @@ void main() { await Future.delayed(Duration.zero); final SurfaceDefinition? surface = shortTimeoutController.registry - .getSurface(surfaceId); + .watchDefinition(surfaceId) + .value; expect(surface, isNotNull); // Updates NOT applied, so components should be empty (or default) expect(surface!.components, isEmpty); diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6d22d20e9..1a2c3e272 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -139,4 +139,42 @@ void main() { ); }, ); + + testWidgets('rebuilds when components change after creation', ( + WidgetTester tester, + ) async { + const surfaceId = 'testSurface'; + controller.handleMessage( + const UpdateComponents( + surfaceId: surfaceId, + components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'first'}), + ], + ), + ); + controller.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Surface(surfaceContext: controller.contextFor(surfaceId)), + ), + ); + expect(find.text('first'), findsOneWidget); + + // Updating the live surface should rebuild it in place. + controller.handleMessage( + const UpdateComponents( + surfaceId: surfaceId, + components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'second'}), + ], + ), + ); + await tester.pump(); + + expect(find.text('second'), findsOneWidget); + expect(find.text('first'), findsNothing); + }); } diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart index 20022d989..1b14e98d0 100644 --- a/packages/genui/test/model/a2ui_message_test.dart +++ b/packages/genui/test/model/a2ui_message_test.dart @@ -8,7 +8,7 @@ import 'package:genui/src/catalog/basic_catalog.dart'; import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/catalog.dart'; import 'package:genui/src/model/data_model.dart'; -import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/src/schema/schema.dart'; @@ -100,6 +100,75 @@ void main() { expect(message.toJson(), containsPair('version', 'v0.9')); }); + test('UpdateDataModel preserves explicit null value', () { + final message = A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': { + surfaceIdKey: 's1', + 'path': '/x', + 'value': null, + }, + }); + + final update = message as UpdateDataModel; + expect(update.path, DataPath('/x')); + expect(update.value, isNull); + expect(update.hasValue, isTrue); + + final body = update.toJson()['updateDataModel'] as Map; + expect(body.containsKey('value'), isTrue); + expect(body['value'], isNull); + }); + + test('UpdateDataModel preserves omitted value as remove-key', () { + final message = UpdateDataModel.removeKey( + surfaceId: 's1', + path: DataPath('/x'), + ); + + final body = message.toJson()['updateDataModel'] as Map; + expect(message.path, DataPath('/x')); + expect(message.hasValue, isFalse); + expect(body.containsKey('value'), isFalse); + + final reparsed = + A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': { + surfaceIdKey: 's1', + 'path': '/x', + }, + }) + as UpdateDataModel; + expect(reparsed.hasValue, isFalse); + }); + + test( + 'UpdateDataModel.toCoreMessage/fromCore preserves explicit null value', + () { + final original = UpdateDataModel(surfaceId: 's1', path: DataPath('/x')); + expect(original.hasValue, isTrue); + expect(original.value, isNull); + + final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); + expect(roundtripped.hasValue, isTrue); + expect(roundtripped.value, isNull); + expect(roundtripped.path, DataPath('/x')); + }, + ); + + test('UpdateDataModel.toCoreMessage/fromCore preserves omitted value', () { + final original = UpdateDataModel.removeKey( + surfaceId: 's1', + path: DataPath('/x'), + ); + expect(original.hasValue, isFalse); + + final roundtripped = UpdateDataModel.fromCore(original.toCoreMessage()); + expect(roundtripped.hasValue, isFalse); + expect(roundtripped.path, DataPath('/x')); + }); + test('DeleteSurface.toJson includes version', () { const message = DeleteSurface(surfaceId: 's1'); expect(message.toJson(), containsPair('version', 'v0.9')); diff --git a/packages/genui/test/model/data_model_edge_cases_test.dart b/packages/genui/test/model/data_model_edge_cases_test.dart index 8e6ca2acb..779476907 100644 --- a/packages/genui/test/model/data_model_edge_cases_test.dart +++ b/packages/genui/test/model/data_model_edge_cases_test.dart @@ -84,18 +84,22 @@ void main() { expect(dataModel.getValue>(DataPath('/list')), ['a', 'b']); }); - test('List Boundaries: Out of bounds (index > length) is ignored', () { + test('List Boundaries: sparse writes fill skipped entries with null', () { dataModel.update(DataPath('/list/0'), 'a'); - // Try to write to index 2 (skipping 1) + // Write to index 2 (skipping 1). a2ui_core auto-vivifies sparse + // list entries with null rather than silently ignoring the write. dataModel.update(DataPath('/list/2'), 'c'); - expect(dataModel.getValue>(DataPath('/list')), ['a']); - // Verify length is still 1 + expect(dataModel.getValue>(DataPath('/list')), [ + 'a', + null, + 'c', + ]); final List? list = dataModel.getValue>( DataPath('/list'), ); - expect(list?.length, 1); + expect(list?.length, 3); }); test('Null Handling: Setting map key to null removes it', () { diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index d7ae0642f..3d3a963f4 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; diff --git a/packages/genui/test/transport/a2ui_parser_transformer_test.dart b/packages/genui/test/transport/a2ui_parser_transformer_test.dart index e614a7503..9c03872ee 100644 --- a/packages/genui/test/transport/a2ui_parser_transformer_test.dart +++ b/packages/genui/test/transport/a2ui_parser_transformer_test.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/generation_events.dart'; -import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/transport/a2ui_parser_transformer.dart'; import 'package:test/test.dart'; diff --git a/packages/genui/test/widgets/widget_utilities_test.dart b/packages/genui/test/widgets/widget_utilities_test.dart new file mode 100644 index 000000000..372547a10 --- /dev/null +++ b/packages/genui/test/widgets/widget_utilities_test.dart @@ -0,0 +1,72 @@ +// 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('BoundObject', () { + testWidgets('rebuilds on in-place map mutation at a parent path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/map'), {'count': 1}); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundObject( + dataContext: DataContext(dataModel, '/'), + value: const {'path': '/map'}, + builder: (context, value) { + if (value is Map) { + return Text('count=${value['count']}'); + } + return const Text('no map'); + }, + ), + ), + ); + expect(find.text('count=1'), findsOneWidget); + + // Mutate a nested key. a2ui_core's DataModel updates the map in place + // and bubble-notifies the parent path /map. BoundObject must rebuild + // even though the resolved value at /map is the same Map instance. + dataModel.update(DataPath('/map/count'), 2); + await tester.pump(); + + expect(find.text('count=2'), findsOneWidget); + }); + }); + + group('BoundList', () { + testWidgets('rebuilds on in-place list mutation at the bound path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/items'), ['a', 'b']); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundList( + dataContext: DataContext(dataModel, '/'), + value: const {'path': '/items'}, + builder: (context, value) { + if (value == null) return const Text('null'); + return Text('count=${value.length}'); + }, + ), + ), + ); + expect(find.text('count=2'), findsOneWidget); + + dataModel.update(DataPath('/items/2'), 'c'); + await tester.pump(); + + expect(find.text('count=3'), findsOneWidget); + }); + }); +}