From 00f2e306ba5d2d8eede113a2289f7583882e3f6b Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 7 May 2026 20:26:10 -0700 Subject: [PATCH 1/9] first commit --- .../plugins/firebase/ai/GeneratedLocalAI.kt | 131 +++++++++++++++ .../plugins/firebase/ai/LocalAIImpl.kt | 20 +++ .../ios/Classes/GeneratedLocalAI.swift | 150 ++++++++++++++++++ .../firebase_ai/ios/Classes/LocalAIImpl.swift | 60 +++++++ .../lib/src/generated/local_ai.g.dart | 128 +++++++++++++++ .../lib/src/hybrid_generative_model.dart | 70 ++++++++ .../firebase_ai/lib/src/web/chrome_ai.dart | 43 +++++ .../firebase_ai/pigeons/local_ai.dart | 23 +++ packages/firebase_ai/firebase_ai/pubspec.yaml | 1 + .../test/hybrid_generative_model_test.dart | 125 +++++++++++++++ 10 files changed, 751 insertions(+) create mode 100644 packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt create mode 100644 packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt create mode 100644 packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift create mode 100644 packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift create mode 100644 packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart create mode 100644 packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart create mode 100644 packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart create mode 100644 packages/firebase_ai/firebase_ai/pigeons/local_ai.dart create mode 100644 packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt new file mode 100644 index 000000000000..67f4339ada6c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt @@ -0,0 +1,131 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object GeneratedLocalAIPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() +private open class GeneratedLocalAIPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface LocalAIApi { + fun isAvailable(callback: (Result) -> Unit) + fun generateContent(prompt: String, callback: (Result) -> Unit) + fun warmup(callback: (Result) -> Unit) + + companion object { + /** The codec used by LocalAIApi. */ + val codec: MessageCodec by lazy { + GeneratedLocalAIPigeonCodec() + } + /** Sets up an instance of `LocalAIApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: LocalAIApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.isAvailable$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.isAvailable{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.generateContent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val promptArg = args[0] as String + api.generateContent(promptArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.warmup$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.warmup{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt new file mode 100644 index 000000000000..989471eca819 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt @@ -0,0 +1,20 @@ +package io.flutter.plugins.firebase.ai + +class LocalAIImpl : LocalAIApi { + override fun isAvailable(callback: (Result) -> Unit) { + // Placeholder for AICore availability check. + // Assumes Gemini Nano is available on supported devices. + callback(Result.success(true)) + } + + override fun generateContent(prompt: String, callback: (Result) -> Unit) { + // Placeholder for raw AICore API call. + // In a real implementation, this would interact with the system's AI service. + callback(Result.success("Local response from AICore for: $prompt")) + } + + override fun warmup(callback: (Result) -> Unit) { + // Android uses default models (Gemini Nano), so warmup is likely a no-op. + callback(Result.success(Unit)) + } +} diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift b/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift new file mode 100644 index 000000000000..6293e64cf830 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift @@ -0,0 +1,150 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class GeneratedLocalAIPigeonCodecReader: FlutterStandardReader { +} + +private class GeneratedLocalAIPigeonCodecWriter: FlutterStandardWriter { +} + +private class GeneratedLocalAIPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return GeneratedLocalAIPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return GeneratedLocalAIPigeonCodecWriter(data: data) + } +} + +class GeneratedLocalAIPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = GeneratedLocalAIPigeonCodec(readerWriter: GeneratedLocalAIPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol LocalAIApi { + func isAvailable(completion: @escaping (Result) -> Void) + func generateContent(prompt: String, completion: @escaping (Result) -> Void) + func warmup(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class LocalAIApiSetup { + static var codec: FlutterStandardMessageCodec { GeneratedLocalAIPigeonCodec.shared } + /// Sets up an instance of `LocalAIApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalAIApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let isAvailableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.firebase_ai.LocalAIApi.isAvailable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isAvailableChannel.setMessageHandler { _, reply in + api.isAvailable { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + isAvailableChannel.setMessageHandler(nil) + } + let generateContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.firebase_ai.LocalAIApi.generateContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + generateContentChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let promptArg = args[0] as! String + api.generateContent(prompt: promptArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + generateContentChannel.setMessageHandler(nil) + } + let warmupChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.firebase_ai.LocalAIApi.warmup\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + warmupChannel.setMessageHandler { _, reply in + api.warmup { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + warmupChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift new file mode 100644 index 000000000000..1cf53fd7ec46 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift @@ -0,0 +1,60 @@ +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#endif + +#if canImport(FoundationModels) +import FoundationModels +#endif + +class LocalAIImpl: LocalAIApi { + func isAvailable(completion: @escaping (Result) -> Void) { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + // Assume available if API is supported on this version. + // Real implementation might check if model is downloaded or active. + completion(.success(true)) + } else { + completion(.success(false)) + } + #else + completion(.success(false)) + #endif + } + + func generateContent(prompt: String, completion: @escaping (Result) -> Void) { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + Task { + do { + // Placeholder for raw FoundationModels API call. + // Based on findings in iOS SDK, a session is typically used. + // Here we assume a simple default session or shared instance for demonstration. + + // let session = try await FoundationModels.LanguageModelSession.default() + // let response = try await session.respond(to: prompt) + // completion(.success(response.text)) + + // For now, since we cannot fully verify the raw API without full headers, + // we return a placeholder response indicating local execution. + completion(.success("Local response from FoundationModels for: \(prompt)")) + } catch { + completion(.failure(error)) + } + } + } else { + completion(.failure(PigeonError(code: "UNSUPPORTED", message: "FoundationModels not available on this OS version", details: nil))) + } + #else + completion(.failure(PigeonError(code: "UNSUPPORTED", message: "FoundationModels not available in this build", details: nil))) + #endif + } + + func warmup(completion: @escaping (Result) -> Void) { + // iOS uses default models, so warmup is likely a no-op. + completion(.success(())) + } +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart new file mode 100644 index 000000000000..03ae51ad8aea --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart @@ -0,0 +1,128 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class LocalAIApi { + /// Constructor for [LocalAIApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + LocalAIApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future isAvailable() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.firebase_ai.LocalAIApi.isAvailable$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as bool; + } + + Future generateContent(String prompt) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.firebase_ai.LocalAIApi.generateContent$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([prompt]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as String; + } + + Future warmup() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.firebase_ai.LocalAIApi.warmup$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart new file mode 100644 index 000000000000..111f146cecee --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'api.dart'; +import 'content.dart'; +import 'base_model.dart'; +import 'generated/local_ai.g.dart'; + +enum InferenceMode { + preferCloud, + preferLocal, + onlyLocal, + onlyCloud, +} + +class HybridGenerativeModel { + final GenerativeModel cloudModel; + final LocalAIApi localApi; + final InferenceMode mode; + + HybridGenerativeModel({ + required this.cloudModel, + required this.localApi, + this.mode = InferenceMode.preferCloud, + }); + + Future generateContent(Iterable prompt) async { + switch (mode) { + case InferenceMode.onlyCloud: + return cloudModel.generateContent(prompt); + case InferenceMode.onlyLocal: + return _generateLocal(prompt); + case InferenceMode.preferCloud: + try { + return await cloudModel.generateContent(prompt); + } catch (e) { + if (await localApi.isAvailable()) { + return _generateLocal(prompt); + } + rethrow; + } + case InferenceMode.preferLocal: + if (await localApi.isAvailable()) { + try { + return await _generateLocal(prompt); + } catch (e) { + return cloudModel.generateContent(prompt); + } + } + return cloudModel.generateContent(prompt); + } + } + + Future _generateLocal(Iterable prompt) async { + final promptString = prompt.map((c) => c.parts.whereType().map((p) => p.text).join()).join(); + final responseText = await localApi.generateContent(promptString); + + return GenerateContentResponse([ + Candidate( + Content('model', [TextPart(responseText)]), + null, // safetyRatings + null, // citationMetadata + null, // finishReason + null, // finishMessage + ) + ], null); // promptFeedback + } + + Future warmup() async { + await localApi.warmup(); + } +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart new file mode 100644 index 000000000000..47b2251fe807 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'dart:js_interop'; + +@JS('window.ai') +external JSObject? get windowAI; + +extension WindowAIExtension on JSObject { + @JS('languageModel') + external JSObject get languageModel; +} + +extension LanguageModelExtension on JSObject { + @JS('create') + external JSPromise create(JSObject? options); +} + +extension ModelInstanceExtension on JSObject { + @JS('prompt') + external JSPromise prompt(JSString input); +} + +class ChromeAI { + JSObject? _model; + + Future isAvailable() async { + if (windowAI == null) return false; + return true; + } + + Future warmup() async { + if (windowAI == null) throw Exception('window.ai not available'); + final lm = windowAI!.languageModel; + _model = await lm.create(null).toDart; + } + + Future generateContent(String prompt) async { + if (_model == null) { + await warmup(); + } + final response = await _model!.prompt(prompt.toJS).toDart; + return response.toDart; + } +} diff --git a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart new file mode 100644 index 000000000000..02c96449d7c1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart @@ -0,0 +1,23 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/local_ai.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt', + kotlinOptions: KotlinOptions(), + swiftOut: 'ios/Classes/GeneratedLocalAI.swift', + swiftOptions: SwiftOptions(), + dartPackageName: 'firebase_ai', +)) + +@HostApi() +abstract class LocalAIApi { + @async + bool isAvailable(); + + @async + String generateContent(String prompt); + + @async + void warmup(); +} diff --git a/packages/firebase_ai/firebase_ai/pubspec.yaml b/packages/firebase_ai/firebase_ai/pubspec.yaml index bdbc9fb8effe..8ad3e2a55191 100644 --- a/packages/firebase_ai/firebase_ai/pubspec.yaml +++ b/packages/firebase_ai/firebase_ai/pubspec.yaml @@ -37,6 +37,7 @@ dev_dependencies: sdk: flutter matcher: ^0.12.16 mockito: ^5.0.0 + pigeon: 26.3.4 plugin_platform_interface: ^2.1.3 flutter: diff --git a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart new file mode 100644 index 000000000000..bc84367e731c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_ai/src/hybrid_generative_model.dart'; +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_ai/src/generated/local_ai.g.dart'; +import 'package:firebase_ai/src/base_model.dart'; +import 'package:firebase_ai/src/client.dart'; +import 'package:firebase_core/firebase_core.dart'; + +class MockApiClient implements ApiClient { + bool shouldFail = false; + String responseText = 'Cloud Response'; + + @override + Future> makeRequest(Uri uri, Map body) async { + if (shouldFail) throw Exception('Cloud Failed'); + // Return a minimal valid JSON response that parseGenerateContentResponse can handle. + // We need to mock the response structure. + return { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': responseText} + ] + } + } + ] + }; + } + + @override + Stream> streamRequest(Uri uri, Map body) { + throw UnimplementedError(); + } +} + +class MockLocalApi extends LocalAIApi { + bool available = true; + bool shouldFail = false; + String responseText = 'Local Response'; + + @override + Future isAvailable() async => available; + + @override + Future generateContent(String prompt) async { + if (shouldFail) throw Exception('Local Failed'); + return responseText; + } + + @override + Future warmup() async {} +} + +class MockFirebaseApp implements FirebaseApp { + @override + String get name => '[DEFAULT]'; + + @override + FirebaseOptions get options => FirebaseOptions( + apiKey: 'dummy_api_key', + appId: 'dummy_app_id', + messagingSenderId: 'dummy_sender_id', + projectId: 'dummy_project_id', + ); + + @override + bool get isAutomaticDataCollectionEnabled => false; + + @override + Future delete() async {} + + @override + Future setAutomaticDataCollectionEnabled(bool enabled) async {} + + @override + Future setAutomaticResourceManagementEnabled(bool enabled) async {} + + @override + T? getService() => null; + + @override + void registerService(T service) {} +} + +void main() { + test('preferCloud succeeds on cloud', () async { + final apiClient = MockApiClient(); + final local = MockLocalApi(); + final app = MockFirebaseApp(); + + final cloud = createModelWithClient( + app: app, + location: 'us-central1', + model: 'gemini-pro', + client: apiClient, + useVertexBackend: false, + ); + + final model = HybridGenerativeModel(cloudModel: cloud, localApi: local, mode: InferenceMode.preferCloud); + + final response = await model.generateContent([Content.text('hello')]); + expect(response.text, 'Cloud Response'); + }); + + test('preferCloud falls back to local on cloud failure', () async { + final apiClient = MockApiClient()..shouldFail = true; + final local = MockLocalApi(); + final app = MockFirebaseApp(); + + final cloud = createModelWithClient( + app: app, + location: 'us-central1', + model: 'gemini-pro', + client: apiClient, + useVertexBackend: false, + ); + + final model = HybridGenerativeModel(cloudModel: cloud, localApi: local, mode: InferenceMode.preferCloud); + + final response = await model.generateContent([Content.text('hello')]); + expect(response.text, 'Local Response'); + }); +} From 4e2e762312c59dfa1a8876e2a7b6d0c226479d04 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 7 May 2026 20:34:23 -0700 Subject: [PATCH 2/9] add headers --- .../plugins/firebase/ai/GeneratedLocalAI.kt | 13 +++++++++++++ .../io/flutter/plugins/firebase/ai/LocalAIImpl.kt | 14 ++++++++++++++ .../ios/Classes/GeneratedLocalAI.swift | 13 +++++++++++++ .../firebase_ai/ios/Classes/LocalAIImpl.swift | 14 ++++++++++++++ .../firebase_ai/lib/src/generated/local_ai.g.dart | 13 +++++++++++++ .../lib/src/hybrid_generative_model.dart | 14 ++++++++++++++ .../firebase_ai/lib/src/web/chrome_ai.dart | 14 ++++++++++++++ .../firebase_ai/firebase_ai/pigeons/copyright.txt | 13 +++++++++++++ .../firebase_ai/firebase_ai/pigeons/local_ai.dart | 15 +++++++++++++++ .../test/hybrid_generative_model_test.dart | 14 ++++++++++++++ 10 files changed, 137 insertions(+) create mode 100644 packages/firebase_ai/firebase_ai/pigeons/copyright.txt diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt index 67f4339ada6c..625c7719061b 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt @@ -1,3 +1,16 @@ +// 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. // Autogenerated from Pigeon (v26.3.4), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt index 989471eca819..1034e998f229 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt @@ -1,3 +1,17 @@ +// 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. + package io.flutter.plugins.firebase.ai class LocalAIImpl : LocalAIApi { diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift b/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift index 6293e64cf830..9c15bd3f6f4c 100644 --- a/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift +++ b/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift @@ -1,3 +1,16 @@ +// 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. // Autogenerated from Pigeon (v26.3.4), do not edit directly. // See also: https://pub.dev/packages/pigeon diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift index 1cf53fd7ec46..a98d8e0ae154 100644 --- a/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift +++ b/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift @@ -1,3 +1,17 @@ +// 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 Foundation #if os(iOS) diff --git a/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart index 03ae51ad8aea..779b5dd705d6 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart @@ -1,3 +1,16 @@ +// 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. // Autogenerated from Pigeon (v26.3.4), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: unused_import, unused_shown_name diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart index 111f146cecee..d887e9549893 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -1,3 +1,17 @@ +// 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 'api.dart'; import 'content.dart'; diff --git a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart index 47b2251fe807..31280ce745d6 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart @@ -1,3 +1,17 @@ +// 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 'dart:js_interop'; diff --git a/packages/firebase_ai/firebase_ai/pigeons/copyright.txt b/packages/firebase_ai/firebase_ai/pigeons/copyright.txt new file mode 100644 index 000000000000..274f8376c0bc --- /dev/null +++ b/packages/firebase_ai/firebase_ai/pigeons/copyright.txt @@ -0,0 +1,13 @@ +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. diff --git a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart index 02c96449d7c1..fd6ba3463f9b 100644 --- a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart +++ b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart @@ -1,3 +1,17 @@ +// 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 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( @@ -8,6 +22,7 @@ import 'package:pigeon/pigeon.dart'; swiftOut: 'ios/Classes/GeneratedLocalAI.swift', swiftOptions: SwiftOptions(), dartPackageName: 'firebase_ai', + copyrightHeader: 'pigeons/copyright.txt', )) @HostApi() diff --git a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart index bc84367e731c..9e6e3fc1ad2a 100644 --- a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart @@ -1,3 +1,17 @@ +// 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 'package:flutter_test/flutter_test.dart'; import 'package:firebase_ai/src/hybrid_generative_model.dart'; import 'package:firebase_ai/src/api.dart'; From df24420cbeff17c41e1716bad56204c846d4094b Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 7 May 2026 20:44:12 -0700 Subject: [PATCH 3/9] added stream in --- .../plugins/firebase/ai/FirebaseAIPlugin.kt | 24 ++++ .../plugins/firebase/ai/GeneratedLocalAI.kt | 20 ++++ .../plugins/firebase/ai/LocalAIImpl.kt | 7 ++ .../firebase_ai/FirebaseAIPlugin.swift | 24 ++++ .../firebase_ai}/GeneratedLocalAI.swift | 18 +++ .../Sources/firebase_ai}/LocalAIImpl.swift | 34 +++--- .../lib/src/generated/local_ai.g.dart | 18 +++ .../lib/src/hybrid_generative_model.dart | 108 ++++++++++++++++++ .../firebase_ai/lib/src/web/chrome_ai.dart | 51 +++++++++ .../firebase_ai/pigeons/local_ai.dart | 5 +- .../test/hybrid_generative_model_test.dart | 83 +++++++++++++- 11 files changed, 377 insertions(+), 15 deletions(-) rename packages/firebase_ai/firebase_ai/ios/{Classes => firebase_ai/Sources/firebase_ai}/GeneratedLocalAI.swift (87%) rename packages/firebase_ai/firebase_ai/ios/{Classes => firebase_ai/Sources/firebase_ai}/LocalAIImpl.swift (68%) diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt index 3377f693d3e5..31fc09460d7a 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt @@ -32,6 +32,11 @@ class FirebaseAIPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { context = binding.applicationContext channel = MethodChannel(binding.binaryMessenger, "plugins.flutter.io/firebase_ai") channel.setMethodCallHandler(this) + + LocalAIApi.setUp(binding.binaryMessenger, LocalAIImpl()) + + val eventChannel = io.flutter.plugin.common.EventChannel(binding.binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.stream") + eventChannel.setStreamHandler(LocalAIStreamHandler.shared) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -99,3 +104,22 @@ class FirebaseAIPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private const val TAG = "FirebaseAIPlugin" } } + +class LocalAIStreamHandler : io.flutter.plugin.common.EventChannel.StreamHandler { + companion object { + val shared = LocalAIStreamHandler() + } + private var eventSink: io.flutter.plugin.common.EventChannel.EventSink? = null + + override fun onListen(arguments: Any?, events: io.flutter.plugin.common.EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + fun sendEvent(event: String) { + eventSink?.success(event) + } +} diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt index 625c7719061b..164aca591dc7 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt @@ -74,6 +74,7 @@ interface LocalAIApi { fun isAvailable(callback: (Result) -> Unit) fun generateContent(prompt: String, callback: (Result) -> Unit) fun warmup(callback: (Result) -> Unit) + fun startStreaming(prompt: String, callback: (Result) -> Unit) companion object { /** The codec used by LocalAIApi. */ @@ -139,6 +140,25 @@ interface LocalAIApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.startStreaming$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val promptArg = args[0] as String + api.startStreaming(promptArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt index 1034e998f229..011eaa3fdf71 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt @@ -31,4 +31,11 @@ class LocalAIImpl : LocalAIApi { // Android uses default models (Gemini Nano), so warmup is likely a no-op. callback(Result.success(Unit)) } + + override fun startStreaming(prompt: String, callback: (Result) -> Unit) { + // Simulate streaming by sending chunks to the shared stream handler. + LocalAIStreamHandler.shared.sendEvent("Local chunk 1 for: $prompt") + LocalAIStreamHandler.shared.sendEvent("Local chunk 2 for: $prompt") + callback(Result.success(Unit)) + } } diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift index a76212182bda..c5e35514f622 100644 --- a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift @@ -32,6 +32,11 @@ public class FirebaseAIPlugin: NSObject, FlutterPlugin { ) let instance = FirebaseAIPlugin() registrar.addMethodCallDelegate(instance, channel: channel) + + LocalAIApiSetup.setUp(binaryMessenger: messenger, api: LocalAIImpl()) + + let eventChannel = FlutterEventChannel(name: "dev.flutter.pigeon.firebase_ai.LocalAIApi.stream", binaryMessenger: messenger) + eventChannel.setStreamHandler(LocalAIStreamHandler.shared) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -47,3 +52,22 @@ public class FirebaseAIPlugin: NSObject, FlutterPlugin { } } } + +class LocalAIStreamHandler: NSObject, FlutterStreamHandler { + static let shared = LocalAIStreamHandler() + private var eventSink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } + + func sendEvent(_ event: String) { + eventSink?(event) + } +} diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift similarity index 87% rename from packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift rename to packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift index 9c15bd3f6f4c..0cf923c458db 100644 --- a/packages/firebase_ai/firebase_ai/ios/Classes/GeneratedLocalAI.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift @@ -104,6 +104,7 @@ protocol LocalAIApi { func isAvailable(completion: @escaping (Result) -> Void) func generateContent(prompt: String, completion: @escaping (Result) -> Void) func warmup(completion: @escaping (Result) -> Void) + func startStreaming(prompt: String, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -159,5 +160,22 @@ class LocalAIApiSetup { } else { warmupChannel.setMessageHandler(nil) } + let startStreamingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.firebase_ai.LocalAIApi.startStreaming\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startStreamingChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let promptArg = args[0] as! String + api.startStreaming(prompt: promptArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + startStreamingChannel.setMessageHandler(nil) + } } } diff --git a/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift similarity index 68% rename from packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift rename to packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift index a98d8e0ae154..4a45808ba1c6 100644 --- a/packages/firebase_ai/firebase_ai/ios/Classes/LocalAIImpl.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift @@ -28,8 +28,6 @@ class LocalAIImpl: LocalAIApi { func isAvailable(completion: @escaping (Result) -> Void) { #if canImport(FoundationModels) if #available(iOS 26.0, macOS 26.0, *) { - // Assume available if API is supported on this version. - // Real implementation might check if model is downloaded or active. completion(.success(true)) } else { completion(.success(false)) @@ -44,16 +42,6 @@ class LocalAIImpl: LocalAIApi { if #available(iOS 26.0, macOS 26.0, *) { Task { do { - // Placeholder for raw FoundationModels API call. - // Based on findings in iOS SDK, a session is typically used. - // Here we assume a simple default session or shared instance for demonstration. - - // let session = try await FoundationModels.LanguageModelSession.default() - // let response = try await session.respond(to: prompt) - // completion(.success(response.text)) - - // For now, since we cannot fully verify the raw API without full headers, - // we return a placeholder response indicating local execution. completion(.success("Local response from FoundationModels for: \(prompt)")) } catch { completion(.failure(error)) @@ -68,7 +56,27 @@ class LocalAIImpl: LocalAIApi { } func warmup(completion: @escaping (Result) -> Void) { - // iOS uses default models, so warmup is likely a no-op. completion(.success(())) } + + func startStreaming(prompt: String, completion: @escaping (Result) -> Void) { + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + Task { + do { + // Simulate streaming by sending chunks to the shared stream handler. + LocalAIStreamHandler.shared.sendEvent("Local chunk 1 for: \(prompt)") + LocalAIStreamHandler.shared.sendEvent("Local chunk 2 for: \(prompt)") + completion(.success(())) + } catch { + completion(.failure(error)) + } + } + } else { + completion(.failure(PigeonError(code: "UNSUPPORTED", message: "FoundationModels not available on this OS version", details: nil))) + } + #else + completion(.failure(PigeonError(code: "UNSUPPORTED", message: "FoundationModels not available in this build", details: nil))) + #endif + } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart index 779b5dd705d6..ff6cba9cccd0 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/generated/local_ai.g.dart @@ -138,4 +138,22 @@ class LocalAIApi { ) ; } + + Future startStreaming(String prompt) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.firebase_ai.LocalAIApi.startStreaming$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([prompt]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart index d887e9549893..8f5433028eec 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -13,6 +13,8 @@ // limitations under the License. import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; import 'api.dart'; import 'content.dart'; import 'base_model.dart'; @@ -81,4 +83,110 @@ class HybridGenerativeModel { Future warmup() async { await localApi.warmup(); } + + Stream generateContentStream(Iterable prompt) { + switch (mode) { + case InferenceMode.onlyCloud: + return cloudModel.generateContentStream(prompt); + case InferenceMode.onlyLocal: + return generateLocalStream(prompt); + case InferenceMode.preferCloud: + final controller = StreamController(); + var yieldedData = false; + + try { + cloudModel.generateContentStream(prompt).listen( + (response) { + yieldedData = true; + controller.add(response); + }, + onError: (e) async { + if (!yieldedData && await localApi.isAvailable()) { + generateLocalStream(prompt).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } else { + controller.addError(e); + controller.close(); + } + }, + onDone: controller.close, + ); + } catch (e) { + localApi.isAvailable().then((available) { + if (available) { + generateLocalStream(prompt).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } else { + controller.addError(e); + controller.close(); + } + }); + } + + return controller.stream; + + case InferenceMode.preferLocal: + final controller = StreamController(); + + localApi.isAvailable().then((available) { + if (available) { + var yieldedData = false; + generateLocalStream(prompt).listen( + (response) { + yieldedData = true; + controller.add(response); + }, + onError: (e) { + if (!yieldedData) { + cloudModel.generateContentStream(prompt).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } else { + controller.addError(e); + controller.close(); + } + }, + onDone: controller.close, + ); + } else { + cloudModel.generateContentStream(prompt).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } + }); + + return controller.stream; + } + } + + @visibleForTesting + Stream generateLocalStream(Iterable prompt) { + final promptString = prompt.map((c) => c.parts.whereType().map((p) => p.text).join()).join(); + + localApi.startStreaming(promptString); + + final channel = EventChannel('dev.flutter.pigeon.firebase_ai.LocalAIApi.stream'); + return channel.receiveBroadcastStream().map((event) { + final responseText = event as String; + return GenerateContentResponse([ + Candidate( + Content('model', [TextPart(responseText)]), + null, + null, + null, + null, + ) + ], null); + }); + } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart index 31280ce745d6..01c10d6c2c30 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart @@ -31,6 +31,27 @@ extension LanguageModelExtension on JSObject { extension ModelInstanceExtension on JSObject { @JS('prompt') external JSPromise prompt(JSString input); + + @JS('promptStreaming') + external JSObject promptStreaming(JSString input); +} + +extension ReadableStreamExtension on JSObject { + @JS('getReader') + external JSObject getReader(); +} + +extension ReadableStreamDefaultReaderExtension on JSObject { + @JS('read') + external JSPromise read(); +} + +extension ReadableStreamDefaultReadResultExtension on JSObject { + @JS('done') + external bool get done; + + @JS('value') + external JSString get value; } class ChromeAI { @@ -54,4 +75,34 @@ class ChromeAI { final response = await _model!.prompt(prompt.toJS).toDart; return response.toDart; } + + Stream generateContentStream(String prompt) { + final controller = StreamController(); + + warmup().then((_) { + final stream = _model!.promptStreaming(prompt.toJS); + final reader = stream.getReader(); + + void readNext() { + reader.read().toDart.then((result) { + if (result.done) { + controller.close(); + return; + } + controller.add(result.value.toDart); + readNext(); + }).catchError((e) { + controller.addError(e); + controller.close(); + }); + } + + readNext(); + }).catchError((e) { + controller.addError(e); + controller.close(); + }); + + return controller.stream; + } } diff --git a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart index fd6ba3463f9b..84fbaae0b328 100644 --- a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart +++ b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart @@ -19,7 +19,7 @@ import 'package:pigeon/pigeon.dart'; dartOptions: DartOptions(), kotlinOut: 'android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt', kotlinOptions: KotlinOptions(), - swiftOut: 'ios/Classes/GeneratedLocalAI.swift', + swiftOut: 'ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift', swiftOptions: SwiftOptions(), dartPackageName: 'firebase_ai', copyrightHeader: 'pigeons/copyright.txt', @@ -35,4 +35,7 @@ abstract class LocalAIApi { @async void warmup(); + + @async + void startStreaming(String prompt); } diff --git a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart index 9e6e3fc1ad2a..626056b5564b 100644 --- a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart @@ -45,7 +45,20 @@ class MockApiClient implements ApiClient { @override Stream> streamRequest(Uri uri, Map body) { - throw UnimplementedError(); + if (shouldFail) throw Exception('Cloud Failed'); + return Stream.fromIterable([ + { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': responseText} + ] + } + } + ] + } + ]); } } @@ -136,4 +149,72 @@ void main() { final response = await model.generateContent([Content.text('hello')]); expect(response.text, 'Local Response'); }); + + test('preferCloud streaming succeeds on cloud', () async { + final apiClient = MockApiClient(); + final local = MockLocalApi(); + final app = MockFirebaseApp(); + + final cloud = createModelWithClient( + app: app, + location: 'us-central1', + model: 'gemini-pro', + client: apiClient, + useVertexBackend: false, + ); + + final model = HybridGenerativeModel(cloudModel: cloud, localApi: local, mode: InferenceMode.preferCloud); + + final responses = model.generateContentStream([Content.text('hello')]); + final textList = await responses.map((r) => r.text).toList(); + expect(textList, ['Cloud Response']); + }); + + test('preferCloud streaming falls back to local on cloud failure before data', () async { + final apiClient = MockApiClient()..shouldFail = true; + final local = MockLocalApi(); + final app = MockFirebaseApp(); + + final cloud = createModelWithClient( + app: app, + location: 'us-central1', + model: 'gemini-pro', + client: apiClient, + useVertexBackend: false, + ); + + final mockLocalStream = Stream.fromIterable([ + GenerateContentResponse([ + Candidate(Content('model', [TextPart('Local Response')]), null, null, null, null) + ], null) + ]); + + final model = TestHybridGenerativeModel( + cloudModel: cloud, + localApi: local, + mode: InferenceMode.preferCloud, + mockLocalStream: mockLocalStream, + ); + + final responses = model.generateContentStream([Content.text('hello')]); + final textList = await responses.map((r) => r.text).toList(); + expect(textList, ['Local Response']); + }); +} + +class TestHybridGenerativeModel extends HybridGenerativeModel { + Stream? mockLocalStream; + + TestHybridGenerativeModel({ + required super.cloudModel, + required super.localApi, + super.mode, + this.mockLocalStream, + }); + + @override + Stream generateLocalStream(Iterable prompt) { + if (mockLocalStream != null) return mockLocalStream!; + return super.generateLocalStream(prompt); + } } From fb83e4356a3758b2ce5a0ede958c4fc1a8730175 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 7 May 2026 20:47:22 -0700 Subject: [PATCH 4/9] fix analyzer --- .../lib/src/hybrid_generative_model.dart | 68 +++++++++++++------ .../firebase_ai/lib/src/web/chrome_ai.dart | 20 ++++++ .../test/hybrid_generative_model_test.dart | 28 +++++--- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart index 8f5433028eec..c0297a7f84f7 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -13,31 +13,46 @@ // limitations under the License. import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; + import 'api.dart'; -import 'content.dart'; import 'base_model.dart'; +import 'content.dart'; import 'generated/local_ai.g.dart'; +/// Modes for hybrid inference. enum InferenceMode { + /// Prefer cloud, fallback to local on failure. preferCloud, + /// Prefer local, fallback to cloud on failure. preferLocal, + /// Only use local model. onlyLocal, + /// Only use cloud model. onlyCloud, } +/// A generative model that supports hybrid inference (local and cloud). class HybridGenerativeModel { - final GenerativeModel cloudModel; - final LocalAIApi localApi; - final InferenceMode mode; - + /// Creates a [HybridGenerativeModel]. HybridGenerativeModel({ required this.cloudModel, required this.localApi, this.mode = InferenceMode.preferCloud, }); + /// The cloud model to use. + final GenerativeModel cloudModel; + + /// The local AI API bridge. + final LocalAIApi localApi; + + /// The inference mode. + final InferenceMode mode; + + /// Generates content responding to [prompt]. Future generateContent(Iterable prompt) async { switch (mode) { case InferenceMode.onlyCloud: @@ -80,10 +95,12 @@ class HybridGenerativeModel { ], null); // promptFeedback } + /// Warms up the local model (e.g., triggers download on Web). Future warmup() async { await localApi.warmup(); } + /// Generates a stream of content responding to [prompt]. Stream generateContentStream(Iterable prompt) { switch (mode) { case InferenceMode.onlyCloud: @@ -109,7 +126,7 @@ class HybridGenerativeModel { ); } else { controller.addError(e); - controller.close(); + unawaited(controller.close()); } }, onDone: controller.close, @@ -124,7 +141,7 @@ class HybridGenerativeModel { ); } else { controller.addError(e); - controller.close(); + unawaited(controller.close()); } }); } @@ -151,7 +168,7 @@ class HybridGenerativeModel { ); } else { controller.addError(e); - controller.close(); + unawaited(controller.close()); } }, onDone: controller.close, @@ -169,24 +186,33 @@ class HybridGenerativeModel { } } + /// Generates a stream of content from the local model. @visibleForTesting Stream generateLocalStream(Iterable prompt) { final promptString = prompt.map((c) => c.parts.whereType().map((p) => p.text).join()).join(); - localApi.startStreaming(promptString); + final controller = StreamController(); - final channel = EventChannel('dev.flutter.pigeon.firebase_ai.LocalAIApi.stream'); - return channel.receiveBroadcastStream().map((event) { - final responseText = event as String; - return GenerateContentResponse([ - Candidate( - Content('model', [TextPart(responseText)]), - null, - null, - null, - null, - ) - ], null); + localApi.startStreaming(promptString).then((_) { + const channel = EventChannel('dev.flutter.pigeon.firebase_ai.LocalAIApi.stream'); + channel.receiveBroadcastStream().map((event) { + final responseText = event as String; + // ignore: prefer_const_constructors + return GenerateContentResponse([ + Candidate( + Content('model', [TextPart(responseText)]), + null, + null, + null, + null, + ) + ], null); + }).listen(controller.add, onError: controller.addError, onDone: controller.close); + }).catchError((e) { + controller.addError(e); + unawaited(controller.close()); }); + + return controller.stream; } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart index 01c10d6c2c30..763b14ceec9b 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/web/chrome_ai.dart @@ -15,59 +15,78 @@ import 'dart:async'; import 'dart:js_interop'; +/// JS Interop for window.ai @JS('window.ai') external JSObject? get windowAI; +/// Extension on JSObject for window.ai extension WindowAIExtension on JSObject { + /// Access the language model API. @JS('languageModel') external JSObject get languageModel; } +/// Extension on JSObject for languageModel extension LanguageModelExtension on JSObject { + /// Create a new model instance. @JS('create') external JSPromise create(JSObject? options); } +/// Extension on JSObject for model instance extension ModelInstanceExtension on JSObject { + /// Prompt the model for a non-streaming response. @JS('prompt') external JSPromise prompt(JSString input); + /// Prompt the model for a streaming response. @JS('promptStreaming') external JSObject promptStreaming(JSString input); } +/// Extension on JSObject for ReadableStream extension ReadableStreamExtension on JSObject { + /// Get a reader for the stream. @JS('getReader') external JSObject getReader(); } +/// Extension on JSObject for ReadableStreamDefaultReader extension ReadableStreamDefaultReaderExtension on JSObject { + /// Read a chunk from the stream. @JS('read') external JSPromise read(); } +/// Extension on JSObject for ReadableStreamDefaultReadResult extension ReadableStreamDefaultReadResultExtension on JSObject { + /// Indicates if the stream is done. @JS('done') external bool get done; + /// The chunk value. @JS('value') external JSString get value; } +/// Wrapper for Chrome's window.ai API. class ChromeAI { JSObject? _model; + /// Checks if window.ai is available. Future isAvailable() async { if (windowAI == null) return false; return true; } + /// Warms up the model (creates an instance). Future warmup() async { if (windowAI == null) throw Exception('window.ai not available'); final lm = windowAI!.languageModel; _model = await lm.create(null).toDart; } + /// Generates content for a prompt. Future generateContent(String prompt) async { if (_model == null) { await warmup(); @@ -76,6 +95,7 @@ class ChromeAI { return response.toDart; } + /// Generates a stream of content for a prompt. Stream generateContentStream(String prompt) { final controller = StreamController(); diff --git a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart index 626056b5564b..4bb906007128 100644 --- a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart @@ -12,14 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:flutter_test/flutter_test.dart'; -import 'package:firebase_ai/src/hybrid_generative_model.dart'; +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; + import 'package:firebase_ai/src/api.dart'; -import 'package:firebase_ai/src/content.dart'; -import 'package:firebase_ai/src/generated/local_ai.g.dart'; import 'package:firebase_ai/src/base_model.dart'; import 'package:firebase_ai/src/client.dart'; +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_ai/src/generated/local_ai.g.dart'; +import 'package:firebase_ai/src/hybrid_generative_model.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; class MockApiClient implements ApiClient { bool shouldFail = false; @@ -28,8 +32,6 @@ class MockApiClient implements ApiClient { @override Future> makeRequest(Uri uri, Map body) async { if (shouldFail) throw Exception('Cloud Failed'); - // Return a minimal valid JSON response that parseGenerateContentResponse can handle. - // We need to mock the response structure. return { 'candidates': [ { @@ -78,14 +80,20 @@ class MockLocalApi extends LocalAIApi { @override Future warmup() async {} + + @override + Future startStreaming(String prompt) async { + if (shouldFail) throw Exception('Local Failed'); + } } +// ignore: avoid_implementing_value_types class MockFirebaseApp implements FirebaseApp { @override String get name => '[DEFAULT]'; @override - FirebaseOptions get options => FirebaseOptions( + FirebaseOptions get options => const FirebaseOptions( apiKey: 'dummy_api_key', appId: 'dummy_app_id', messagingSenderId: 'dummy_sender_id', @@ -185,7 +193,7 @@ void main() { final mockLocalStream = Stream.fromIterable([ GenerateContentResponse([ - Candidate(Content('model', [TextPart('Local Response')]), null, null, null, null) + Candidate(Content('model', [const TextPart('Local Response')]), null, null, null, null) ], null) ]); @@ -203,8 +211,6 @@ void main() { } class TestHybridGenerativeModel extends HybridGenerativeModel { - Stream? mockLocalStream; - TestHybridGenerativeModel({ required super.cloudModel, required super.localApi, @@ -212,6 +218,8 @@ class TestHybridGenerativeModel extends HybridGenerativeModel { this.mockLocalStream, }); + Stream? mockLocalStream; + @override Stream generateLocalStream(Iterable prompt) { if (mockLocalStream != null) return mockLocalStream!; From 6361939afa6904a6281161e4724393f528991ba8 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 7 May 2026 21:05:48 -0700 Subject: [PATCH 5/9] first example test setup --- .../example/android/app/build.gradle.kts | 3 + .../example/android/settings.gradle.kts | 3 + .../firebase_ai/example/lib/main.dart | 15 +- .../example/lib/pages/hybrid_page.dart | 177 ++++++++++++++++++ .../firebase_ai/lib/firebase_ai.dart | 1 + .../lib/src/hybrid_generative_model.dart | 4 +- 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart diff --git a/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts index 5b2cf7547615..d818671f2416 100644 --- a/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts +++ b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts @@ -1,5 +1,8 @@ plugins { id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") diff --git a/packages/firebase_ai/firebase_ai/example/android/settings.gradle.kts b/packages/firebase_ai/firebase_ai/example/android/settings.gradle.kts index ab39a10a29ba..bd7522f75402 100644 --- a/packages/firebase_ai/firebase_ai/example/android/settings.gradle.kts +++ b/packages/firebase_ai/firebase_ai/example/android/settings.gradle.kts @@ -19,6 +19,9 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.3" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index b91bbd282276..c7a81f63df12 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -24,13 +24,14 @@ import 'package:flutter/material.dart'; import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; import 'pages/function_calling_page.dart'; +import 'pages/grounding_page.dart'; +import 'pages/hybrid_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/server_template_page.dart'; -import 'pages/grounding_page.dart'; import 'pages/token_count_page.dart'; void main() async { @@ -179,6 +180,11 @@ class _HomeScreenState extends State { title: 'Grounding', useVertexBackend: useVertexBackend, ); + case 11: + return HybridPage( + title: 'Hybrid Mode', + model: currentModel, + ); default: // Fallback to the first page in case of an unexpected index @@ -313,6 +319,13 @@ class _HomeScreenState extends State { label: 'Grounding', tooltip: 'Search & Maps Grounding', ), + BottomNavigationBarItem( + icon: Icon( + Icons.auto_awesome, + ), + label: 'Hybrid', + tooltip: 'Hybrid Mode', + ), ], currentIndex: _selectedIndex, onTap: _onItemTapped, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart new file mode 100644 index 000000000000..6530ad9aab3e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart @@ -0,0 +1,177 @@ +// 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 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; + +class HybridPage extends StatefulWidget { + final String title; + final GenerativeModel model; + + const HybridPage({super.key, required this.title, required this.model}); + + @override + State createState() => _HybridPageState(); +} + +class _HybridPageState extends State { + late HybridGenerativeModel _hybridModel; + InferenceMode _selectedMode = InferenceMode.preferCloud; + final TextEditingController _promptController = TextEditingController(); + String _response = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _hybridModel = HybridGenerativeModel( + cloudModel: widget.model, + mode: _selectedMode, + ); + } + + void _updateMode(InferenceMode? newMode) { + if (newMode != null) { + setState(() { + _selectedMode = newMode; + _hybridModel = HybridGenerativeModel( + cloudModel: widget.model, + mode: _selectedMode, + ); + }); + } + } + + Future _generate() async { + setState(() { + _isLoading = true; + _response = ''; + }); + try { + final response = await _hybridModel.generateContent([Content.text(_promptController.text)]); + setState(() { + _response = response.text ?? 'No response'; + }); + } catch (e) { + setState(() { + _response = 'Error: $e'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _stream() { + setState(() { + _isLoading = true; + _response = ''; + }); + + _hybridModel.generateContentStream([Content.text(_promptController.text)]).listen( + (response) { + setState(() { + _response += response.text ?? ''; + }); + }, + onError: (e) { + setState(() { + _response += '\nError: $e'; + _isLoading = false; + }); + }, + onDone: () { + setState(() { + _isLoading = false; + }); + }, + ); + } + + Future _warmup() async { + setState(() { + _isLoading = true; + _response = 'Warming up...'; + }); + try { + await _hybridModel.warmup(); + setState(() { + _response = 'Warmup completed!'; + }); + } catch (e) { + setState(() { + _response = 'Warmup failed: $e'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + DropdownButton( + value: _selectedMode, + onChanged: _updateMode, + items: InferenceMode.values.map((mode) { + return DropdownMenuItem( + value: mode, + child: Text(mode.toString().split('.').last), + ); + }).toList(), + ), + TextField( + controller: _promptController, + decoration: const InputDecoration(labelText: 'Prompt'), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _isLoading ? null : _generate, + child: const Text('Generate'), + ), + ElevatedButton( + onPressed: _isLoading ? null : _stream, + child: const Text('Stream'), + ), + ElevatedButton( + onPressed: _isLoading ? null : _warmup, + child: const Text('Warmup'), + ), + ], + ), + const SizedBox(height: 16), + if (_isLoading) const CircularProgressIndicator(), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Text(_response), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 9e48a0c58686..39e89b9c335e 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -66,6 +66,7 @@ export 'src/error.dart' QuotaExceeded, UnsupportedUserLocation; export 'src/firebase_ai.dart' show FirebaseAI; +export 'src/hybrid_generative_model.dart' show HybridGenerativeModel, InferenceMode; export 'src/image_config.dart' show ImageConfig, ImageAspectRatio, ImageSize; export 'src/imagen/imagen_api.dart' show diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart index c0297a7f84f7..eb9acaf7392b 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -39,9 +39,9 @@ class HybridGenerativeModel { /// Creates a [HybridGenerativeModel]. HybridGenerativeModel({ required this.cloudModel, - required this.localApi, + LocalAIApi? localApi, this.mode = InferenceMode.preferCloud, - }); + }) : localApi = localApi ?? LocalAIApi(); /// The cloud model to use. final GenerativeModel cloudModel; From 4c6d3fb6e45781ecf66fdd5b0c66de49e3c611b2 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Mon, 11 May 2026 16:25:11 -0700 Subject: [PATCH 6/9] create symlink for macos --- .../macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift | 1 + .../macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift | 1 + 2 files changed, 2 insertions(+) create mode 120000 packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift create mode 120000 packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift new file mode 120000 index 000000000000..66462ed47796 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift @@ -0,0 +1 @@ +../../../../../ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift new file mode 120000 index 000000000000..241eb79650c3 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift @@ -0,0 +1 @@ +../../../../../ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift \ No newline at end of file From 5ac856d16d6ea9a25e4277028b84ed39dc963b0c Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 14 May 2026 21:28:07 -0700 Subject: [PATCH 7/9] fix symlink --- packages/firebase_ai/firebase_ai/example/pubspec.yaml | 2 +- .../firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift | 2 +- .../macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/pubspec.yaml b/packages/firebase_ai/firebase_ai/example/pubspec.yaml index d87320464c2c..e1a59c2af10b 100644 --- a/packages/firebase_ai/firebase_ai/example/pubspec.yaml +++ b/packages/firebase_ai/firebase_ai/example/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: sdk: flutter flutter_animate: ^4.5.2 flutter_markdown: ^0.7.7+1 - flutter_soloud: ^4.0.4 + flutter_soloud: ^4.0.5 image: ^4.5.4 image_picker: ^1.1.2 path_provider: ^2.1.5 diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift index 66462ed47796..eaf753545e92 120000 --- a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift @@ -1 +1 @@ -../../../../../ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift \ No newline at end of file +../../../../ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift index 241eb79650c3..63b3bffc2520 120000 --- a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift @@ -1 +1 @@ -../../../../../ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift \ No newline at end of file +../../../../ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift \ No newline at end of file From f3fbcaef536e4b66fee2f13a369a51ac009f9900 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Thu, 14 May 2026 22:29:40 -0700 Subject: [PATCH 8/9] real android local model hook up --- .../firebase_ai/android/build.gradle | 6 ++ .../firebase_ai/android/local-config.gradle | 2 +- .../plugins/firebase/ai/FirebaseAIPlugin.kt | 4 ++ .../plugins/firebase/ai/GeneratedLocalAI.kt | 1 + .../plugins/firebase/ai/LocalAIImpl.kt | 69 +++++++++++++++---- .../example/android/app/build.gradle.kts | 4 +- .../firebase_ai/example/lib/main.dart | 8 +-- .../firebase_ai/FirebaseAIPlugin.swift | 4 ++ .../Sources/firebase_ai/LocalAIImpl.swift | 1 + .../lib/src/hybrid_generative_model.dart | 29 +++++--- .../firebase_ai/pigeons/local_ai.dart | 2 +- .../test/hybrid_generative_model_test.dart | 2 +- 12 files changed, 101 insertions(+), 31 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/android/build.gradle b/packages/firebase_ai/firebase_ai/android/build.gradle index 13affc9f6740..de366a6360c8 100644 --- a/packages/firebase_ai/firebase_ai/android/build.gradle +++ b/packages/firebase_ai/firebase_ai/android/build.gradle @@ -47,3 +47,9 @@ android { disable 'InvalidPackage' } } + +dependencies { + implementation "com.google.mlkit:genai-prompt:1.0.0-beta2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" +} + diff --git a/packages/firebase_ai/firebase_ai/android/local-config.gradle b/packages/firebase_ai/firebase_ai/android/local-config.gradle index 2adcdf5c1729..1b0612dbff12 100644 --- a/packages/firebase_ai/firebase_ai/android/local-config.gradle +++ b/packages/firebase_ai/firebase_ai/android/local-config.gradle @@ -1,6 +1,6 @@ ext { compileSdk=34 - minSdk=23 + minSdk=26 targetSdk=34 javaVersion = JavaVersion.toVersion(17) androidGradlePluginVersion = '8.3.0' diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt index 31fc09460d7a..709fd5a101f6 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt @@ -122,4 +122,8 @@ class LocalAIStreamHandler : io.flutter.plugin.common.EventChannel.StreamHandler fun sendEvent(event: String) { eventSink?.success(event) } + + fun closeStream() { + eventSink?.endOfStream() + } } diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt index 164aca591dc7..70de84824ccb 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt @@ -15,6 +15,7 @@ // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") +package io.flutter.plugins.firebase.ai import android.util.Log import io.flutter.plugin.common.BasicMessageChannel diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt index 011eaa3fdf71..131934fa4563 100644 --- a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/LocalAIImpl.kt @@ -14,28 +14,73 @@ package io.flutter.plugins.firebase.ai +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.GenerateContentRequest +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.common.FeatureStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.catch + class LocalAIImpl : LocalAIApi { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val mlkitModel by lazy { Generation.getClient() } + override fun isAvailable(callback: (Result) -> Unit) { - // Placeholder for AICore availability check. - // Assumes Gemini Nano is available on supported devices. - callback(Result.success(true)) + coroutineScope.launch { + try { + val status = mlkitModel.checkStatus() + callback(Result.success(status == FeatureStatus.AVAILABLE)) + } catch (e: Exception) { + callback(Result.success(false)) + } + } } override fun generateContent(prompt: String, callback: (Result) -> Unit) { - // Placeholder for raw AICore API call. - // In a real implementation, this would interact with the system's AI service. - callback(Result.success("Local response from AICore for: $prompt")) + coroutineScope.launch { + try { + val request = GenerateContentRequest.builder(TextPart(prompt)).build() + val response = mlkitModel.generateContent(request) + val text = response.candidates.firstOrNull()?.text ?: "" + callback(Result.success(text)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } } override fun warmup(callback: (Result) -> Unit) { - // Android uses default models (Gemini Nano), so warmup is likely a no-op. - callback(Result.success(Unit)) + coroutineScope.launch { + try { + mlkitModel.warmup() + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } } override fun startStreaming(prompt: String, callback: (Result) -> Unit) { - // Simulate streaming by sending chunks to the shared stream handler. - LocalAIStreamHandler.shared.sendEvent("Local chunk 1 for: $prompt") - LocalAIStreamHandler.shared.sendEvent("Local chunk 2 for: $prompt") - callback(Result.success(Unit)) + coroutineScope.launch { + try { + val request = GenerateContentRequest.builder(TextPart(prompt)).build() + mlkitModel.generateContentStream(request) + .catch { e -> + callback(Result.failure(e)) + } + .collect { chunk -> + val text = chunk.candidates.firstOrNull()?.text ?: "" + if (text.isNotEmpty()) { + LocalAIStreamHandler.shared.sendEvent(text) + } + } + LocalAIStreamHandler.shared.closeStream() + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } } } diff --git a/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts index d818671f2416..ac049dce4225 100644 --- a/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts +++ b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle.kts @@ -25,9 +25,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.firebase_ai_example" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index e7b531e8395c..964ef59a48a8 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -19,7 +19,7 @@ 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 'package:firebase_ai_example/firebase_options.dart'; import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; @@ -38,9 +38,9 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); // Enable this line instead once have the firebase_options.dart generated and // imported through flutterfire_cli. - // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - await Firebase.initializeApp(); - await FirebaseAuth.instance.signInAnonymously(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // await Firebase.initializeApp(); + // await FirebaseAuth.instance.signInAnonymously(); runApp(const GenerativeAISample()); } diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift index c5e35514f622..8ad5c689a7b0 100644 --- a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift @@ -70,4 +70,8 @@ class LocalAIStreamHandler: NSObject, FlutterStreamHandler { func sendEvent(_ event: String) { eventSink?(event) } + + func closeStream() { + eventSink?(FlutterEndOfEventStream) + } } diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift index 4a45808ba1c6..c99e114983ef 100644 --- a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift @@ -67,6 +67,7 @@ class LocalAIImpl: LocalAIApi { // Simulate streaming by sending chunks to the shared stream handler. LocalAIStreamHandler.shared.sendEvent("Local chunk 1 for: \(prompt)") LocalAIStreamHandler.shared.sendEvent("Local chunk 2 for: \(prompt)") + LocalAIStreamHandler.shared.closeStream() completion(.success(())) } catch { completion(.failure(error)) diff --git a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart index eb9acaf7392b..9699b2e9e7f8 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/hybrid_generative_model.dart @@ -192,10 +192,11 @@ class HybridGenerativeModel { final promptString = prompt.map((c) => c.parts.whereType().map((p) => p.text).join()).join(); final controller = StreamController(); - - localApi.startStreaming(promptString).then((_) { - const channel = EventChannel('dev.flutter.pigeon.firebase_ai.LocalAIApi.stream'); - channel.receiveBroadcastStream().map((event) { + const channel = EventChannel('dev.flutter.pigeon.firebase_ai.LocalAIApi.stream'); + StreamSubscription? subscription; + + controller.onListen = () { + subscription = channel.receiveBroadcastStream().map((event) { final responseText = event as String; // ignore: prefer_const_constructors return GenerateContentResponse([ @@ -207,11 +208,21 @@ class HybridGenerativeModel { null, ) ], null); - }).listen(controller.add, onError: controller.addError, onDone: controller.close); - }).catchError((e) { - controller.addError(e); - unawaited(controller.close()); - }); + }).listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + + localApi.startStreaming(promptString).catchError((e) { + controller.addError(e); + unawaited(controller.close()); + }); + }; + + controller.onCancel = () { + subscription?.cancel(); + }; return controller.stream; } diff --git a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart index 84fbaae0b328..0c856a44316a 100644 --- a/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart +++ b/packages/firebase_ai/firebase_ai/pigeons/local_ai.dart @@ -18,7 +18,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/src/generated/local_ai.g.dart', dartOptions: DartOptions(), kotlinOut: 'android/src/main/kotlin/io/flutter/plugins/firebase/ai/GeneratedLocalAI.kt', - kotlinOptions: KotlinOptions(), + kotlinOptions: KotlinOptions(package: 'io.flutter.plugins.firebase.ai'), swiftOut: 'ios/firebase_ai/Sources/firebase_ai/GeneratedLocalAI.swift', swiftOptions: SwiftOptions(), dartPackageName: 'firebase_ai', diff --git a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart index 4bb906007128..49489bc2faf7 100644 --- a/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/hybrid_generative_model_test.dart @@ -116,7 +116,7 @@ class MockFirebaseApp implements FirebaseApp { T? getService() => null; @override - void registerService(T service) {} + void registerService(T service, {Future Function(T)? dispose}) {} } void main() { From b25639dabf7ae588453a80bc8a4def0cec465a82 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Fri, 15 May 2026 13:57:28 -0700 Subject: [PATCH 9/9] iOS local model implementation --- .../example/lib/pages/hybrid_page.dart | 2 +- .../Sources/firebase_ai/LocalAIImpl.swift | 65 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart index 6530ad9aab3e..5c65faf80f5b 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/hybrid_page.dart @@ -166,7 +166,7 @@ class _HybridPageState extends State { const SizedBox(height: 16), Expanded( child: SingleChildScrollView( - child: Text(_response), + child: SelectableText(_response), ), ), ], diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift index c99e114983ef..1aff20e75611 100644 --- a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/LocalAIImpl.swift @@ -25,10 +25,35 @@ import FoundationModels #endif class LocalAIImpl: LocalAIApi { + private static var _isModelReady: Bool? + func isAvailable(completion: @escaping (Result) -> Void) { #if canImport(FoundationModels) if #available(iOS 26.0, macOS 26.0, *) { - completion(.success(true)) + if let ready = LocalAIImpl._isModelReady { + completion(.success(ready)) + return + } + + let model = FoundationModels.SystemLanguageModel.default + guard model.isAvailable else { + LocalAIImpl._isModelReady = false + completion(.success(false)) + return + } + + // Perform a lightweight check to verify the model is actually loadable and functional + Task { + do { + let session = FoundationModels.LanguageModelSession(model: model) + _ = try await session.respond(to: Prompt("Hello")) + LocalAIImpl._isModelReady = true + completion(.success(true)) + } catch { + LocalAIImpl._isModelReady = false + completion(.success(false)) + } + } } else { completion(.success(false)) } @@ -42,7 +67,22 @@ class LocalAIImpl: LocalAIApi { if #available(iOS 26.0, macOS 26.0, *) { Task { do { - completion(.success("Local response from FoundationModels for: \(prompt)")) + let model = FoundationModels.SystemLanguageModel.default + guard model.isAvailable else { + completion(.failure(PigeonError(code: "UNAVAILABLE", message: "SystemLanguageModel is not available on this device", details: nil))) + return + } + let session = FoundationModels.LanguageModelSession(model: model) + let response = try await session.respond(to: Prompt(prompt)) + let content = response.rawContent + + let responseText: String + if case let .string(text) = content.kind { + responseText = text + } else { + responseText = content.jsonString + } + completion(.success(responseText)) } catch { completion(.failure(error)) } @@ -64,9 +104,24 @@ class LocalAIImpl: LocalAIApi { if #available(iOS 26.0, macOS 26.0, *) { Task { do { - // Simulate streaming by sending chunks to the shared stream handler. - LocalAIStreamHandler.shared.sendEvent("Local chunk 1 for: \(prompt)") - LocalAIStreamHandler.shared.sendEvent("Local chunk 2 for: \(prompt)") + let model = FoundationModels.SystemLanguageModel.default + guard model.isAvailable else { + completion(.failure(PigeonError(code: "UNAVAILABLE", message: "SystemLanguageModel is not available on this device", details: nil))) + return + } + let session = FoundationModels.LanguageModelSession(model: model) + let stream = session.streamResponse(to: Prompt(prompt)) + + for try await snapshot in stream { + let content = snapshot.rawContent + let chunkText: String + if case let .string(text) = content.kind { + chunkText = text + } else { + chunkText = content.jsonString + } + LocalAIStreamHandler.shared.sendEvent(chunkText) + } LocalAIStreamHandler.shared.closeStream() completion(.success(())) } catch {