diff --git a/.github/workflows/flutter_packages.yaml b/.github/workflows/flutter_packages.yaml index be8fd6871..64cefd2aa 100644 --- a/.github/workflows/flutter_packages.yaml +++ b/.github/workflows/flutter_packages.yaml @@ -7,6 +7,7 @@ name: Flutter GenUI CI on: + workflow_dispatch: push: branches: - main diff --git a/README.md b/README.md index a5047fff9..8044c4be2 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ graph TD genui_google_generative_ai --> genui ``` +## A2UI Support + +The Flutter Gen UI SDK uses the [A2UI protocol](https://a2ui.org) to represent UI content internally. The [genui_a2ui](packages/genui_a2ui/) package allows it to act as a renderer for UIs generated by an A2UI backend agent, similar to the [other A2UI renderers](https://github.com/google/A2UI/tree/main/renderers) which are maintained within the A2UI repository. + +The Flutter Gen UI SDK currently supports A2UI v0.8. + ## Getting started See the [genui getting started guide](packages/genui/README.md#getting-started-with-genui). diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index d5db9e968..6592bfd81 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -49,7 +49,7 @@ class AiClientState { required this.surfaceUpdateController, }); - /// The A2uiMessageProcessor. + /// The A2UI message processor. final A2uiMessageProcessor a2uiMessageProcessor; /// The content generator. diff --git a/examples/verdure/client/lib/features/ai/ai_provider.g.dart b/examples/verdure/client/lib/features/ai/ai_provider.g.dart index 1d7b33794..653c79ce8 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.g.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.g.dart @@ -11,7 +11,7 @@ part of 'ai_provider.dart'; /// A provider for the A2A server URL. @ProviderFor(a2aServerUrl) -const a2aServerUrlProvider = A2aServerUrlProvider._(); +final a2aServerUrlProvider = A2aServerUrlProvider._(); /// A provider for the A2A server URL. @@ -19,7 +19,7 @@ final class A2aServerUrlProvider extends $FunctionalProvider, String, FutureOr> with $FutureModifier, $FutureProvider { /// A provider for the A2A server URL. - const A2aServerUrlProvider._() + A2aServerUrlProvider._() : super( from: null, argument: null, @@ -44,12 +44,12 @@ final class A2aServerUrlProvider } } -String _$a2aServerUrlHash() => r'fb16ccf2eefdfdf9b81b39fde313a810d4a46b7d'; +String _$a2aServerUrlHash() => r'e5a70281840b0af7c5883ef985c6632f50d6adfe'; /// A provider for the A2UI agent connector. @ProviderFor(a2uiAgentConnector) -const a2uiAgentConnectorProvider = A2uiAgentConnectorProvider._(); +final a2uiAgentConnectorProvider = A2uiAgentConnectorProvider._(); /// A provider for the A2UI agent connector. @@ -64,7 +64,7 @@ final class A2uiAgentConnectorProvider $FutureModifier, $FutureProvider { /// A provider for the A2UI agent connector. - const A2uiAgentConnectorProvider._() + A2uiAgentConnectorProvider._() : super( from: null, argument: null, @@ -91,17 +91,17 @@ final class A2uiAgentConnectorProvider } String _$a2uiAgentConnectorHash() => - r'e5a3ac7de14b11c412702a3b07acce472a57d77d'; + r'8caf7aa00b2707c0de7f3843cce4306e15d9cd8f'; /// The AI provider. @ProviderFor(Ai) -const aiProvider = AiProvider._(); +final aiProvider = AiProvider._(); /// The AI provider. final class AiProvider extends $AsyncNotifierProvider { /// The AI provider. - const AiProvider._() + AiProvider._() : super( from: null, argument: null, @@ -120,7 +120,7 @@ final class AiProvider extends $AsyncNotifierProvider { Ai create() => Ai(); } -String _$aiHash() => r'52d35fd967ce52d5fc89c3852302e91409c88b68'; +String _$aiHash() => r'c3856857d43497b1869b7c2ebba2189b6ac7c521'; /// The AI provider. @@ -129,7 +129,6 @@ abstract class _$Ai extends $AsyncNotifier { @$mustCallSuper @override void runBuild() { - final created = build(); final ref = this.ref as $Ref, AiClientState>; final element = ref.element @@ -139,6 +138,6 @@ abstract class _$Ai extends $AsyncNotifier { Object?, Object? >; - element.handleValue(ref, created); + element.handleCreate(ref, build); } } diff --git a/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart b/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart index 742819f0e..29063eb8e 100644 --- a/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart +++ b/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart @@ -44,7 +44,7 @@ class OrderConfirmationScreen extends ConsumerWidget { error: (error, stackTrace) => Center(child: Text('Error: $error')), ), bottomNavigationBar: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: ElevatedButton( onPressed: () => context.go('/'), child: const Text('Back to Start'), diff --git a/examples/verdure/client/lib/features/screens/questionnaire_screen.dart b/examples/verdure/client/lib/features/screens/questionnaire_screen.dart index 3e202be45..caf463d52 100644 --- a/examples/verdure/client/lib/features/screens/questionnaire_screen.dart +++ b/examples/verdure/client/lib/features/screens/questionnaire_screen.dart @@ -25,12 +25,12 @@ class _QuestionnaireScreenState extends ConsumerState { @override Widget build(BuildContext context) { ref.listen>(aiProvider, (previous, next) { - if (next is AsyncData && !_initialRequestSent) { + if (_initialRequestSent) return; + if (next case AsyncData(value: final aiState)) { setState(() { _initialRequestSent = true; }); - final AiClientState? aiState = next.value; - aiState?.conversation.sendRequest( + aiState.conversation.sendRequest( UserMessage.text('USER_SUBMITTED_DETAILS'), ); } diff --git a/examples/verdure/client/lib/features/screens/upload_photo_screen.dart b/examples/verdure/client/lib/features/screens/upload_photo_screen.dart index 6da05857d..aa82e3e91 100644 --- a/examples/verdure/client/lib/features/screens/upload_photo_screen.dart +++ b/examples/verdure/client/lib/features/screens/upload_photo_screen.dart @@ -33,7 +33,7 @@ class UploadPhotoScreen extends ConsumerWidget { ), body: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -52,7 +52,9 @@ class UploadPhotoScreen extends ConsumerWidget { ), const SizedBox(height: 16), Text( - '''Upload a photo of your front or back yard, and our designers will use it to create a custom vision. Get ready to see the potential.''', + 'Upload a photo of your front or back yard, ' + 'and our designers will use it to create a custom vision. ' + 'Get ready to see the potential.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onSurface, @@ -146,34 +148,34 @@ class InfoCard extends StatelessWidget { child: InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16, children: [ CircleAvatar( maxRadius: 25, child: Icon(icon, size: 25, color: const Color(0xff15a34a)), ), - if (title != null || subtitle != null) const SizedBox(width: 16), if (title != null || subtitle != null) Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ - if (title != null) + if (title case final title?) Text( - title!, + title, style: Theme.of(context).textTheme.titleMedium! .copyWith( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface, ), ), - if (subtitle != null) const SizedBox(height: 4), - if (subtitle != null) + if (subtitle case final subtitle?) Text( - subtitle!, + subtitle, style: Theme.of(context).textTheme.bodyMedium! .copyWith( color: Theme.of(context).colorScheme.onSurface, diff --git a/examples/verdure/client/lib/features/screens/welcome_screen.dart b/examples/verdure/client/lib/features/screens/welcome_screen.dart index 7ac66181e..a93eb304d 100644 --- a/examples/verdure/client/lib/features/screens/welcome_screen.dart +++ b/examples/verdure/client/lib/features/screens/welcome_screen.dart @@ -37,12 +37,12 @@ class WelcomeScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.only(top: 64.0), + padding: const EdgeInsets.only(top: 64), child: Row( mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, children: [ const Icon(Icons.eco, color: Colors.white, size: 32), - const SizedBox(width: 8), Text( 'Verdure', style: Theme.of(context).textTheme.headlineSmall @@ -55,8 +55,9 @@ class WelcomeScreen extends StatelessWidget { ), ), const Padding( - padding: EdgeInsets.all(16.0), + padding: EdgeInsets.all(16), child: Column( + spacing: 16, children: [ Text( 'Envision Your Dream Landscape', @@ -68,9 +69,9 @@ class WelcomeScreen extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - SizedBox(height: 16), Text( - '''Bring your perfect outdoor space to life with our suite of AI design agents.''', + 'Bring your perfect outdoor space to life with ' + 'our suite of AI design agents.', textAlign: TextAlign.center, style: TextStyle( fontFamily: 'SpaceGrotesk', @@ -89,6 +90,7 @@ class WelcomeScreen extends StatelessWidget { Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: Column( + spacing: 16, children: [ ElevatedButton( onPressed: () => context.push('/upload_photo'), @@ -99,12 +101,10 @@ class WelcomeScreen extends StatelessWidget { ), child: const Text('Start New Project'), ), - const SizedBox(height: 16), TextButton( onPressed: () {}, child: const Text('Explore Ideas'), ), - const SizedBox(height: 16), TextButton( onPressed: () {}, child: const Text('I\'m a returning user'), diff --git a/examples/verdure/client/lib/features/state/loading_state.dart b/examples/verdure/client/lib/features/state/loading_state.dart index 5f43688ef..97e53e976 100644 --- a/examples/verdure/client/lib/features/state/loading_state.dart +++ b/examples/verdure/client/lib/features/state/loading_state.dart @@ -36,7 +36,7 @@ class LoadingState { } else if (_isProcessingValue && !isProcessing.value) { // Went from true to false, reset messages after a short delay // to allow the fade-out animation to complete. - Future.delayed(const Duration(milliseconds: 500), clearMessages); + Future.delayed(const Duration(milliseconds: 500), clearMessages); } _isProcessingValue = isProcessing.value; }); diff --git a/examples/verdure/client/lib/features/widgets/app_navigator.dart b/examples/verdure/client/lib/features/widgets/app_navigator.dart index b48e8a3ea..6fac8e573 100644 --- a/examples/verdure/client/lib/features/widgets/app_navigator.dart +++ b/examples/verdure/client/lib/features/widgets/app_navigator.dart @@ -23,7 +23,7 @@ class AppNavigator extends ConsumerStatefulWidget { } class _AppNavigatorState extends ConsumerState { - StreamSubscription? _subscription; + StreamSubscription? _subscription; @override void initState() { @@ -31,8 +31,8 @@ class _AppNavigatorState extends ConsumerState { // It's safe to use ref.read here because we are not rebuilding the widget // when the provider changes, but instead subscribing to a stream. final AsyncValue aiState = ref.read(aiProvider); - if (aiState is AsyncData) { - _subscription = aiState.value!.surfaceUpdateController.stream.listen( + if (aiState case AsyncData(:final value)) { + _subscription = value.surfaceUpdateController.stream.listen( _onSurfaceUpdate, ); } @@ -62,9 +62,9 @@ class _AppNavigatorState extends ConsumerState { @override Widget build(BuildContext context) { ref.listen>(aiProvider, (previous, next) { - if (next is AsyncData) { + if (next case AsyncData(:final value?)) { _subscription?.cancel(); - _subscription = next.value!.surfaceUpdateController.stream.listen( + _subscription = value.surfaceUpdateController.stream.listen( _onSurfaceUpdate, ); } diff --git a/examples/verdure/client/lib/features/widgets/global_progress_indicator.dart b/examples/verdure/client/lib/features/widgets/global_progress_indicator.dart index 747370d73..b1ebe29c6 100644 --- a/examples/verdure/client/lib/features/widgets/global_progress_indicator.dart +++ b/examples/verdure/client/lib/features/widgets/global_progress_indicator.dart @@ -111,27 +111,27 @@ class _LoadingMessagesState extends State<_LoadingMessages> { color: Colors.transparent, child: Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), + padding: const EdgeInsets.symmetric(horizontal: 24), child: Container( - height: 80.0, + height: 80, padding: const EdgeInsets.symmetric(horizontal: 24), decoration: BoxDecoration( color: colorScheme.primary, - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16, children: [ const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( - strokeWidth: 2.0, + strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ), - const SizedBox(width: 16), Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 500), diff --git a/examples/verdure/client/pubspec.yaml b/examples/verdure/client/pubspec.yaml index 72343a944..f0a32297b 100644 --- a/examples/verdure/client/pubspec.yaml +++ b/examples/verdure/client/pubspec.yaml @@ -3,8 +3,10 @@ # found in the LICENSE file. name: verdure -description: "A new Flutter project." -publish_to: "none" +description: >- + A sample of a Flutter client interacting with a Python-based A2A + (Agent-to-Agent) server for landscape design. +publish_to: none version: 0.1.0 environment: @@ -16,7 +18,7 @@ dependencies: device_info_plus: ^12.2.0 flutter: sdk: flutter - flutter_riverpod: ^3.0.3 + flutter_riverpod: ^3.1.0 flutter_svg: ^2.2.2 genui: ^0.6.0 genui_a2ui: ^0.6.0 @@ -24,15 +26,15 @@ dependencies: image_picker: ^1.2.0 logging: ^1.3.0 mime: ^2.0.0 - riverpod_annotation: ^3.0.3 + riverpod_annotation: ^4.0.0 dev_dependencies: - build_runner: ^2.7.1 + build_runner: ^2.10.3 dart_flutter_team_lints: ^3.5.2 flutter_lints: ^6.0.0 flutter_test: sdk: flutter - riverpod_generator: ^3.0.3 + riverpod_generator: ^4.0.0 flutter: uses-material-design: true diff --git a/packages/genai_primitives/example/main.dart b/packages/genai_primitives/example/main.dart index b17da41a4..e1770535a 100644 --- a/packages/genai_primitives/example/main.dart +++ b/packages/genai_primitives/example/main.dart @@ -3,12 +3,22 @@ // found in the LICENSE file. import 'dart:convert'; +import 'dart:core'; +import 'dart:core' as core; import 'dart:typed_data'; import 'package:genai_primitives/genai_primitives.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; -void main() { +void main({void Function(Object?)? output}) { + void print(Object? object) { + if (output != null) { + output(object); + } else { + core.print(object); + } + } + print('--- GenAI Primitives Example ---'); // 1. Define a Tool diff --git a/packages/genai_primitives/lib/genai_primitives.dart b/packages/genai_primitives/lib/genai_primitives.dart index aa212c4cd..440557ce0 100644 --- a/packages/genai_primitives/lib/genai_primitives.dart +++ b/packages/genai_primitives/lib/genai_primitives.dart @@ -7,4 +7,5 @@ library; export 'src/chat_message.dart'; export 'src/message_parts.dart'; +export 'src/parts.dart'; export 'src/tool_definition.dart'; diff --git a/packages/genai_primitives/lib/src/chat_message.dart b/packages/genai_primitives/lib/src/chat_message.dart index 70afc7678..31a60c726 100644 --- a/packages/genai_primitives/lib/src/chat_message.dart +++ b/packages/genai_primitives/lib/src/chat_message.dart @@ -2,130 +2,160 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'message_parts.dart'; -import 'utils.dart'; +import 'parts.dart'; -/// A message in a conversation between a user and a model. +final class _Json { + static const parts = 'parts'; + static const role = 'role'; + static const metadata = 'metadata'; +} + +/// A message between participants of the interaction. @immutable -class ChatMessage { +final class ChatMessage { /// Creates a new message. + /// + /// If `parts` or `metadata` is not provided, an empty collections are used. + /// + /// If there are no parts of type [TextPart], the [text] property + /// will be empty. + /// + /// If there is more than one part of type [TextPart], the [text] property + /// will be a concatenation of all of them. const ChatMessage({ required this.role, - required this.parts, + this.parts = const Parts([]), this.metadata = const {}, }); - /// Creates a message from a JSON-compatible map. - factory ChatMessage.fromJson(Map json) => ChatMessage( - role: ChatMessageRole.values.byName(json['role'] as String), - parts: (json['parts'] as List) - .map((p) => Part.fromJson(p as Map)) - .toList(), - metadata: (json['metadata'] as Map?) ?? const {}, - ); - /// Creates a system message. - factory ChatMessage.system( + /// + /// Converts [text] to a [TextPart] and puts it as a first member of + /// the [parts] list. + /// + /// [parts] may contain any type of [Part], including additional + /// instances of [TextPart]. + ChatMessage.system( String text, { List parts = const [], - Map? metadata, - }) => ChatMessage( - role: ChatMessageRole.system, - parts: [TextPart(text), ...parts], - metadata: metadata ?? const {}, - ); - - /// Creates a user message with text. - factory ChatMessage.user( + Map metadata = const {}, + }) : this( + role: ChatMessageRole.system, + parts: Parts.fromText(text, parts: parts), + metadata: metadata, + ); + + /// Creates a user message. + /// + /// Converts [text] to a [TextPart] and puts it as a first member of + /// the [parts] list. + /// + /// [parts] may contain any type of [Part], including additional + /// instances of [TextPart]. + ChatMessage.user( String text, { List parts = const [], - Map? metadata, - }) => ChatMessage( - role: ChatMessageRole.user, - parts: [TextPart(text), ...parts], - metadata: metadata ?? const {}, - ); - - /// Creates a model message with text. - factory ChatMessage.model( + Map metadata = const {}, + }) : this( + role: ChatMessageRole.user, + parts: Parts.fromText(text, parts: parts), + metadata: metadata, + ); + + /// Creates a model message. + /// + /// Converts [text] to a [TextPart] and puts it as a first member of + /// the [parts] list. + /// + /// [parts] may contain any type of [Part], including additional + /// instances of [TextPart]. + ChatMessage.model( String text, { List parts = const [], - Map? metadata, + Map metadata = const {}, + }) : this( + role: ChatMessageRole.model, + parts: Parts.fromText(text, parts: parts), + metadata: metadata, + ); + + /// Deserializes a message. + /// + /// The message is compatible with [toJson]. + factory ChatMessage.fromJson( + Map json, { + Map converterRegistry = + defaultPartConverterRegistry, }) => ChatMessage( - role: ChatMessageRole.model, - parts: [TextPart(text), ...parts], - metadata: metadata ?? const {}, + role: ChatMessageRole.values.byName(json[_Json.role] as String), + parts: Parts.fromJson( + json[_Json.parts] as List, + converterRegistry: converterRegistry, + ), + metadata: (json[_Json.metadata] as Map?) ?? const {}, ); + /// Serializes the message to JSON. + Map toJson() => { + _Json.parts: parts.toJson(), + _Json.metadata: metadata, + _Json.role: role.name, + }; + /// The role of the message author. final ChatMessageRole role; /// The content parts of the message. - final List parts; + final Parts parts; /// Optional metadata associated with this message. - /// Can include information like suppressed content, warnings, etc. - final Map metadata; + /// + /// This can include information like suppressed content, warnings, etc. + final Map metadata; - /// Gets the text content of the message by concatenating all text parts. - String get text => parts.whereType().map((p) => p.text).join(); + /// Concatenated [TextPart] parts. + String get text => parts.text; - /// Checks if this message contains any tool calls. - bool get hasToolCalls => - parts.whereType().any((p) => p.kind == ToolPartKind.call); + /// Whether this message contains any tool calls. + bool get hasToolCalls => parts.toolCalls.isNotEmpty; /// Gets all tool calls in this message. - List get toolCalls => parts - .whereType() - .where((p) => p.kind == ToolPartKind.call) - .toList(); + List get toolCalls => parts.toolCalls; - /// Checks if this message contains any tool results. - bool get hasToolResults => - parts.whereType().any((p) => p.kind == ToolPartKind.result); + /// Whether this message contains any tool results. + bool get hasToolResults => parts.toolResults.isNotEmpty; /// Gets all tool results in this message. - List get toolResults => parts - .whereType() - .where((p) => p.kind == ToolPartKind.result) - .toList(); - - /// Converts the message to a JSON-compatible map. - Map toJson() => { - 'role': role.name, - 'parts': parts.map((p) => p.toJson()).toList(), - 'metadata': metadata, - }; + List get toolResults => parts.toolResults; @override bool operator ==(Object other) { if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + const deepEquality = DeepCollectionEquality(); return other is ChatMessage && - other.role == role && - listEquals(other.parts, parts) && - mapEquals(other.metadata, metadata); + deepEquality.equals(other.parts, parts) && + deepEquality.equals(other.metadata, metadata); } @override - int get hashCode => Object.hash( - role, - Object.hashAll(parts), - Object.hashAll(metadata.entries), - ); + int get hashCode => Object.hashAll([parts, metadata]); @override - String toString() => - 'Message(role: $role, parts: $parts, metadata: $metadata)'; + String toString() => 'Message(parts: $parts, metadata: $metadata)'; } /// The role of a message author. /// /// The role indicates the source of the message or the intended perspective. /// For example, a system message is sent to the model to set context, -/// a user message is sent to the model, and a model message is a response -/// to the user. +/// a user message is sent to the model as a request, +/// and a model message is a response to the user request. enum ChatMessageRole { /// A message from the system that sets context or instructions for the model. /// diff --git a/packages/genai_primitives/lib/src/message_parts.dart b/packages/genai_primitives/lib/src/message_parts.dart index 0077cb8de..1ad6fa229 100644 --- a/packages/genai_primitives/lib/src/message_parts.dart +++ b/packages/genai_primitives/lib/src/message_parts.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:collection/collection.dart'; import 'package:cross_file/cross_file.dart' show XFile; import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; @@ -12,132 +13,124 @@ import 'package:mime/mime.dart'; import 'package:mime/src/default_extension_map.dart'; import 'package:path/path.dart' as p; -import 'utils.dart'; +final class _Json { + static const type = 'type'; + static const content = 'content'; + static const mimeType = 'mimeType'; + static const name = 'name'; + static const bytes = 'bytes'; + static const url = 'url'; + static const id = 'id'; + static const arguments = 'arguments'; + static const result = 'result'; +} + +final class _Part { + static const text = 'Text'; + static const data = 'Data'; + static const link = 'Link'; + static const tool = 'Tool'; +} /// Base class for message content parts. +/// +/// To create a custom part implementation, extend this class and ensure the +/// following requirements are met for a robust implementation: +/// +/// * **Equality and Hashing**: Override [operator ==] and [hashCode] to +/// ensure value-based equality. +/// * **Serialization**: Implement a `toJson()` method that returns a +/// JSON-encodable [Map]. The map must contain a `type` field with a unique +/// string identifier for the custom part. See [defaultPartConverterRegistry] +/// for the default registry and existing part types. +/// * **Deserialization**: Implement a `JsonToPartConverter` that can recreate +/// the part from its JSON representation. +/// * Pass extended [defaultPartConverterRegistry] to all methods `fromJson` +/// that accept a converter registry. @immutable -abstract class Part { +abstract base class Part { /// Creates a new part. const Part(); - /// Creates a part from a JSON-compatible map. - factory Part.fromJson(Map json) => switch (json['type']) { - 'TextPart' => TextPart(json['content'] as String), - 'DataPart' => () { - final content = json['content'] as Map; - final dataUri = content['bytes'] as String; - final Uri uri = Uri.parse(dataUri); - return DataPart( - uri.data!.contentAsBytes(), - mimeType: content['mimeType'] as String, - name: content['name'] as String?, - ); - }(), - 'LinkPart' => () { - final content = json['content'] as Map; - return LinkPart( - Uri.parse(content['url'] as String), - mimeType: content['mimeType'] as String?, - name: content['name'] as String?, - ); - }(), - 'ToolPart' => () { - final content = json['content'] as Map; - // Check if it's a call or result based on presence of arguments or result - if (content.containsKey('arguments')) { - return ToolPart.call( - callId: content['id'] as String, - toolName: content['name'] as String, - arguments: content['arguments'] as Map? ?? {}, - ); - } else { - return ToolPart.result( - callId: content['id'] as String, - toolName: content['name'] as String, - result: content['result'], - ); - } - }(), - _ => throw UnimplementedError('Unknown part type: ${json['type']}'), - }; + /// Deserializes a part from a JSON map. + /// + /// The [converterRegistry] parameter is a map of part types to converters. + /// If the registry is not provided, [defaultPartConverterRegistry] is used. + /// + /// If you need to deserialize a part that is not in the default registry, + /// extend [defaultPartConverterRegistry] and pass it to this method. + factory Part.fromJson( + Map json, { + Map converterRegistry = + defaultPartConverterRegistry, + }) { + final type = json[_Json.type] as String; + final JsonToPartConverter? converter = converterRegistry[type]; + if (converter == null) { + throw UnimplementedError('Unknown part type: $type'); + } + return converter.convert(json); + } - /// The default MIME type for binary data. - static const defaultMimeType = 'application/octet-stream'; + /// Serializes the part to a JSON map. + /// + /// The returned map must contain a key `type` with a unique string + /// identifier for the custom part. See keys of [defaultPartConverterRegistry] + /// for existing part types. + Map toJson(); +} - /// Gets the MIME type for a file. - static String mimeType(String path, {Uint8List? headerBytes}) => - lookupMimeType(path, headerBytes: headerBytes) ?? defaultMimeType; +typedef JsonToPartConverter = Converter, Part>; +typedef _JsonToPartFunction = Part Function(Map json); - /// Gets the name for a MIME type. - static String nameFromMimeType(String mimeType) { - final String ext = extensionFromMimeType(mimeType) ?? '.bin'; - return mimeType.startsWith('image/') ? 'image.$ext' : 'file.$ext'; - } +/// Converter registry. +/// +/// The key of a map entry is the part type. +/// The value is the converter that knows how to convert that part type. +const defaultPartConverterRegistry = { + _Part.text: PartConverter(TextPart.fromJson), + _Part.data: PartConverter(DataPart.fromJson), + _Part.link: PartConverter(LinkPart.fromJson), + _Part.tool: PartConverter(ToolPart.fromJson), +}; - /// Gets the extension for a MIME type. - static String? extensionFromMimeType(String mimeType) { - final String ext = defaultExtensionMap.entries - .firstWhere( - (e) => e.value == mimeType, - orElse: () => const MapEntry('', ''), - ) - .key; - return ext.isNotEmpty ? ext : null; - } +/// A converter that converts a JSON map to a [Part]. +@visibleForTesting +class PartConverter extends JsonToPartConverter { + const PartConverter(this._function); - /// Converts the part to a JSON-compatible map. - Map toJson() { - final String typeName; - final Object content; - switch (this) { - case final TextPart p: - typeName = 'TextPart'; - content = p.text; - break; - case final DataPart p: - typeName = 'DataPart'; - content = { - if (p.name != null) 'name': p.name, - 'mimeType': p.mimeType, - 'bytes': 'data:${p.mimeType};base64,${base64Encode(p.bytes)}', - }; - break; - case final LinkPart p: - typeName = 'LinkPart'; - content = { - if (p.name != null) 'name': p.name, - if (p.mimeType != null) 'mimeType': p.mimeType, - 'url': p.url.toString(), - }; - break; - case final ToolPart p: - typeName = 'ToolPart'; - content = { - 'id': p.callId, - 'name': p.toolName, - if (p.arguments != null) 'arguments': p.arguments, - if (p.result != null) 'result': p.result, - }; - break; - default: - throw UnimplementedError('Unknown part type: $runtimeType'); - } - return {'type': typeName, 'content': content}; + final _JsonToPartFunction _function; + + @override + Part convert(Map input) { + return _function(input); } } /// A text part of a message. @immutable -class TextPart extends Part { +final class TextPart extends Part { /// Creates a new text part. const TextPart(this.text); /// The text content. final String text; + /// Creates a text part from a JSON-compatible map. + factory TextPart.fromJson(Map json) { + return TextPart(json[_Json.content] as String); + } + + @override + Map toJson() => { + _Json.type: _Part.text, + _Json.content: text, + }; + @override bool operator ==(Object other) { if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; return other is TextPart && other.text == text; } @@ -150,10 +143,22 @@ class TextPart extends Part { /// A data part containing binary data (e.g., images). @immutable -class DataPart extends Part { +final class DataPart extends Part { /// Creates a new data part. DataPart(this.bytes, {required this.mimeType, String? name}) - : name = name ?? Part.nameFromMimeType(mimeType); + : name = name ?? nameFromMimeType(mimeType); + + /// Creates a data part from a JSON-compatible map. + factory DataPart.fromJson(Map json) { + final content = json[_Json.content] as Map; + final dataUri = content[_Json.bytes] as String; + final Uri uri = Uri.parse(dataUri); + return DataPart( + uri.data!.contentAsBytes(), + mimeType: content[_Json.mimeType] as String, + name: content[_Json.name] as String?, + ); + } /// Creates a data part from an [XFile]. static Future fromFile(XFile file) async { @@ -161,7 +166,7 @@ class DataPart extends Part { final String? name = _nameFromPath(file.path) ?? _emptyNull(file.name); final String mimeType = _emptyNull(file.mimeType) ?? - Part.mimeType( + mimeTypeForFile( name ?? '', headerBytes: Uint8List.fromList( bytes.take(defaultMagicNumbersMaxLength).toList(), @@ -192,11 +197,24 @@ class DataPart extends Part { /// Optional name for the data. final String? name; + @override + Map toJson() => { + _Json.type: _Part.data, + _Json.content: { + if (name != null) _Json.name: name, + _Json.mimeType: mimeType, + _Json.bytes: 'data:$mimeType;base64,${base64Encode(bytes)}', + }, + }; + @override bool operator ==(Object other) { if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + const deepEquality = DeepCollectionEquality(); return other is DataPart && - listEquals(other.bytes, bytes) && + deepEquality.equals(other.bytes, bytes) && other.mimeType == mimeType && other.name == name; } @@ -207,11 +225,38 @@ class DataPart extends Part { @override String toString() => 'DataPart(mimeType: $mimeType, name: $name, bytes: ${bytes.length})'; + + @visibleForTesting + static const defaultMimeType = 'application/octet-stream'; + + /// Gets the MIME type for a file. + @visibleForTesting + static String mimeTypeForFile(String path, {Uint8List? headerBytes}) => + lookupMimeType(path, headerBytes: headerBytes) ?? defaultMimeType; + + /// Gets the name for a MIME type. + @visibleForTesting + static String nameFromMimeType(String mimeType) { + final String ext = extensionFromMimeType(mimeType) ?? 'bin'; + return mimeType.startsWith('image/') ? 'image.$ext' : 'file.$ext'; + } + + /// Gets the extension for a MIME type. + @visibleForTesting + static String? extensionFromMimeType(String mimeType) { + final String ext = defaultExtensionMap.entries + .firstWhere( + (e) => e.value == mimeType, + orElse: () => const MapEntry('', ''), + ) + .key; + return ext.isNotEmpty ? ext : null; + } } /// A link part referencing external content. @immutable -class LinkPart extends Part { +final class LinkPart extends Part { /// Creates a new link part. const LinkPart(this.url, {this.mimeType, this.name}); @@ -224,9 +269,31 @@ class LinkPart extends Part { /// Optional name for the link. final String? name; + /// Creates a link part from a JSON-compatible map. + factory LinkPart.fromJson(Map json) { + final content = json[_Json.content] as Map; + return LinkPart( + Uri.parse(content[_Json.url] as String), + mimeType: content[_Json.mimeType] as String?, + name: content[_Json.name] as String?, + ); + } + + @override + Map toJson() => { + _Json.type: _Part.link, + _Json.content: { + if (name != null) _Json.name: name, + if (mimeType != null) _Json.mimeType: mimeType, + _Json.url: url.toString(), + }, + }; + @override bool operator ==(Object other) { if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is LinkPart && other.url == url && other.mimeType == mimeType && @@ -242,8 +309,7 @@ class LinkPart extends Part { /// A tool interaction part of a message. @immutable -class ToolPart extends Part { - /// Creates a tool call part. +final class ToolPart extends Part { /// Creates a tool call part. const ToolPart.call({ required this.callId, @@ -270,24 +336,54 @@ class ToolPart extends Part { final String toolName; /// The arguments for a tool call (null for results). - final Map? arguments; + final Map? arguments; /// The result of a tool execution (null for calls). - final dynamic result; + final Object? result; /// The arguments as a JSON string. - String get argumentsRaw => arguments != null - ? (arguments!.isEmpty ? '{}' : jsonEncode(arguments)) - : ''; + String get argumentsRaw => arguments == null ? '' : jsonEncode(arguments); + + /// Creates a tool part from a JSON-compatible map. + factory ToolPart.fromJson(Map json) { + final content = json[_Json.content] as Map; + if (content.containsKey(_Json.arguments)) { + return ToolPart.call( + callId: content[_Json.id] as String, + toolName: content[_Json.name] as String, + arguments: content[_Json.arguments] as Map? ?? {}, + ); + } else { + return ToolPart.result( + callId: content[_Json.id] as String, + toolName: content[_Json.name] as String, + result: content[_Json.result], + ); + } + } + + @override + Map toJson() => { + _Json.type: _Part.tool, + _Json.content: { + _Json.id: callId, + _Json.name: toolName, + if (arguments != null) _Json.arguments: arguments, + if (result != null) _Json.result: result, + }, + }; @override bool operator ==(Object other) { if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + + const deepEquality = DeepCollectionEquality(); return other is ToolPart && other.kind == kind && other.callId == callId && other.toolName == toolName && - mapEquals(other.arguments, arguments) && + deepEquality.equals(other.arguments, arguments) && other.result == result; } @@ -320,25 +416,3 @@ enum ToolPartKind { /// The result of a tool execution. result, } - -/// Static helper methods for extracting specific types of parts from a list. -extension MessagePartHelpers on Iterable { - /// Extracts and concatenates all text content from TextPart instances. - /// - /// Returns a single string with all text content concatenated together - /// without any separators. Empty text parts are included in the result. - String get text => whereType().map((p) => p.text).join(); - - /// Extracts all tool call parts from the list. - /// - /// Returns only ToolPart instances where kind == ToolPartKind.call. - List get toolCalls => - whereType().where((p) => p.kind == ToolPartKind.call).toList(); - - /// Extracts all tool result parts from the list. - /// - /// Returns only ToolPart instances where kind == ToolPartKind.result. - List get toolResults => whereType() - .where((p) => p.kind == ToolPartKind.result) - .toList(); -} diff --git a/packages/genai_primitives/lib/src/parts.dart b/packages/genai_primitives/lib/src/parts.dart new file mode 100644 index 000000000..d97baef2d --- /dev/null +++ b/packages/genai_primitives/lib/src/parts.dart @@ -0,0 +1,93 @@ +// 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:collection'; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'message_parts.dart'; + +/// A collection of message parts. +@immutable +final class Parts extends ListBase { + /// Creates a new collection of parts. + const Parts(this._parts); + + /// Creates a collection of parts from text and optional other parts. + /// + /// Converts [text] to a [TextPart] and puts it as a first member of + /// the [parts] list. + factory Parts.fromText(String text, {Iterable parts = const []}) => + Parts([TextPart(text), ...parts]); + + /// Deserializes parts from a JSON list. + factory Parts.fromJson( + List json, { + Map converterRegistry = + defaultPartConverterRegistry, + }) { + return Parts( + json + .map( + (e) => Part.fromJson( + e as Map, + converterRegistry: converterRegistry, + ), + ) + .toList(), + ); + } + + final List _parts; + + @override + int get length => _parts.length; + + @override + set length(int newLength) => throw UnsupportedError('Parts is immutable'); + + @override + Part operator [](int index) => _parts[index]; + + @override + void operator []=(int index, Part value) => + throw UnsupportedError('Parts is immutable'); + + /// Serializes parts to a JSON list. + List toJson() => _parts.map((p) => p.toJson()).toList(); + + /// Extracts and concatenates all text content from TextPart instances. + /// + /// Returns a single string with all text content concatenated together + /// without any separators. Empty text parts are included in the result. + String get text => whereType().map((p) => p.text).join(); + + /// Extracts all tool call parts from the list. + /// + /// Returns only ToolPart instances where kind == ToolPartKind.call. + List get toolCalls => + whereType().where((p) => p.kind == ToolPartKind.call).toList(); + + /// Extracts all tool result parts from the list. + /// + /// Returns only ToolPart instances where kind == ToolPartKind.result. + List get toolResults => whereType() + .where((p) => p.kind == ToolPartKind.result) + .toList(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + const deepEquality = DeepCollectionEquality(); + return other is Parts && deepEquality.equals(other._parts, _parts); + } + + @override + int get hashCode => const DeepCollectionEquality().hash(_parts); + + @override + String toString() => _parts.toString(); +} diff --git a/packages/genai_primitives/lib/src/tool_definition.dart b/packages/genai_primitives/lib/src/tool_definition.dart index 6bfd183e5..c03cc59c7 100644 --- a/packages/genai_primitives/lib/src/tool_definition.dart +++ b/packages/genai_primitives/lib/src/tool_definition.dart @@ -4,6 +4,12 @@ import 'package:json_schema_builder/json_schema_builder.dart'; +final class _Json { + static const name = 'name'; + static const description = 'description'; + static const inputSchema = 'inputSchema'; +} + /// A tool that can be called by the LLM. class ToolDefinition { /// Creates a [ToolDefinition]. @@ -15,9 +21,27 @@ class ToolDefinition { inputSchema ?? Schema.fromMap({ 'type': 'object', - 'properties': {}, + 'properties': {}, }); + /// Deserializes a tool from a JSON map. + factory ToolDefinition.fromJson(Map json) { + return ToolDefinition( + name: json[_Json.name] as String, + description: json[_Json.description] as String, + inputSchema: Schema.fromMap( + json[_Json.inputSchema] as Map, + ), + ); + } + + /// Serializes the tool to a JSON map. + Map toJson() => { + _Json.name: name, + _Json.description: description, + _Json.inputSchema: inputSchema.value, + }; + /// The unique name of the tool that clearly communicates its purpose. final String name; @@ -28,11 +52,4 @@ class ToolDefinition { /// Schema to parse and validate tool's input arguments. Following the [JSON /// Schema specification](https://json-schema.org). final Schema inputSchema; - - /// Converts the tool to a JSON-serializable map. - Map toJson() => { - 'name': name, - 'description': description, - 'inputSchema': inputSchema.value, - }; } diff --git a/packages/genai_primitives/lib/src/utils.dart b/packages/genai_primitives/lib/src/utils.dart deleted file mode 100644 index 49c97998c..000000000 --- a/packages/genai_primitives/lib/src/utils.dart +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// Helper functions for equality checks -bool listEquals(List? a, List? b) { - if (a == null) return b == null; - if (b == null || a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; -} - -bool mapEquals(Map? a, Map? b) { - if (a == null) return b == null; - if (b == null || a.length != b.length) return false; - for (final K key in a.keys) { - if (!b.containsKey(key) || a[key] != b[key]) return false; - } - return true; -} diff --git a/packages/genai_primitives/pubspec.lock b/packages/genai_primitives/pubspec.lock index 1edd9c015..349c0b8f6 100644 --- a/packages/genai_primitives/pubspec.lock +++ b/packages/genai_primitives/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" diff --git a/packages/genai_primitives/pubspec.yaml b/packages/genai_primitives/pubspec.yaml index c85671617..5a81adb7e 100644 --- a/packages/genai_primitives/pubspec.yaml +++ b/packages/genai_primitives/pubspec.yaml @@ -13,6 +13,7 @@ environment: sdk: ">=3.9.2 <4.0.0" dependencies: + collection: any cross_file: ^0.3.5+1 json_schema_builder: ^0.1.3 meta: ^1.17.0 diff --git a/packages/genai_primitives/test/custom_part_test.dart b/packages/genai_primitives/test/custom_part_test.dart new file mode 100644 index 000000000..f0448f938 --- /dev/null +++ b/packages/genai_primitives/test/custom_part_test.dart @@ -0,0 +1,123 @@ +// 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:genai_primitives/genai_primitives.dart'; +import 'package:test/test.dart'; + +base class CustomPart extends Part { + final String customField; + + const CustomPart(this.customField); + + @override + Map toJson() { + return { + 'type': 'Custom', + 'content': {'customField': customField}, + }; + } + + @override + bool operator ==(Object other) => + other is CustomPart && other.customField == customField; + + @override + int get hashCode => customField.hashCode; + + @override + String toString() => 'CustomPart($customField)'; +} + +class CustomPartConverter extends Converter, Part> { + const CustomPartConverter(); + + @override + Part convert(Map input) { + if (input['type'] == 'Custom') { + final content = input['content'] as Map; + return CustomPart(content['customField'] as String); + } + throw UnimplementedError('Unknown custom part type: ${input['type']}'); + } +} + +void main() { + group('Custom Part Serialization', () { + test('round trip serialization with custom type', () { + const originalPart = CustomPart('custom_value'); + + // Serialize + final Map json = originalPart.toJson(); + expect(json['type'], equals('Custom')); + expect( + (json['content'] as Map)['customField'], + equals('custom_value'), + ); + + // Deserialize using Part.fromJson with customConverter + final reconstructedPart = Part.fromJson( + json, + converterRegistry: {'Custom': const CustomPartConverter()}, + ); + + expect(reconstructedPart, isA()); + expect( + (reconstructedPart as CustomPart).customField, + equals('custom_value'), + ); + expect(reconstructedPart, equals(originalPart)); + }); + + test('Part.fromJson throws UnimplementedError for custom type', () { + final Map json = { + 'type': 'Custom', + 'content': {'customField': 'val'}, + }; + + expect(() => Part.fromJson(json), throwsUnimplementedError); + }); + + test('Part.fromJson handles standard types even with custom converter', () { + const textPart = TextPart('hello'); + final Map json = textPart.toJson(); + + // Should still work for standard parts + final reconstructed = Part.fromJson( + json, + converterRegistry: { + ...defaultPartConverterRegistry, + 'Custom': const CustomPartConverter(), + }, + ); + + expect(reconstructed, equals(textPart)); + }); + }); + + group('ChatMessage with Custom Part', () { + test('deserialization with custom registry', () { + final message = const ChatMessage( + role: ChatMessageRole.user, + parts: Parts([CustomPart('custom_content')]), + ); + final Map json = message.toJson(); + + final reconstructed = ChatMessage.fromJson( + json, + converterRegistry: { + ...defaultPartConverterRegistry, + 'Custom': const CustomPartConverter(), + }, + ); + + expect(reconstructed.parts.first, isA()); + expect( + (reconstructed.parts.first as CustomPart).customField, + equals('custom_content'), + ); + }); + }); +} diff --git a/packages/genai_primitives/test/example_test.dart b/packages/genai_primitives/test/example_test.dart new file mode 100644 index 000000000..44a03bbda --- /dev/null +++ b/packages/genai_primitives/test/example_test.dart @@ -0,0 +1,149 @@ +// 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:test/test.dart'; + +import '../example/main.dart' as example; + +void main() { + test('runExample', () { + final output = StringBuffer(); + example.main(output: (object) => output.writeln(object.toString())); + + // If the test fails update expected output, and check diff for this file. + expect(output.toString(), _expectedOutput); + }); +} + +const _expectedOutput = ''' +--- GenAI Primitives Example --- + +[Tool Definition] +{ + "name": "get_weather", + "description": "Get the current weather for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "description": "The unit of temperature", + "enum": [ + "celsius", + "fahrenheit" + ] + } + }, + "required": [ + "location" + ] + } +} + +[Initial Conversation] +system: You are a helpful weather assistant. Use the get_weather tool when needed. +user: What is the weather in London? + +[Model Response with Tool Call] +Tool Call: get_weather({location: London, unit: celsius}) + +[Tool Result] +Result: {temperature: 15, condition: Cloudy} + +[Final Model Response with Data] +Text: Here is a chart of the weather trend: +Attachment: weather_chart.png (image/png, 4 bytes) + +[Full History JSON] +[ + { + "parts": [ + { + "type": "Text", + "content": "You are a helpful weather assistant. Use the get_weather tool when needed." + } + ], + "metadata": {}, + "role": "system" + }, + { + "parts": [ + { + "type": "Text", + "content": "What is the weather in London?" + } + ], + "metadata": {}, + "role": "user" + }, + { + "parts": [ + { + "type": "Text", + "content": "" + }, + { + "type": "Text", + "content": "Thinking: User wants weather for London..." + }, + { + "type": "Tool", + "content": { + "id": "call_123", + "name": "get_weather", + "arguments": { + "location": "London", + "unit": "celsius" + } + } + } + ], + "metadata": {}, + "role": "model" + }, + { + "parts": [ + { + "type": "Text", + "content": "" + }, + { + "type": "Tool", + "content": { + "id": "call_123", + "name": "get_weather", + "result": { + "temperature": 15, + "condition": "Cloudy" + } + } + } + ], + "metadata": {}, + "role": "user" + }, + { + "parts": [ + { + "type": "Text", + "content": "Here is a chart of the weather trend:" + }, + { + "type": "Data", + "content": { + "name": "weather_chart.png", + "mimeType": "image/png", + "bytes": "data:image/png;base64,iVBORw==" + } + } + ], + "metadata": {}, + "role": "model" + } +] +'''; diff --git a/packages/genai_primitives/test/genai_primitives_test.dart b/packages/genai_primitives/test/genai_primitives_test.dart index f813a7112..395cdda67 100644 --- a/packages/genai_primitives/test/genai_primitives_test.dart +++ b/packages/genai_primitives/test/genai_primitives_test.dart @@ -4,11 +4,76 @@ import 'dart:typed_data'; +import 'package:cross_file/cross_file.dart'; import 'package:genai_primitives/genai_primitives.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:test/test.dart'; void main() { + // In this test dynamic is used instead of Object? + // to test support for dynamic types. + group('Part', () { + test('mimeType helper', () { + // Test with extensions (may be environment dependent for text/plain) + expect( + DataPart.mimeTypeForFile('test.png'), + anyOf(equals('image/png'), equals('application/octet-stream')), + ); + + // Test with header bytes (sniffing should be environment independent) + final pngHeader = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]); + expect( + DataPart.mimeTypeForFile('unknown', headerBytes: pngHeader), + equals('image/png'), + ); + + final pdfHeader = Uint8List.fromList([0x25, 0x50, 0x44, 0x46]); + expect( + DataPart.mimeTypeForFile('file', headerBytes: pdfHeader), + equals('application/pdf'), + ); + }); + + test('nameFromMimeType helper', () { + expect(DataPart.nameFromMimeType('image/png'), equals('image.png')); + expect(DataPart.nameFromMimeType('application/pdf'), equals('file.pdf')); + expect(DataPart.nameFromMimeType('unknown/type'), equals('file.bin')); + }); + + test('extensionFromMimeType helper', () { + expect(DataPart.extensionFromMimeType('image/png'), equals('png')); + expect(DataPart.extensionFromMimeType('application/pdf'), equals('pdf')); + expect(DataPart.extensionFromMimeType('unknown/type'), isNull); + }); + + test('defaultMimeType helper', () { + expect(DataPart.defaultMimeType, equals('application/octet-stream')); + }); + + test('uses defaultMimeType when unknown', () { + expect( + DataPart.mimeTypeForFile('unknown_file_no_extension'), + equals(DataPart.defaultMimeType), + ); + }); + + test('fromJson throws on unknown type', () { + expect( + () => Part.fromJson({'type': 'Unknown', 'content': ''}), + throwsUnimplementedError, + ); + }); + }); + group('MessagePart', () { group('TextPart', () { test('creation', () { @@ -30,7 +95,7 @@ void main() { test('JSON serialization', () { const part = TextPart('hello'); final Map json = part.toJson(); - expect(json, equals({'type': 'TextPart', 'content': 'hello'})); + expect(json, equals({'type': 'Text', 'content': 'hello'})); final reconstructed = Part.fromJson(json); expect(reconstructed, isA()); @@ -62,7 +127,7 @@ void main() { final part = DataPart(bytes, mimeType: 'image/png', name: 'test.png'); final Map json = part.toJson(); - expect(json['type'], equals('DataPart')); + expect(json['type'], equals('Data')); final content = json['content'] as Map; expect(content['mimeType'], equals('image/png')); expect(content['name'], equals('test.png')); @@ -75,6 +140,49 @@ void main() { expect(dataPart.name, equals('test.png')); expect(dataPart.bytes, equals(bytes)); }); + + test('fromFile creation', () async { + final bytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]); + final file = XFile.fromData( + bytes, + mimeType: 'image/png', + name: 'my_file.png', + ); + + final DataPart part = await DataPart.fromFile(file); + expect(part.bytes, equals(bytes)); + expect(part.mimeType, equals('image/png')); + // XFile.fromData might not preserve the name in some test environments + expect(part.name, anyOf(equals('my_file.png'), equals('image.png'))); + }); + + test('fromFile with unknown MIME type detection', () async { + // PNG header + final bytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]); + final file = XFile.fromData(bytes, name: 'temp_file.png'); + + final DataPart part = await DataPart.fromFile(file); + expect(part.mimeType, equals('image/png')); + expect(part.name, anyOf(equals('temp_file.png'), equals('image.png'))); + }); }); group('LinkPart', () { @@ -101,7 +209,7 @@ void main() { final part = LinkPart(uri, mimeType: 'image/png', name: 'image'); final Map json = part.toJson(); - expect(json['type'], equals('LinkPart')); + expect(json['type'], equals('Link')); final content = json['content'] as Map; expect(content['url'], equals(uri.toString())); expect(content['mimeType'], equals('image/png')); @@ -139,8 +247,7 @@ void main() { arguments: {'city': 'London'}, ); final Map json = part.toJson(); - - expect(json['type'], equals('ToolPart')); + expect(json['type'], equals('Tool')); final content = json['content'] as Map; expect(content['id'], equals('call_1')); expect(content['name'], equals('get_weather')); @@ -157,6 +264,32 @@ void main() { expect(toolPart.callId, equals('call_1')); expect(toolPart.arguments, equals({'city': 'London'})); }); + + test('toString', () { + const part = ToolPart.call( + callId: 'c1', + toolName: 't1', + arguments: {'a': 1}, + ); + expect(part.toString(), contains('ToolPart.call')); + expect(part.toString(), contains('c1')); + }); + + test('argumentsRaw', () { + const part1 = ToolPart.call( + callId: 'c1', + toolName: 't1', + arguments: {}, + ); + expect(part1.argumentsRaw, equals('{}')); + + const part2 = ToolPart.call( + callId: 'c2', + toolName: 't2', + arguments: {'a': 1}, + ); + expect(part2.argumentsRaw, equals('{"a":1}')); + }); }); group('Result', () { @@ -171,6 +304,17 @@ void main() { expect(part.toolName, equals('get_weather')); expect(part.result, equals({'temp': 20})); expect(part.arguments, isNull); + expect(part.argumentsRaw, equals('')); + }); + + test('toString', () { + const part = ToolPart.result( + callId: 'c1', + toolName: 't1', + result: 'ok', + ); + expect(part.toString(), contains('ToolPart.result')); + expect(part.toString(), contains('c1')); }); test('JSON serialization', () { @@ -180,8 +324,7 @@ void main() { result: {'temp': 20}, ); final Map json = part.toJson(); - - expect(json['type'], equals('ToolPart')); + expect(json['type'], equals('Tool')); final content = json['content'] as Map; expect(content['id'], equals('call_1')); expect(content['name'], equals('get_weather')); @@ -198,19 +341,64 @@ void main() { }); }); - group('ChatMessage', () { - test('factories', () { - final system = ChatMessage.system('instructions'); - expect(system.role, equals(ChatMessageRole.system)); - expect(system.text, equals('instructions')); + group('Message', () { + test('fromParts', () { + final fromParts = const ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hello')]), + ); + expect(fromParts.text, equals('hello')); + }); + + group('Named constructors', () { + test('system', () { + final message = ChatMessage.system( + 'instruction', + parts: [const TextPart(' extra')], + metadata: {'a': 1}, + ); + expect(message.role, equals(ChatMessageRole.system)); + expect(message.text, equals('instruction extra')); + expect(message.parts.first, isA()); + expect((message.parts.first as TextPart).text, equals('instruction')); + expect(message.parts[1], isA()); + expect((message.parts[1] as TextPart).text, equals(' extra')); + expect(message.metadata, equals({'a': 1})); + }); + + test('user', () { + final message = ChatMessage.user( + 'hello', + parts: [const TextPart(' world')], + metadata: {'b': 2}, + ); + expect(message.role, equals(ChatMessageRole.user)); + expect(message.text, equals('hello world')); + expect(message.parts.first, isA()); + expect((message.parts.first as TextPart).text, equals('hello')); + expect(message.metadata, equals({'b': 2})); + }); - final user = ChatMessage.user('hello'); - expect(user.role, equals(ChatMessageRole.user)); - expect(user.text, equals('hello')); + test('model', () { + final message = ChatMessage.model( + 'response', + parts: [ + const ToolPart.call(callId: 'id', toolName: 't', arguments: {}), + ], + metadata: {'c': 3}, + ); + expect(message.role, equals(ChatMessageRole.model)); + expect(message.text, equals('response')); + expect(message.parts.first, isA()); + expect((message.parts.first as TextPart).text, equals('response')); + expect(message.parts[1], isA()); + expect(message.metadata, equals({'c': 3})); + }); + }); - final model = ChatMessage.model('hi'); - expect(model.role, equals(ChatMessageRole.model)); - expect(model.text, equals('hi')); + test('default constructor', () { + final message = ChatMessage.system('instructions'); + expect(message.text, equals('instructions')); }); test('helpers', () { @@ -227,7 +415,7 @@ void main() { final msg1 = ChatMessage( role: ChatMessageRole.model, - parts: [const TextPart('Hi'), toolCall], + parts: Parts([const TextPart('Hi'), toolCall]), ); expect(msg1.hasToolCalls, isTrue); expect(msg1.hasToolResults, isFalse); @@ -235,7 +423,10 @@ void main() { expect(msg1.toolResults, isEmpty); expect(msg1.text, equals('Hi')); - final msg2 = ChatMessage(role: ChatMessageRole.user, parts: [toolResult]); + final msg2 = ChatMessage( + role: ChatMessageRole.user, + parts: Parts([toolResult]), + ); expect(msg2.hasToolCalls, isFalse); expect(msg2.hasToolResults, isTrue); expect(msg2.toolCalls, isEmpty); @@ -243,7 +434,11 @@ void main() { }); test('metadata', () { - final msg = ChatMessage.user('hi', metadata: {'key': 'value'}); + final msg = const ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hi')]), + metadata: {'key': 'value'}, + ); expect(msg.metadata['key'], equals('value')); final Map json = msg.toJson(); @@ -257,12 +452,85 @@ void main() { final msg = ChatMessage.model('response'); final Map json = msg.toJson(); - expect(json['role'], equals('model')); expect((json['parts'] as List).length, equals(1)); final reconstructed = ChatMessage.fromJson(json); expect(reconstructed, equals(msg)); }); + + test('equality and hashCode', () { + const msg1 = ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hi')]), + metadata: {'k': 'v'}, + ); + const msg2 = ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hi')]), + metadata: {'k': 'v'}, + ); + const msg3 = ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hello')]), + ); + const msg4 = ChatMessage( + role: ChatMessageRole.user, + parts: Parts([TextPart('hi')]), + metadata: {'k': 'other'}, + ); + + expect(msg1, equals(msg2)); + expect(msg1.hashCode, equals(msg2.hashCode)); + expect(msg1, isNot(equals(msg3))); + expect(msg1, isNot(equals(msg4))); + }); + + test('text concatenation', () { + final msg = const ChatMessage( + role: ChatMessageRole.model, + parts: Parts([ + TextPart('Part 1. '), + ToolPart.call(callId: '1', toolName: 't', arguments: {}), + TextPart('Part 2.'), + ]), + ); + expect(msg.text, equals('Part 1. Part 2.')); + }); + + test('toString', () { + final msg = ChatMessage.user('hi'); + expect(msg.toString(), contains('Message')); + expect(msg.toString(), contains('parts: [TextPart(hi)]')); + }); + }); + + group('Parts', () { + test('fromText', () { + final parts = Parts.fromText( + 'Hello', + parts: [ + const ToolPart.call(callId: 'c1', toolName: 't1', arguments: {}), + ], + ); + expect(parts.length, equals(2)); + expect(parts.first, isA()); + expect((parts.first as TextPart).text, equals('Hello')); + expect(parts.last, isA()); + }); + + test('helpers', () { + final parts = const Parts([ + TextPart('Hello'), + ToolPart.call(callId: 'c1', toolName: 't1', arguments: {}), + ToolPart.result(callId: 'c2', toolName: 't2', result: 'r'), + ]); + + expect(parts.text, equals('Hello')); + expect(parts.toolCalls, hasLength(1)); + expect(parts.toolCalls.first.callId, equals('c1')); + expect(parts.toolResults, hasLength(1)); + expect(parts.toolResults.first.callId, equals('c2')); + }); }); group('ToolDefinition', () { @@ -281,7 +549,7 @@ void main() { expect(json['inputSchema'], isNotNull); // Since we don't have a fromJson in ToolDefinition (yet?), we just test - // serialization If we needed it, we would add it. For now, testing that + // serialization. If we needed it, we would add it. For now, testing that // it produces expected map structure. final schemaMap = json['inputSchema'] as Map; expect(schemaMap['type'], equals('object')); diff --git a/packages/genui/.guides/docs/create_a_custom_catalogitem.md b/packages/genui/.guides/docs/create_a_custom_catalogitem.md index 66629ec96..aa56e4443 100644 --- a/packages/genui/.guides/docs/create_a_custom_catalogitem.md +++ b/packages/genui/.guides/docs/create_a_custom_catalogitem.md @@ -8,14 +8,13 @@ description: | Follow these steps to create your own, custom widgets and make them available to the agent for generation. -## 1. Import `json_schema_builder` +## 1. Depend on `json_schema_builder` -Add the `json_schema_builder` package as a dependency in `pubspec.yaml`. Use the -same commit reference as the one for `genui`. +Use `flutter pub add` to add `json_schema_builder` as a dependency in +your `pubspec.yaml` file: -```yaml -dependencies: - json_schema_builder: ^0.1.3 +```bash +flutter pub add json_schema_builder ``` ## 2. Create the new widget's schema diff --git a/packages/genui/.guides/setup.md b/packages/genui/.guides/setup.md index 83cdecc82..4cc0bced6 100644 --- a/packages/genui/.guides/setup.md +++ b/packages/genui/.guides/setup.md @@ -40,14 +40,11 @@ Logic, follow these instructions: [Firebase's Flutter Setup guide](https://firebase.google.com/docs/flutter/setup) to add Firebase to your app. Run `flutterfire configure` to configure your app. -4. In `pubspec.yaml`, add `genui` and `genui_firebase_ai` to the - `dependencies` section. As of this writing, it's best to use pub's git - dependency to refer directly to this project's source. - - ```yaml - dependencies: - genui: ^0.5.1 - genui_firebase_ai: ^0.5.1 +4. Use `flutter pub add` to add the `genui` and `genui_firebase_ai` packages as + dependencies in your `pubspec.yaml` file: + + ```bash + flutter pub add genui genui_firebase_ai ``` 5. In your app's `main` method, ensure that the widget bindings are initialized, @@ -66,13 +63,11 @@ Logic, follow these instructions: To use `genui` with a generic agent provider that supports the A2UI protocol, use the `genui_a2ui` package. -1. In `pubspec.yaml`, add `genui` and `genui_a2ui` to the `dependencies` - section. +1. Use `flutter pub add` to add the `genui` and `genui_a2ui` packages as + dependencies in your `pubspec.yaml` file: - ```yaml - dependencies: - genui: ^0.5.1 - genui_a2ui: ^0.5.1 + ```bash + flutter pub add genui genui_a2ui ``` 2. Use the `A2uiContentGenerator` to connect to your agent provider. @@ -82,13 +77,11 @@ use the `genui_a2ui` package. To use `genui` with the Google Generative AI API, use the `genui_google_generative_ai` package. -1. In `pubspec.yaml`, add `genui` and `genui_google_generative_ai` to the - `dependencies` section. +1. Use `flutter pub add` to add the `genui` and `genui_google_generative_ai` packages as + dependencies in your `pubspec.yaml` file: - ```yaml - dependencies: - genui: ^0.5.1 - genui_google_generative_ai: ^0.5.1 + ```bash + flutter pub add genui genui_google_generative_ai ``` 2. Use the `GoogleGenerativeAiContentGenerator` to connect to the Google diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index b8f2cb642..c58945a3b 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,5 +1,9 @@ # `genui` Changelog +## 0.6.2 (in progress) + +- **Fix**: Improved error handling for catalog example loading to include context about the invalid item (#653). + ## 0.6.1 - **Fix**: Corrected `DateTimeInput` catalog item JSON key mapping (#622). diff --git a/packages/genui/README.md b/packages/genui/README.md index d3639c66d..9cb013f2d 100644 --- a/packages/genui/README.md +++ b/packages/genui/README.md @@ -97,14 +97,11 @@ Logic, follow these instructions: 3. Follow the first three steps in [Firebase's Flutter Setup guide](https://firebase.google.com/docs/flutter/setup) to add Firebase to your app. -4. In `pubspec.yaml`, add `genui` and `genui_firebase_ai` to the - `dependencies` section. - - ```yaml - dependencies: - # ... - genui: 0.5.0 - genui_firebase_ai: 0.5.0 +4. Use `flutter pub add` to add the `genui` and `genui_firebase_ai` packages as + dependencies in your `pubspec.yaml` file: + + ```bash + flutter pub add genui genui_firebase_ai ``` 5. In your app's `main` method, ensure that the widget bindings are initialized, @@ -298,19 +295,13 @@ In addition to using the catalog of widgets in `CoreCatalogItems`, you can create custom widgets for the agent to generate. Use the following instructions. -#### Import `json_schema_builder` - -Add the `json_schema_builder` package as a dependency in `pubspec.yaml`. Use the -same commit reference as the one for `genui`. +#### Depend on the `json_schema_builder` package -```yaml -dependencies: - # ... - json_schema_builder: - git: - url: https://github.com/flutter/genui.git - path: packages/json_schema_builder +Use `flutter pub add` to add `json_schema_builder` as a dependency in +your `pubspec.yaml` file: +```bash +flutter pub add json_schema_builder ``` #### Create the new widget's schema diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 699f5ea37..c40b901e5 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -67,30 +67,39 @@ class _DebugCatalogViewState extends State { final surfaceId = '${item.name}$indexPart'; final String exampleJsonString = exampleBuilder(); - final exampleData = jsonDecode(exampleJsonString) as List; - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) - .toList(); + try { + final exampleData = jsonDecode(exampleJsonString) as List; - Component? rootComponent; - rootComponent = components.firstWhereOrNull((c) => c.id == 'root'); + final List components = exampleData + .map((e) => Component.fromJson(e as JsonMap)) + .toList(); - if (rootComponent == null) { - debugPrint( - 'Skipping example for ${item.name} because it is missing a root ' - 'component.', + Component? rootComponent; + rootComponent = components.firstWhereOrNull((c) => c.id == 'root'); + + if (rootComponent == null) { + debugPrint( + 'Skipping example for ${item.name} because it is missing a root ' + 'component.', + ); + continue; + } + + _a2uiMessageProcessor.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + _a2uiMessageProcessor.handleMessage( + BeginRendering(surfaceId: surfaceId, root: rootComponent.id), + ); + surfaceIds.add(surfaceId); + } catch (e, s) { + debugPrint('Failed to load example for "${item.name}":\n$e\n$s'); + throw Exception( + 'Failed to load example for "${item.name}". Check logs for ' + 'details.', ); - continue; } - - _a2uiMessageProcessor.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - _a2uiMessageProcessor.handleMessage( - BeginRendering(surfaceId: surfaceId, root: rootComponent.id), - ); - surfaceIds.add(surfaceId); } } } diff --git a/packages/genui_a2ui/CHANGELOG.md b/packages/genui_a2ui/CHANGELOG.md index b65efc8ad..3a19af820 100644 --- a/packages/genui_a2ui/CHANGELOG.md +++ b/packages/genui_a2ui/CHANGELOG.md @@ -1,5 +1,7 @@ # `genui_a2ui` Changelog +## 0.6.2 (in progress) + ## 0.6.1 - **Refactor**: Switched to using a local implementation of the A2A client library, removing the dependency on `package:a2a` (#627). diff --git a/packages/genui_a2ui/README.md b/packages/genui_a2ui/README.md index aa2193488..4edfeb378 100644 --- a/packages/genui_a2ui/README.md +++ b/packages/genui_a2ui/README.md @@ -20,17 +20,12 @@ An integration package for [`genui`](https://pub.dev/packages/genui) and the [A2 ### Installation -Add the following to your `pubspec.yaml`: - -```yaml -dependencies: - flutter: - sdk: flutter - genui: ^0.6.0 # Or the latest version - genui_a2ui: ^0.6.0 # Or the latest version -``` +Use `flutter pub add` to add the latest versions of `genui` and `genui_a2ui` as +dependencies in your `pubspec.yaml` file: -Then run `flutter pub get`. +```bash +flutter pub add genui genui_a2ui +``` ### Basic Usage diff --git a/packages/genui_dartantic/CHANGELOG.md b/packages/genui_dartantic/CHANGELOG.md index fa193e769..21fb86187 100644 --- a/packages/genui_dartantic/CHANGELOG.md +++ b/packages/genui_dartantic/CHANGELOG.md @@ -1,8 +1,11 @@ # `genui_dartantic` Changelog +## 0.6.2 (in progress) + ## 0.6.1 -- **Feature**: Re-introduced package to monorepo with `DartanticContentGenerator` (#583, #624). +- Updated `pubspec.yaml` to use the latest version of `dartantic_ai` (2.2.0) +- Re-introduced package to monorepo with `DartanticContentGenerator` (#583, #624). ## 0.6.0 diff --git a/packages/genui_dartantic/pubspec.yaml b/packages/genui_dartantic/pubspec.yaml index 2ed372a04..07119e606 100644 --- a/packages/genui_dartantic/pubspec.yaml +++ b/packages/genui_dartantic/pubspec.yaml @@ -16,7 +16,7 @@ environment: flutter: ">=3.35.7 <4.0.0" dependencies: - dartantic_ai: ">=2.0.3 <2.1.0" # TODO(#637): Pinned due to a breakage in latest (2.1.1) due to a downstream breaking change in mistral_ai 0.1.1+. + dartantic_ai: ^2.2.0 flutter: sdk: flutter genui: ^0.6.0 diff --git a/packages/genui_firebase_ai/CHANGELOG.md b/packages/genui_firebase_ai/CHANGELOG.md index db99ffcbb..b7e2887ae 100644 --- a/packages/genui_firebase_ai/CHANGELOG.md +++ b/packages/genui_firebase_ai/CHANGELOG.md @@ -1,5 +1,7 @@ # `genui_firebase_ai` Changelog +## 0.6.2 (in progress) + ## 0.6.1 ## 0.6.0 diff --git a/packages/genui_google_generative_ai/CHANGELOG.md b/packages/genui_google_generative_ai/CHANGELOG.md index 91362c7fd..1658d1db8 100644 --- a/packages/genui_google_generative_ai/CHANGELOG.md +++ b/packages/genui_google_generative_ai/CHANGELOG.md @@ -1,5 +1,7 @@ # `genui_google_generative_ai` Changelog +## 0.6.2 (in progress) + ## 0.6.1 - **Fix**: Ensure bytes are not null when creating Blob in content converter. diff --git a/packages/genui_google_generative_ai/README.md b/packages/genui_google_generative_ai/README.md index 05b128216..e02d284c7 100644 --- a/packages/genui_google_generative_ai/README.md +++ b/packages/genui_google_generative_ai/README.md @@ -14,7 +14,12 @@ To use this package, you will need a Gemini API key. If you don't already have o ### Installation -Add this package to your `pubspec.yaml`: "genui_google_generative_ai" +Use `flutter pub add` to add the latest versions of `genui` and `genui_google_generative_ai` as +dependencies in your `pubspec.yaml` file: + +```bash +flutter pub add genui genui_google_generative_ai +``` ### Usage diff --git a/pubspec.lock b/pubspec.lock index 6d541136a..9bf4a6f41 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "8.4.0" + version: "8.4.1" analyzer_buffer: dependency: transitive description: @@ -33,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.11" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" - url: "https://pub.dev" - source: hosted - version: "0.13.10" anthropic_sdk_dart: dependency: transitive description: @@ -125,10 +117,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -157,10 +149,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" collection: dependency: transitive description: @@ -209,22 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" - url: "https://pub.dev" - source: hosted - version: "0.8.1" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" - url: "https://pub.dev" - source: hosted - version: "1.0.0+8.4.0" dart_flutter_team_lints: dependency: transitive description: @@ -245,10 +221,10 @@ packages: dependency: transitive description: name: dartantic_ai - sha256: a3d89d1c3d639dee220cdaab7a9793f7b0eaa6e9b1f1749a65776be2a9baeb70 + sha256: "135ee92512598b6c1b9e2f3b93225fa76ae83e46211ca1e4933af8884b218798" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.2.2" dartantic_interface: dependency: transitive description: @@ -301,10 +277,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" file: dependency: transitive description: @@ -455,10 +431,10 @@ packages: dependency: transitive description: name: flutter_markdown_plus - sha256: a3335b1047d4cbdcd20819cf69d9f2ac0e334ae13420104fb6035da1b404a0fa + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "1.0.7" flutter_math_fork: dependency: transitive description: @@ -479,10 +455,10 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde" + sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.1.0" flutter_svg: dependency: transitive description: @@ -827,10 +803,10 @@ packages: dependency: transitive description: name: mcp_dart - sha256: "436566d733fd1b9cfaeda148756596cd3e77b755f75df2d576128b55bdbc61e0" + sha256: "5b6c3b7085e02c085cc48efc77b560c2fb509dcdb57afc781bc2239eac9d8c7e" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.1" meta: dependency: transitive description: @@ -848,21 +824,21 @@ packages: source: hosted version: "2.0.0" mistralai_dart: - dependency: "direct overridden" + dependency: transitive description: name: mistralai_dart - sha256: "479b1a26a4613d1fcf28df27c5c27f9fa6052291a12cfaf26867a349a15dda20" + sha256: "46e2679228468d3a3a7bbcda35e3e5bc53e9c0fe51de51ae31cdfc817dc1756d" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.1+1" mockito: dependency: transitive description: name: mockito - sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.6.3" nested: dependency: transitive description: @@ -907,10 +883,10 @@ packages: dependency: transitive description: name: openai_dart - sha256: "0c392263f5aeadf93c9bef0ce9f4781f4ce45de4e4b84858d5508148dfbfd637" + sha256: "037605a210cb3b1d8ac72b11a4ace26f25ee9267aaf981d2af1d7f0524adcbf5" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.2" package_config: dependency: transitive description: @@ -1035,34 +1011,34 @@ packages: dependency: transitive description: name: riverpod - sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59 + sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.1.0" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: a0f68adb078b790faa3c655110a017f9a7b7b079a57bbd40f540e80dce5fcd29 + sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" url: "https://pub.dev" source: hosted - version: "1.0.0-dev.7" + version: "1.0.0-dev.8" riverpod_annotation: dependency: transitive description: name: riverpod_annotation - sha256: "7230014155777fc31ba3351bc2cb5a3b5717b11bfafe52b1553cb47d385f8897" + sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "4.0.0" riverpod_generator: dependency: transitive description: name: riverpod_generator - sha256: "49894543a42cf7a9954fc4e7366b6d3cb2e6ec0fa07775f660afcdd92d097702" + sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "4.0.0+1" rxdart: dependency: transitive description: @@ -1320,10 +1296,10 @@ packages: dependency: transitive description: name: watcher - sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b650f2b3..029ee15de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,8 +30,3 @@ workspace: flutter: uses-material-design: true - -# Pin mistralai_dart to 0.1.1 (avoid 0.1.1+1 which has breaking API changes). -# dartantic_ai 2.1.1 is not compatible with mistralai_dart 0.1.1+1. -dependency_overrides: - mistralai_dart: 0.1.1 diff --git a/tool/release/README.md b/tool/release/README.md index 5d098ec30..93fe2fe09 100644 --- a/tool/release/README.md +++ b/tool/release/README.md @@ -2,9 +2,18 @@ This Dart-based command-line tool automates the package publishing process for this monorepo using a safe, two-stage workflow. -## Two-Stage Publish Workflow +## Prerequisites -The process is split into two distinct commands, `bump` and `publish`, to separate release preparation from the act of publishing. +#### Permissions to publish a package to pub.dev + +Make sure you have 'admin' permissions for the [labs.flutter.dev publisher](https://pub.dev/publishers/labs.flutter.dev), which you can verify on the [admin page](https://pub.dev/publishers/labs.flutter.dev/admin). + +If you do not have permissions, ask an existing admin from the linked page to add you. + +## How to release GenUI SDK + +The process is a two-stage publish workflow. It is split into two distinct commands, `bump` and `publish`, +to separate release preparation from the act of publishing. ### 0. Update Dependencies