diff --git a/firebase-ai/app/build.gradle.kts b/firebase-ai/app/build.gradle.kts index 362f6bc919..5d79a9127f 100644 --- a/firebase-ai/app/build.gradle.kts +++ b/firebase-ai/app/build.gradle.kts @@ -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) @@ -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) } \ No newline at end of file diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt index c16f5c3993..dd84995265 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt @@ -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 @@ -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?" + ) + }, ) ) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatScreen.kt index b55cc89ced..80841fd540 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatScreen.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatScreen.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -70,7 +84,7 @@ class ChatRoute(val sampleId: String) fun ChatScreen( chatViewModel: ChatViewModel = viewModel() ) { - val messages: List by chatViewModel.messages.collectAsStateWithLifecycle() + val messages: List by chatViewModel.messages.collectAsStateWithLifecycle() val isLoading: Boolean by chatViewModel.isLoading.collectAsStateWithLifecycle() val errorMessage: String? by chatViewModel.errorMessage.collectAsStateWithLifecycle() val attachments: List by chatViewModel.attachments.collectAsStateWithLifecycle() @@ -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 } @@ -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) ) @@ -212,7 +228,7 @@ fun ChatBubbleItem( .padding(16.dp) .fillMaxWidth() ) { - chatMessage.parts.forEach { part -> + message.content.parts.forEach { part -> when (part) { is TextPart -> { Text( @@ -272,6 +288,76 @@ 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) } + } + } + } } } } @@ -279,9 +365,41 @@ fun ChatBubbleItem( } } +@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, + chatMessages: List, listState: LazyListState, modifier: Modifier = Modifier ) { @@ -470,4 +588,4 @@ fun AttachmentsList( } } } -} +} \ No newline at end of file diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatViewModel.kt index 59742a4040..982047b0b2 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatViewModel.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/text/ChatViewModel.kt @@ -13,6 +13,7 @@ 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 @@ -20,6 +21,14 @@ 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() { @@ -37,10 +46,10 @@ class ChatViewModel( private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage - private val _messageList: MutableList = - sample.chatHistory.toMutableStateList() - private val _messages = MutableStateFlow>(_messageList) - val messages: StateFlow> = + private val _messageList: MutableList = + sample.chatHistory.map { UiChatMessage(it) }.toMutableStateList() + private val _messages = MutableStateFlow>(_messageList) + val messages: StateFlow> = _messages private val _attachmentsList: MutableList = @@ -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) @@ -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 { @@ -114,4 +136,4 @@ class ChatViewModel( private fun decodeBitmapFromImage(input: ByteArray) = BitmapFactory.decodeByteArray(input, 0, input.size) -} +} \ No newline at end of file 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 65a1fa06c1..9f6ebf2134 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 @@ -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 @@ -25,5 +26,6 @@ data class Sample( val initialPrompt: Content? = null, val systemInstructions: Content? = null, val generationConfig: GenerationConfig? = null, - val chatHistory: List = emptyList() -) + val chatHistory: List = emptyList(), + val tools: List? = null, +) \ No newline at end of file diff --git a/firebase-ai/app/src/main/res/values/themes.xml b/firebase-ai/app/src/main/res/values/themes.xml index d82976cfb7..35071aca45 100644 --- a/firebase-ai/app/src/main/res/values/themes.xml +++ b/firebase-ai/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ -