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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
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)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.google.firebase.quickstart.ai

import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.generationConfig
import com.google.firebase.quickstart.ai.ui.navigation.Category
Expand Down Expand Up @@ -203,5 +204,18 @@ val FIREBASE_AI_SAMPLES = listOf(
" anything important which people say in the video."
)
}
),
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,118 @@ 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
}
}

// Use WebSettingsCompat to safely set the dark mode on API < 23.
// This is a no-op on API >= 23. On versions > 23, the WebView
// will correctly use the system theme.
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
if (isDarkTheme) {
WebSettingsCompat.setForceDark(
settings,
WebSettingsCompat.FORCE_DARK_ON
)
} else {
WebSettingsCompat.setForceDark(
settings,
WebSettingsCompat.FORCE_DARK_OFF
)
}
}

// The HTML content from the backend has its own styling.
// Set the WebView background to transparent to let the chat bubble's
// background show through.
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 +588,4 @@ fun AttachmentsList(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ import com.google.firebase.ai.ai
import com.google.firebase.ai.type.Content
import com.google.firebase.ai.type.FileDataPart
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.quickstart.ai.FIREBASE_AI_SAMPLES
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* 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 @@ -37,10 +46,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 All @@ -61,7 +70,8 @@ class ChatViewModel(
).generativeModel(
modelName = sample.modelName ?: "gemini-2.0-flash",
systemInstruction = sample.systemInstructions,
generationConfig = sample.generationConfig
generationConfig = sample.generationConfig,
tools = sample.tools
)
chat = generativeModel.startChat(sample.chatHistory)

Expand All @@ -80,14 +90,26 @@ class ChatViewModel(
.text(userMessage)
.build()

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

viewModelScope.launch {
_isLoading.value = true
try {
val response = chat.sendMessage(prompt)
_messageList.add(response.candidates.first().content)
_errorMessage.value = null // clear errors
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
}
} catch (e: Exception) {
_errorMessage.value = e.localizedMessage
} finally {
Expand All @@ -114,4 +136,4 @@ class ChatViewModel(

private fun decodeBitmapFromImage(input: ByteArray) =
BitmapFactory.decodeByteArray(input, 0, input.size)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.google.firebase.quickstart.ai.ui.navigation

import com.google.firebase.ai.type.Content
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.GenerationConfig
import java.util.UUID

Expand All @@ -25,5 +26,6 @@ data class Sample(
val initialPrompt: Content? = null,
val systemInstructions: Content? = null,
val generationConfig: GenerationConfig? = null,
val chatHistory: List<Content> = emptyList()
)
val chatHistory: List<Content> = emptyList(),
val tools: List<Tool>? = null,
)
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>
Loading
Loading