diff --git a/firebaseai/ChatExample/Models/ChatMessage.swift b/firebaseai/ChatExample/Models/ChatMessage.swift index 6f7ab321b..345337937 100644 --- a/firebaseai/ChatExample/Models/ChatMessage.swift +++ b/firebaseai/ChatExample/Models/ChatMessage.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAI import Foundation enum Participant { @@ -22,12 +23,19 @@ enum Participant { struct ChatMessage: Identifiable, Equatable { let id = UUID().uuidString var message: String + var groundingMetadata: GroundingMetadata? let participant: Participant var pending = false static func pending(participant: Participant) -> ChatMessage { Self(message: "", participant: participant, pending: true) } + + // TODO(andrewheard): Add Equatable conformance to GroundingMetadata and remove this + static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool { + lhs.id == rhs.id && lhs.message == rhs.message && lhs.participant == rhs.participant && lhs + .pending == rhs.pending + } } extension ChatMessage { diff --git a/firebaseai/ChatExample/Screens/ConversationScreen.swift b/firebaseai/ChatExample/Screens/ConversationScreen.swift index 0d8d29b66..2b3e5cc10 100644 --- a/firebaseai/ChatExample/Screens/ConversationScreen.swift +++ b/firebaseai/ChatExample/Screens/ConversationScreen.swift @@ -18,15 +18,22 @@ import SwiftUI struct ConversationScreen: View { let firebaseService: FirebaseAI + let title: String @StateObject var viewModel: ConversationViewModel @State private var userPrompt = "" - init(firebaseService: FirebaseAI) { + init(firebaseService: FirebaseAI, title: String, searchGroundingEnabled: Bool = false) { + let model = firebaseService.generativeModel( + modelName: "gemini-2.0-flash-001", + tools: searchGroundingEnabled ? [.googleSearch()] : [] + ) + self.title = title self.firebaseService = firebaseService _viewModel = - StateObject(wrappedValue: ConversationViewModel(firebaseService: firebaseService)) + StateObject(wrappedValue: ConversationViewModel(firebaseService: firebaseService, + model: model)) } enum FocusedField: Hashable { @@ -88,7 +95,7 @@ struct ConversationScreen: View { } } } - .navigationTitle("Chat example") + .navigationTitle(title) .onAppear { focusedField = .message } @@ -123,7 +130,7 @@ struct ConversationScreen_Previews: PreviewProvider { .firebaseAI()) // Example service init var body: some View { - ConversationScreen(firebaseService: FirebaseAI.firebaseAI()) + ConversationScreen(firebaseService: FirebaseAI.firebaseAI(), title: "Chat sample") .onAppear { viewModel.messages = ChatMessage.samples } @@ -132,7 +139,7 @@ struct ConversationScreen_Previews: PreviewProvider { static var previews: some View { NavigationStack { - ConversationScreen(firebaseService: FirebaseAI.firebaseAI()) + ConversationScreen(firebaseService: FirebaseAI.firebaseAI(), title: "Chat sample") } } } diff --git a/firebaseai/ChatExample/ViewModels/ConversationViewModel.swift b/firebaseai/ChatExample/ViewModels/ConversationViewModel.swift index 29cc06db4..98a237ec3 100644 --- a/firebaseai/ChatExample/ViewModels/ConversationViewModel.swift +++ b/firebaseai/ChatExample/ViewModels/ConversationViewModel.swift @@ -35,9 +35,15 @@ class ConversationViewModel: ObservableObject { private var chatTask: Task? - init(firebaseService: FirebaseAI) { - model = firebaseService.generativeModel(modelName: "gemini-2.0-flash-001") - chat = model.startChat() + init(firebaseService: FirebaseAI, model: GenerativeModel? = nil) { + if let model { + self.model = model + } else { + self.model = firebaseService.generativeModel( + modelName: "gemini-2.0-flash-001" + ) + } + chat = self.model.startChat() } func sendMessage(_ text: String, streaming: Bool = true) async { @@ -85,7 +91,14 @@ class ConversationViewModel: ObservableObject { if let text = chunk.text { messages[messages.count - 1].message += text } + + if let candidate = chunk.candidates.first { + if let groundingMetadata = candidate.groundingMetadata { + self.messages[self.messages.count - 1].groundingMetadata = groundingMetadata + } + } } + } catch { self.error = error print(error.localizedDescription) @@ -119,6 +132,12 @@ class ConversationViewModel: ObservableObject { // replace pending message with backend response messages[messages.count - 1].message = responseText messages[messages.count - 1].pending = false + + if let candidate = response?.candidates.first { + if let groundingMetadata = candidate.groundingMetadata { + self.messages[self.messages.count - 1].groundingMetadata = groundingMetadata + } + } } } catch { self.error = error diff --git a/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift new file mode 100644 index 000000000..eaf66c076 --- /dev/null +++ b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift @@ -0,0 +1,79 @@ +// Copyright 2025 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 SwiftUI +import WebKit + +/// A view that renders Google Search suggestions with links that allow users +/// to view the search results in the device's default browser. +/// This is added to the bottom of chat messages containing results grounded +/// in Google Search. +struct GoogleSearchSuggestionView: UIViewRepresentable { + let htmlString: String + + // This Coordinator class will act as the web view's navigation delegate. + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + // Check if the navigation was triggered by a user clicking a link. + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url { + // Open the URL in the system's default browser (e.g., Safari). + UIApplication.shared.open(url) + } + // Cancel the navigation inside our small web view. + decisionHandler(.cancel) + return + } + // For all other navigation types (like the initial HTML load), allow it. + decisionHandler(.allow) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + // Set the coordinator as the navigation delegate. + webView.navigationDelegate = context.coordinator + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + // The renderedContent is an HTML snippet with CSS. + // For it to render correctly, we wrap it in a basic HTML document structure. + let fullHTML = """ + + + + + + + + \(htmlString) + + + """ + uiView.loadHTMLString(fullHTML, baseURL: nil) + } +} diff --git a/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift b/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift new file mode 100644 index 000000000..92479d3ac --- /dev/null +++ b/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift @@ -0,0 +1,81 @@ +// Copyright 2023 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 FirebaseAI +import SwiftUI + +/// A view that displays a chat message that is grounded in Google Search. +struct GroundedResponseView: View { + var message: ChatMessage + var groundingMetadata: GroundingMetadata + + var body: some View { + // We can only display a response grounded in Google Search if the searchEntrypoint is non-nil. + let isCompliant = (groundingMetadata.groundingChunks.isEmpty || groundingMetadata + .searchEntryPoint != nil) + if isCompliant { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + // Message text + ResponseTextView(message: message) + + if !groundingMetadata.groundingChunks.isEmpty { + Divider() + // Source links + ForEach(0 ..< groundingMetadata.groundingChunks.count, id: \.self) { index in + if let webChunk = groundingMetadata.groundingChunks[index].web { + SourceLinkView( + title: webChunk.title ?? "Untitled Source", + uri: webChunk.uri + ) + } + } + } + // Search suggestions + if let searchEntryPoint = groundingMetadata.searchEntryPoint { + Divider() + GoogleSearchSuggestionView(htmlString: searchEntryPoint.renderedContent) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +/// A view for a single, clickable source link. +struct SourceLinkView: View { + let title: String + let uri: String? + + var body: some View { + if let uri, let url = URL(string: uri) { + Link(destination: url) { + HStack(spacing: 4) { + Image(systemName: "link") + .font(.caption) + .foregroundColor(.secondary) + Text(title) + .font(.footnote) + .underline() + .lineLimit(1) + .multilineTextAlignment(.leading) + } + } + .buttonStyle(.plain) + } + } +} diff --git a/firebaseai/ChatExample/Views/MessageView.swift b/firebaseai/ChatExample/Views/MessageView.swift index 714445907..b11c903e5 100644 --- a/firebaseai/ChatExample/Views/MessageView.swift +++ b/firebaseai/ChatExample/Views/MessageView.swift @@ -14,6 +14,7 @@ import MarkdownUI import SwiftUI +import FirebaseAI struct RoundedCorner: Shape { var radius: CGFloat = .infinity @@ -42,29 +43,43 @@ struct MessageContentView: View { if message.pending { BouncingDots() } else { - Markdown(message.message) - .markdownTextStyle { - FontFamilyVariant(.normal) - FontSize(.em(0.85)) - ForegroundColor(message.participant == .system ? Color(UIColor.label) : .white) - } - .markdownBlockStyle(\.codeBlock) { configuration in - configuration.label - .relativeLineSpacing(.em(0.25)) - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(.em(0.85)) - ForegroundColor(Color(.label)) - } - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .markdownMargin(top: .zero, bottom: .em(0.8)) - } + // Grounded Response + if let groundingMetadata = message.groundingMetadata { + GroundedResponseView(message: message, groundingMetadata: groundingMetadata) + } else { + // Non-grounded response + ResponseTextView(message: message) + } } } } +struct ResponseTextView: View { + var message: ChatMessage + + var body: some View { + Markdown(message.message) + .markdownTextStyle { + FontFamilyVariant(.normal) + FontSize(.em(0.85)) + ForegroundColor(message.participant == .system ? Color(UIColor.label) : .white) + } + .markdownBlockStyle(\.codeBlock) { configuration in + configuration.label + .relativeLineSpacing(.em(0.25)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + ForegroundColor(Color(.label)) + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .markdownMargin(top: .zero, bottom: .em(0.8)) + } + } +} + struct MessageView: View { var message: ChatMessage diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index e7ef45bc0..bd44485ab 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F562B1112F600C08E95 /* ConversationViewModel.swift */; }; 886F95E12B17D5010036F07A /* ConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F542B1112CA00C08E95 /* ConversationScreen.swift */; }; 886F95E32B17D6630036F07A /* GenerativeAIUIComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 886F95E22B17D6630036F07A /* GenerativeAIUIComponents */; }; + AEE793DF2E256D3900708F02 /* GoogleSearchSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE793DC2E256D3900708F02 /* GoogleSearchSuggestionView.swift */; }; + AEE793E02E256D3900708F02 /* GroundedResponseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE793DD2E256D3900708F02 /* GroundedResponseView.swift */; }; DE26D95F2DBB3E9F007E6668 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = DE26D95E2DBB3E9F007E6668 /* FirebaseAI */; }; DEFECAA92D7B4CCD00EF9621 /* ImagenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFECAA72D7B4CCD00EF9621 /* ImagenViewModel.swift */; }; DEFECAAA2D7B4CCD00EF9621 /* ImagenScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFECAA62D7B4CCD00EF9621 /* ImagenScreen.swift */; }; @@ -59,6 +61,8 @@ 88E10F582B11131900C08E95 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; 88E10F5A2B11133E00C08E95 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; 88E10F5C2B11135000C08E95 /* BouncingDots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncingDots.swift; sourceTree = ""; }; + AEE793DC2E256D3900708F02 /* GoogleSearchSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSearchSuggestionView.swift; sourceTree = ""; }; + AEE793DD2E256D3900708F02 /* GroundedResponseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroundedResponseView.swift; sourceTree = ""; }; DEFECAA62D7B4CCD00EF9621 /* ImagenScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagenScreen.swift; sourceTree = ""; }; DEFECAA72D7B4CCD00EF9621 /* ImagenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagenViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -254,6 +258,7 @@ 88E10F512B11124100C08E95 /* Views */ = { isa = PBXGroup; children = ( + AEE793DE2E256D3900708F02 /* Grounding */, 88263BEE2B239BFE008AB09B /* ErrorView.swift */, 88E10F5A2B11133E00C08E95 /* MessageView.swift */, 88E10F5C2B11135000C08E95 /* BouncingDots.swift */, @@ -278,6 +283,15 @@ path = Screens; sourceTree = ""; }; + AEE793DE2E256D3900708F02 /* Grounding */ = { + isa = PBXGroup; + children = ( + AEE793DC2E256D3900708F02 /* GoogleSearchSuggestionView.swift */, + AEE793DD2E256D3900708F02 /* GroundedResponseView.swift */, + ); + path = Grounding; + sourceTree = ""; + }; DEFECAA82D7B4CCD00EF9621 /* ImagenScreen */ = { isa = PBXGroup; children = ( @@ -385,6 +399,8 @@ 886F95E12B17D5010036F07A /* ConversationScreen.swift in Sources */, 88263BF02B239C09008AB09B /* ErrorView.swift in Sources */, 886F95D62B17BA010036F07A /* GenerateContentViewModel.swift in Sources */, + AEE793DF2E256D3900708F02 /* GoogleSearchSuggestionView.swift in Sources */, + AEE793E02E256D3900708F02 /* GroundedResponseView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -615,7 +631,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.13.0; + minimumVersion = 12.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/firebaseai/FirebaseAIExample/ContentView.swift b/firebaseai/FirebaseAIExample/ContentView.swift index 3d6e8e32f..93247fa5c 100644 --- a/firebaseai/FirebaseAIExample/ContentView.swift +++ b/firebaseai/FirebaseAIExample/ContentView.swift @@ -57,10 +57,19 @@ struct ContentView: View { Label("Multi-modal", systemImage: "doc.richtext") } NavigationLink { - ConversationScreen(firebaseService: firebaseService) + ConversationScreen(firebaseService: firebaseService, title: "Chat") } label: { Label("Chat", systemImage: "ellipsis.message.fill") } + NavigationLink { + ConversationScreen( + firebaseService: firebaseService, + title: "Grounding", + searchGroundingEnabled: true + ) + } label: { + Label("Grounding with Google Search", systemImage: "magnifyingglass") + } NavigationLink { FunctionCallingScreen(firebaseService: firebaseService) } label: {