diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt index 9290d8e49..17f0447d0 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt @@ -21,29 +21,29 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import androidx.navigation.NavDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.firebase.Firebase import com.google.firebase.appcheck.appCheck import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory -import com.google.firebase.quickstart.ai.feature.live.BidiViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.FunctionCallingViewModel import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel -import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeViewModel import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeViewModel +import com.google.firebase.quickstart.ai.feature.live.BidiViewModel import com.google.firebase.quickstart.ai.feature.text.ChatViewModel import com.google.firebase.quickstart.ai.feature.text.ServerPromptTemplateViewModel import com.google.firebase.quickstart.ai.feature.text.SvgViewModel import com.google.firebase.quickstart.ai.ui.ChatScreen +import com.google.firebase.quickstart.ai.ui.FunctionCallingScreen +import com.google.firebase.quickstart.ai.ui.HybridInferenceScreen import com.google.firebase.quickstart.ai.ui.ServerPromptScreen import com.google.firebase.quickstart.ai.ui.StreamRealtimeScreen import com.google.firebase.quickstart.ai.ui.StreamRealtimeVideoScreen -import com.google.firebase.quickstart.ai.ui.HybridInferenceScreen import com.google.firebase.quickstart.ai.ui.SvgScreen -import com.google.firebase.quickstart.ai.ui.ThinkingModeScreen import com.google.firebase.quickstart.ai.ui.SystemInstructionScreen +import com.google.firebase.quickstart.ai.ui.ThinkingModeScreen import com.google.firebase.quickstart.ai.ui.navigation.FIREBASE_AI_SAMPLES import com.google.firebase.quickstart.ai.ui.navigation.MainMenuScreen import com.google.firebase.quickstart.ai.ui.navigation.ScreenType @@ -143,6 +143,10 @@ class MainActivity : ComponentActivity() { ScreenType.SYSTEM_INSTRUCTION -> { (vm as? SystemInstructionViewModel)?.let { SystemInstructionScreen(it) } } + + ScreenType.FUNCTION_CALLING -> { + (vm as? FunctionCallingViewModel)?.let { FunctionCallingScreen(it) } + } } } } diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/FunctionCallingViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/FunctionCallingViewModel.kt new file mode 100644 index 000000000..6dde16b44 --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/hybrid/FunctionCallingViewModel.kt @@ -0,0 +1,388 @@ +package com.google.firebase.quickstart.ai.feature.hybrid + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.ai.DownloadStatus +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig +import com.google.firebase.ai.OnDeviceModelOption +import com.google.firebase.ai.OnDeviceModelStatus +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.AutoFunctionDeclaration +import com.google.firebase.ai.type.FirebaseAutoFunctionException +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.FunctionDeclaration +import com.google.firebase.ai.type.FunctionResponsePart +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.JsonSchema +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.Schema +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.content +import com.google.firebase.quickstart.ai.feature.text.functioncalling.WeatherRepository +import com.google.firebase.quickstart.ai.ui.FunctionCallInfo +import com.google.firebase.quickstart.ai.ui.FunctionCallingUiState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +object FunctionCallingRoute + +enum class FunctionToolOption(val label: String) { + MANUAL("Manual (FunctionDeclaration)"), + AUTO("Automatic (AutoFunctionDeclaration)"), + UNSUPPORTED("Unsupported Tool (Google Search)"), + NONE("No Tools (Baseline)") +} + +@OptIn(PublicPreviewAPI::class) +class FunctionCallingViewModel : ViewModel() { + private val _uiState = MutableStateFlow(FunctionCallingUiState()) + val uiState: StateFlow = _uiState + + private var testJob: Job? = null + private var statusJob: Job? = null + + init { + checkAndDownloadModel() + } + + fun checkAndDownloadModel(modelOption: OnDeviceModelOption? = null) { + statusJob?.cancel() + statusJob = viewModelScope.launch { + try { + val statusCheckModel = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( + modelName = "gemini-3.5-flash-lite", + onDeviceConfig = OnDeviceConfig( + mode = InferenceMode.PREFER_ON_DEVICE, + modelOption = modelOption + ) + ) + val status = statusCheckModel.onDeviceExtension?.checkStatus() + val statusText = when (status) { + OnDeviceModelStatus.AVAILABLE -> "Model available" + OnDeviceModelStatus.DOWNLOADABLE -> "Model downloadable" + OnDeviceModelStatus.DOWNLOADING -> "Model downloading..." + else -> "On-device model unavailable" + } + _uiState.update { it.copy(modelStatus = statusText) } + + if (status == OnDeviceModelStatus.DOWNLOADABLE) { + statusCheckModel.onDeviceExtension?.download()?.collect { downloadStatus -> + when (downloadStatus) { + is DownloadStatus.DownloadStarted -> { + _uiState.update { it.copy(modelStatus = "Downloading model...") } + } + is DownloadStatus.DownloadInProgress -> { + val progress = downloadStatus.totalBytesDownloaded + _uiState.update { it.copy(modelStatus = "Downloading: $progress bytes") } + } + is DownloadStatus.DownloadCompleted -> { + _uiState.update { it.copy(modelStatus = "Model available") } + } + is DownloadStatus.DownloadFailed -> { + _uiState.update { + it.copy( + modelStatus = "Download failed", + errorMessage = "Model download failed" + ) + } + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { + it.copy( + modelStatus = "Error checking status", + errorMessage = e.localizedMessage ?: e.toString() + ) + } + } + } + } + + private suspend fun fetchWeatherAuto(input: JsonObject): FunctionResponsePart { + val city = (input["city"] as? JsonPrimitive)?.content + val state = (input["state"] as? JsonPrimitive)?.content + val date = (input["date"] as? JsonPrimitive)?.content + + return if (city == null || state == null || date == null) { + throw FirebaseAutoFunctionException("Unable to fetch weather - parameters were missing") + } else { + val response = WeatherRepository.fetchWeather(city, state, date) + FunctionResponsePart("fetchWeather", response) + } + } + + fun runTest( + promptText: String, + toolOption: FunctionToolOption, + modelOptionSelection: OnDeviceModelOptionSelection, + mode: InferenceMode, + method: GenerationMethod + ) { + testJob?.cancel() + testJob = viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = true, + errorMessage = null, + functionCalls = emptyList(), + functionExecutionResult = null, + responseText = null, + inferenceSource = null, + logOutput = "Running inference..." + ) + } + + Log.i(TAG, "==========================================================") + Log.i(TAG, ">>> [FUNCTION CALLING TEST - INPUT] >>>") + Log.i(TAG, "toolOption: ${toolOption.label}") + Log.i(TAG, "modelOption: ${modelOptionSelection.label}") + Log.i(TAG, "Inference Mode: $mode") + Log.i(TAG, "Generation Method: ${method.label}") + Log.i(TAG, "Prompt: \"$promptText\"") + Log.i(TAG, "==========================================================") + + try { + val manualWeatherDeclaration = FunctionDeclaration( + name = "fetchWeather", + description = "Get the weather conditions for a specific US city on a specific date.", + parameters = mapOf( + "city" to Schema.string("The US city of the location."), + "state" to Schema.string("The US state of the location."), + "date" to Schema.string( + "The date for which to get the weather. Date must be in the format: YYYY-MM-DD." + ) + ) + ) + + val autoWeatherDeclaration = AutoFunctionDeclaration.create( + functionName = "fetchWeather", + description = "Get the weather conditions for a specific US city on a specific date.", + inputSchema = JsonSchema.obj( + properties = mapOf( + "city" to JsonSchema.string("The US city of the location."), + "state" to JsonSchema.string("The US state of the location."), + "date" to JsonSchema.string( + "The date for which to get the weather. Date must be in the format: YYYY-MM-DD." + ) + ) + ), + functionReference = ::fetchWeatherAuto + ) + + val tools: List? = when (toolOption) { + FunctionToolOption.MANUAL -> listOf(Tool.functionDeclarations(listOf(manualWeatherDeclaration))) + FunctionToolOption.AUTO -> listOf( + Tool.functionDeclarations(autoFunctionDeclarations = listOf(autoWeatherDeclaration)) + ) + FunctionToolOption.UNSUPPORTED -> listOf(Tool.googleSearch()) + FunctionToolOption.NONE -> null + } + + val model = Firebase.ai(backend = GenerativeBackend.googleAI()).generativeModel( + modelName = "gemini-3.5-flash-lite", + tools = tools, + onDeviceConfig = OnDeviceConfig( + mode = mode, + modelOption = modelOptionSelection.option, + tools = tools + ) + ) + + val functionCallInfos = mutableListOf() + var fullText = "" + var executionResult: String? = null + var source: InferenceSource? = null + var totalChunks = 0 + + when (method) { + GenerationMethod.UNARY -> { + val response = model.generateContent(promptText) + val calls = response.functionCalls + fullText = response.text.orEmpty() + source = response.inferenceSource + + calls.forEach { call -> + functionCallInfos.add( + FunctionCallInfo( + name = call.name, + args = call.args.toString(), + id = call.id + ) + ) + } + + if (toolOption == FunctionToolOption.MANUAL && calls.isNotEmpty()) { + val firstCall = calls.first() + val city = (firstCall.args["city"] as? JsonPrimitive)?.content ?: "Boston" + val state = (firstCall.args["state"] as? JsonPrimitive)?.content ?: "MA" + val date = (firstCall.args["date"] as? JsonPrimitive)?.content ?: "2024-10-17" + val weatherJson = WeatherRepository.fetchWeather(city, state, date) + executionResult = weatherJson.toString() + + val followUp = model.generateContent( + content(role = "user") { text(promptText) }, + content(role = "model") { part(firstCall) }, + content(role = "user") { + part(FunctionResponsePart(firstCall.name, weatherJson, firstCall.id)) + } + ) + fullText = followUp.text.orEmpty() + source = followUp.inferenceSource + } else if (toolOption == FunctionToolOption.AUTO && calls.isNotEmpty()) { + val firstCall = calls.first() + val city = (firstCall.args["city"] as? JsonPrimitive)?.content ?: "Boston" + val state = (firstCall.args["state"] as? JsonPrimitive)?.content ?: "MA" + val date = (firstCall.args["date"] as? JsonPrimitive)?.content ?: "2024-10-17" + executionResult = WeatherRepository.fetchWeather(city, state, date).toString() + } + } + + GenerationMethod.STREAMING -> { + val detectedCalls = mutableListOf() + model.generateContentStream(promptText).collect { chunk -> + totalChunks++ + val chunkText = chunk.text + if (!chunkText.isNullOrEmpty()) { + fullText += chunkText + } + chunk.functionCalls.forEach { call -> + detectedCalls.add(call) + functionCallInfos.add( + FunctionCallInfo( + name = call.name, + args = call.args.toString(), + id = call.id + ) + ) + } + source = chunk.inferenceSource + _uiState.update { + it.copy( + functionCalls = functionCallInfos.toList(), + responseText = fullText.ifEmpty { null }, + inferenceSource = formatInferenceSource(source) + ) + } + } + + if (toolOption == FunctionToolOption.MANUAL && detectedCalls.isNotEmpty()) { + val firstCall = detectedCalls.first() + val city = (firstCall.args["city"] as? JsonPrimitive)?.content ?: "Boston" + val state = (firstCall.args["state"] as? JsonPrimitive)?.content ?: "MA" + val date = (firstCall.args["date"] as? JsonPrimitive)?.content ?: "2024-10-17" + val weatherJson = WeatherRepository.fetchWeather(city, state, date) + executionResult = weatherJson.toString() + + val followUp = model.generateContent( + content(role = "user") { text(promptText) }, + content(role = "model") { part(firstCall) }, + content(role = "user") { + part(FunctionResponsePart(firstCall.name, weatherJson, firstCall.id)) + } + ) + fullText = followUp.text.orEmpty() + source = followUp.inferenceSource + } + } + } + + val sourceName = formatInferenceSource(source) + Log.i(TAG, "<<< [FUNCTION CALLING TEST - SUCCESS] <<<") + Log.i(TAG, "Inference Source: $sourceName") + Log.i(TAG, "Function Calls: ${functionCallInfos.size}") + Log.i(TAG, "Response Text: \"$fullText\"") + + val callsSummary = if (functionCallInfos.isNotEmpty()) { + functionCallInfos.joinToString("\n") { "- ${it.name}(${it.args}) [id=${it.id}]" } + } else { + "" + } + + val summaryLog = """ + === INPUT === + toolOption: ${toolOption.label} + modelOption: ${modelOptionSelection.label} + Mode: $mode + Method: ${method.label} + Prompt: "$promptText" + + === OUTPUT (SUCCESS) === + Inference Source: $sourceName + Function Calls (${functionCallInfos.size}): + $callsSummary + + Execution Result: + ${executionResult ?: ""} + + Final Response (${fullText.length} chars): + $fullText + """.trimIndent() + + _uiState.update { + it.copy( + isLoading = false, + functionCalls = functionCallInfos, + functionExecutionResult = executionResult, + responseText = fullText, + inferenceSource = sourceName, + logOutput = summaryLog + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val causeName = e.cause?.let { " (cause: ${it::class.java.simpleName})" }.orEmpty() + Log.e(TAG, "<<< [FUNCTION CALLING TEST - FAILURE] <<<", e) + + val errorLog = """ + === INPUT === + toolOption: ${toolOption.label} + modelOption: ${modelOptionSelection.label} + Mode: $mode + Method: ${method.label} + + === OUTPUT (FAILURE) === + Exception: ${e::class.java.simpleName}$causeName + Message: ${e.localizedMessage ?: e.toString()} + """.trimIndent() + + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "${e::class.java.simpleName}$causeName: ${e.localizedMessage ?: e.toString()}", + logOutput = errorLog + ) + } + } + } + } + + private fun formatInferenceSource(source: InferenceSource?): String = + when (source) { + InferenceSource.ON_DEVICE -> "ON_DEVICE" + InferenceSource.IN_CLOUD -> "IN_CLOUD" + null -> "UNKNOWN" + else -> source.toString() + } + + companion object { + private const val TAG = "FunctionCallingDemo" + } +} diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingScreen.kt new file mode 100644 index 000000000..cbcf3d8bf --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingScreen.kt @@ -0,0 +1,345 @@ +package com.google.firebase.quickstart.ai.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.quickstart.ai.feature.hybrid.FunctionCallingViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.FunctionToolOption +import com.google.firebase.quickstart.ai.feature.hybrid.GenerationMethod +import com.google.firebase.quickstart.ai.feature.hybrid.InferenceModeOption +import com.google.firebase.quickstart.ai.feature.hybrid.OnDeviceModelOptionSelection + +@OptIn(PublicPreviewAPI::class) +@Composable +fun FunctionCallingScreen( + viewModel: FunctionCallingViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var prompt by rememberSaveable { + mutableStateOf("What was the weather in Boston, MA on October 17, 2024?") + } + var selectedToolOption by rememberSaveable { mutableStateOf(FunctionToolOption.MANUAL) } + var selectedModelOption by rememberSaveable { mutableStateOf(OnDeviceModelOptionSelection.DEFAULT) } + var selectedMode by rememberSaveable { mutableStateOf(InferenceModeOption.PREFER_ON_DEVICE) } + var selectedMethod by rememberSaveable { mutableStateOf(GenerationMethod.UNARY) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "On-Device Model Status", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = uiState.modelStatus, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Text( + text = "1. Function Tool Configuration:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FunctionToolOption.entries.forEach { option -> + FilterChip( + selected = selectedToolOption == option, + onClick = { selectedToolOption = option }, + label = { Text(option.label) } + ) + } + } + + Text( + text = "2. On-Device Model Option:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OnDeviceModelOptionSelection.entries.forEach { modelOpt -> + FilterChip( + selected = selectedModelOption == modelOpt, + onClick = { + selectedModelOption = modelOpt + viewModel.checkAndDownloadModel(modelOpt.option) + }, + label = { Text(modelOpt.label) } + ) + } + } + + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + label = { Text("User Prompt") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4 + ) + + Text( + text = "3. Inference Mode:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InferenceModeOption.entries.forEach { modeOption -> + FilterChip( + selected = selectedMode == modeOption, + onClick = { selectedMode = modeOption }, + label = { Text(modeOption.label) } + ) + } + } + + Text( + text = "4. Generation Method:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + GenerationMethod.entries.forEach { method -> + FilterChip( + selected = selectedMethod == method, + onClick = { selectedMethod = method }, + label = { Text(method.label) } + ) + } + } + + Button( + onClick = { + viewModel.runTest( + promptText = prompt, + toolOption = selectedToolOption, + modelOptionSelection = selectedModelOption, + mode = selectedMode.mode, + method = selectedMethod + ) + }, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Text("Run Function Calling Test") + } + + val errorMessage = uiState.errorMessage + if (errorMessage != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Error / Exception", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + if (uiState.functionCalls.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Function Call(s) Detected (response.functionCalls)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + uiState.functionCalls.forEach { call -> + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = "Function: ${call.name}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = "Arguments: ${call.args}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + if (call.id != null) { + Text( + text = "ID: ${call.id}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + + val execResult = uiState.functionExecutionResult + if (execResult != null) { + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = "Execution Result:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = execResult, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + + val responseText = uiState.responseText + if (responseText != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Model Response (response.text)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Surface( + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.small + ) { + Text( + text = uiState.inferenceSource ?: "UNKNOWN", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + Text( + text = responseText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Diagnostics & Logcat Summary", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = uiState.logOutput, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + } +} diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingUiState.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingUiState.kt new file mode 100644 index 000000000..f6b946167 --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/FunctionCallingUiState.kt @@ -0,0 +1,18 @@ +package com.google.firebase.quickstart.ai.ui + +data class FunctionCallInfo( + val name: String, + val args: String, + val id: String? = null +) + +data class FunctionCallingUiState( + val modelStatus: String = "Checking model status...", + val isLoading: Boolean = false, + val functionCalls: List = emptyList(), + val functionExecutionResult: String? = null, + val responseText: String? = null, + val inferenceSource: String? = null, + val logOutput: String = "Configure function calling options above and tap Run.", + val errorMessage: String? = null +) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt index c4b20978e..71890ec8b 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/FirebaseAISamples.kt @@ -1,11 +1,13 @@ package com.google.firebase.quickstart.ai.ui.navigation +import com.google.firebase.quickstart.ai.feature.hybrid.FunctionCallingRoute +import com.google.firebase.quickstart.ai.feature.hybrid.FunctionCallingViewModel import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceRoute import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel -import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeRoute -import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeViewModel import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionRoute import com.google.firebase.quickstart.ai.feature.hybrid.SystemInstructionViewModel +import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeRoute +import com.google.firebase.quickstart.ai.feature.hybrid.ThinkingModeViewModel import com.google.firebase.quickstart.ai.feature.live.StreamAudioViewModel import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeAudioRoute import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeVideoRoute @@ -66,7 +68,7 @@ val FIREBASE_AI_SAMPLES = listOf( route = AudioSummarizationRoute, screenType = ScreenType.CHAT, viewModelClass = AudioSummarizationViewModel::class, - categories = listOf(Category.MULTIMODAL_UNDERSTANDING), + categories = listOf(Category.MULTIMODAL_UNDERSTANDING) ), Sample( title = "Summarize video", @@ -143,7 +145,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Grounding with Google Search", description = "Use Grounding with Google Search to get responses based on up-to-date information from the" + - " web.", + " web.", route = GoogleSearchGroundingRoute, screenType = ScreenType.CHAT, viewModelClass = GoogleSearchGroundingViewModel::class, @@ -152,7 +154,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Manual function calling", description = "Use function calling to get the weather conditions" + - " for a specific US city on a specific date.", + " for a specific US city on a specific date.", route = WeatherChatRoute, screenType = ScreenType.CHAT, viewModelClass = WeatherChatViewModel::class, @@ -161,7 +163,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Automatic function calling", description = "Use automatic function calling to get the weather conditions" + - " for a specific US city on a specific date.", + " for a specific US city on a specific date.", route = AutoFunctionCallRoute, screenType = ScreenType.CHAT, viewModelClass = AutoFunctionCallViewModel::class, @@ -170,7 +172,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Gemini Live (audio input)", description = "Use bidirectional streaming to get information about" + - " weather conditions for a specific US city on a specific date", + " weather conditions for a specific US city on a specific date", route = StreamRealtimeAudioRoute, screenType = ScreenType.BIDI, viewModelClass = StreamAudioViewModel::class, @@ -179,7 +181,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Gemini Live (video input)", description = "Use bidirectional streaming to chat with Gemini using your" + - " phone's camera", + " phone's camera", route = StreamRealtimeVideoRoute, screenType = ScreenType.BIDI_VIDEO, viewModelClass = StreamVideoViewModel::class, @@ -188,11 +190,11 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Server Prompt Templates - Gemini", description = "Generate an invoice using server prompt templates. Note that you need to setup the template" + - " in the Firebase console before running this demo.", + " in the Firebase console before running this demo.", route = ServerPromptTemplateRoute, screenType = ScreenType.SERVER_PROMPT, viewModelClass = ServerPromptTemplateViewModel::class, - categories = listOf(Category.SERVER_PROMPTS), + categories = listOf(Category.SERVER_PROMPTS) ), Sample( title = "Hybrid Receipt Scanner", @@ -205,7 +207,7 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Hybrid Thinking Mode", description = "Test enableThinking and response.thoughtSummary across On-Device, Hybrid, and Cloud " + - "inference modes.", + "inference modes.", route = ThinkingModeRoute, screenType = ScreenType.THINKING_MODE, viewModelClass = ThinkingModeViewModel::class, @@ -214,10 +216,20 @@ val FIREBASE_AI_SAMPLES = listOf( Sample( title = "Hybrid System Instructions", description = """Test systemInstruction (single text, multiple text parts, and non-text part validation) - | across On-Device, Hybrid, and Cloud inference modes.""".trimMargin(), + | across On-Device, Hybrid, and Cloud inference modes. + """.trimMargin(), route = SystemInstructionRoute, screenType = ScreenType.SYSTEM_INSTRUCTION, viewModelClass = SystemInstructionViewModel::class, categories = listOf(Category.HYBRID) + ), + Sample( + title = "Hybrid Function Calling", + description = "Test function calling tools across On-Device, Hybrid, and Cloud " + + "inference modes with automatic cloud fallback.", + route = FunctionCallingRoute, + screenType = ScreenType.FUNCTION_CALLING, + viewModelClass = FunctionCallingViewModel::class, + categories = listOf(Category.HYBRID, Category.TOOLS_FC) ) ) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt index 91397a1f8..fb16e435a 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/ui/navigation/Sample.kt @@ -23,7 +23,8 @@ enum class ScreenType { BIDI_VIDEO, HYBRID, THINKING_MODE, - SYSTEM_INSTRUCTION + SYSTEM_INSTRUCTION, + FUNCTION_CALLING } data class Sample( @@ -32,5 +33,5 @@ data class Sample( val route: Any, val screenType: ScreenType, val viewModelClass: KClass? = null, - val categories: List, + val categories: List ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c29785c56..aa54f095c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,8 +51,8 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3C coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3Compose" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3Compose" } compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"} -firebase-ai = { module = "com.google.firebase:firebase-ai", version = "18.1.0" } -firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version = "16.0.0-beta07" } +firebase-ai = { module = "com.google.firebase:firebase-ai", version = "18.2.0" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version = "16.0.0-beta08" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-appcheck-debug = { module = "com.google.firebase:firebase-appcheck-debug" } junit = { group = "junit", name = "junit", version.ref = "junit" }