Skip to content

Commit

Permalink
AI Chat handoff (#3785)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204167627774280/1209119202522826/f

**Description**:
Add handoff support using the C-S-S communication layer
  • Loading branch information
Bunn authored Jan 14, 2025
1 parent c077b61 commit e662540
Show file tree
Hide file tree
Showing 10 changed files with 489 additions and 29 deletions.
32 changes: 32 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@
317DF60B2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317DF60A2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift */; };
317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; };
31860A5B2C57ED2D005561F5 /* DuckPlayerStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */; };
318C5B492D3022FE00DAA5FC /* AIChatPayloadHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318C5B482D3022FE00DAA5FC /* AIChatPayloadHandling.swift */; };
318C5B4D2D302BDB00DAA5FC /* AIChatPayloadHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318C5B4C2D302BDB00DAA5FC /* AIChatPayloadHandlerTests.swift */; };
31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */; };
319A37152829A55F0079FBCE /* AutofillListItemTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319A37142829A55F0079FBCE /* AutofillListItemTableViewCell.swift */; };
319A37172829C8AD0079FBCE /* UITableViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319A37162829C8AD0079FBCE /* UITableViewExtension.swift */; };
Expand All @@ -189,6 +191,10 @@
31C138A827A3E9C900FFD4B2 /* URLDownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C138A727A3E9C900FFD4B2 /* URLDownloadSession.swift */; };
31C138AC27A403CB00FFD4B2 /* DownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C138AB27A403CB00FFD4B2 /* DownloadManagerTests.swift */; };
31C138B227A4097800FFD4B2 /* DownloadTestsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C138B127A4097800FFD4B2 /* DownloadTestsHelper.swift */; };
31C3149E2D2EEB44009A412A /* AIChatScriptUserValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C3149B2D2EEB44009A412A /* AIChatScriptUserValues.swift */; };
31C3149F2D2EEB44009A412A /* AIChatUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C3149C2D2EEB44009A412A /* AIChatUserScript.swift */; };
31C314A02D2EEB44009A412A /* AIChatUserScriptHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C3149D2D2EEB44009A412A /* AIChatUserScriptHandling.swift */; };
31C314A22D2EF614009A412A /* AIChatViewControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C314A12D2EF60A009A412A /* AIChatViewControllerManager.swift */; };
31C70B5528045E3500FB6AD1 /* SecureVaultReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C70B5428045E3500FB6AD1 /* SecureVaultReporter.swift */; };
31C70B5B2804C61000FB6AD1 /* SaveAutofillLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C70B5A2804C61000FB6AD1 /* SaveAutofillLoginManager.swift */; };
31C7D71C27515A6300A95D0A /* MockVoiceSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C7D71B27515A6300A95D0A /* MockVoiceSearchHelper.swift */; };
Expand Down Expand Up @@ -1584,6 +1590,8 @@
317DF60A2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedPageSheetPresentationAnimator.swift; sourceTree = "<group>"; };
317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackStorage.swift; sourceTree = "<group>"; };
31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerStorage.swift; sourceTree = "<group>"; };
318C5B482D3022FE00DAA5FC /* AIChatPayloadHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatPayloadHandling.swift; sourceTree = "<group>"; };
318C5B4C2D302BDB00DAA5FC /* AIChatPayloadHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatPayloadHandlerTests.swift; sourceTree = "<group>"; };
31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsHeaderView.swift; sourceTree = "<group>"; };
319A37142829A55F0079FBCE /* AutofillListItemTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillListItemTableViewCell.swift; sourceTree = "<group>"; };
319A37162829C8AD0079FBCE /* UITableViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewExtension.swift; sourceTree = "<group>"; };
Expand All @@ -1600,6 +1608,10 @@
31C138A727A3E9C900FFD4B2 /* URLDownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLDownloadSession.swift; sourceTree = "<group>"; };
31C138AB27A403CB00FFD4B2 /* DownloadManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTests.swift; sourceTree = "<group>"; };
31C138B127A4097800FFD4B2 /* DownloadTestsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTestsHelper.swift; sourceTree = "<group>"; };
31C3149B2D2EEB44009A412A /* AIChatScriptUserValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatScriptUserValues.swift; sourceTree = "<group>"; };
31C3149C2D2EEB44009A412A /* AIChatUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatUserScript.swift; sourceTree = "<group>"; };
31C3149D2D2EEB44009A412A /* AIChatUserScriptHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatUserScriptHandling.swift; sourceTree = "<group>"; };
31C314A12D2EF60A009A412A /* AIChatViewControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewControllerManager.swift; sourceTree = "<group>"; };
31C70B5428045E3500FB6AD1 /* SecureVaultReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureVaultReporter.swift; sourceTree = "<group>"; };
31C70B5A2804C61000FB6AD1 /* SaveAutofillLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveAutofillLoginManager.swift; sourceTree = "<group>"; };
31C7D71B27515A6300A95D0A /* MockVoiceSearchHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockVoiceSearchHelper.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3731,6 +3743,7 @@
310EEA2D2CFFCDB60043CA1A /* AIChat */ = {
isa = PBXGroup;
children = (
318C5B4C2D302BDB00DAA5FC /* AIChatPayloadHandlerTests.swift */,
31E77B262D038BB9006F1C9F /* OmnibarAccessoryHandlerTests.swift */,
310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */,
);
Expand All @@ -3740,6 +3753,8 @@
311C79E22CF790270021196A /* AIChat */ = {
isa = PBXGroup;
children = (
31C314A32D2EF9DF009A412A /* UserScript */,
31C314A12D2EF60A009A412A /* AIChatViewControllerManager.swift */,
311711902D00E53A0063AC3D /* OmnibarAccessoryHandling.swift */,
316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */,
);
Expand Down Expand Up @@ -3909,6 +3924,17 @@
name = Helper;
sourceTree = "<group>";
};
31C314A32D2EF9DF009A412A /* UserScript */ = {
isa = PBXGroup;
children = (
318C5B482D3022FE00DAA5FC /* AIChatPayloadHandling.swift */,
31C3149B2D2EEB44009A412A /* AIChatScriptUserValues.swift */,
31C3149C2D2EEB44009A412A /* AIChatUserScript.swift */,
31C3149D2D2EEB44009A412A /* AIChatUserScriptHandling.swift */,
);
path = UserScript;
sourceTree = "<group>";
};
31DE43C72C2DAA7F00F8C51F /* Resources */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -8266,6 +8292,7 @@
98A860EF2C4682E00077FE4D /* BookmarksDebugViewController.swift in Sources */,
980891A222369ADB00313A70 /* FeedbackUserText.swift in Sources */,
1DEAADFF2BA7832F00E25A97 /* EmailProtectionView.swift in Sources */,
31C314A22D2EF614009A412A /* AIChatViewControllerManager.swift in Sources */,
988F3DD3237DE8D900AEE34C /* ForgetDataAlert.swift in Sources */,
D6FEB8B12B7498A300C3615F /* HeadlessWebView.swift in Sources */,
9FDEC7BC2C91204900C7A692 /* AppIconPickerViewModel.swift in Sources */,
Expand Down Expand Up @@ -8419,6 +8446,9 @@
85E58C2C28FDA94F006A801A /* FavoritesViewController.swift in Sources */,
1E8AD1CF27C000A000ABA377 /* CompleteDownloadRow.swift in Sources */,
98D98A8F25ED952F00D8E3DF /* BrowsingMenuButton.swift in Sources */,
31C3149E2D2EEB44009A412A /* AIChatScriptUserValues.swift in Sources */,
31C3149F2D2EEB44009A412A /* AIChatUserScript.swift in Sources */,
31C314A02D2EEB44009A412A /* AIChatUserScriptHandling.swift in Sources */,
6FB1FEA22C256ACD0075B68B /* NewTabPageManager.swift in Sources */,
9865DFF922A8220D00D27829 /* FavoritesOverlay.swift in Sources */,
1E4DCF4627B6A33600961E25 /* DownloadsListViewModel.swift in Sources */,
Expand All @@ -8427,6 +8457,7 @@
6FEC0B882C999961006B4F6E /* FavoritesListInteractingAdapter.swift in Sources */,
F4F6DFB626E6B71300ED7E12 /* BookmarkFoldersTableViewController.swift in Sources */,
8586A11024CCCD040049720E /* TabsBarViewController.swift in Sources */,
318C5B492D3022FE00DAA5FC /* AIChatPayloadHandling.swift in Sources */,
6FEC0B852C999352006B4F6E /* FavoriteItem.swift in Sources */,
9F8E0F2D2CCA618E001EA7C5 /* VideoPlayerView.swift in Sources */,
F1D796F41E7C2A410019D451 /* BookmarksDelegate.swift in Sources */,
Expand Down Expand Up @@ -8578,6 +8609,7 @@
F13B4BFB1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift in Sources */,
8528AE7E212EF5FF00D0BD74 /* AppRatingPromptTests.swift in Sources */,
981FED692201FE69008488D7 /* AutoClearSettingsScreenTests.swift in Sources */,
318C5B4D2D302BDB00DAA5FC /* AIChatPayloadHandlerTests.swift in Sources */,
9F9EE4CE2C377D4900D4118E /* OnboardingFirePixelMock.swift in Sources */,
4BC21A2F27238B7500229F0E /* RunLoopExtensionTests.swift in Sources */,
314A3EFC293905EC00D3D4C8 /* BrokenSiteReportingTests.swift in Sources */,
Expand Down
103 changes: 103 additions & 0 deletions DuckDuckGo/AIChat/AIChatViewControllerManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// AIChatViewControllerManager.swift
// DuckDuckGo
//
// Copyright © 2025 DuckDuckGo. All rights reserved.
//
// 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 UserScript
import AIChat
import Foundation
import BrowserServicesKit
import WebKit
import Core

protocol AIChatViewControllerManagerDelegate: AnyObject {
func aiChatViewControllerManager(_ manager: AIChatViewControllerManager, didRequestToLoad url: URL)
}

final class AIChatViewControllerManager {
weak var delegate: AIChatViewControllerManagerDelegate?
private var aiChatUserScript: AIChatUserScript?
private var payloadHandler = AIChatPayloadHandler()
private let privacyConfigurationManager: PrivacyConfigurationManaging
private let internalUserDecider: InternalUserDecider

init(privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager,
internalUserDecider: InternalUserDecider = AppDependencyProvider.shared.internalUserDecider) {
self.privacyConfigurationManager = privacyConfigurationManager
self.internalUserDecider = internalUserDecider
}

@MainActor
lazy var aiChatViewController: AIChatViewController = {
let settings = AIChatSettings(privacyConfigurationManager: privacyConfigurationManager,
internalUserDecider: internalUserDecider)

let webviewConfiguration = WKWebViewConfiguration.persistent()
let userContentController = UserContentController()
userContentController.delegate = self

webviewConfiguration.userContentController = userContentController
let aiChatViewController = AIChatViewController(settings: settings,
webViewConfiguration: webviewConfiguration)
aiChatViewController.delegate = self
return aiChatViewController
}()

@MainActor
func openAIChat(_ query: URLQueryItem? = nil, payload: Any? = nil, on viewController: UIViewController) {
let roundedPageSheet = RoundedPageSheetContainerViewController(
contentViewController: aiChatViewController,
allowedOrientation: .portrait)

if let query = query {
aiChatViewController.loadQuery(query)
}

// Force a reload to trigger the user script getUserValues
if let payload = payload as? AIChatPayload {
payloadHandler.setPayload(payload)
aiChatViewController.reload()
}
viewController.present(roundedPageSheet, animated: true, completion: nil)
}
}

extension AIChatViewControllerManager: UserContentControllerDelegate {
@MainActor
func userContentController(_ userContentController: UserContentController,
didInstallContentRuleLists contentRuleLists: [String: WKContentRuleList],
userScripts: UserScriptsProvider,
updateEvent: ContentBlockerRulesManager.UpdateEvent) {

guard let userScripts = userScripts as? UserScripts else { fatalError("Unexpected UserScripts") }
self.aiChatUserScript = userScripts.aiChatUserScript
self.aiChatUserScript?.setPayloadHandler(self.payloadHandler)
}
}

// MARK: - AIChatViewControllerDelegate
extension AIChatViewControllerManager: AIChatViewControllerDelegate {
func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) {
delegate?.aiChatViewControllerManager(self, didRequestToLoad: url)
viewController.dismiss(animated: true)
}

func aiChatViewControllerDidFinish(_ viewController: AIChatViewController) {
viewController.dismiss(animated: true)

}
}
66 changes: 66 additions & 0 deletions DuckDuckGo/AIChat/UserScript/AIChatPayloadHandling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// AIChatPayloadHandling.swift
// DuckDuckGo
//
// Copyright © 2025 DuckDuckGo. All rights reserved.
//
// 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.
//

typealias AIChatPayload = [String: Any]

/// A protocol that defines a generic interface for handling payloads.
///
/// Types conforming to `PayloadHandler` are responsible for managing a payload
/// of a specific type, including setting, consuming, and resetting the payload.
protocol AIChatPayloadHandling {
/// The type of payload that the handler manages.
associatedtype PayloadType

/// Sets the payload to be managed by the handler.
///
/// - Parameter payload: The payload to be set.
func setPayload(_ payload: PayloadType)

/// Consumes and returns the current payload.
///
/// This method returns the current payload and resets the handler,
/// clearing the payload after it is consumed.
///
/// - Returns: The current payload, or `nil` if no payload is set.
func consumePayload() -> PayloadType?

/// Resets the handler, clearing the current payload.
///
/// After calling this method, the handler will no longer have a payload set.
func reset()
}

final class AIChatPayloadHandler: AIChatPayloadHandling {
typealias PayloadType = AIChatPayload

private var payload: AIChatPayload?

func setPayload(_ payload: AIChatPayload) {
self.payload = payload
}

func consumePayload() -> AIChatPayload? {
defer { reset() }
return payload
}

func reset() {
self.payload = nil
}
}
64 changes: 64 additions & 0 deletions DuckDuckGo/AIChat/UserScript/AIChatScriptUserValues.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// AIChatScriptUserValues.swift
// DuckDuckGo
//
// Copyright © 2025 DuckDuckGo. All rights reserved.
//
// 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 Foundation

public struct AIChatScriptUserValues: Codable {
let isAIChatHandoffEnabled: Bool
let platform: String
let aiChatPayload: AIChatPayload?

enum CodingKeys: String, CodingKey {
case isAIChatHandoffEnabled
case platform
case aiChatPayload
}

init(isAIChatHandoffEnabled: Bool, platform: String, aiChatPayload: [String: Any]?) {
self.isAIChatHandoffEnabled = isAIChatHandoffEnabled
self.platform = platform
self.aiChatPayload = aiChatPayload
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isAIChatHandoffEnabled = try container.decode(Bool.self, forKey: .isAIChatHandoffEnabled)
platform = try container.decode(String.self, forKey: .platform)

if let aiChatPayloadData = try? container.decodeIfPresent(Data.self, forKey: .aiChatPayload) {
aiChatPayload = try JSONSerialization.jsonObject(with: aiChatPayloadData, options: []) as? AIChatPayload
} else {
aiChatPayload = nil
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(isAIChatHandoffEnabled, forKey: .isAIChatHandoffEnabled)
try container.encode(platform, forKey: .platform)

if let aiChatPayload = aiChatPayload,
let data = try? JSONSerialization.data(withJSONObject: aiChatPayload, options: []),
let jsonString = String(data: data, encoding: .utf8) {
try container.encode(jsonString, forKey: .aiChatPayload)
} else {
try container.encodeNil(forKey: .aiChatPayload)
}
}
}
Loading

0 comments on commit e662540

Please sign in to comment.