Skip to content

[FirebaseAI] Add Grounding with Google search sample #2687

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions firebase-ai/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.serialization.json)

// Material for XML-based theme
implementation(libs.material)

// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.ai)
Expand All @@ -72,4 +75,7 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)

// Webkit
implementation(libs.androidx.webkit)
Comment on lines +79 to +80
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move these 2 lines up? (around line 62, next to the other androidx dependencies that use implementation())

}
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,17 @@ val FIREBASE_AI_SAMPLES = listOf(
text("What was the weather in Boston, MA on October 17, 2024?")
}
),
Sample(
title = "Grounding with Google Search",
description = "Use Grounding with Google Search to get responses based on up-to-date information from the web.",
navRoute = "chat",
categories = listOf(Category.TEXT, Category.DOCUMENT),
modelName = "gemini-2.5-flash",
tools = listOf(Tool.googleSearch()),
initialPrompt = content {
text(
"What's the weather in Chicago this weekend?"
)
},
),
)
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package com.google.firebase.quickstart.ai.feature.text

import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.provider.OpenableColumns
import android.text.format.Formatter
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -22,6 +28,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
Expand All @@ -31,6 +38,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
Expand All @@ -50,16 +58,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.firebase.ai.type.Content
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import com.google.firebase.ai.type.FileDataPart
import com.google.firebase.ai.type.ImagePart
import com.google.firebase.ai.type.InlineDataPart
import com.google.firebase.ai.type.TextPart
import com.google.firebase.ai.type.WebGroundingChunk
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable

Expand All @@ -70,7 +84,7 @@ class ChatRoute(val sampleId: String)
fun ChatScreen(
chatViewModel: ChatViewModel = viewModel<ChatViewModel>()
) {
val messages: List<Content> by chatViewModel.messages.collectAsStateWithLifecycle()
val messages: List<UiChatMessage> by chatViewModel.messages.collectAsStateWithLifecycle()
val isLoading: Boolean by chatViewModel.isLoading.collectAsStateWithLifecycle()
val errorMessage: String? by chatViewModel.errorMessage.collectAsStateWithLifecycle()
val attachments: List<Attachment> by chatViewModel.attachments.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -162,17 +176,19 @@ fun ChatScreen(

@Composable
fun ChatBubbleItem(
chatMessage: Content
message: UiChatMessage
) {
val isModelMessage = chatMessage.role == "model"
val isModelMessage = message.content.role == "model"

val backgroundColor = when (chatMessage.role) {
val isDarkTheme = isSystemInDarkTheme()

val backgroundColor = when (message.content.role) {
"user" -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.secondaryContainer
}

val textColor = if (isModelMessage) {
MaterialTheme.colorScheme.onSecondaryContainer
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onTertiaryContainer
}
Expand All @@ -196,7 +212,7 @@ fun ChatBubbleItem(
.fillMaxWidth()
) {
Text(
text = chatMessage.role?.uppercase() ?: "USER",
text = message.content.role?.uppercase() ?: "USER",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
Expand All @@ -212,7 +228,7 @@ fun ChatBubbleItem(
.padding(16.dp)
.fillMaxWidth()
) {
chatMessage.parts.forEach { part ->
message.content.parts.forEach { part ->
when (part) {
is TextPart -> {
Text(
Expand Down Expand Up @@ -272,16 +288,98 @@ fun ChatBubbleItem(
}
}
}
message.groundingMetadata?.let { metadata ->
HorizontalDivider(modifier = Modifier.padding(vertical = 18.dp))

// Search Entry Point (WebView)
metadata.searchEntryPoint?.let { searchEntryPoint ->
val context = LocalContext.current
AndroidView(factory = {
WebView(it).apply {
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
request?.url?.let { uri ->
val intent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(intent)
}
// Return true to indicate we handled the URL loading
return true
}
}

setBackgroundColor(android.graphics.Color.TRANSPARENT)
loadDataWithBaseURL(
null,
searchEntryPoint.renderedContent,
"text/html",
"UTF-8",
null
)
}
},
modifier = Modifier
.clip(RoundedCornerShape(22.dp))
.fillMaxHeight()
.fillMaxWidth()
)
}

if (metadata.groundingChunks.isNotEmpty()) {
Text(
text = "Sources",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
metadata.groundingChunks.forEach { chunk ->
chunk.web?.let { SourceLinkView(it) }
}
}
}
}
}
}
}
}
}

@Composable
fun SourceLinkView(
webChunk: WebGroundingChunk
) {
val context = LocalContext.current
val annotatedString = AnnotatedString.Builder(webChunk.title ?: "Untitled Source").apply {
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
start = 0,
end = webChunk.title?.length ?: "Untitled Source".length
)
webChunk.uri?.let { addStringAnnotation("URL", it, 0, it.length) }
}.toAnnotatedString()

Row(modifier = Modifier.padding(bottom = 8.dp)) {
Icon(
Icons.Default.Attachment,
contentDescription = "Source link",
modifier = Modifier.padding(end = 8.dp)
)
ClickableText(text = annotatedString, onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
}
})
}
}

@Composable
fun ChatList(
chatMessages: List<Content>,
chatMessages: List<UiChatMessage>,
listState: LazyListState,
modifier: Modifier = Modifier
) {
Expand Down Expand Up @@ -470,4 +568,4 @@ fun AttachmentsList(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.google.firebase.ai.type.Content
import com.google.firebase.ai.type.FileDataPart
import com.google.firebase.ai.type.FunctionResponsePart
import com.google.firebase.ai.type.GenerateContentResponse
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.GroundingMetadata
import com.google.firebase.ai.type.TextPart
import com.google.firebase.ai.type.asTextOrNull
import com.google.firebase.ai.type.content
Expand All @@ -25,6 +27,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonPrimitive

/**
* A wrapper for a model [Content] object that includes additional UI-specific metadata.
*/
data class UiChatMessage(
val content: Content,
val groundingMetadata: GroundingMetadata? = null,
)

class ChatViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
Expand All @@ -42,10 +52,10 @@ class ChatViewModel(
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage

private val _messageList: MutableList<Content> =
sample.chatHistory.toMutableStateList()
private val _messages = MutableStateFlow<List<Content>>(_messageList)
val messages: StateFlow<List<Content>> =
private val _messageList: MutableList<UiChatMessage> =
sample.chatHistory.map { UiChatMessage(it) }.toMutableStateList()
private val _messages = MutableStateFlow<List<UiChatMessage>>(_messageList)
val messages: StateFlow<List<UiChatMessage>> =
_messages

private val _attachmentsList: MutableList<Attachment> =
Expand Down Expand Up @@ -86,16 +96,28 @@ class ChatViewModel(
.text(userMessage)
.build()

_messageList.add(prompt)
_messageList.add(UiChatMessage(prompt))

viewModelScope.launch {
_isLoading.value = true
try {
val response = chat.sendMessage(prompt)
if (response.functionCalls.isEmpty()) {
// Samples without function calling can simply display
// the response in the UI
_messageList.add(response.candidates.first().content)
// Samples without function calling can display the response in the UI
val candidate = response.candidates.first()

// Compliance check for grounding
if (candidate.groundingMetadata != null
&& candidate.groundingMetadata?.groundingChunks?.isNotEmpty() == true
&& candidate.groundingMetadata?.searchEntryPoint == null) {
_errorMessage.value =
"Could not display the response because it was missing required attribution components."
} else {
_messageList.add(
UiChatMessage(candidate.content, candidate.groundingMetadata)
)
_errorMessage.value = null // clear errors
}
} else {
// Samples WITH function calling need to perform
// additional handling
Expand Down Expand Up @@ -154,7 +176,9 @@ class ChatViewModel(
})

Log.d("ChatViewModel", "Model responded with: ${finalResponse.text}")
_messageList.add(finalResponse.candidates.first().content)
val candidate = finalResponse.candidates.first()
_messageList.add(UiChatMessage(candidate.content,
candidate.groundingMetadata))
}

else -> {
Expand Down
3 changes: 2 additions & 1 deletion firebase-ai/app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Theme.FirebaseAIServices" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.FirebaseAIServices" parent="Theme.Material3.DayNight.NoActionBar" />

</resources>
2 changes: 1 addition & 1 deletion firebase-ai/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"}
firebase-ai = { module = "com.google.firebase:firebase-ai" }
firebase-ai = { module = "com.google.firebase:firebase-ai", version = "16.2.0" }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a comment so that we don't forget to undo this before merging

firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" }
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ activityCompose = "1.10.1"
composeBom = "2025.07.00"
googleServices = "4.4.3"
composeNavigation = "2.9.2"
material = "1.12.0"
webkit = "1.11.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
Expand Down Expand Up @@ -41,6 +43,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"}
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
material = { module = "com.google.android.material:material", version.ref = "material" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down
Loading