From 15d6f042756ca6913b0b54bd24854caab9d2b518 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Thu, 3 Jul 2025 14:44:27 -0400 Subject: [PATCH 01/11] [FirebaseAI] Add usage of Grounding with Google Search --- firebaseai/FirebaseAISample/ContentView.swift | 5 + .../Screens/GroundingScreen.swift | 120 ++++++++++++++ .../ViewModels/GroundingViewModel.swift | 59 +++++++ .../Views/GroundedResponseViews.swift | 151 ++++++++++++++++++ .../GroundingSample/Views/UIConstants.swift | 37 +++++ .../GroundingSample/Views/WebView.swift | 77 +++++++++ 6 files changed, 449 insertions(+) create mode 100644 firebaseai/GroundingSample/Screens/GroundingScreen.swift create mode 100644 firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift create mode 100644 firebaseai/GroundingSample/Views/GroundedResponseViews.swift create mode 100644 firebaseai/GroundingSample/Views/UIConstants.swift create mode 100644 firebaseai/GroundingSample/Views/WebView.swift diff --git a/firebaseai/FirebaseAISample/ContentView.swift b/firebaseai/FirebaseAISample/ContentView.swift index 3f2b15a4c..9bb335b62 100644 --- a/firebaseai/FirebaseAISample/ContentView.swift +++ b/firebaseai/FirebaseAISample/ContentView.swift @@ -61,6 +61,11 @@ struct ContentView: View { } label: { Label("Chat", systemImage: "ellipsis.message.fill") } + NavigationLink { + GroundingScreen(firebaseService: firebaseService) + } label: { + Label("Grounding", systemImage: "magnifyingglass") + } NavigationLink { FunctionCallingScreen(firebaseService: firebaseService) } label: { diff --git a/firebaseai/GroundingSample/Screens/GroundingScreen.swift b/firebaseai/GroundingSample/Screens/GroundingScreen.swift new file mode 100644 index 000000000..e674ef3df --- /dev/null +++ b/firebaseai/GroundingSample/Screens/GroundingScreen.swift @@ -0,0 +1,120 @@ +// 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 FirebaseAI +import SwiftUI + +struct GroundingScreen: View { + @StateObject var viewModel: GroundingViewModel + + init(firebaseService: FirebaseAI) { + _viewModel = StateObject(wrappedValue: GroundingViewModel(firebaseService: firebaseService)) + } + + var body: some View { + VStack(spacing: 0) { + // Top App Bar + HStack { + Image(systemName: "line.3.horizontal") + Text("Your App") + .frame(maxWidth: .infinity, alignment: .center) + Image(systemName: "person.circle.fill") + } + .font(.title2) + .padding() + .background(Color.appBackground) + + Divider() + + // Main content + ScrollView { + VStack(spacing: 20) { + if viewModel.inProgress { + ProgressView().padding() + } + + VStack(spacing: 20) { + if let response = viewModel.response, + let candidate = response.candidates.first { + UserPromptView(prompt: viewModel.sentPrompt) + .frame(maxWidth: .infinity, alignment: .trailing) + + ModelResponseTurnView(candidate: candidate) + .frame(maxWidth: .infinity, alignment: .leading) + + } else if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .padding() + } + } + .padding() + } + } + .background(Color.appBackground) + .onTapGesture { + hideKeyboard() + } + + // Input Field + GroundingInputView( + userInput: $viewModel.userInput, + isGenerating: viewModel.inProgress + ) { + Task { + await viewModel.generateGroundedResponse() + } + } + } + .navigationTitle("Grounding") + .navigationBarHidden(true) + } + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } +} + +private struct GroundingInputView: View { + @Binding var userInput: String + var isGenerating: Bool + var onSend: () -> Void + + var body: some View { + HStack { + TextField("Ask a question...", text: $userInput) + .textFieldStyle(.plain) + .padding(10) + .background(Color.inputBackground) + .cornerRadius(20) + + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(.title) + .foregroundColor(userInput.isEmpty ? .gray : .accentColor) + } + .disabled(userInput.isEmpty || isGenerating) + } + .padding() + .background(Color.appBackground.shadow(radius: 2, y: -1)) + } +} + + +#Preview { + NavigationView { + GroundingScreen(firebaseService: FirebaseAI.firebaseAI()) + } +} diff --git a/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift b/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift new file mode 100644 index 000000000..1514645c7 --- /dev/null +++ b/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift @@ -0,0 +1,59 @@ +// 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 FirebaseAI +import Foundation +import OSLog + +@MainActor +class GroundingViewModel: ObservableObject { + private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai") + + @Published var userInput: String = "What's the weather in Chicago this weekend?" + @Published var sentPrompt: String = "" + + @Published var response: GenerateContentResponse? + @Published var errorMessage: String? + @Published var inProgress = false + + private let model: GenerativeModel + + init(firebaseService: FirebaseAI) { + model = firebaseService.generativeModel( + modelName: "gemini-2.5-flash", + tools: [.googleSearch()] + ) + } + + func generateGroundedResponse() async { + guard !userInput.isEmpty else { return } + + inProgress = true + defer { inProgress = false } + + errorMessage = nil + response = nil + sentPrompt = userInput + + do { + let result = try await model.generateContent(userInput) + + response = result + userInput = "" // Clear input field on success + } catch { + logger.error("Error generating content: \(error)") + errorMessage = error.localizedDescription + } + } +} diff --git a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift new file mode 100644 index 000000000..172c9713b --- /dev/null +++ b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift @@ -0,0 +1,151 @@ +// 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 FirebaseAI +import SwiftUI + +// This extension adds the missing `text` property to the Candidate model. +extension Candidate { + /// A computed property to extract and join all text parts from the candidate's content. + var text: String? { + let textParts = content.parts.compactMap { $0 as? TextPart } + if textParts.isEmpty { + return nil + } + return textParts.map { $0.text }.joined() + } +} + +/// Displays the user's prompt in a chat bubble, aligned to the right. +struct UserPromptView: View { + let prompt: String + + var body: some View { + HStack { + Spacer() // Aligns the bubble to the right + Text(prompt) + .padding(12) + .background(Color.userPromptBackground) + .foregroundColor(Color.userPromptText) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + // Set a max width to prevent the bubble from taking the full screen width. + .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .trailing) + } +} + +/// The content (text and sources) that goes inside the model's response bubble. +struct ModelResponseContentView: View { + let text: String + let groundingMetadata: GroundingMetadata? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(text) + + if let groundingChunks = groundingMetadata?.groundingChunks, !groundingChunks.isEmpty { + Divider() + VStack(alignment: .leading) { + Text("Sources") + .font(.footnote) + .foregroundColor(.secondary) + + ForEach(0 ..< groundingChunks.count, id: \.self) { index in + if let webChunk = groundingChunks[index].web { + SourceLinkView( + title: webChunk.title ?? "Untitled Source", + uri: webChunk.uri + ) + } + } + } + } + } + } +} + +/// The complete visual component for the model's turn. +struct ModelResponseTurnView: View { + let candidate: Candidate + + var body: some View { + // A grounded response is non-compliant if it has groundingChunks, but no search suggestions to display in searchEntrypoint. + let isNonCompliant = (candidate.groundingMetadata != nil && !(candidate.groundingMetadata?.groundingChunks.isEmpty)! && candidate.groundingMetadata?.searchEntryPoint == nil) + if isNonCompliant { + ComplianceErrorView() + } else { + // This view handles both compliant grounded responses and non-grounded responses. + HStack(alignment: .top, spacing: 8) { + Image(systemName: "sparkle") + .font(.title) + .foregroundColor(.secondary) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 8) { + ModelResponseContentView( + text: candidate.text ?? "No text in response.", + groundingMetadata: candidate.groundingMetadata + ) + .padding(12) + .background(Color.modelResponseBackground) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + + if let searchEntryPoint = candidate.groundingMetadata?.searchEntryPoint { + WebView(htmlString: searchEntryPoint.renderedContent) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + } + } + .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) + } + } +} + +/// A view to show when a response cannot be displayed due to compliance reasons. +struct ComplianceErrorView: View { + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Could not display the response because it was missing required attribution components.") + } + .padding() + .background(Color.modelResponseBackground) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +/// A simplified 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/GroundingSample/Views/UIConstants.swift b/firebaseai/GroundingSample/Views/UIConstants.swift new file mode 100644 index 000000000..fda355fcf --- /dev/null +++ b/firebaseai/GroundingSample/Views/UIConstants.swift @@ -0,0 +1,37 @@ +// 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 + +extension Color { + static let appBackground = Color(uiColor: .systemBackground) + static let inputBackground = Color(uiColor: .systemGray5) + static let userPromptBackground = Color( + uiColor: UIColor( + light: UIColor.cyan.withAlphaComponent(0.2), + dark: UIColor.cyan.withAlphaComponent(0.4) + ) + ) + static let userPromptText = Color(uiColor: UIColor(light: .black, dark: .white)) + static let modelResponseBackground = Color(uiColor: .systemGray6) +} + +extension UIColor { + /// Custom initializer to handle different colors for light and dark mode. + convenience init(light: UIColor, dark: UIColor) { + self.init { traitCollection in + traitCollection.userInterfaceStyle == .dark ? dark : light + } + } +} diff --git a/firebaseai/GroundingSample/Views/WebView.swift b/firebaseai/GroundingSample/Views/WebView.swift new file mode 100644 index 000000000..56fb2724e --- /dev/null +++ b/firebaseai/GroundingSample/Views/WebView.swift @@ -0,0 +1,77 @@ +// 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 + +struct WebView: 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) + } +} From ec1c9641a765d7e817bf1ad883d513275d7de9a9 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Thu, 3 Jul 2025 15:09:06 -0400 Subject: [PATCH 02/11] Add xcode project changes --- .../project.pbxproj | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index e16ce774c..5e0ca8cad 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -27,7 +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 */; }; - DE26D95F2DBB3E9F007E6668 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = DE26D95E2DBB3E9F007E6668 /* FirebaseAI */; }; + AEF66A332DF220800010A70C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AEF66A322DF220800010A70C /* GoogleService-Info.plist */; }; + AEF66A362DF222560010A70C /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = AEF66A352DF222560010A70C /* FirebaseAI */; }; DEFECAA92D7B4CCD00EF9621 /* ImagenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFECAA72D7B4CCD00EF9621 /* ImagenViewModel.swift */; }; DEFECAAA2D7B4CCD00EF9621 /* ImagenScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFECAA62D7B4CCD00EF9621 /* ImagenScreen.swift */; }; /* End PBXBuildFile section */ @@ -59,18 +60,23 @@ 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 = ""; }; + AEF66A322DF220800010A70C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AE36CB182E16CD1C006FC3CB /* GroundingSample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GroundingSample; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 8848C82C2B0D04BC007B434F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DE26D95F2DBB3E9F007E6668 /* FirebaseAI in Frameworks */, 886F95D82B17BA420036F07A /* MarkdownUI in Frameworks */, 886F95E32B17D6630036F07A /* GenerativeAIUIComponents in Frameworks */, + AEF66A362DF222560010A70C /* FirebaseAI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -144,6 +150,7 @@ 8848C8262B0D04BC007B434F = { isa = PBXGroup; children = ( + AE36CB182E16CD1C006FC3CB /* GroundingSample */, DEFECAA82D7B4CCD00EF9621 /* ImagenScreen */, 88B8A9352B0FCBA700424728 /* GenerativeAIUIComponents */, 869200B22B879C4F00482873 /* GoogleService-Info.plist */, @@ -154,6 +161,7 @@ 86C1F4822BC726150026816F /* FunctionCallingSample */, 8848C8302B0D04BC007B434F /* Products */, 88209C222B0FBE1700F64795 /* Frameworks */, + AEF66A322DF220800010A70C /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -302,11 +310,14 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + AE36CB182E16CD1C006FC3CB /* GroundingSample */, + ); name = FirebaseAISample; packageProductDependencies = ( 886F95D72B17BA420036F07A /* MarkdownUI */, 886F95E22B17D6630036F07A /* GenerativeAIUIComponents */, - DE26D95E2DBB3E9F007E6668 /* FirebaseAI */, + AEF66A352DF222560010A70C /* FirebaseAI */, ); productName = GenerativeAISample; productReference = 8848C82F2B0D04BC007B434F /* FirebaseAISample.app */; @@ -339,7 +350,7 @@ packageReferences = ( 88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DEA09AC32B1FCE22001962D9 /* XCRemoteSwiftPackageReference "NetworkImage" */, - DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + AEF66A342DF222560010A70C /* XCLocalSwiftPackageReference "../../sdks/firebase-ios-sdk" */, ); productRefGroup = 8848C8302B0D04BC007B434F /* Products */; projectDirPath = ""; @@ -356,6 +367,7 @@ buildActionMask = 2147483647; files = ( 8848C83A2B0D04BD007B434F /* Preview Assets.xcassets in Resources */, + AEF66A332DF220800010A70C /* GoogleService-Info.plist in Resources */, 8848C8372B0D04BD007B434F /* Assets.xcassets in Resources */, 869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */, ); @@ -593,6 +605,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AEF66A342DF222560010A70C /* XCLocalSwiftPackageReference "../../sdks/firebase-ios-sdk" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../sdks/firebase-ios-sdk"; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { isa = XCRemoteSwiftPackageReference; @@ -610,14 +629,6 @@ revision = 7aff8d1b31148d32c5933d75557d42f6323ee3d1; }; }; - DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 11.13.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -630,9 +641,8 @@ isa = XCSwiftPackageProductDependency; productName = GenerativeAIUIComponents; }; - DE26D95E2DBB3E9F007E6668 /* FirebaseAI */ = { + AEF66A352DF222560010A70C /* FirebaseAI */ = { isa = XCSwiftPackageProductDependency; - package = DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAI; }; /* End XCSwiftPackageProductDependency section */ From d9fe94da88ff99055f5bca407afd4bef8377f816 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Thu, 3 Jul 2025 15:24:04 -0400 Subject: [PATCH 03/11] remove app bar, user icon, candidate extension --- .../Screens/GroundingScreen.swift | 21 ++--- .../Views/GroundedResponseViews.swift | 77 +++++++++---------- 2 files changed, 42 insertions(+), 56 deletions(-) diff --git a/firebaseai/GroundingSample/Screens/GroundingScreen.swift b/firebaseai/GroundingSample/Screens/GroundingScreen.swift index e674ef3df..66f3d04e8 100644 --- a/firebaseai/GroundingSample/Screens/GroundingScreen.swift +++ b/firebaseai/GroundingSample/Screens/GroundingScreen.swift @@ -24,17 +24,6 @@ struct GroundingScreen: View { var body: some View { VStack(spacing: 0) { - // Top App Bar - HStack { - Image(systemName: "line.3.horizontal") - Text("Your App") - .frame(maxWidth: .infinity, alignment: .center) - Image(systemName: "person.circle.fill") - } - .font(.title2) - .padding() - .background(Color.appBackground) - Divider() // Main content @@ -45,12 +34,13 @@ struct GroundingScreen: View { } VStack(spacing: 20) { - if let response = viewModel.response, - let candidate = response.candidates.first { + if let response = viewModel.response { + // User Prompt turn UserPromptView(prompt: viewModel.sentPrompt) .frame(maxWidth: .infinity, alignment: .trailing) - ModelResponseTurnView(candidate: candidate) + // Model Response turn (handles compliance internally) + ModelResponseTurnView(response: response) .frame(maxWidth: .infinity, alignment: .leading) } else if let errorMessage = viewModel.errorMessage { @@ -78,7 +68,7 @@ struct GroundingScreen: View { } } .navigationTitle("Grounding") - .navigationBarHidden(true) + .navigationBarTitleDisplayMode(.inline) } private func hideKeyboard() { @@ -112,7 +102,6 @@ private struct GroundingInputView: View { } } - #Preview { NavigationView { GroundingScreen(firebaseService: FirebaseAI.firebaseAI()) diff --git a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift index 172c9713b..0bc4ad952 100644 --- a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift +++ b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift @@ -11,21 +11,10 @@ // 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 -// This extension adds the missing `text` property to the Candidate model. -extension Candidate { - /// A computed property to extract and join all text parts from the candidate's content. - var text: String? { - let textParts = content.parts.compactMap { $0 as? TextPart } - if textParts.isEmpty { - return nil - } - return textParts.map { $0.text }.joined() - } -} - /// Displays the user's prompt in a chat bubble, aligned to the right. struct UserPromptView: View { let prompt: String @@ -74,51 +63,59 @@ struct ModelResponseContentView: View { } } -/// The complete visual component for the model's turn. +/// The complete visual component for the model's turn. It handles compliance checks internally. struct ModelResponseTurnView: View { - let candidate: Candidate + let response: GenerateContentResponse var body: some View { - // A grounded response is non-compliant if it has groundingChunks, but no search suggestions to display in searchEntrypoint. - let isNonCompliant = (candidate.groundingMetadata != nil && !(candidate.groundingMetadata?.groundingChunks.isEmpty)! && candidate.groundingMetadata?.searchEntryPoint == nil) - if isNonCompliant { - ComplianceErrorView() - } else { - // This view handles both compliant grounded responses and non-grounded responses. - HStack(alignment: .top, spacing: 8) { - Image(systemName: "sparkle") - .font(.title) - .foregroundColor(.secondary) - .padding(.top, 4) + // Use `if-let` to safely unwrap the first candidate. This is the correct SwiftUI pattern. + if let candidate = response.candidates.first { + // A response is non-compliant ONLY if groundingMetadata exists but searchEntryPoint is nil. + let isNonCompliant = (candidate.groundingMetadata != nil && candidate.groundingMetadata? + .searchEntryPoint == nil) - VStack(alignment: .leading, spacing: 8) { - ModelResponseContentView( - text: candidate.text ?? "No text in response.", - groundingMetadata: candidate.groundingMetadata - ) - .padding(12) - .background(Color.modelResponseBackground) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + if isNonCompliant { + ComplianceErrorView() + } else { + // This branch handles both compliant grounded responses and non-grounded responses. + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + ModelResponseContentView( + // Use the convenience accessor on the response object. + text: response.text ?? "No text in response.", + groundingMetadata: candidate.groundingMetadata + ) + .padding(12) + .background(Color.modelResponseBackground) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - if let searchEntryPoint = candidate.groundingMetadata?.searchEntryPoint { - WebView(htmlString: searchEntryPoint.renderedContent) - .frame(height: 44) - .clipShape(RoundedRectangle(cornerRadius: 22)) + if let searchEntryPoint = candidate.groundingMetadata?.searchEntryPoint { + WebView(htmlString: searchEntryPoint.renderedContent) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } } } + .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) } - .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) + } else { + // This `else` branch handles the case where the response has no candidates. + ComplianceErrorView( + message: "The response was blocked or contained no content." + ) } } } -/// A view to show when a response cannot be displayed due to compliance reasons. +/// A view to show when a response cannot be displayed due to compliance or other errors. struct ComplianceErrorView: View { + var message = "Could not display the response because it was missing required attribution components." + var body: some View { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) - Text("Could not display the response because it was missing required attribution components.") + Text(message) } .padding() .background(Color.modelResponseBackground) From 8b176d1a970a7ffe9b070a35e15b4098a3c98efb Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Thu, 3 Jul 2025 15:30:01 -0400 Subject: [PATCH 04/11] style --- .../GroundingSample/Views/GroundedResponseViews.swift | 3 ++- firebaseai/GroundingSample/Views/WebView.swift | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift index 0bc4ad952..a7ffbfa73 100644 --- a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift +++ b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift @@ -109,7 +109,8 @@ struct ModelResponseTurnView: View { /// A view to show when a response cannot be displayed due to compliance or other errors. struct ComplianceErrorView: View { - var message = "Could not display the response because it was missing required attribution components." + var message = + "Could not display the response because it was missing required attribution components." var body: some View { HStack { diff --git a/firebaseai/GroundingSample/Views/WebView.swift b/firebaseai/GroundingSample/Views/WebView.swift index 56fb2724e..f34d6c739 100644 --- a/firebaseai/GroundingSample/Views/WebView.swift +++ b/firebaseai/GroundingSample/Views/WebView.swift @@ -20,11 +20,9 @@ struct WebView: UIViewRepresentable { // 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 - ) { + 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 { From 9f02ca3bd290adc3a1740156a22a5debb13feea9 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 11 Jul 2025 14:07:30 -0400 Subject: [PATCH 05/11] Refactor to improve reusability! --- .../ChatSample/Models/ChatMessage.swift | 7 + .../Screens/ConversationScreen.swift | 17 +- .../ViewModels/ConversationViewModel.swift | 19 ++- firebaseai/ChatSample/Views/MessageView.swift | 133 ++++++++++++++-- .../Views/WebView.swift | 0 .../project.pbxproj | 14 +- firebaseai/FirebaseAISample/ContentView.swift | 8 +- .../Screens/GroundingScreen.swift | 109 ------------- .../ViewModels/GroundingViewModel.swift | 59 ------- .../Views/GroundedResponseViews.swift | 149 ------------------ .../GroundingSample/Views/UIConstants.swift | 37 ----- 11 files changed, 162 insertions(+), 390 deletions(-) rename firebaseai/{GroundingSample => ChatSample}/Views/WebView.swift (100%) delete mode 100644 firebaseai/GroundingSample/Screens/GroundingScreen.swift delete mode 100644 firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift delete mode 100644 firebaseai/GroundingSample/Views/GroundedResponseViews.swift delete mode 100644 firebaseai/GroundingSample/Views/UIConstants.swift diff --git a/firebaseai/ChatSample/Models/ChatMessage.swift b/firebaseai/ChatSample/Models/ChatMessage.swift index 6f7ab321b..d5176b65f 100644 --- a/firebaseai/ChatSample/Models/ChatMessage.swift +++ b/firebaseai/ChatSample/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,18 @@ 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) } + + 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/ChatSample/Screens/ConversationScreen.swift b/firebaseai/ChatSample/Screens/ConversationScreen.swift index 0b845efd1..ae2c22cb5 100644 --- a/firebaseai/ChatSample/Screens/ConversationScreen.swift +++ b/firebaseai/ChatSample/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 { @@ -85,7 +92,7 @@ struct ConversationScreen: View { } } } - .navigationTitle("Chat sample") + .navigationTitle(title) .onAppear { focusedField = .message } @@ -120,7 +127,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 } @@ -129,7 +136,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/ChatSample/ViewModels/ConversationViewModel.swift b/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift index 29cc06db4..19c0f6f94 100644 --- a/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift +++ b/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift @@ -35,9 +35,9 @@ 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) { + self.model = model + chat = self.model.startChat() } func sendMessage(_ text: String, streaming: Bool = true) async { @@ -85,7 +85,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 +126,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/ChatSample/Views/MessageView.swift b/firebaseai/ChatSample/Views/MessageView.swift index 79894503f..41fa31ad4 100644 --- a/firebaseai/ChatSample/Views/MessageView.swift +++ b/firebaseai/ChatSample/Views/MessageView.swift @@ -14,6 +14,7 @@ import MarkdownUI import SwiftUI +import FirebaseAI struct RoundedCorner: Shape { var radius: CGFloat = .infinity @@ -42,25 +43,82 @@ 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)) + // 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 GroundedResponseView: View { + var message: ChatMessage + var groundingMetadata: GroundingMetadata + + var body: some View { + // We can only display a grounded response if the searchEntrypoint is non-nil. + // If the searchEntrypoint is nil, we can only display the response if it's not grounded. + let isNonCompliant = (!groundingMetadata.groundingChunks.isEmpty && groundingMetadata + .searchEntryPoint == nil) + if isNonCompliant { + ComplianceErrorView() + } else { + 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 + ) + } } - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .markdownMargin(top: .zero, bottom: .em(0.8)) + } + // Search suggestions + if let searchEntryPoint = groundingMetadata.searchEntryPoint { + Divider() + WebView(htmlString: searchEntryPoint.renderedContent) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } } + } + .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) } } } @@ -106,3 +164,44 @@ struct MessageView_Previews: PreviewProvider { } } } + +/// A simplified 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) + } + } +} + +/// A view to show when a response cannot be displayed due to compliance or other errors. +struct ComplianceErrorView: View { + var message = + "Could not display the response because it was missing required attribution components." + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(message) + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/firebaseai/GroundingSample/Views/WebView.swift b/firebaseai/ChatSample/Views/WebView.swift similarity index 100% rename from firebaseai/GroundingSample/Views/WebView.swift rename to firebaseai/ChatSample/Views/WebView.swift diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index 5e0ca8cad..b12f027e6 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -27,6 +27,7 @@ 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 */; }; + AE0B52E32E1EB82F003FFFE7 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */; }; AEF66A332DF220800010A70C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AEF66A322DF220800010A70C /* GoogleService-Info.plist */; }; AEF66A362DF222560010A70C /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = AEF66A352DF222560010A70C /* FirebaseAI */; }; DEFECAA92D7B4CCD00EF9621 /* ImagenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFECAA72D7B4CCD00EF9621 /* ImagenViewModel.swift */; }; @@ -60,15 +61,12 @@ 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 = ""; }; + AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; AEF66A322DF220800010A70C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 */ -/* Begin PBXFileSystemSynchronizedRootGroup section */ - AE36CB182E16CD1C006FC3CB /* GroundingSample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GroundingSample; sourceTree = ""; }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 8848C82C2B0D04BC007B434F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -150,7 +148,6 @@ 8848C8262B0D04BC007B434F = { isa = PBXGroup; children = ( - AE36CB182E16CD1C006FC3CB /* GroundingSample */, DEFECAA82D7B4CCD00EF9621 /* ImagenScreen */, 88B8A9352B0FCBA700424728 /* GenerativeAIUIComponents */, 869200B22B879C4F00482873 /* GoogleService-Info.plist */, @@ -266,6 +263,7 @@ 88E10F5A2B11133E00C08E95 /* MessageView.swift */, 88E10F5C2B11135000C08E95 /* BouncingDots.swift */, 889873842B208563005B4896 /* ErrorDetailsView.swift */, + AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */, ); path = Views; sourceTree = ""; @@ -310,9 +308,6 @@ ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - AE36CB182E16CD1C006FC3CB /* GroundingSample */, - ); name = FirebaseAISample; packageProductDependencies = ( 886F95D72B17BA420036F07A /* MarkdownUI */, @@ -388,6 +383,7 @@ 8848C8352B0D04BC007B434F /* ContentView.swift in Sources */, 886F95D52B17BA010036F07A /* GenerateContentScreen.swift in Sources */, 8848C8332B0D04BC007B434F /* FirebaseAISampleApp.swift in Sources */, + AE0B52E32E1EB82F003FFFE7 /* WebView.swift in Sources */, 886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */, 886F95DD2B17D5010036F07A /* MessageView.swift in Sources */, 886F95DC2B17BAEF0036F07A /* PhotoReasoningScreen.swift in Sources */, diff --git a/firebaseai/FirebaseAISample/ContentView.swift b/firebaseai/FirebaseAISample/ContentView.swift index 9bb335b62..6b7f6bfbb 100644 --- a/firebaseai/FirebaseAISample/ContentView.swift +++ b/firebaseai/FirebaseAISample/ContentView.swift @@ -57,12 +57,16 @@ struct ContentView: View { Label("Multi-modal", systemImage: "doc.richtext") } NavigationLink { - ConversationScreen(firebaseService: firebaseService) + ConversationScreen(firebaseService: firebaseService, title: "Chat Sample") } label: { Label("Chat", systemImage: "ellipsis.message.fill") } NavigationLink { - GroundingScreen(firebaseService: firebaseService) + ConversationScreen( + firebaseService: firebaseService, + title: "Grounding", + searchGroundingEnabled: true + ) } label: { Label("Grounding", systemImage: "magnifyingglass") } diff --git a/firebaseai/GroundingSample/Screens/GroundingScreen.swift b/firebaseai/GroundingSample/Screens/GroundingScreen.swift deleted file mode 100644 index 66f3d04e8..000000000 --- a/firebaseai/GroundingSample/Screens/GroundingScreen.swift +++ /dev/null @@ -1,109 +0,0 @@ -// 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 FirebaseAI -import SwiftUI - -struct GroundingScreen: View { - @StateObject var viewModel: GroundingViewModel - - init(firebaseService: FirebaseAI) { - _viewModel = StateObject(wrappedValue: GroundingViewModel(firebaseService: firebaseService)) - } - - var body: some View { - VStack(spacing: 0) { - Divider() - - // Main content - ScrollView { - VStack(spacing: 20) { - if viewModel.inProgress { - ProgressView().padding() - } - - VStack(spacing: 20) { - if let response = viewModel.response { - // User Prompt turn - UserPromptView(prompt: viewModel.sentPrompt) - .frame(maxWidth: .infinity, alignment: .trailing) - - // Model Response turn (handles compliance internally) - ModelResponseTurnView(response: response) - .frame(maxWidth: .infinity, alignment: .leading) - - } else if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - .foregroundColor(.red) - .padding() - } - } - .padding() - } - } - .background(Color.appBackground) - .onTapGesture { - hideKeyboard() - } - - // Input Field - GroundingInputView( - userInput: $viewModel.userInput, - isGenerating: viewModel.inProgress - ) { - Task { - await viewModel.generateGroundedResponse() - } - } - } - .navigationTitle("Grounding") - .navigationBarTitleDisplayMode(.inline) - } - - private func hideKeyboard() { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), - to: nil, from: nil, for: nil) - } -} - -private struct GroundingInputView: View { - @Binding var userInput: String - var isGenerating: Bool - var onSend: () -> Void - - var body: some View { - HStack { - TextField("Ask a question...", text: $userInput) - .textFieldStyle(.plain) - .padding(10) - .background(Color.inputBackground) - .cornerRadius(20) - - Button(action: onSend) { - Image(systemName: "arrow.up.circle.fill") - .font(.title) - .foregroundColor(userInput.isEmpty ? .gray : .accentColor) - } - .disabled(userInput.isEmpty || isGenerating) - } - .padding() - .background(Color.appBackground.shadow(radius: 2, y: -1)) - } -} - -#Preview { - NavigationView { - GroundingScreen(firebaseService: FirebaseAI.firebaseAI()) - } -} diff --git a/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift b/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift deleted file mode 100644 index 1514645c7..000000000 --- a/firebaseai/GroundingSample/ViewModels/GroundingViewModel.swift +++ /dev/null @@ -1,59 +0,0 @@ -// 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 FirebaseAI -import Foundation -import OSLog - -@MainActor -class GroundingViewModel: ObservableObject { - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai") - - @Published var userInput: String = "What's the weather in Chicago this weekend?" - @Published var sentPrompt: String = "" - - @Published var response: GenerateContentResponse? - @Published var errorMessage: String? - @Published var inProgress = false - - private let model: GenerativeModel - - init(firebaseService: FirebaseAI) { - model = firebaseService.generativeModel( - modelName: "gemini-2.5-flash", - tools: [.googleSearch()] - ) - } - - func generateGroundedResponse() async { - guard !userInput.isEmpty else { return } - - inProgress = true - defer { inProgress = false } - - errorMessage = nil - response = nil - sentPrompt = userInput - - do { - let result = try await model.generateContent(userInput) - - response = result - userInput = "" // Clear input field on success - } catch { - logger.error("Error generating content: \(error)") - errorMessage = error.localizedDescription - } - } -} diff --git a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift b/firebaseai/GroundingSample/Views/GroundedResponseViews.swift deleted file mode 100644 index a7ffbfa73..000000000 --- a/firebaseai/GroundingSample/Views/GroundedResponseViews.swift +++ /dev/null @@ -1,149 +0,0 @@ -// 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 FirebaseAI -import SwiftUI - -/// Displays the user's prompt in a chat bubble, aligned to the right. -struct UserPromptView: View { - let prompt: String - - var body: some View { - HStack { - Spacer() // Aligns the bubble to the right - Text(prompt) - .padding(12) - .background(Color.userPromptBackground) - .foregroundColor(Color.userPromptText) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - } - // Set a max width to prevent the bubble from taking the full screen width. - .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .trailing) - } -} - -/// The content (text and sources) that goes inside the model's response bubble. -struct ModelResponseContentView: View { - let text: String - let groundingMetadata: GroundingMetadata? - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(text) - - if let groundingChunks = groundingMetadata?.groundingChunks, !groundingChunks.isEmpty { - Divider() - VStack(alignment: .leading) { - Text("Sources") - .font(.footnote) - .foregroundColor(.secondary) - - ForEach(0 ..< groundingChunks.count, id: \.self) { index in - if let webChunk = groundingChunks[index].web { - SourceLinkView( - title: webChunk.title ?? "Untitled Source", - uri: webChunk.uri - ) - } - } - } - } - } - } -} - -/// The complete visual component for the model's turn. It handles compliance checks internally. -struct ModelResponseTurnView: View { - let response: GenerateContentResponse - - var body: some View { - // Use `if-let` to safely unwrap the first candidate. This is the correct SwiftUI pattern. - if let candidate = response.candidates.first { - // A response is non-compliant ONLY if groundingMetadata exists but searchEntryPoint is nil. - let isNonCompliant = (candidate.groundingMetadata != nil && candidate.groundingMetadata? - .searchEntryPoint == nil) - - if isNonCompliant { - ComplianceErrorView() - } else { - // This branch handles both compliant grounded responses and non-grounded responses. - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 8) { - ModelResponseContentView( - // Use the convenience accessor on the response object. - text: response.text ?? "No text in response.", - groundingMetadata: candidate.groundingMetadata - ) - .padding(12) - .background(Color.modelResponseBackground) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - - if let searchEntryPoint = candidate.groundingMetadata?.searchEntryPoint { - WebView(htmlString: searchEntryPoint.renderedContent) - .frame(height: 44) - .clipShape(RoundedRectangle(cornerRadius: 22)) - } - } - } - .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) - } - } else { - // This `else` branch handles the case where the response has no candidates. - ComplianceErrorView( - message: "The response was blocked or contained no content." - ) - } - } -} - -/// A view to show when a response cannot be displayed due to compliance or other errors. -struct ComplianceErrorView: View { - var message = - "Could not display the response because it was missing required attribution components." - - var body: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(message) - } - .padding() - .background(Color.modelResponseBackground) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} - -/// A simplified 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/GroundingSample/Views/UIConstants.swift b/firebaseai/GroundingSample/Views/UIConstants.swift deleted file mode 100644 index fda355fcf..000000000 --- a/firebaseai/GroundingSample/Views/UIConstants.swift +++ /dev/null @@ -1,37 +0,0 @@ -// 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 - -extension Color { - static let appBackground = Color(uiColor: .systemBackground) - static let inputBackground = Color(uiColor: .systemGray5) - static let userPromptBackground = Color( - uiColor: UIColor( - light: UIColor.cyan.withAlphaComponent(0.2), - dark: UIColor.cyan.withAlphaComponent(0.4) - ) - ) - static let userPromptText = Color(uiColor: UIColor(light: .black, dark: .white)) - static let modelResponseBackground = Color(uiColor: .systemGray6) -} - -extension UIColor { - /// Custom initializer to handle different colors for light and dark mode. - convenience init(light: UIColor, dark: UIColor) { - self.init { traitCollection in - traitCollection.userInterfaceStyle == .dark ? dark : light - } - } -} From 2c7880203fb1cef79633c1c2cc1a37f864b35e8f Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 11 Jul 2025 14:16:46 -0400 Subject: [PATCH 06/11] set default model --- .../ChatSample/ViewModels/ConversationViewModel.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift b/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift index 19c0f6f94..98a237ec3 100644 --- a/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift +++ b/firebaseai/ChatSample/ViewModels/ConversationViewModel.swift @@ -35,8 +35,14 @@ class ConversationViewModel: ObservableObject { private var chatTask: Task? - init(firebaseService: FirebaseAI, model: GenerativeModel) { - self.model = model + 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() } From c9d86e0ca58cef3435837055450ecb97069fff68 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 11 Jul 2025 17:16:07 -0400 Subject: [PATCH 07/11] Temporarily use `CocoaPods-12.0.0` branch for SPM dependency --- .../project.pbxproj | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index f8c6ac357..7bb977292 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -3,10 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ + 8611DB362E21AA1600132740 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8611DB352E21AA1600132740 /* WebView.swift */; }; 869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 869200B22B879C4F00482873 /* GoogleService-Info.plist */; }; 86C1F4832BC726150026816F /* FunctionCallingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */; }; 86C1F4842BC726150026816F /* FunctionCallingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */; }; @@ -27,14 +28,13 @@ 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 */; }; - AE0B52E32E1EB82F003FFFE7 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */; }; - AEF66A332DF220800010A70C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AEF66A322DF220800010A70C /* GoogleService-Info.plist */; }; - AEF66A362DF222560010A70C /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = AEF66A352DF222560010A70C /* FirebaseAI */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 8611DB352E21AA1600132740 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 869200B22B879C4F00482873 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingScreen.swift; sourceTree = ""; }; 86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingViewModel.swift; sourceTree = ""; }; @@ -61,8 +61,6 @@ 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 = ""; }; - AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - AEF66A322DF220800010A70C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 */ @@ -72,9 +70,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DE26D95F2DBB3E9F007E6668 /* FirebaseAI in Frameworks */, 886F95D82B17BA420036F07A /* MarkdownUI in Frameworks */, 886F95E32B17D6630036F07A /* GenerativeAIUIComponents in Frameworks */, - AEF66A362DF222560010A70C /* FirebaseAI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -158,7 +156,6 @@ 86C1F4822BC726150026816F /* FunctionCallingExample */, 8848C8302B0D04BC007B434F /* Products */, 88209C222B0FBE1700F64795 /* Frameworks */, - AEF66A322DF220800010A70C /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -263,7 +260,7 @@ 88E10F5A2B11133E00C08E95 /* MessageView.swift */, 88E10F5C2B11135000C08E95 /* BouncingDots.swift */, 889873842B208563005B4896 /* ErrorDetailsView.swift */, - AE0B52DD2E1EB82F003FFFE7 /* WebView.swift */, + 8611DB352E21AA1600132740 /* WebView.swift */, ); path = Views; sourceTree = ""; @@ -312,7 +309,7 @@ packageProductDependencies = ( 886F95D72B17BA420036F07A /* MarkdownUI */, 886F95E22B17D6630036F07A /* GenerativeAIUIComponents */, - AEF66A352DF222560010A70C /* FirebaseAI */, + DE26D95E2DBB3E9F007E6668 /* FirebaseAI */, ); productName = GenerativeAIExample; productReference = 8848C82F2B0D04BC007B434F /* FirebaseAIExample.app */; @@ -345,7 +342,7 @@ packageReferences = ( 88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DEA09AC32B1FCE22001962D9 /* XCRemoteSwiftPackageReference "NetworkImage" */, - AEF66A342DF222560010A70C /* XCLocalSwiftPackageReference "../../sdks/firebase-ios-sdk" */, + DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 8848C8302B0D04BC007B434F /* Products */; projectDirPath = ""; @@ -362,7 +359,6 @@ buildActionMask = 2147483647; files = ( 8848C83A2B0D04BD007B434F /* Preview Assets.xcassets in Resources */, - AEF66A332DF220800010A70C /* GoogleService-Info.plist in Resources */, 8848C8372B0D04BD007B434F /* Assets.xcassets in Resources */, 869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */, ); @@ -382,8 +378,8 @@ 88263BF12B239C11008AB09B /* ErrorDetailsView.swift in Sources */, 8848C8352B0D04BC007B434F /* ContentView.swift in Sources */, 886F95D52B17BA010036F07A /* GenerateContentScreen.swift in Sources */, - 8848C8332B0D04BC007B434F /* FirebaseAISampleApp.swift in Sources */, - AE0B52E32E1EB82F003FFFE7 /* WebView.swift in Sources */, + 8611DB362E21AA1600132740 /* WebView.swift in Sources */, + 8848C8332B0D04BC007B434F /* FirebaseAIExampleApp.swift in Sources */, 886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */, 886F95DD2B17D5010036F07A /* MessageView.swift in Sources */, 886F95DC2B17BAEF0036F07A /* PhotoReasoningScreen.swift in Sources */, @@ -601,13 +597,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - AEF66A342DF222560010A70C /* XCLocalSwiftPackageReference "../../sdks/firebase-ios-sdk" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../../sdks/firebase-ios-sdk"; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { isa = XCRemoteSwiftPackageReference; @@ -625,6 +614,14 @@ revision = 7aff8d1b31148d32c5933d75557d42f6323ee3d1; }; }; + DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; + requirement = { + branch = "CocoaPods-12.0.0"; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -637,8 +634,9 @@ isa = XCSwiftPackageProductDependency; productName = GenerativeAIUIComponents; }; - AEF66A352DF222560010A70C /* FirebaseAI */ = { + DE26D95E2DBB3E9F007E6668 /* FirebaseAI */ = { isa = XCSwiftPackageProductDependency; + package = DEFECAAB2D7BB49700EF9621 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAI; }; /* End XCSwiftPackageProductDependency section */ From 99c1362ecd87af62584886f5db254262e97bfa8c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 11 Jul 2025 17:23:00 -0400 Subject: [PATCH 08/11] Temporarily use commit SHA before Analytics 12.0 for SPM dependency --- firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index 7bb977292..f04616dac 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -618,8 +618,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { - branch = "CocoaPods-12.0.0"; - kind = branch; + kind = revision; + revision = e3110da07b49112d154f68c3df2cf22e2227233c; }; }; /* End XCRemoteSwiftPackageReference section */ From fb7725506baf14b9419776286d68c5b5caf28bff Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 14 Jul 2025 12:49:35 -0400 Subject: [PATCH 09/11] Refactor --- .../GoogleSearchSuggestionView.swift} | 2 +- .../Grounding/GroundedResponseView.swift | 81 ++++++++++++++++++ .../ChatExample/Views/MessageView.swift | 84 ------------------- .../project.pbxproj | 20 ++++- 4 files changed, 98 insertions(+), 89 deletions(-) rename firebaseai/ChatExample/Views/{WebView.swift => Grounding/GoogleSearchSuggestionView.swift} (97%) create mode 100644 firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift diff --git a/firebaseai/ChatExample/Views/WebView.swift b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift similarity index 97% rename from firebaseai/ChatExample/Views/WebView.swift rename to firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift index f34d6c739..3b9f30435 100644 --- a/firebaseai/ChatExample/Views/WebView.swift +++ b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift @@ -15,7 +15,7 @@ import SwiftUI import WebKit -struct WebView: UIViewRepresentable { +struct GoogleSearchSuggestionView: UIViewRepresentable { let htmlString: String // This Coordinator class will act as the web view's navigation delegate. diff --git a/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift b/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift new file mode 100644 index 000000000..25e6b08b7 --- /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: UIScreen.main.bounds.width * 0.8, 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 e40bc4a91..b11c903e5 100644 --- a/firebaseai/ChatExample/Views/MessageView.swift +++ b/firebaseai/ChatExample/Views/MessageView.swift @@ -80,49 +80,6 @@ struct ResponseTextView: View { } } -struct GroundedResponseView: View { - var message: ChatMessage - var groundingMetadata: GroundingMetadata - - var body: some View { - // We can only display a grounded response if the searchEntrypoint is non-nil. - // If the searchEntrypoint is nil, we can only display the response if it's not grounded. - let isNonCompliant = (!groundingMetadata.groundingChunks.isEmpty && groundingMetadata - .searchEntryPoint == nil) - if isNonCompliant { - ComplianceErrorView() - } else { - 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() - WebView(htmlString: searchEntryPoint.renderedContent) - .frame(height: 44) - .clipShape(RoundedRectangle(cornerRadius: 22)) - } - } - } - .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) - } - } -} - struct MessageView: View { var message: ChatMessage @@ -164,44 +121,3 @@ struct MessageView_Previews: PreviewProvider { } } } - -/// A simplified 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) - } - } -} - -/// A view to show when a response cannot be displayed due to compliance or other errors. -struct ComplianceErrorView: View { - var message = - "Could not display the response because it was missing required attribution components." - - var body: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(message) - } - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index f04616dac..4276efa0c 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 8611DB362E21AA1600132740 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8611DB352E21AA1600132740 /* WebView.swift */; }; 869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 869200B22B879C4F00482873 /* GoogleService-Info.plist */; }; 86C1F4832BC726150026816F /* FunctionCallingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */; }; 86C1F4842BC726150026816F /* FunctionCallingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */; }; @@ -28,13 +27,14 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 8611DB352E21AA1600132740 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 869200B22B879C4F00482873 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingScreen.swift; sourceTree = ""; }; 86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingViewModel.swift; sourceTree = ""; }; @@ -61,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 */ @@ -256,11 +258,11 @@ 88E10F512B11124100C08E95 /* Views */ = { isa = PBXGroup; children = ( + AEE793DE2E256D3900708F02 /* Grounding */, 88263BEE2B239BFE008AB09B /* ErrorView.swift */, 88E10F5A2B11133E00C08E95 /* MessageView.swift */, 88E10F5C2B11135000C08E95 /* BouncingDots.swift */, 889873842B208563005B4896 /* ErrorDetailsView.swift */, - 8611DB352E21AA1600132740 /* WebView.swift */, ); path = Views; sourceTree = ""; @@ -281,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 = ( @@ -378,7 +389,6 @@ 88263BF12B239C11008AB09B /* ErrorDetailsView.swift in Sources */, 8848C8352B0D04BC007B434F /* ContentView.swift in Sources */, 886F95D52B17BA010036F07A /* GenerateContentScreen.swift in Sources */, - 8611DB362E21AA1600132740 /* WebView.swift in Sources */, 8848C8332B0D04BC007B434F /* FirebaseAIExampleApp.swift in Sources */, 886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */, 886F95DD2B17D5010036F07A /* MessageView.swift in Sources */, @@ -389,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; }; From 3db183b255702c011e48bc9dae899569e7de8f97 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 15 Jul 2025 16:41:43 -0700 Subject: [PATCH 10/11] Update grounding PR branch to use Firebase 12 release --- firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj index 4276efa0c..bd44485ab 100644 --- a/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj +++ b/firebaseai/FirebaseAIExample.xcodeproj/project.pbxproj @@ -630,8 +630,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { - kind = revision; - revision = e3110da07b49112d154f68c3df2cf22e2227233c; + kind = upToNextMajorVersion; + minimumVersion = 12.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ From 58580a7be6a22d445c81ea62bcc36edeaa0ef39f Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 16 Jul 2025 15:58:19 -0500 Subject: [PATCH 11/11] Review fixes --- firebaseai/ChatExample/Models/ChatMessage.swift | 1 + .../Views/Grounding/GoogleSearchSuggestionView.swift | 4 ++++ .../ChatExample/Views/Grounding/GroundedResponseView.swift | 2 +- firebaseai/FirebaseAIExample/ContentView.swift | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/firebaseai/ChatExample/Models/ChatMessage.swift b/firebaseai/ChatExample/Models/ChatMessage.swift index d5176b65f..345337937 100644 --- a/firebaseai/ChatExample/Models/ChatMessage.swift +++ b/firebaseai/ChatExample/Models/ChatMessage.swift @@ -31,6 +31,7 @@ struct ChatMessage: Identifiable, Equatable { 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 diff --git a/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift index 3b9f30435..eaf66c076 100644 --- a/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift +++ b/firebaseai/ChatExample/Views/Grounding/GoogleSearchSuggestionView.swift @@ -15,6 +15,10 @@ 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 diff --git a/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift b/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift index 25e6b08b7..92479d3ac 100644 --- a/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift +++ b/firebaseai/ChatExample/Views/Grounding/GroundedResponseView.swift @@ -51,7 +51,7 @@ struct GroundedResponseView: View { } } } - .frame(maxWidth: UIScreen.main.bounds.width * 0.8, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/firebaseai/FirebaseAIExample/ContentView.swift b/firebaseai/FirebaseAIExample/ContentView.swift index 8e0287f18..93247fa5c 100644 --- a/firebaseai/FirebaseAIExample/ContentView.swift +++ b/firebaseai/FirebaseAIExample/ContentView.swift @@ -68,7 +68,7 @@ struct ContentView: View { searchGroundingEnabled: true ) } label: { - Label("Grounding", systemImage: "magnifyingglass") + Label("Grounding with Google Search", systemImage: "magnifyingglass") } NavigationLink { FunctionCallingScreen(firebaseService: firebaseService)