diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 296fbbc79a91..16eef3c46e8d 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -25,13 +25,10 @@ import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; import 'pages/function_calling_page.dart'; import 'pages/image_generation_page.dart'; -import 'pages/image_prompt_page.dart'; -import 'pages/json_schema_page.dart'; -import 'pages/multimodal_page.dart'; -import 'pages/schema_page.dart'; +import 'pages/capabilities_page.dart'; import 'pages/server_template_page.dart'; import 'pages/grounding_page.dart'; -import 'pages/token_count_page.dart'; +import 'pages/integration_test_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -144,38 +141,33 @@ class _HomeScreenState extends State { useVertexBackend: useVertexBackend, ); case 1: - return MultimodalPage(title: 'Multimodal', model: currentModel); + return CapabilitiesPage( + title: 'Capabilities', + model: currentModel, + ); case 2: - return TokenCountPage(title: 'Token Count', model: currentModel); - case 3: // FunctionCallingPage initializes its own model as per original design return FunctionCallingPage( title: 'Function Calling', useVertexBackend: useVertexBackend, ); - case 4: - return ImagePromptPage(title: 'Image Prompt', model: currentModel); - case 5: + case 3: return ImageGenerationPage( title: 'Image Gen', useVertexBackend: useVertexBackend, ); - case 6: - return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); - case 7: - return JsonSchemaPage(title: 'JSON Schema', model: currentModel); - case 8: + case 4: return BidiPage( title: 'Live Stream', model: currentModel, useVertexBackend: useVertexBackend, ); - case 9: + case 5: return ServerTemplatePage( title: 'Server Template', useVertexBackend: useVertexBackend, ); - case 10: + case 6: return GroundingPage( title: 'Grounding', useVertexBackend: useVertexBackend, @@ -198,6 +190,18 @@ class _HomeScreenState extends State { 'Flutter + ${widget.useVertexBackend ? 'Vertex AI' : 'Google AI'}', ), actions: [ + IconButton( + icon: const Icon(Icons.playlist_play), + tooltip: 'Run Integration Tests', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const IntegrationTestPage(), + ), + ); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -259,40 +263,20 @@ class _HomeScreenState extends State { tooltip: 'Chat', ), BottomNavigationBarItem( - icon: Icon(Icons.perm_media), - label: 'Multimodal', - tooltip: 'Multimodal Prompt', - ), - BottomNavigationBarItem( - icon: Icon(Icons.numbers), - label: 'Tokens', - tooltip: 'Token Count', + icon: Icon(Icons.star), + label: 'Capabilities', + tooltip: 'Model Capabilities', ), BottomNavigationBarItem( icon: Icon(Icons.functions), label: 'Functions', tooltip: 'Function Calling', ), - BottomNavigationBarItem( - icon: Icon(Icons.image), - label: 'Image', - tooltip: 'Image Prompt', - ), BottomNavigationBarItem( icon: Icon(Icons.brush), label: 'NanoBanana', tooltip: 'Image Generation', ), - BottomNavigationBarItem( - icon: Icon(Icons.schema), - label: 'Schema', - tooltip: 'Schema Prompt', - ), - BottomNavigationBarItem( - icon: Icon(Icons.data_object), - label: 'JSON', - tooltip: 'JSON Schema', - ), BottomNavigationBarItem( icon: Icon( Icons.stream, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart new file mode 100644 index 000000000000..6787f1cce1aa --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart @@ -0,0 +1,884 @@ +// Copyright 2026 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 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/services.dart'; +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; +import '../widgets/message_widget.dart'; + +final record = AudioRecorder(); + +class CapabilitiesPage extends StatefulWidget { + const CapabilitiesPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _CapabilitiesPageState(); +} + +class _CapabilitiesPageState extends State + with TickerProviderStateMixin { + late final TabController _tabController; + + // Multimodal Tab State + final ScrollController _multimodalScrollController = ScrollController(); + final List _multimodalMessages = []; + bool _multimodalLoading = false; + bool _recording = false; + + // Structured Tab State + final ScrollController _structuredScrollController = ScrollController(); + final List _structuredMessages = []; + bool _structuredLoading = false; + + // Tokens Tab State + final ScrollController _tokensScrollController = ScrollController(); + final List _tokensMessages = []; + bool _tokensLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _multimodalScrollController.dispose(); + _structuredScrollController.dispose(); + _tokensScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Multimodal'), + Tab(text: 'Structured'), + Tab(text: 'Tokens'), + ], + ), + ), + body: SelectionArea( + child: TabBarView( + controller: _tabController, + children: [ + _buildMultimodalTab(), + _buildStructuredTab(), + _buildTokensTab(), + ], + ), + ), + ); + } + + void _scrollDown(ScrollController controller) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (controller.hasClients) { + controller.animateTo( + controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 750), + curve: Curves.easeOutCirc, + ); + } + }, + ); + } + + 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'), + ), + ], + ); + }, + ); + } + + // ========================================== + // MULTIMODAL TAB LOGIC + // ========================================== + + Future _sendImagePrompt() async { + setState(() { + _multimodalLoading = true; + }); + try { + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); + const promptText = + "describe the two pictures and compare what's in common"; + final content = [ + Content.multi([ + const TextPart(promptText), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), + ]), + ]; + _multimodalMessages.add( + MessageData( + imageBytes: catBytes.buffer.asUint8List(), + text: promptText, + fromUser: true, + ), + ); + _multimodalMessages.add( + MessageData( + imageBytes: sconeBytes.buffer.asUint8List(), + fromUser: true, + ), + ); + + var response = await widget.model.generateContent(content); + var text = response.text; + _multimodalMessages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + } else { + setState(() { + _multimodalLoading = false; + _scrollDown(_multimodalScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _multimodalLoading = false; + }); + } finally { + setState(() { + _multimodalLoading = false; + }); + } + } + + Future _sendStorageUriPrompt() async { + setState(() { + _multimodalLoading = true; + }); + try { + const promptText = 'Describe this image'; + final content = [ + Content.multi([ + const TextPart(promptText), + const FileData( + 'image/jpeg', + 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', + ), + ]), + ]; + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); + + var response = await widget.model.generateContent(content); + var text = response.text; + _multimodalMessages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + } else { + setState(() { + _multimodalLoading = false; + _scrollDown(_multimodalScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _multimodalLoading = false; + }); + } finally { + setState(() { + _multimodalLoading = false; + }); + } + } + + Future recordAudio() async { + if (!await record.hasPermission()) { + debugPrint('Audio recording permission denied'); + return; + } + + final dir = Directory( + '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', + ); + + await dir.create(recursive: true); + + String filePath = + '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await record.start( + const RecordConfig( + encoder: AudioEncoder.wav, + ), + path: filePath, + ); + } + + Future stopRecord() async { + var path = await record.stop(); + + if (path == null) { + debugPrint('Failed to stop recording'); + return; + } + + debugPrint('Recording saved to: $path'); + + try { + File file = File(path); + final audio = await file.readAsBytes(); + debugPrint('Audio file size: ${audio.length} bytes'); + + final audioPart = InlineDataPart('audio/wav', audio); + + await _submitAudioToModel(audioPart); + + await file.delete(); + debugPrint('Recording deleted successfully.'); + } catch (e) { + debugPrint('Error processing recording: $e'); + } + } + + Future _submitAudioToModel(InlineDataPart audioPart) async { + try { + String textPrompt = 'What is in the audio recording?'; + const prompt = TextPart('What is in the audio recording?'); + + setState(() { + _multimodalMessages.add(MessageData(text: textPrompt, fromUser: true)); + _multimodalLoading = true; + }); + + final response = await widget.model.generateContent([ + Content.multi([prompt, audioPart]), + ]); + + setState(() { + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending audio to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Future _testVideo() async { + try { + setState(() { + _multimodalLoading = true; + }); + + ByteData videoBytes = + await rootBundle.load('assets/videos/landscape.mp4'); + + const promptText = 'Can you tell me what is in the video?'; + + setState(() { + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); + }); + + final videoPart = + InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([const TextPart(promptText), videoPart]), + ]); + + setState(() { + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending video to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Future _testDocumentReading() async { + try { + setState(() { + _multimodalLoading = true; + }); + + ByteData docBytes = + await rootBundle.load('assets/documents/gemini_summary.pdf'); + + const promptText = + 'Write me a summary in one sentence what this document is about.'; + + setState(() { + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); + }); + + final pdfPart = + InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([const TextPart(promptText), pdfPart]), + ]); + + setState(() { + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending document to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Widget _buildMultimodalTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _multimodalScrollController, + itemBuilder: (context, idx) { + var content = _multimodalMessages[idx]; + return MessageWidget( + text: content.text, + image: content.imageBytes == null + ? null + : Image.memory( + content.imageBytes!, + cacheWidth: 400, + cacheHeight: 400, + ), + isFromUser: content.fromUser ?? false, + ); + }, + itemCount: _multimodalMessages.length, + ), + ), + if (_multimodalLoading && !_recording) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 5), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: _multimodalLoading + ? null + : () async { + setState(() { + _recording = !_recording; + }); + if (_recording) { + await recordAudio(); + } else { + await stopRecord(); + } + }, + icon: Icon( + Icons.mic, + color: _recording ? Colors.red : null, + ), + label: Text(_recording ? 'Stop Rec' : 'Record Audio'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _sendImagePrompt, + icon: const Icon(Icons.image), + label: const Text('Test Images'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _sendStorageUriPrompt, + icon: const Icon(Icons.storage), + label: const Text('Test GCS Image'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _testVideo, + icon: const Icon(Icons.video_collection), + label: const Text('Test Video'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _testDocumentReading, + icon: const Icon(Icons.edit_document), + label: const Text('Test Doc'), + ), + ], + ), + ), + ], + ), + ); + } + + // ========================================== + // STRUCTURED TAB LOGIC + // ========================================== + + Future _promptSchemaTest() async { + setState(() { + _structuredLoading = true; + }); + try { + final content = [ + Content.text( + "For use in a children's card game, generate 10 animal-based " + 'characters.', + ), + ]; + + final jsonSchema = Schema.object( + properties: { + 'characters': Schema.array( + items: Schema.object( + properties: { + 'name': + Schema.string(description: 'The name of the character.'), + 'species': Schema.string(description: 'The animal species.'), + 'age': Schema.integer( + description: 'The age of the character in years.', + minimum: 1, + maximum: 1000, + ), + 'isMythical': Schema.boolean( + description: 'Whether the animal is a mythical creature.', + ), + 'powerLevel': Schema.number( + description: 'Power level from 0.0 to 10.0.', + minimum: 0, + maximum: 10, + ), + 'accessory': Schema.enumString( + enumValues: ['hat', 'belt', 'shoes'], + description: 'Optional accessory.', + nullable: true, + ), + }, + propertyOrdering: [ + 'name', + 'species', + 'age', + 'isMythical', + 'powerLevel', + 'accessory', + ], + ), + ), + }, + description: 'A list of characters for a card game.', + optionalProperties: ['accessory'], + ); + + _structuredMessages.add( + MessageData( + text: 'Generate 10 animal-based characters (Schema)', + fromUser: true, + ), + ); + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: jsonSchema, + ), + ); + + if (response.text == null) { + _showError('No response from API.'); + } else { + final text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text!) as Object?); + _structuredMessages + .add(MessageData(text: '```json\n$text\n```', fromUser: false)); + setState(() { + _structuredLoading = false; + _scrollDown(_structuredScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _structuredLoading = false; + }); + } + } + + Future _promptJsonSchemaTest() async { + setState(() { + _structuredLoading = true; + }); + try { + final content = [ + Content.text( + 'Generate a widget hierarchy with a column containing two text widgets ', + ), + ]; + + final textWidgetSchema = JSONSchema.object( + properties: { + 'type': JSONSchema.enumString(enumValues: ['Text']), + 'text': JSONSchema.string(), + }, + ); + + final rowWidgetSchema = JSONSchema.object( + properties: { + 'type': JSONSchema.enumString(enumValues: ['Row']), + 'children': JSONSchema.array( + items: JSONSchema.ref(r'#/$defs/text_widget'), + ), + }, + ); + + final jsonSchema = JSONSchema.object( + defs: { + 'text_widget': textWidgetSchema, + }, + properties: { + 'type': JSONSchema.enumString(enumValues: ['Column']), + 'children': JSONSchema.array( + items: JSONSchema.anyOf( + schemas: [ + JSONSchema.ref(r'#/$defs/text_widget'), + rowWidgetSchema, + ], + ), + ), + }, + ); + + _structuredMessages.add( + MessageData( + text: 'Generate a widget hierarchy... (JSON Schema)', + fromUser: true, + ), + ); + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseJsonSchema: jsonSchema.toJson(), + ), + ); + + var text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text ?? '') as Object?); + _structuredMessages + .add(MessageData(text: '```json\n$text\n```', fromUser: false)); + + setState(() { + _structuredLoading = false; + _scrollDown(_structuredScrollController); + }); + } catch (e) { + _showError(e.toString()); + setState(() { + _structuredLoading = false; + }); + } + } + + Widget _buildStructuredTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _structuredScrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _structuredMessages[idx].text, + isFromUser: _structuredMessages[idx].fromUser ?? false, + ); + }, + itemCount: _structuredMessages.length, + ), + ), + if (_structuredLoading) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _structuredLoading ? null : _promptSchemaTest, + child: const Text('Schema Prompt'), + ), + ElevatedButton( + onPressed: _structuredLoading ? null : _promptJsonSchemaTest, + child: const Text('JSON Schema Prompt'), + ), + ], + ), + ), + ], + ), + ); + } + + // ========================================== + // TOKENS TAB LOGIC + // ========================================== + + Future _testCountToken() async { + setState(() { + _tokensLoading = true; + }); + + try { + // 1. Text only + const prompt = 'tell a short story'; + _tokensMessages.add( + MessageData( + text: 'Count tokens for text: "$prompt"', + fromUser: true, + ), + ); + + final content = Content.text(prompt); + final tokenResponse = await widget.model.countTokens([content]); + _tokensMessages.add( + MessageData( + text: 'Token Count (Text): ${tokenResponse.totalTokens}', + fromUser: false, + ), + ); + + // 2. Multimodal (Text + Image + PDF) + _tokensMessages.add( + MessageData( + text: + 'Count tokens for Multimodal (Text + cat.jpg + gemini_summary.pdf)', + fromUser: true, + ), + ); + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + ByteData docBytes = + await rootBundle.load('assets/documents/gemini_summary.pdf'); + final multimodalContent = Content.multi([ + const TextPart('Describe this cat and summarize this document.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + InlineDataPart('application/pdf', docBytes.buffer.asUint8List()), + ]); + + final multimodalTokenResponse = + await widget.model.countTokens([multimodalContent]); + final promptDetails = multimodalTokenResponse.promptTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + + _tokensMessages.add( + MessageData( + text: + 'Token Count (Multimodal): ${multimodalTokenResponse.totalTokens}\n' + 'Details: $promptDetails', + fromUser: false, + ), + ); + } catch (e) { + _showError(e.toString()); + } finally { + setState(() { + _tokensLoading = false; + _scrollDown(_tokensScrollController); + }); + } + } + + Future _testUsageMetadata() async { + setState(() { + _tokensLoading = true; + }); + + _tokensMessages.add( + MessageData( + text: + 'Generate content with text + cat.jpg (with Thinking enabled) and check Usage Metadata details', + fromUser: true, + ), + ); + + try { + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + final content = [ + Content.multi([ + const TextPart('Describe this image in detail.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + ]), + ]; + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + thinkingConfig: + ThinkingConfig.withThinkingBudget(2048, includeThoughts: true), + ), + ); + + // Extract thoughts if any + final thoughts = response.candidates.firstOrNull?.content.parts + .whereType() + .where((p) => p.isThought ?? false) + .map((p) => p.text) + .join(); + + if (thoughts != null && thoughts.isNotEmpty) { + _tokensMessages.add( + MessageData( + text: thoughts, + fromUser: false, + isThought: true, + ), + ); + } + + final usageMetadata = response.usageMetadata; + + if (usageMetadata != null) { + final promptDetails = usageMetadata.promptTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + final candidateDetails = usageMetadata.candidatesTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + + final message = ''' +Usage Metadata: +- promptTokenCount: ${usageMetadata.promptTokenCount} (Details: $promptDetails) +- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} (Details: $candidateDetails) +- totalTokenCount: ${usageMetadata.totalTokenCount} +- thoughtsTokenCount: ${usageMetadata.thoughtsTokenCount} +- cachedContentTokenCount: ${usageMetadata.cachedContentTokenCount} +'''; + _tokensMessages.add( + MessageData( + text: message, + fromUser: false, + ), + ); + } else { + _tokensMessages.add( + MessageData( + text: 'No usage metadata available.', + fromUser: false, + ), + ); + } + } catch (e) { + _showError(e.toString()); + } finally { + setState(() { + _tokensLoading = false; + _scrollDown(_tokensScrollController); + }); + } + } + + Widget _buildTokensTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _tokensScrollController, + itemBuilder: (context, idx) { + final msg = _tokensMessages[idx]; + return MessageWidget( + text: msg.text, + isFromUser: msg.fromUser ?? false, + isThought: msg.isThought, + ); + }, + itemCount: _tokensMessages.length, + ), + ), + if (_tokensLoading) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _tokensLoading ? null : _testCountToken, + child: const Text('Count Tokens'), + ), + ElevatedButton( + onPressed: _tokensLoading ? null : _testUsageMetadata, + child: const Text('Usage Metadata'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart index 6c5aad8fb6e0..68e52d5b5fe9 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart @@ -78,7 +78,7 @@ class _GroundingPageState extends State { : FirebaseAI.googleAI(); _model = aiProvider.generativeModel( - model: 'gemini-3.1-flash-lite', + model: 'gemini-3.5-flash', tools: tools.isNotEmpty ? tools : null, toolConfig: toolConfig, ); diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart deleted file mode 100644 index 5c5009ca3158..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart +++ /dev/null @@ -1,248 +0,0 @@ -// 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 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/services.dart'; -import '../widgets/message_widget.dart'; - -class ImagePromptPage extends StatefulWidget { - const ImagePromptPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _ImagePromptPageState(); -} - -class _ImagePromptPageState extends State { - final ScrollController _scrollController = ScrollController(); - final TextEditingController _textController = TextEditingController(); - final FocusNode _textFieldFocus = FocusNode(); - final List _generatedContent = []; - 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) { - var content = _generatedContent[idx]; - return MessageWidget( - text: content.text, - image: content.imageBytes == null - ? null - : Image.memory( - content.imageBytes!, - cacheWidth: 400, - cacheHeight: 400, - ), - isFromUser: content.fromUser ?? false, - ); - }, - itemCount: _generatedContent.length, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 25, - horizontal: 15, - ), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - focusNode: _textFieldFocus, - controller: _textController, - ), - ), - const SizedBox.square( - dimension: 15, - ), - if (!_loading) - IconButton( - onPressed: () async { - await _sendImagePrompt(_textController.text); - }, - icon: Icon( - Icons.image, - color: Theme.of(context).colorScheme.primary, - ), - ), - if (!_loading) - IconButton( - onPressed: () async { - await _sendStorageUriPrompt(_textController.text); - }, - icon: Icon( - Icons.storage, - color: Theme.of(context).colorScheme.primary, - ), - ) - else - const CircularProgressIndicator(), - ], - ), - ), - ], - ), - ), - ); - } - - Future _sendImagePrompt(String message) async { - setState(() { - _loading = true; - }); - try { - ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); - ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); - final content = [ - Content.multi([ - TextPart(message), - // The only accepted mime types are image/*. - InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), - InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), - ]), - ]; - _generatedContent.add( - MessageData( - imageBytes: catBytes.buffer.asUint8List(), - text: message, - fromUser: true, - ), - ); - _generatedContent.add( - MessageData( - imageBytes: sconeBytes.buffer.asUint8List(), - fromUser: true, - ), - ); - - var response = await widget.model.generateContent(content); - var text = response.text; - _generatedContent.add(MessageData(text: text, fromUser: false)); - - if (text == null) { - _showError('No response from API.'); - return; - } else { - setState(() { - _loading = false; - _scrollDown(); - }); - } - } catch (e) { - _showError(e.toString()); - setState(() { - _loading = false; - }); - } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - } - } - - Future _sendStorageUriPrompt(String message) async { - setState(() { - _loading = true; - }); - try { - final content = [ - Content.multi([ - TextPart(message), - const FileData( - 'image/jpeg', - 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', - ), - ]), - ]; - _generatedContent.add(MessageData(text: message, fromUser: true)); - - var response = await widget.model.generateContent(content); - var text = response.text; - _generatedContent.add(MessageData(text: text, fromUser: false)); - - if (text == null) { - _showError('No response from API.'); - return; - } else { - 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/integration_test_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart new file mode 100644 index 000000000000..46064e810058 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart @@ -0,0 +1,1025 @@ +// Copyright 2026 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:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:firebase_ai/firebase_ai.dart'; + +class IntegrationTestPage extends StatefulWidget { + const IntegrationTestPage({super.key}); + + @override + State createState() => _IntegrationTestPageState(); +} + +enum TestStatus { pending, running, passed, failed } + +class TestResult { + final TestStatus status; + final String logs; + final String? responseJson; + final String? errorMessage; + + TestResult({ + required this.status, + required this.logs, + this.responseJson, + this.errorMessage, + }); + + static TestResult pending() => + TestResult(status: TestStatus.pending, logs: 'Pending...'); +} + +class TestItem { + final String id; + final String name; + final String description; + final Future Function(FirebaseAI provider, TestLogger logger) run; + TestResult googleAIResult; + TestResult vertexAIResult; + + TestItem({ + required this.id, + required this.name, + required this.description, + required this.run, + }) : googleAIResult = TestResult.pending(), + vertexAIResult = TestResult.pending(); +} + +class TestLogger { + final _buffer = StringBuffer(); + void log(String message) { + final time = DateTime.now().toIso8601String().substring(11, 19); + _buffer.writeln('[$time] $message'); + } + + @override + String toString() => _buffer.toString(); +} + +class _IntegrationTestPageState extends State { + bool _isRunning = false; + double _progress = 0; + late List _testCases; + + @override + void initState() { + super.initState(); + _initializeTestCases(); + } + + void _initializeTestCases() { + _testCases = [ + TestItem( + id: '1', + name: 'Stateless Text Gen (gemini-3.1-flash-lite)', + description: + 'Verifies simple stateless text generation with a precise answer target using gemini-3.1-flash-lite.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.1-flash-lite...'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); + const prompt = "Reply with exactly the word 'SUCCESS' in uppercase."; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? + false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected response to contain "SUCCESS"', + ); + } + }, + ), + TestItem( + id: '2', + name: 'Stateless Text Gen (gemini-3.5-flash)', + description: + 'Verifies simple stateless text generation with a precise answer target using gemini-3.5-flash.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.5-flash...'); + final model = provider.generativeModel(model: 'gemini-3.5-flash'); + const prompt = "Reply with exactly the word 'SUCCESS' in uppercase."; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? + false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected response to contain "SUCCESS"', + ); + } + }, + ), + TestItem( + id: '3', + name: 'System Instructions', + description: + 'Verifies system instructions config is serialized and respected.', + run: (provider, logger) async { + logger.log('Initializing model with medieval system instruction...'); + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + systemInstruction: Content.text( + 'You are a medieval knight. Respond only with Shakespearean knightly terms.', + ), + ); + const prompt = 'Who are you?'; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response received: "${response.text}"'); + final responseText = response.text?.toLowerCase() ?? ''; + final containsKnightTerms = responseText.contains('thou') || + responseText.contains('thee') || + responseText.contains('sir') || + responseText.contains('knight') || + responseText.contains('ye') || + responseText.contains('hath') || + responseText.contains('doth'); + if (containsKnightTerms) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: + 'Response did not seem to contain Shakespearean knightly terms.', + ); + } + }, + ), + TestItem( + id: '4', + name: 'Stateful Chat History', + description: + 'Verifies stateful conversation preservation across turns via ChatSession.', + run: (provider, logger) async { + logger.log('Initializing model and starting ChatSession...'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final chat = model.startChat(); + + const prompt1 = 'My secret agent name is Agent Orange.'; + logger.log('Sending message 1: "$prompt1"'); + final resp1 = await chat.sendMessage(Content.text(prompt1)); + logger.log('Response 1: "${resp1.text}"'); + + const prompt2 = 'What is my secret agent name?'; + logger.log('Sending message 2: "$prompt2"'); + final resp2 = await chat.sendMessage(Content.text(prompt2)); + logger.log('Response 2: "${resp2.text}"'); + + if (resp2.text?.toLowerCase().contains('agent orange') ?? false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: + 'Turn 1 Response:\n${resp1.text}\n\nTurn 2 Response:\n${resp2.text}', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: resp2.text, + errorMessage: 'Expected second response to recall "Agent Orange"', + ); + } + }, + ), + TestItem( + id: '5', + name: 'Auto Function Calling', + description: + 'Verifies automatic function/tool call execution via AutoFunctionDeclaration.', + run: (provider, logger) async { + logger.log( + 'Declaring AutoFunctionDeclaration for getSuperHeroPower...', + ); + final autoPowerTool = AutoFunctionDeclaration( + name: 'getSuperHeroPower', + description: 'Returns the superpower of a given superhero by name.', + parameters: { + 'heroName': + Schema.string(description: 'The name of the superhero.'), + }, + callable: (args) async { + final hero = args['heroName'] as String?; + logger.log( + 'CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"', + ); + if (hero?.toLowerCase().contains('laserman') ?? false) { + return {'power': 'Laser Eyes'}; + } + return {'power': 'Super Strength'}; + }, + ); + + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + tools: [ + Tool.functionDeclarations([autoPowerTool]), + ], + ); + final chat = model.startChat(); + + const prompt = 'What superpower does LaserMan have?'; + logger.log('Sending message triggering auto-function: "$prompt"'); + final response = await chat.sendMessage(Content.text(prompt)); + logger.log('Final response: "${response.text}"'); + + if (response.text?.toLowerCase().contains('laser') ?? false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: + 'Expected final response to mention "Laser Eyes" or "Laser"', + ); + } + }, + ), + TestItem( + id: '6', + name: 'Manual Function Calling', + description: + 'Verifies manual function call prediction and response handling.', + run: (provider, logger) async { + logger.log('Declaring FunctionDeclaration...'); + final powerTool = FunctionDeclaration( + 'getSuperHeroPower', + 'Returns the superpower of a given superhero by name.', + parameters: { + 'heroName': + Schema.string(description: 'The name of the superhero.'), + }, + ); + + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + tools: [ + Tool.functionDeclarations([powerTool]), + ], + ); + + const prompt = 'What superpower does LaserMan have?'; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + final functionCalls = response.functionCalls; + + logger.log( + 'Function calls predicted: ${functionCalls.map((c) => c.name).toList()}', + ); + + if (functionCalls.isEmpty) { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected function call prediction but got none.', + ); + } + + final call = functionCalls.first; + logger.log( + 'Intercepted call details: name=${call.name}, args=${call.args}', + ); + final hero = call.args['heroName'] as String?; + String power = 'Super Strength'; + if (hero?.toLowerCase().contains('laserman') ?? false) { + power = 'Laser Eyes'; + } + + logger.log( + 'Manually constructing FunctionResponse: {"power": "$power"}', + ); + final manualResponse = FunctionResponse(call.name, {'power': power}); + + logger + .log('Sending second request with history + FunctionResponse...'); + final nextResponse = await model.generateContent([ + Content.text(prompt), + response.candidates.first.content, + Content.functionResponses([manualResponse]), + ]); + + logger.log('Final response: "${nextResponse.text}"'); + if (nextResponse.text?.toLowerCase().contains('laser') ?? false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: nextResponse.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: nextResponse.text, + errorMessage: 'Expected final response to mention "Laser Eyes"', + ); + } + }, + ), + TestItem( + id: '7', + name: 'Code Execution Tool', + description: + 'Verifies Tool.codeExecution() internal code execution and parts extraction.', + run: (provider, logger) async { + logger.log( + 'Initializing model gemini-3.5-flash with Tool.codeExecution()...', + ); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + tools: [Tool.codeExecution()], + ); + + const prompt = + 'Write a Python script to print the 10th Fibonacci number, then execute it.'; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final parts = response.candidates.firstOrNull?.content.parts ?? []; + final hasExecutableCode = parts.any((p) => p is ExecutableCodePart); + final hasCodeResult = parts.any((p) => p is CodeExecutionResultPart); + + logger.log('Response received. Parsing parts:'); + for (var i = 0; i < parts.length; i++) { + final p = parts[i]; + logger.log(' Part $i: Type=${p.runtimeType}'); + if (p is ExecutableCodePart) { + logger.log(' Code:\n${p.code}'); + } else if (p is CodeExecutionResultPart) { + logger.log(' Output:\n${p.output}'); + } + } + + if (hasExecutableCode && hasCodeResult) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text, + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: + 'Missing ExecutableCodePart or CodeExecutionResultPart in response parts.', + ); + } + }, + ), + TestItem( + id: '8', + name: 'Search Grounding (gemini-3.5-flash)', + description: + 'Verifies Google Search Grounding tool configuration and grounding metadata.', + run: (provider, logger) async { + logger.log( + 'Initializing model gemini-3.5-flash with Tool.googleSearch()...', + ); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + tools: [Tool.googleSearch()], + ); + const prompt = 'Who is the current CEO of Google?'; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + + final grounding = response.candidates.firstOrNull?.groundingMetadata; + if (grounding != null) { + final sources = grounding.groundingChunks + .map((c) => c.web?.uri ?? '') + .join('\n'); + logger.log('Grounding chunks found. Web sources:\n$sources'); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: + 'Text Response: ${response.text}\n\nGrounding Sources:\n$sources', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: + 'Expected GroundingMetadata in response but got null.', + ); + } + }, + ), + TestItem( + id: '9', + name: 'Streaming Generation', + description: + 'Verifies generateContentStream works and aggregates chunks correctly.', + run: (provider, logger) async { + logger.log('Initializing model for streaming...'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); + + const prompt = 'Write a 2-paragraph poem about a computer.'; + logger.log('Starting prompt stream: "$prompt"'); + final stream = model.generateContentStream([Content.text(prompt)]); + + final textBuffer = StringBuffer(); + int chunkCount = 0; + + await for (final chunk in stream) { + chunkCount++; + final chunkText = chunk.text ?? ''; + logger.log('Chunk $chunkCount: length=${chunkText.length}'); + textBuffer.write(chunkText); + } + + logger.log( + 'Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}', + ); + if (textBuffer.isNotEmpty) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: textBuffer.toString(), + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Stream aggregated output was empty.', + ); + } + }, + ), + TestItem( + id: '10', + name: 'Advanced Token Counting', + description: + 'Verifies counting tokens on multimodal inputs (text + image asset).', + run: (provider, logger) async { + logger.log('Loading cat.jpg asset...'); + final catBytes = await rootBundle.load('assets/images/cat.jpg'); + + logger.log('Initializing model for token counting...'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); + + final content = [ + Content.multi([ + const TextPart('Describe this cat.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + ]), + ]; + + logger.log('Invoking countTokens API...'); + final response = await model.countTokens(content); + logger.log('Total Tokens: ${response.totalTokens}'); + + if (response.totalTokens > 0) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Total Tokens: ${response.totalTokens}', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Expected token count > 0', + ); + } + }, + ), + TestItem( + id: '11', + name: 'Usage Metadata & Thinking', + description: + 'Verifies extraction of thought logs and usage metadata with ThinkingConfig.', + run: (provider, logger) async { + logger.log( + 'Initializing model gemini-3.5-flash with ThinkingConfig...', + ); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + generationConfig: GenerationConfig( + thinkingConfig: ThinkingConfig.withThinkingBudget(2048, + includeThoughts: true, + ), + ), + ); + + const prompt = + 'If a train travels 100 miles at 50 mph, and another travels 120 miles at 60 mph, which arrives first?'; + logger.log('Sending prompt with thinking enabled: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final usage = response.usageMetadata; + final thoughts = response.thoughtSummary; + logger.log('Response text: "${response.text}"'); + logger.log('Thoughts extracted: "$thoughts"'); + logger.log( + 'Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}', + ); + + if (usage != null && usage.totalTokenCount! > 0) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: + 'Thoughts:\n$thoughts\n\nResponse:\n${response.text}\n\nMetadata:\n- Total Tokens: ${usage.totalTokenCount}\n- Thoughts Tokens: ${usage.thoughtsTokenCount}', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Usage metadata was null or invalid.', + ); + } + }, + ), + TestItem( + id: '12', + name: 'Multimodal Response Modality', + description: + 'Verifies text + image response output generation via gemini-2.5-flash-image model.', + run: (provider, logger) async { + logger.log( + 'Initializing gemini-2.5-flash-image with dual response modalities...', + ); + final model = provider.generativeModel( + model: 'gemini-2.5-flash-image', + generationConfig: GenerationConfig( + responseModalities: [ + ResponseModalities.text, + ResponseModalities.image, + ], + ), + ); + + const prompt = 'Draw a simple red triangle on a blue background.'; + logger.log('Sending multimodal output prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final imageParts = response.inlineDataParts + .where((p) => p.mimeType.startsWith('image/')); + logger.log('Response text: "${response.text}"'); + logger.log('Image parts returned: ${imageParts.length}'); + + if (imageParts.isNotEmpty) { + final img = imageParts.first; + logger.log( + 'Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes', + ); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: + 'Text Response: ${response.text}\nImage generated successfully: mime=${img.mimeType}, size=${img.bytes.length} bytes', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: + 'No generated image was returned in response inline parts.', + ); + } + }, + ), + ]; + } + + Future _runTestItem(TestItem item, bool isVertex) async { + final provider = isVertex + ? FirebaseAI.vertexAI(location: 'global') + : FirebaseAI.googleAI(); + final logger = TestLogger(); + + setState(() { + if (isVertex) { + item.vertexAIResult = + TestResult(status: TestStatus.running, logs: 'Running...'); + } else { + item.googleAIResult = + TestResult(status: TestStatus.running, logs: 'Running...'); + } + }); + + try { + logger.log( + 'Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...', + ); + final result = await item.run(provider, logger); + setState(() { + if (isVertex) { + item.vertexAIResult = result; + } else { + item.googleAIResult = result; + } + }); + } catch (e, s) { + logger.log('CRITICAL ERROR: $e\n$s'); + setState(() { + final failResult = TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: e.toString(), + ); + if (isVertex) { + item.vertexAIResult = failResult; + } else { + item.googleAIResult = failResult; + } + }); + } + } + + Future _runSuite() async { + if (_isRunning) return; + + setState(() { + _isRunning = true; + _progress = 0; + for (final item in _testCases) { + item.googleAIResult = TestResult.pending(); + item.vertexAIResult = TestResult.pending(); + } + }); + + final totalSteps = _testCases.length * 2; + int completedSteps = 0; + + for (final item in _testCases) { + // Run Google AI + await _runTestItem(item, false); + completedSteps++; + setState(() { + _progress = completedSteps / totalSteps; + }); + + // Run Vertex AI + await _runTestItem(item, true); + completedSteps++; + setState(() { + _progress = completedSteps / totalSteps; + }); + } + + setState(() { + _isRunning = false; + }); + } + + Widget _buildProviderSummary(String title, List results) { + final total = results.length; + final passed = results.where((r) => r.status == TestStatus.passed).length; + final failed = results.where((r) => r.status == TestStatus.failed).length; + final running = results.where((r) => r.status == TestStatus.running).length; + + Color cardColor = Colors.blueGrey.shade900; + if (total > 0 && running == 0) { + if (failed > 0) { + cardColor = Colors.red.shade900.withAlpha(150); + } else if (passed == total) { + cardColor = Colors.green.shade900.withAlpha(150); + } + } + + return Card( + color: cardColor, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Pass: $passed/$total', + style: TextStyle(color: Colors.green.shade300), + ), + Text('Fail: $failed', + style: TextStyle( + color: failed > 0 ? Colors.red.shade300 : Colors.grey, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusBadge(TestResult result) { + switch (result.status) { + case TestStatus.pending: + return Chip( + label: const Text('PENDING', + style: TextStyle(fontSize: 10, color: Colors.grey), + ), + backgroundColor: Colors.grey.shade800, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + case TestStatus.running: + return const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ); + case TestStatus.passed: + return Chip( + label: const Text('PASS', + style: TextStyle( + fontSize: 10, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.green.withAlpha(40), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + case TestStatus.failed: + return Chip( + label: const Text('FAIL', + style: TextStyle( + fontSize: 10, + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red.withAlpha(40), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + } + + Widget _buildLogsConsole(String title, TestResult result) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withAlpha(150), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade800), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + const Divider(height: 8), + if (result.errorMessage != null) ...[ + Text('ERROR:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.red.shade300, + ), + ), + SelectableText(result.errorMessage!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.red.shade200, + ), + ), + const SizedBox(height: 8), + ], + if (result.responseJson != null) ...[ + const Text('RESPONSE:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.purpleAccent, + ), + ), + SelectableText(result.responseJson!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.purpleAccent, + ), + ), + const SizedBox(height: 8), + ], + const Text('EXECUTION LOGS:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + SelectableText(result.logs, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.white70, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final googleAIResults = + _testCases.map((item) => item.googleAIResult).toList(); + final vertexAIResults = + _testCases.map((item) => item.vertexAIResult).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('One-Click SDK Integration Tests'), + actions: [ + if (!_isRunning) + IconButton( + icon: const Icon(Icons.play_arrow, color: Colors.greenAccent), + onPressed: _runSuite, + tooltip: 'Run All Tests', + ) + else + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.cyanAccent, + ), + ), + ), + ], + ), + body: Column( + children: [ + if (_isRunning) + LinearProgressIndicator( + value: _progress, + minHeight: 6, + color: Colors.cyanAccent, + ), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildProviderSummary( + 'Google AI Suite', + googleAIResults, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildProviderSummary( + 'Vertex AI Suite', + vertexAIResults, + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: _testCases.length, + itemBuilder: (context, index) { + final item = _testCases[index]; + return Card( + margin: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.grey.shade800, + child: Text(item.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + title: Text(item.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(item.description, + style: + const TextStyle(fontSize: 12, color: Colors.grey), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('G: ', + style: TextStyle( + fontSize: 10, + color: Colors.grey, + ), + ), + _buildStatusBadge(item.googleAIResult), + ], + ), + const SizedBox(width: 8), + Row( + children: [ + const Text('V: ', + style: TextStyle( + fontSize: 10, + color: Colors.grey, + ), + ), + _buildStatusBadge(item.vertexAIResult), + ], + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildLogsConsole( + 'Google AI Details', + item.googleAIResult, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildLogsConsole( + 'Vertex AI Details', + item.vertexAIResult, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} 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 deleted file mode 100644 index 18f0b6e4939d..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart +++ /dev/null @@ -1,200 +0,0 @@ -// 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/multimodal_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart deleted file mode 100644 index 9c559abdaada..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright 2026 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:io'; - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/services.dart'; -import '../widgets/message_widget.dart'; -import 'package:record/record.dart'; -import 'package:path_provider/path_provider.dart'; - -final record = AudioRecorder(); - -class MultimodalPage extends StatefulWidget { - const MultimodalPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _MultimodalPageState(); -} - -class _MultimodalPageState extends State { - final ScrollController _scrollController = ScrollController(); - final List _messages = []; - bool _recording = false; - bool _loading = false; - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - Future recordAudio() async { - if (!await record.hasPermission()) { - debugPrint('Audio recording permission denied'); - return; - } - - final dir = Directory( - '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', - ); - - await dir.create(recursive: true); - - String filePath = - '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; - - await record.start( - const RecordConfig( - encoder: AudioEncoder.wav, - ), - path: filePath, - ); - } - - Future stopRecord() async { - var path = await record.stop(); - - if (path == null) { - debugPrint('Failed to stop recording'); - return; - } - - debugPrint('Recording saved to: $path'); - - try { - File file = File(path); - final audio = await file.readAsBytes(); - debugPrint('Audio file size: ${audio.length} bytes'); - - final audioPart = InlineDataPart('audio/wav', audio); - - await _submitAudioToModel(audioPart); - - await file.delete(); - debugPrint('Recording deleted successfully.'); - } catch (e) { - debugPrint('Error processing recording: $e'); - } - } - - Future _submitAudioToModel(InlineDataPart audioPart) async { - try { - String textPrompt = 'What is in the audio recording?'; - const prompt = TextPart('What is in the audio recording?'); - - setState(() { - _messages.add(MessageData(text: textPrompt, fromUser: true)); - _loading = true; - }); - - final response = await widget.model.generateContent([ - Content.multi([prompt, audioPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending audio to model: $e'); - setState(() { - _loading = false; - }); - } - } - - Future _testVideo() async { - try { - setState(() { - _loading = true; - }); - - ByteData videoBytes = - await rootBundle.load('assets/videos/landscape.mp4'); - - const promptText = 'Can you tell me what is in the video?'; - - setState(() { - _messages.add(MessageData(text: promptText, fromUser: true)); - }); - - final videoPart = - InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([const TextPart(promptText), videoPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending video to model: $e'); - setState(() { - _loading = false; - }); - } - } - - Future _testDocumentReading() async { - try { - setState(() { - _loading = true; - }); - - ByteData docBytes = - await rootBundle.load('assets/documents/gemini_summary.pdf'); - - const promptText = - 'Write me a summary in one sentence what this document is about.'; - - setState(() { - _messages.add(MessageData(text: promptText, fromUser: true)); - }); - - final pdfPart = - InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([const TextPart(promptText), pdfPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending document to model: $e'); - setState(() { - _loading = false; - }); - } - } - - void _scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemBuilder: (context, idx) { - return MessageWidget( - text: _messages[idx].text, - isFromUser: _messages[idx].fromUser ?? false, - ); - }, - itemCount: _messages.length, - ), - ), - if (_loading) - const Padding( - padding: EdgeInsets.all(8), - child: CircularProgressIndicator(), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 15, - horizontal: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading - ? null - : () async { - setState(() { - _recording = !_recording; - }); - if (_recording) { - await recordAudio(); - } else { - await stopRecord(); - } - }, - icon: Icon( - Icons.mic, - color: _recording - ? Colors.red - : Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - Text( - _recording ? 'Stop' : 'Record', - style: const TextStyle(fontSize: 12), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading ? null : _testVideo, - icon: Icon( - Icons.video_collection, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - const Text( - 'Test Video', - style: TextStyle(fontSize: 12), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading ? null : _testDocumentReading, - icon: Icon( - Icons.edit_document, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - const Text( - 'Test Doc', - style: TextStyle(fontSize: 12), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} 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 deleted file mode 100644 index e59378ac6100..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart +++ /dev/null @@ -1,185 +0,0 @@ -// 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 SchemaPromptPage extends StatefulWidget { - const SchemaPromptPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _SchemaPromptPageState(); -} - -class _SchemaPromptPageState 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 _promptSchemaTest(); - } - : null, - child: const Text('Schema Prompt'), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future _promptSchemaTest() async { - setState(() { - _loading = true; - }); - try { - final content = [ - Content.text( - "For use in a children's card game, generate 10 animal-based " - 'characters.', - ), - ]; - - final jsonSchema = Schema.object( - properties: { - 'characters': Schema.array( - items: Schema.object( - properties: { - 'name': Schema.string(), - 'age': Schema.integer(), - 'species': Schema.string(), - 'accessory': - Schema.enumString(enumValues: ['hat', 'belt', 'shoes']), - }, - ), - ), - }, - optionalProperties: ['accessory'], - ); - - final response = await widget.model.generateContent( - content, - generationConfig: GenerationConfig( - responseMimeType: 'application/json', - responseSchema: jsonSchema, - ), - ); - - 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(); - }); - } - } 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/token_count_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart deleted file mode 100644 index 90b6b374cbfb..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart +++ /dev/null @@ -1,145 +0,0 @@ -// 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 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import '../widgets/message_widget.dart'; - -class TokenCountPage extends StatefulWidget { - const TokenCountPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _TokenCountPageState(); -} - -class _TokenCountPageState extends State { - final List _messages = []; - bool _loading = false; - - @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( - 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 _testCountToken(); - } - : null, - child: const Text('Count Tokens'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _testUsageMetadata(); - } - : null, - child: const Text('Usage Metadata'), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future _testCountToken() async { - setState(() { - _loading = true; - }); - - const prompt = 'tell a short story'; - final content = Content.text(prompt); - final tokenResponse = await widget.model.countTokens([content]); - final tokenResult = 'Token Count: ${tokenResponse.totalTokens}'; - _messages.add(MessageData(text: tokenResult, fromUser: false)); - - setState(() { - _loading = false; - }); - } - - Future _testUsageMetadata() async { - setState(() { - _loading = true; - }); - - const prompt = - 'Tell a story about a magic backpack and the person who found it.'; - final content = [Content.text(prompt)]; - final response = await widget.model.generateContent(content); - final usageMetadata = response.usageMetadata; - - if (usageMetadata != null) { - final message = ''' -Usage Metadata: -- promptTokenCount: ${usageMetadata.promptTokenCount} -- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} -- totalTokenCount: ${usageMetadata.totalTokenCount} -- thoughtsTokenCount: ${usageMetadata.thoughtsTokenCount} -- toolUsePromptTokenCount: ${usageMetadata.toolUsePromptTokenCount} -- cachedContentTokenCount: ${usageMetadata.cachedContentTokenCount} -- promptTokensDetails: ${usageMetadata.promptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- candidatesTokensDetails: ${usageMetadata.candidatesTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- toolUsePromptTokensDetails: ${usageMetadata.toolUsePromptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- cacheTokensDetails: ${usageMetadata.cacheTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -'''; - _messages.add(MessageData(text: message, fromUser: false)); - } else { - _messages.add( - MessageData(text: 'No usage metadata available.', fromUser: false), - ); - } - - setState(() { - _loading = false; - }); - } -}