diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index f61e6aa12..7a009e1a5 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -1,92 +1,3 @@ # Gemini Code Assistant Context -This document provides context for the Gemini Code Assistant to understand the `genui` project. - -## Project Overview - -This is a monorepo for a Generative UI SDK (`genui`). The SDK allows developers to add interactive, dynamic, and graphical UI to their applications, generated by a Large Language Model (LLM). Instead of rendering static text responses from an LLM, this SDK allows the LLM to compose UIs from a developer-provided widget catalog. - -The project is structured as a monorepo containing several Dart and Flutter packages, along with example applications. - -### Key Packages - -| Package | Description | -| ------------------------------------ | ----------------------------------------------------------------------- | -| `packages/genui` | The core framework for employing Generative UI. | -| `packages/genui_a2ui` | Integration with the A2UI Streaming UI Protocol. | -| `packages/genui_firebase_ai` | Firebase AI integration for `genui`. | -| `packages/genui_google_generative_ai`| Integration with Google Cloud Generative Language API. | -| `packages/json_schema_builder` | A Dart JSON Schema package with validation, used by the core framework. | - -### Example Applications - -The `examples` directory contains sample applications demonstrating the usage of the `genui` SDK. - -| Example | Description | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `simple_chat` | A minimal example of a conversational chat application. It demonstrates the fundamental concepts of `genui`, such as initializing the `GenUiConversation`, sending user messages, and rendering the AI-generated UI surfaces using the default core widget catalog. | -| `travel_app` | A more advanced example of a travel planning assistant. It showcases dynamic UI generation, the use of a custom, domain-specific widget catalog, and how user interactions with the UI can be fed back to the AI to refine the conversation. | -| `catalog_gallery` | A simple application that displays the widgets available in the catalog. It's a useful tool for developers to visualize the components that the AI can use to build UIs. | -| `verdure` | A full-stack example (Flutter client + Python server) of a landscape design agent using the A2A protocol. | -| `custom_backend` | Demonstrates how a custom backend can interact with `genui`. | - -The `simple_chat` and `travel_app` examples are good starting points for understanding the library's capabilities. - -## Implementation Details - -For a deeper understanding of the project's architecture and data flow, refer to the following documents: - -- **`packages/genui/DESIGN.md`**: Provides a comprehensive overview of the core `genui` package's architecture, purpose, and implementation. - -## Building and Running - -The project uses standard `flutter` and `dart` commands. A comprehensive script is provided to automate fixes, formatting, analysis, and testing. - -### Key Commands - -- **Run all checks and tests:** - - ```bash - ./tool/run_all_tests_and_fixes.sh - ``` - - This script wraps the `test_and_fix` Dart tool (`tool/test_and_fix`) to run `dart fix`, `dart format`, `fix_copyright` (in `tool/fix_copyright`), `flutter test`, and `flutter analyze` for all packages and examples in the repository. It is used by developers before comitting code. It takes a while to run, and is not idempotent: it will reformat code and add copyright notices if necessary. - -## Development Conventions - -### Code Style and Formatting - -- The project follows the linting rules in the `analysis_options.yaml` file at the top of the repo. -- Code formatting is enforced using the `dart_format` tool. -- The `tool/run_all_tests_and_fixes.sh` script should be run before committing to ensure all files are correctly formatted and analyzed. Don't run it every time you want to test something, just before you commit, since it does more than just test things, and takes a while. - -### Testing - -- Widget and unit tests are located in the `test` directory of each package/example. -- Tests are run using `flutter test`. -- The CI pipeline, defined in `.github/workflows/flutter_packages.yaml`, runs tests for all packages on every push and pull request to the `main` branch. - -### Copyright Headers - -- All files must have a copyright header. -- The `tool/fix_copyright.sh` script (which is called by `run_all_tests_and_fixes.sh`) can be used to automatically add or update copyright headers. - -### Firebase Integration - -- The examples and the `genui_firebase_ai` package use Firebase. -- A script at `tool/stub_firebase_options.sh` is used in CI to create a stub `firebase_options.dart` file. For local development, developers need to configure their own Firebase project by following the instructions in [`packages/genui/README.md`](packages/genui/README.md#configure-firebase-ai-logic). - -## Updating the Guides (`packages/genui/.guides`) - -When asked to update the developer guides located in `packages/genui/.guides`, it is critical to ensure the documentation accurately reflects the current state of the codebase. Before making any changes to the guides, you must read *all* the Dart code in the following packages: - -- `packages/genui` -- `packages/genui_firebase_ai` -- `packages/genui_google_generative_ai` -- `packages/genui_a2ui` - -This ensures that any code examples, API references, and architectural explanations in the guides are up-to-date and consistent with the actual implementation. - -## Draft pull requests - -Do not review pull requests when they are in draft state. Wait them to be ready for review. +Follow the specifications in `specs/README.md`. diff --git a/.gemini/README.md b/.gemini/README.md index c0ba461f6..2da6807a1 100644 --- a/.gemini/README.md +++ b/.gemini/README.md @@ -9,7 +9,6 @@ The files in this directory are used to customize the behavior of these tools fo - **`GEMINI.md`**: Provides project-specific context, instructions, and guidelines that are included in the context when using Gemini CLI and Code Assist. This helps the AI understand the project's conventions and requirements. - **`config.yaml`**: Configuration for the Gemini for GitHub tools, such as settings for code review. -- **`styleguide.md`**: Contains the project's style guide, which is used by the Gemini for Github tools to ensure that generated reviews adhere to the project's conventions. - **`commands/`**: A directory containing custom command definitions (e.g., `fix_code.toml`) for the Gemini CLI. ## Documentation 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/analysis_options.yaml b/analysis_options.yaml index a8a2f26cc..281a71bf8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ analyzer: language: strict-casts: true strict-inference: true + strict-raw-types: true linter: rules: @@ -28,6 +29,7 @@ linter: # correctness - always_declare_return_types + - avoid_annotating_with_dynamic - avoid_catching_errors - avoid_dynamic_calls - comment_references @@ -41,4 +43,5 @@ linter: - omit_obvious_local_variable_types - specify_nonobvious_local_variable_types - - specify_nonobvious_property_types \ No newline at end of file + - specify_nonobvious_property_types + - strict_top_level_inference diff --git a/examples/travel_app/lib/src/tools/booking/booking_service.dart b/examples/travel_app/lib/src/tools/booking/booking_service.dart index 88b93d4a5..04fd0d30b 100644 --- a/examples/travel_app/lib/src/tools/booking/booking_service.dart +++ b/examples/travel_app/lib/src/tools/booking/booking_service.dart @@ -24,8 +24,7 @@ class BookingService { } Future listHotels(HotelSearch search) async { - // ignore: inference_failure_on_instance_creation - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); return listHotelsSync(search); } @@ -33,8 +32,7 @@ class BookingService { List listingSelectionIds, String paymentMethodId, ) async { - // ignore: inference_failure_on_instance_creation - await Future.delayed(const Duration(milliseconds: 400)); + await Future.delayed(const Duration(milliseconds: 400)); } /// Synchronous version for example data generation. diff --git a/examples/travel_app/lib/src/widgets/conversation.dart b/examples/travel_app/lib/src/widgets/conversation.dart index 41d8c61e6..a3539d542 100644 --- a/examples/travel_app/lib/src/widgets/conversation.dart +++ b/examples/travel_app/lib/src/widgets/conversation.dart @@ -47,7 +47,7 @@ class Conversation extends StatelessWidget { case UserMessage(): return userPromptBuilder != null ? userPromptBuilder!(context, message) - : ChatMessageWidget( + : ChatMessageView( text: message.parts .whereType() .map((part) => part.text) @@ -63,7 +63,7 @@ class Conversation extends StatelessWidget { if (text.trim().isEmpty) { return const SizedBox.shrink(); } - return ChatMessageWidget( + return ChatMessageView( text: text, icon: Icons.smart_toy_outlined, alignment: MainAxisAlignment.start, @@ -78,13 +78,13 @@ class Conversation extends StatelessWidget { ), ); case InternalMessage(): - return InternalMessageWidget(content: message.text); + return InternalMessageView(content: message.text); case UserUiInteractionMessage(): return userUiInteractionBuilder != null ? userUiInteractionBuilder!(context, message) : const SizedBox.shrink(); case ToolResponseMessage(): - return InternalMessageWidget(content: message.results.toString()); + return InternalMessageView(content: message.results.toString()); } }, ); 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..1da6c9900 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,5 +1,10 @@ # `genui` Changelog +## 0.7.0 (in progress) + +- **Fix**: Improved error handling for catalog example loading to include context about the invalid item (#653). +- **BREAKING**: Renamed `ChatMessageWidget` to `ChatMessageView` and `InternalMessageWidget` to `InternalMessageView` (#661). + ## 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/genui.dart b/packages/genui/lib/genui.dart index bbe293e8a..7a93d55a5 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -16,11 +16,11 @@ export 'src/core/genui_surface.dart'; export 'src/core/prompt_fragments.dart'; export 'src/core/ui_tools.dart'; export 'src/core/widget_utilities.dart'; -export 'src/core/widgets/chat_primitives.dart'; export 'src/development_utilities/catalog_view.dart'; export 'src/facade/direct_call_integration/model.dart'; export 'src/facade/direct_call_integration/utils.dart'; export 'src/facade/gen_ui_conversation.dart'; +export 'src/facade/widgets/chat_primitives.dart'; export 'src/model/a2ui_client_capabilities.dart'; export 'src/model/a2ui_message.dart'; export 'src/model/a2ui_schemas.dart'; diff --git a/packages/genui/lib/src/content_generator.dart b/packages/genui/lib/src/content_generator.dart index 00e9d7d81..359a48886 100644 --- a/packages/genui/lib/src/content_generator.dart +++ b/packages/genui/lib/src/content_generator.dart @@ -11,15 +11,15 @@ import 'model/a2ui_message.dart'; import 'model/chat_message.dart'; /// An error produced by a [ContentGenerator]. -final class ContentGeneratorError { +final class ContentGeneratorError implements Exception { /// The error that occurred. final Object error; /// The stack trace of the error. - final StackTrace stackTrace; + final StackTrace? stackTrace; /// Creates a [ContentGeneratorError]. - const ContentGeneratorError(this.error, this.stackTrace); + const ContentGeneratorError(this.error, [this.stackTrace]); } /// An abstract interface for a content generator. 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/lib/src/core/widgets/chat_primitives.dart b/packages/genui/lib/src/facade/widgets/chat_primitives.dart similarity index 89% rename from packages/genui/lib/src/core/widgets/chat_primitives.dart rename to packages/genui/lib/src/facade/widgets/chat_primitives.dart index 7f40b22d6..5df6bf0dc 100644 --- a/packages/genui/lib/src/core/widgets/chat_primitives.dart +++ b/packages/genui/lib/src/facade/widgets/chat_primitives.dart @@ -5,9 +5,9 @@ import 'package:flutter/material.dart'; /// A widget to display an internal message in the chat. -class InternalMessageWidget extends StatelessWidget { - /// Creates a new [InternalMessageWidget]. - const InternalMessageWidget({super.key, required this.content}); +class InternalMessageView extends StatelessWidget { + /// Creates a new [InternalMessageView]. + const InternalMessageView({super.key, required this.content}); /// The content of the message. final String content; @@ -27,9 +27,9 @@ class InternalMessageWidget extends StatelessWidget { } /// A widget to display a chat message. -class ChatMessageWidget extends StatelessWidget { - /// Creates a new [ChatMessageWidget]. - const ChatMessageWidget({ +class ChatMessageView extends StatelessWidget { + /// Creates a new [ChatMessageView]. + const ChatMessageView({ super.key, required this.text, required this.icon, diff --git a/packages/genui/test/model/data_model_test.dart b/packages/genui/test/model/data_model_test.dart index 4832d0bff..a7540bb02 100644 --- a/packages/genui/test/model/data_model_test.dart +++ b/packages/genui/test/model/data_model_test.dart @@ -148,9 +148,9 @@ void main() { }); test('notifies on child updates', () { - final ValueNotifier?> notifier = dataModel - .subscribe(DataPath('/a')); - Map? value; + final ValueNotifier?> notifier = dataModel + .subscribe>(DataPath('/a')); + Map? value; notifier.addListener(() => value = notifier.value); dataModel.update(DataPath('/a/b'), 1); expect(value, {'b': 1}); @@ -180,8 +180,8 @@ void main() { }); test('does not notify on child updates', () { - final ValueNotifier?> notifier = dataModel - .subscribeToValue(DataPath('/a')); + final ValueNotifier?> notifier = dataModel + .subscribeToValue>(DataPath('/a')); var callCount = 0; notifier.addListener(() => callCount++); dataModel.update(DataPath('/a/b'), 1); @@ -232,7 +232,10 @@ void main() { ], }, ]); - expect(dataModel.getValue(DataPath('/d')), {'d1': 'v1', 'd2': 2}); + expect(dataModel.getValue>(DataPath('/d')), { + 'd1': 'v1', + 'd2': 2, + }); }); test('is permissive with multiple value types', () { @@ -304,7 +307,9 @@ void main() { test('Empty path on getValue returns current data', () { dataModel.update(DataPath('/a'), {'b': 1}); - expect(dataModel.getValue(DataPath('/a')), {'b': 1}); + expect(dataModel.getValue>(DataPath('/a')), { + 'b': 1, + }); }); test('Nested structures are created automatically', () { 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/example/lib/src/jumping_dots.dart b/packages/genui_dartantic/example/lib/src/jumping_dots.dart index 8247987cf..4aebfc3f3 100644 --- a/packages/genui_dartantic/example/lib/src/jumping_dots.dart +++ b/packages/genui_dartantic/example/lib/src/jumping_dots.dart @@ -55,9 +55,11 @@ class _JumpingDotsState extends State controller.reverse(); } }); - await Future.delayed(const Duration(milliseconds: 100)); // Stagger delay + await Future.delayed( + const Duration(milliseconds: 100), + ); // Stagger delay } - await Future.delayed( + await Future.delayed( const Duration(milliseconds: 1000), ); // Delay between loops if (mounted) _startAnimations(); // Loop 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_dartantic/test/dartantic_schema_adapter_test.dart b/packages/genui_dartantic/test/dartantic_schema_adapter_test.dart index c31f86188..267b6f4b0 100644 --- a/packages/genui_dartantic/test/dartantic_schema_adapter_test.dart +++ b/packages/genui_dartantic/test/dartantic_schema_adapter_test.dart @@ -116,7 +116,7 @@ void main() { final result = adaptSchema(schema); expect(result, isNotNull); - expect(result!.schemaMap!['anyOf'], isA()); + expect(result!.schemaMap!['anyOf'], isA>()); }); }); } 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/packages/json_schema_builder/lib/src/schema_registry.dart b/packages/json_schema_builder/lib/src/schema_registry.dart index 8036f7ac6..d33598edd 100644 --- a/packages/json_schema_builder/lib/src/schema_registry.dart +++ b/packages/json_schema_builder/lib/src/schema_registry.dart @@ -97,7 +97,7 @@ class SchemaRegistry { _registerIds(Schema.fromMap(map), baseUri); } - void recurseOnList(List list) { + void recurseOnList(List list) { for (final item in list) { if (item is Map) { recurseOnMap(item); @@ -144,7 +144,7 @@ class SchemaRegistry { // Keywords with list-of-schemas values const listOfSchemasKeywords = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; for (final keyword in listOfSchemasKeywords) { - if (schema.value[keyword] case final List list) { + if (schema.value[keyword] case final List list) { recurseOnList(list); } } diff --git a/packages/json_schema_builder/lib/src/schema_validation.dart b/packages/json_schema_builder/lib/src/schema_validation.dart index ff466498c..4e29e2658 100644 --- a/packages/json_schema_builder/lib/src/schema_validation.dart +++ b/packages/json_schema_builder/lib/src/schema_validation.dart @@ -352,7 +352,7 @@ extension SchemaValidation on Schema { } // 2. Schema Combiners: allOf, anyOf, oneOf, not - if (allOf case final List allOfList?) { + if (allOf case final List allOfList) { final allOfAnnotations = []; for (final subSchema in allOfList) { final ValidationResult result = await validateSubSchema( @@ -370,7 +370,7 @@ extension SchemaValidation on Schema { allAnnotations = allAnnotations.mergeAll(allOfAnnotations); } - if (anyOf case final List anyOfList?) { + if (anyOf case final List anyOfList) { var passedCount = 0; final anyOfAnnotations = []; final allAnyOfErrors = []; @@ -397,7 +397,7 @@ extension SchemaValidation on Schema { allAnnotations = allAnnotations.mergeAll(anyOfAnnotations); } - if (oneOf case final List oneOfList?) { + if (oneOf case final List oneOfList) { var passedCount = 0; AnnotationSet? oneOfAnnotations; for (final subSchema in oneOfList) { @@ -590,7 +590,7 @@ extension SchemaValidation on Schema { ); case JsonType.list: return await (this as ListSchema).validateList( - data as List, + data as List, currentPath, context, dynamicScope, @@ -895,7 +895,7 @@ extension SchemaValidation on Schema { /// This method is called by [validateTypeSpecificKeywords] when the data is /// a [List]. Future validateList( - List data, + List data, List currentPath, ValidationContext context, List dynamicScope, 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/specs/README.md b/specs/README.md new file mode 100644 index 000000000..6bb08cb47 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,37 @@ +# GenUI specifications + +This folder (./specs) provides technical specifications for the GenUI repository, +targeted at both AI models and human developers. + +## Index of Specifications + +This directory contains the following specifications: + +- [Style Guide](styleguide.md) + +## Note for AI models + +If you are an AI model, please read all the specifications in this folder, and follow them carefully. +To signal, that you have read and understood the specifications, please start your reviews and responses with the following text: + +``` +I have read and understood the specifications in ./specs. +``` + +## Documentation + +1. Documentation in the repository (all .md files) should be clear, consistent, concise and up-to-date. +2. Documentation should not contain details that are easy to infer from the code. +3. If code does not match the documentation, there should be TODO comments in the code to signal the discrepancy should be resolved. + +## Code reviews + +Do not review pull requests when they are in draft state, unless explicitly requested by the author. + +## Key commands + +- **Run all checks and tests:** + + ```bash + ./tool/run_all_tests_and_fixes.sh + ``` diff --git a/.gemini/styleguide.md b/specs/styleguide.md similarity index 100% rename from .gemini/styleguide.md rename to specs/styleguide.md diff --git a/tool/fix_copyright/test/fix_copyright_test.dart b/tool/fix_copyright/test/fix_copyright_test.dart index d2b9c64f3..8a4310a3a 100644 --- a/tool/fix_copyright/test/fix_copyright_test.dart +++ b/tool/fix_copyright/test/fix_copyright_test.dart @@ -388,7 +388,7 @@ class FakeProcessManager implements ProcessManager { final List> commands = []; @override - bool canRun(dynamic executable, {String? workingDirectory}) => true; + bool canRun(Object? executable, {String? workingDirectory}) => true; @override bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) => true; 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