diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index b8dbdaaec19c..0de04b5c038c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -12,24 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; // Import after file is generated through flutterfire_cli. // import 'package:firebase_ai_example/firebase_options.dart'; -import 'pages/chat_page.dart'; import 'pages/audio_page.dart'; +import 'pages/bidi_page.dart'; +import 'pages/chat_page.dart'; +import 'pages/document.dart'; import 'pages/function_calling_page.dart'; import 'pages/image_prompt_page.dart'; -import 'pages/token_count_page.dart'; -import 'pages/schema_page.dart'; import 'pages/imagen_page.dart'; -import 'pages/document.dart'; +import 'pages/json_schema_page.dart'; +import 'pages/schema_page.dart'; +import 'pages/token_count_page.dart'; import 'pages/video_page.dart'; -import 'pages/bidi_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -64,11 +65,11 @@ class _GenerativeAISampleState extends State { void _initializeModel(bool useVertexBackend) { if (useVertexBackend) { final vertexInstance = FirebaseAI.vertexAI(auth: FirebaseAuth.instance); - _currentModel = vertexInstance.generativeModel(model: 'gemini-1.5-flash'); + _currentModel = vertexInstance.generativeModel(model: 'gemini-2.5-flash'); _currentImagenModel = _initializeImagenModel(vertexInstance); } else { final googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance); - _currentModel = googleAI.generativeModel(model: 'gemini-2.0-flash'); + _currentModel = googleAI.generativeModel(model: 'gemini-2.5-flash'); _currentImagenModel = _initializeImagenModel(googleAI); } } @@ -184,10 +185,12 @@ class _HomeScreenState extends State { case 6: return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); case 7: - return DocumentPage(title: 'Document Prompt', model: currentModel); + return JsonSchemaPage(title: 'JSON Schema', model: currentModel); case 8: - return VideoPage(title: 'Video Prompt', model: currentModel); + return DocumentPage(title: 'Document Prompt', model: currentModel); case 9: + return VideoPage(title: 'Video Prompt', model: currentModel); + case 10: return BidiPage( title: 'Live Stream', model: currentModel, @@ -230,6 +233,9 @@ class _HomeScreenState extends State { onChanged: widget.onBackendChanged, activeTrackColor: Colors.green.withValues(alpha: 0.5), inactiveTrackColor: Colors.blueGrey.withValues(alpha: 0.5), + // Ignore until activeThumbColor is available on Flutter + // stable. + // ignore: deprecated_member_use activeColor: Colors.green, inactiveThumbColor: Colors.blueGrey, ), @@ -302,6 +308,11 @@ class _HomeScreenState extends State { label: 'Schema', tooltip: 'Schema Prompt', ), + BottomNavigationBarItem( + icon: Icon(Icons.data_object), + label: 'JSON', + tooltip: 'JSON Schema', + ), BottomNavigationBarItem( icon: Icon(Icons.edit_document), label: 'Document', diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart new file mode 100644 index 000000000000..18f0b6e4939d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart @@ -0,0 +1,200 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class JsonSchemaPage extends StatefulWidget { + const JsonSchemaPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _JsonSchemaPageState(); +} + +class _JsonSchemaPageState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + bool _loading = false; + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _promptJsonSchemaTest(); + } + : null, + child: const Text('JSON Schema Prompt'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _promptJsonSchemaTest() async { + setState(() { + _loading = true; + }); + try { + final content = [ + Content.text( + 'Generate a widget hierarchy with a column containing two text widgets ', + ), + ]; + + final jsonSchema = { + r'$defs': { + 'text_widget': { + r'$anchor': 'text_widget', + 'type': 'object', + 'properties': { + 'type': {'const': 'Text'}, + 'text': {'type': 'string'}, + }, + 'required': ['type', 'text'], + }, + }, + 'type': 'object', + 'properties': { + 'type': {'const': 'Column'}, + 'children': { + 'type': 'array', + 'items': { + 'anyOf': [ + {r'$ref': '#text_widget'}, + { + 'type': 'object', + 'properties': { + 'type': {'const': 'Row'}, + 'children': { + 'type': 'array', + 'items': {r'$ref': '#text_widget'}, + }, + }, + 'required': ['type', 'children'], + } + ], + }, + }, + }, + 'required': ['type', 'children'], + }; + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseJsonSchema: jsonSchema, + ), + ); + + var text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text ?? '') as Object?); + _messages.add(MessageData(text: '```json$text```', fromUser: false)); + + setState(() { + _loading = false; + _scrollDown(); + }); + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart index fcbdef64499e..e59378ac6100 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:firebase_ai/firebase_ai.dart'; import '../widgets/message_widget.dart'; @@ -132,13 +134,14 @@ class _SchemaPromptPageState extends State { ), ); - var text = response.text; - _messages.add(MessageData(text: text, fromUser: false)); - - if (text == null) { + if (response.text == null) { _showError('No response from API.'); return; } else { + final text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text!) as Object?); + _messages + .add(MessageData(text: '```json\n$text\n```', fromUser: false)); setState(() { _loading = false; _scrollDown(); diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index 83dbb82407ed..7e4e8fc96d87 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -998,8 +998,10 @@ final class GenerationConfig extends BaseGenerationConfig { super.responseModalities, this.responseMimeType, this.responseSchema, + this.responseJsonSchema, this.thinkingConfig, - }); + }) : assert(responseSchema == null || responseJsonSchema == null, + 'responseSchema and responseJsonSchema cannot both be set.'); /// The set of character sequences (up to 5) that will stop output generation. /// @@ -1018,8 +1020,28 @@ final class GenerationConfig extends BaseGenerationConfig { /// /// - Note: This only applies when the [responseMimeType] supports /// a schema; currently this is limited to `application/json`. + /// + /// Only one of [responseSchema] or [responseJsonSchema] may be specified at + /// the same time. final Schema? responseSchema; + /// The response schema as a JSON-compatible map. + /// + /// - Note: This only applies when the [responseMimeType] supports a schema; + /// currently this is limited to `application/json`. + /// + /// This schema can include more advanced features of JSON than the [Schema] + /// class taken by [responseSchema] supports. See the [Gemini + /// documentation](https://ai.google.dev/api/generate-content#FIELDS.response_json_schema) + /// about the limitations of this feature. + /// + /// Notably, this feature is only supported on Gemini 2.5 and later. Use + /// [responseSchema] for earlier models. + /// + /// Only one of [responseSchema] or [responseJsonSchema] may be specified at + /// the same time. + final Map? responseJsonSchema; + /// Config for thinking features. /// /// An error will be returned if this field is set for models that don't @@ -1036,6 +1058,8 @@ final class GenerationConfig extends BaseGenerationConfig { 'responseMimeType': responseMimeType, if (responseSchema case final responseSchema?) 'responseSchema': responseSchema.toJson(), + if (responseJsonSchema case final responseJsonSchema?) + 'responseJsonSchema': responseJsonSchema, if (thinkingConfig case final thinkingConfig?) 'thinkingConfig': thinkingConfig.toJson(), }; diff --git a/packages/firebase_ai/firebase_ai/test/api_test.dart b/packages/firebase_ai/firebase_ai/test/api_test.dart index ed5596868d62..69f6065bf63e 100644 --- a/packages/firebase_ai/firebase_ai/test/api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/api_test.dart @@ -11,6 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +import 'dart:convert'; + import 'package:firebase_ai/firebase_ai.dart'; import 'package:firebase_ai/src/api.dart'; @@ -442,6 +445,38 @@ void main() { }); }); + test('GenerationConfig toJson with responseJsonSchema', () { + final jsonSchema = { + 'type': 'object', + 'properties': { + 'recipeName': {'type': 'string'} + }, + 'required': ['recipeName'] + }; + final config = GenerationConfig( + responseMimeType: 'application/json', + responseJsonSchema: jsonSchema, + ); + final json = config.toJson(); + expect(json['responseMimeType'], 'application/json'); + final dynamic responseSchema = json['responseJsonSchema']; + expect(responseSchema, isA>()); + expect(responseSchema, equals(jsonSchema)); + }); + + test( + 'throws assertion if both responseSchema and responseJsonSchema are provided', + () { + final schema = Schema.object(properties: {}); + final jsonSchema = + (json.decode('{"type": "string", "title": "MyString"}') as Map) + .cast(); + expect( + () => GenerationConfig( + responseSchema: schema, responseJsonSchema: jsonSchema), + throwsA(isA())); + }); + test('GenerationConfig toJson with empty stopSequences (omitted)', () { final config = GenerationConfig(stopSequences: []); expect(config.toJson(), {}); // Empty list for stopSequences is omitted