diff --git a/app/brave_settings_strings.grdp b/app/brave_settings_strings.grdp index e18de2ee52fc..4ae6b4c4106b 100644 --- a/app/brave_settings_strings.grdp +++ b/app/brave_settings_strings.grdp @@ -712,6 +712,17 @@ Allowed to access localhost resources + + + Leo AI chat + + + Sites can ask to open Leo AI chat + + + Don't allow site to open Leo AI chat + + Standard protection is on. diff --git a/browser/about_flags.cc b/browser/about_flags.cc index bb6539e69f48..207582ab5e87 100644 --- a/browser/about_flags.cc +++ b/browser/about_flags.cc @@ -361,44 +361,47 @@ #endif #if BUILDFLAG(ENABLE_AI_CHAT) -#define BRAVE_AI_CHAT \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat", \ - "Brave AI Chat", \ - "Summarize articles and engage in conversation with AI", \ - kOsWin | kOsMac | kOsLinux | kOsAndroid, \ - FEATURE_VALUE_TYPE(ai_chat::features::kAIChat), \ - }) -#define BRAVE_AI_CHAT_HISTORY \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat-history", \ - "Brave AI Chat History", \ - "Enables AI Chat History persistence and management", \ - kOsWin | kOsMac | kOsLinux, \ - FEATURE_VALUE_TYPE(ai_chat::features::kAIChatHistory), \ - }) -#define BRAVE_AI_CHAT_CONTEXT_MENU_REWRITE_IN_PLACE \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat-context-menu-rewrite-in-place", \ - "Brave AI Chat Rewrite In Place From Context Menu", \ - "Enables AI Chat rewrite in place feature from the context menu", \ - kOsDesktop, \ - FEATURE_VALUE_TYPE(ai_chat::features::kContextMenuRewriteInPlace), \ - }) -#define BRAVE_AI_CHAT_PAGE_CONTENT_REFINE \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat-page-content-refine", \ - "Brave AI Chat Page Content Refine", \ - "Enable local text embedding for long page content in order to find " \ - "most relevant parts to the prompt within context limit.", \ - kOsDesktop | kOsAndroid, \ - FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \ - }) +#define BRAVE_AI_CHAT_FEATURE_ENTRIES \ + EXPAND_FEATURE_ENTRIES( \ + { \ + "brave-ai-chat", \ + "Brave AI Chat", \ + "Summarize articles and engage in conversation with AI", \ + kOsWin | kOsMac | kOsLinux | kOsAndroid, \ + FEATURE_VALUE_TYPE(ai_chat::features::kAIChat), \ + }, \ + { \ + "brave-ai-chat-history", \ + "Brave AI Chat History", \ + "Enables AI Chat History persistence and management", \ + kOsWin | kOsMac | kOsLinux, \ + FEATURE_VALUE_TYPE(ai_chat::features::kAIChatHistory), \ + }, \ + { \ + "brave-ai-chat-context-menu-rewrite-in-place", \ + "Brave AI Chat Rewrite In Place From Context Menu", \ + "Enables AI Chat rewrite in place feature from the context menu", \ + kOsDesktop, \ + FEATURE_VALUE_TYPE(ai_chat::features::kContextMenuRewriteInPlace), \ + }, \ + { \ + "brave-ai-chat-page-content-refine", \ + "Brave AI Chat Page Content Refine", \ + "Enable local text embedding for long page content in order to " \ + "find " \ + "most relevant parts to the prompt within context limit.", \ + kOsDesktop | kOsAndroid, \ + FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \ + }, \ + { \ + "brave-ai-chat-open-leo-from-brave-search", \ + "Open Leo AI Chat from Brave Search", \ + "Enables opening Leo AI Chat from Brave Search", \ + kOsDesktop | kOsAndroid, \ + FEATURE_VALUE_TYPE(ai_chat::features::kOpenAIChatFromBraveSearch), \ + }) #else -#define BRAVE_AI_CHAT -#define BRAVE_AI_CHAT_HISTORY -#define BRAVE_AI_CHAT_CONTEXT_MENU_REWRITE_IN_PLACE -#define BRAVE_AI_CHAT_PAGE_CONTENT_REFINE +#define BRAVE_AI_CHAT_FEATURE_ENTRIES #endif #if BUILDFLAG(ENABLE_AI_REWRITER) #define BRAVE_AI_REWRITER \ @@ -980,10 +983,7 @@ BRAVE_SAFE_BROWSING_ANDROID \ BRAVE_CHANGE_ACTIVE_TAB_ON_SCROLL_EVENT_FEATURE_ENTRIES \ BRAVE_TABS_FEATURE_ENTRIES \ - BRAVE_AI_CHAT \ - BRAVE_AI_CHAT_HISTORY \ - BRAVE_AI_CHAT_CONTEXT_MENU_REWRITE_IN_PLACE \ - BRAVE_AI_CHAT_PAGE_CONTENT_REFINE \ + BRAVE_AI_CHAT_FEATURE_ENTRIES \ BRAVE_AI_REWRITER \ BRAVE_OMNIBOX_FEATURES \ BRAVE_MIDDLE_CLICK_AUTOSCROLL_FEATURE_ENTRY \ diff --git a/browser/ai_chat/BUILD.gn b/browser/ai_chat/BUILD.gn index f882ac93f149..219a13500fb5 100644 --- a/browser/ai_chat/BUILD.gn +++ b/browser/ai_chat/BUILD.gn @@ -10,12 +10,12 @@ assert(enable_ai_chat) static_library("ai_chat") { sources = [ - "//brave/browser/ai_chat/ai_chat_service_factory.cc", - "//brave/browser/ai_chat/ai_chat_service_factory.h", - "//brave/browser/ai_chat/ai_chat_settings_helper.cc", - "//brave/browser/ai_chat/ai_chat_settings_helper.h", - "//brave/browser/ai_chat/ai_chat_utils.cc", - "//brave/browser/ai_chat/ai_chat_utils.h", + "ai_chat_service_factory.cc", + "ai_chat_service_factory.h", + "ai_chat_settings_helper.cc", + "ai_chat_settings_helper.h", + "ai_chat_utils.cc", + "ai_chat_utils.h", ] deps = [ @@ -46,7 +46,10 @@ static_library("ai_chat") { source_set("unit_tests") { testonly = true - sources = [ "ai_chat_throttle_unittest.cc" ] + sources = [ + "ai_chat_throttle_unittest.cc", + "brave_open_ai_chat_permission_context_unittest.cc", + ] deps = [ "//base", @@ -70,6 +73,7 @@ source_set("browser_tests") { sources = [ "//chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.cc", "//chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.h", + "ai_chat_brave_search_throttle_browsertest.cc", "ai_chat_browsertests.cc", "ai_chat_metrics_browsertest.cc", "ai_chat_policy_browsertest.cc", diff --git a/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc b/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc new file mode 100644 index 000000000000..f183830c4d17 --- /dev/null +++ b/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc @@ -0,0 +1,242 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" + +#include +#include + +#include "base/files/file_path.h" +#include "base/location.h" +#include "base/path_service.h" +#include "base/strings/string_util.h" +#include "brave/browser/ui/brave_browser.h" +#include "brave/browser/ui/sidebar/sidebar_controller.h" +#include "brave/browser/ui/sidebar/sidebar_model.h" +#include "brave/components/constants/brave_paths.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "components/permissions/permission_request_manager.h" +#include "components/permissions/test/mock_permission_prompt_factory.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_mock_cert_verifier.h" +#include "content/public/test/test_navigation_observer.h" +#include "content/public/test/test_utils.h" +#include "net/base/net_errors.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +constexpr char kBraveSearchHost[] = "search.brave.com"; +constexpr char kLeoPath[] = "/leo"; +constexpr char kOpenAIChatButtonValidPath[] = "/open_ai_chat_button_valid.html"; +constexpr char kOpenAIChatButtonInvalidPath[] = + "/open_ai_chat_button_invalid.html"; + +} // namespace + +// TODO(jocelyn): This should be changed to PlatformBrowserTest when we support +// Android. https://github.com/brave/brave-browser/issues/41905 +class AIChatBraveSearchThrottleBrowserTest : public InProcessBrowserTest { + public: + AIChatBraveSearchThrottleBrowserTest() + : https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {} + + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + + mock_cert_verifier_.mock_cert_verifier()->set_default_result(net::OK); + host_resolver()->AddRule("*", "127.0.0.1"); + content::SetupCrossSiteRedirector(&https_server_); + + base::FilePath test_data_dir = + base::PathService::CheckedGet(brave::DIR_TEST_DATA); + test_data_dir = test_data_dir.AppendASCII("leo"); + https_server_.ServeFilesFromDirectory(test_data_dir); + ASSERT_TRUE(https_server_.Start()); + + permissions::PermissionRequestManager* manager = + permissions::PermissionRequestManager::FromWebContents( + GetActiveWebContents()); + prompt_factory_ = + std::make_unique(manager); + } + + void TearDownOnMainThread() override { + prompt_factory_.reset(); + InProcessBrowserTest::TearDownOnMainThread(); + } + + void SetUpCommandLine(base::CommandLine* command_line) override { + InProcessBrowserTest::SetUpCommandLine(command_line); + mock_cert_verifier_.SetUpCommandLine(command_line); + } + + void SetUpInProcessBrowserTestFixture() override { + InProcessBrowserTest::SetUpInProcessBrowserTestFixture(); + mock_cert_verifier_.SetUpInProcessBrowserTestFixture(); + } + + void TearDownInProcessBrowserTestFixture() override { + mock_cert_verifier_.TearDownInProcessBrowserTestFixture(); + InProcessBrowserTest::TearDownInProcessBrowserTestFixture(); + } + + content::WebContents* GetActiveWebContents() { + return browser()->tab_strip_model()->GetActiveWebContents(); + } + + void ClickOpenAIChatButton() { + // Modify the href to have test server port and click it. + ASSERT_TRUE(content::ExecJs(GetActiveWebContents()->GetPrimaryMainFrame(), + content::JsReplace(R"( + const link = document.getElementById('continue-with-leo') + const url = new URL(link.href) + url.port = $1 + link.href = url.href + link.click())", + https_server_.port()))); + } + + bool IsLeoOpened() { + sidebar::SidebarController* controller = + static_cast(browser())->sidebar_controller(); + auto index = controller->model()->GetIndexOf( + sidebar::SidebarItem::BuiltInItemType::kChatUI); + return index.has_value() && controller->IsActiveIndex(index); + } + + void CloseLeoPanel(const base::Location& location) { + SCOPED_TRACE(testing::Message() << location.ToString()); + sidebar::SidebarController* controller = + static_cast(browser())->sidebar_controller(); + controller->DeactivateCurrentPanel(); + ASSERT_FALSE(IsLeoOpened()); + } + + void NavigateToTestPage(const base::Location& location, + const std::string& host, + const std::string& path, + int expected_prompt_count) { + SCOPED_TRACE(testing::Message() << location.ToString()); + ASSERT_TRUE(content::NavigateToURL(GetActiveWebContents(), + https_server_.GetURL(host, path))); + EXPECT_FALSE(IsLeoOpened()); + EXPECT_EQ(expected_prompt_count, prompt_factory_->show_count()); + } + + void ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + const base::Location& location, + int expected_prompt_count, + bool expected_leo_opened, + const std::string& expected_last_committed_path = + kOpenAIChatButtonValidPath) { + SCOPED_TRACE(testing::Message() << location.ToString()); + content::TestNavigationObserver observer( + GetActiveWebContents(), net::ERR_ABORTED, + content::MessageLoopRunner::QuitMode::IMMEDIATE, + false /* ignore_uncommitted_navigations */); + ClickOpenAIChatButton(); + observer.Wait(); + + EXPECT_EQ(IsLeoOpened(), expected_leo_opened); + EXPECT_EQ(expected_prompt_count, prompt_factory_->show_count()); + EXPECT_EQ(observer.last_navigation_url().path_piece(), kLeoPath); + EXPECT_EQ(GetActiveWebContents()->GetLastCommittedURL().path_piece(), + expected_last_committed_path); + } + + protected: + net::test_server::EmbeddedTestServer https_server_; + std::unique_ptr prompt_factory_; + + private: + content::ContentMockCertVerifier mock_cert_verifier_; +}; + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenAIChat_AskAndAccept) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::ACCEPT_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenAIChatButtonValidPath, + cur_prompt_count); + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, + /*expected_leo_opened=*/true); + + CloseLeoPanel(FROM_HERE); + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, cur_prompt_count, + /*expected_leo_opened=*/true); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenAIChat_AskAndDeny) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::DENY_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenAIChatButtonValidPath, + cur_prompt_count); + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, /*expected_leo_opened=*/false); + + // Clicking a button again to test no new permission prompt should be shown + // when the permission setting is denied. + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, cur_prompt_count, + /*expected_leo_opened=*/false); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenAIChat_AskAndDismiss) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::DISMISS); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenAIChatButtonValidPath, + cur_prompt_count); + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, /*expected_leo_opened=*/false); + + // Click a button again after dismissing the permission, permission prompt + // should be shown again. + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::ACCEPT_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenAIChatButtonValidPath, + cur_prompt_count); + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, + /*expected_leo_opened=*/true); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenAIChat_MismatchedNonce) { + int cur_prompt_count = 0; + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenAIChatButtonInvalidPath, + cur_prompt_count); + // No permission prompt should be shown. + ClickOpenAIChatAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, cur_prompt_count, /*expected_leo_opened=*/false, + kOpenAIChatButtonInvalidPath); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenAIChat_NotBraveSearchURL) { + // The behavior should be the same as without the throttle. + NavigateToTestPage(FROM_HERE, "brave.com", kOpenAIChatButtonValidPath, 0); + content::TestNavigationObserver observer(GetActiveWebContents()); + ClickOpenAIChatButton(); + observer.Wait(); + + EXPECT_FALSE(IsLeoOpened()); + EXPECT_EQ(0, prompt_factory_->show_count()); + EXPECT_TRUE(observer.last_navigation_succeeded()); + EXPECT_EQ(observer.last_navigation_url().path_piece(), kLeoPath); + EXPECT_EQ(GetActiveWebContents()->GetLastCommittedURL().path_piece(), + kLeoPath); +} diff --git a/browser/ai_chat/brave_open_ai_chat_permission_context_unittest.cc b/browser/ai_chat/brave_open_ai_chat_permission_context_unittest.cc new file mode 100644 index 000000000000..25fce629fc05 --- /dev/null +++ b/browser/ai_chat/brave_open_ai_chat_permission_context_unittest.cc @@ -0,0 +1,115 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "brave/components/permissions/contexts/brave_open_ai_chat_permission_context.h" + +#include + +#include "base/run_loop.h" +#include "base/test/bind.h" +#include "chrome/test/base/chrome_render_view_host_test_harness.h" +#include "components/content_settings/core/common/content_settings.h" +#include "components/permissions/permission_request_data.h" +#include "components/permissions/permission_request_id.h" +#include "components/permissions/permission_request_manager.h" +#include "components/permissions/test/mock_permission_prompt_factory.h" +#include "content/public/browser/permission_result.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace permissions { + +class BraveOpenAIChatPermissionContextTest + : public ChromeRenderViewHostTestHarness { + public: + BraveOpenAIChatPermissionContextTest() = default; + ~BraveOpenAIChatPermissionContextTest() override = default; + + ContentSetting RequestPermission( + BraveOpenAIChatPermissionContext* permission_context, + const GURL& url) { + NavigateAndCommit(url); + prompt_factory_->DocumentOnLoadCompletedInPrimaryMainFrame(); + + const PermissionRequestID id( + web_contents()->GetPrimaryMainFrame()->GetGlobalId(), + PermissionRequestID::RequestLocalId()); + ContentSetting setting = ContentSetting::CONTENT_SETTING_DEFAULT; + base::RunLoop run_loop; + permission_context->RequestPermission( + PermissionRequestData(permission_context, id, /*user_gesture=*/true, + url), + base::BindLambdaForTesting([&](ContentSetting result) { + setting = result; + run_loop.Quit(); + })); + run_loop.Run(); + + return setting; + } + + protected: + void SetUp() override { + ChromeRenderViewHostTestHarness::SetUp(); + PermissionRequestManager::CreateForWebContents(web_contents()); + PermissionRequestManager* manager = + PermissionRequestManager::FromWebContents(web_contents()); + prompt_factory_ = std::make_unique(manager); + } + + void TearDown() override { + prompt_factory_.reset(); + ChromeRenderViewHostTestHarness::TearDown(); + } + + std::unique_ptr prompt_factory_; +}; + +TEST_F(BraveOpenAIChatPermissionContextTest, PromptForBraveSearch) { + GURL brave_search_url("https://search.brave.com"); + + prompt_factory_->set_response_type(PermissionRequestManager::ACCEPT_ALL); + BraveOpenAIChatPermissionContext context(browser_context()); + EXPECT_EQ(ContentSetting::CONTENT_SETTING_ALLOW, + RequestPermission(&context, brave_search_url)); + EXPECT_EQ(prompt_factory_->show_count(), 1); +} + +TEST_F(BraveOpenAIChatPermissionContextTest, BlockForNonBraveSearch) { + GURL brave_url("https://brave.com"); + + prompt_factory_->set_response_type(PermissionRequestManager::ACCEPT_ALL); + BraveOpenAIChatPermissionContext context(browser_context()); + EXPECT_EQ(ContentSetting::CONTENT_SETTING_BLOCK, + RequestPermission(&context, brave_url)); + EXPECT_EQ(prompt_factory_->show_count(), 0); +} + +TEST_F(BraveOpenAIChatPermissionContextTest, NotAllowedInInsecureOrigins) { + BraveOpenAIChatPermissionContext permission_context(browser_context()); + GURL insecure_url("http://search.brave.com"); + GURL secure_url("https://search.brave.com"); + + EXPECT_EQ(content::PermissionStatus::DENIED, + permission_context + .GetPermissionStatus(nullptr /* render_frame_host */, + insecure_url, insecure_url) + .status); + + EXPECT_EQ(content::PermissionStatus::DENIED, + permission_context + .GetPermissionStatus(nullptr /* render_frame_host */, + insecure_url, secure_url) + .status); + + EXPECT_EQ(content::PermissionStatus::ASK, + permission_context + .GetPermissionStatus(nullptr /* render_frame_host */, + secure_url, secure_url) + .status); +} + +} // namespace permissions diff --git a/browser/ai_chat/page_content_fetcher_browsertest.cc b/browser/ai_chat/page_content_fetcher_browsertest.cc index 8d6cfdcd19b5..e9d96d9e2e4a 100644 --- a/browser/ai_chat/page_content_fetcher_browsertest.cc +++ b/browser/ai_chat/page_content_fetcher_browsertest.cc @@ -5,6 +5,8 @@ #include "brave/components/ai_chat/content/browser/page_content_fetcher.h" +#include + #include "base/files/file_path.h" #include "base/functional/bind.h" #include "base/path_service.h" @@ -133,6 +135,20 @@ class PageContentFetcherBrowserTest : public InProcessBrowserTest { run_loop.Run(); } + std::optional GetOpenAIChatButtonNonce() { + std::optional ret_nonce; + base::RunLoop run_loop; + page_content_fetcher_ = + std::make_unique(GetActiveWebContents()); + page_content_fetcher_->GetOpenAIChatButtonNonce(base::BindLambdaForTesting( + [&run_loop, &ret_nonce](const std::optional& nonce) { + ret_nonce = nonce; + run_loop.Quit(); + })); + run_loop.Run(); + return ret_nonce; + } + // Handles returning a .patch file if the user is on a github.com pull request void SetGithubInterceptor() { GURL expected_patch_url = @@ -269,3 +285,35 @@ IN_PROC_BROWSER_TEST_F(PageContentFetcherBrowserTest, GetSearchSummarizerKey) { GetSearchSummarizerKey(FROM_HERE, expected_result); } } + +IN_PROC_BROWSER_TEST_F(PageContentFetcherBrowserTest, + GetOpenAIChatButtonNonce) { + // Test no open Leo button with continue-with-leo ID present. + GURL url = https_server_.GetURL("a.com", "/open_ai_chat_button.html"); + NavigateURL(url); + EXPECT_FALSE(GetOpenAIChatButtonNonce()); + + // Test valid case. + NavigateURL(url); + ASSERT_TRUE(content::ExecJs(GetActiveWebContents()->GetPrimaryMainFrame(), + "document.getElementById('valid').setAttribute('" + "id', 'continue-with-leo')")); + EXPECT_EQ(GetOpenAIChatButtonNonce(), "5566"); + + // Test invalid cases. + const auto invalid_cases = std::to_array( + {"invalid", "not-a-tag", "no-href", "no-nonce", "empty-nonce", + "empty-nonce2", "empty-nonce3", "empty-nonce4", "empty-nonce5", + "empty-nonce6", "not-https-url", "not-search-url", "not-open-leo-url"}); + + for (const auto& invalid_case : invalid_cases) { + SCOPED_TRACE(testing::Message() << "Invalid case: " << invalid_case); + NavigateURL(url); + ASSERT_TRUE(content::ExecJs( + GetActiveWebContents()->GetPrimaryMainFrame(), + content::JsReplace("document.getElementById($1)." + "setAttribute('id', 'continue-with-leo')", + invalid_case))); + EXPECT_FALSE(GetOpenAIChatButtonNonce()); + } +} diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index 32dddb9f7b56..02c5a592a299 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -156,7 +156,9 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #endif #if BUILDFLAG(ENABLE_AI_CHAT) +#include "brave/browser/ai_chat/ai_chat_service_factory.h" #include "brave/browser/ui/webui/ai_chat/ai_chat_ui.h" +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" #include "brave/components/ai_chat/content/browser/ai_chat_tab_helper.h" #include "brave/components/ai_chat/content/browser/ai_chat_throttle.h" #include "brave/components/ai_chat/core/browser/utils.h" @@ -164,6 +166,9 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h" #include "brave/components/ai_chat/core/common/mojom/settings_helper.mojom.h" +#if !BUILDFLAG(IS_ANDROID) +#include "brave/browser/ui/ai_chat/utils.h" +#endif #if BUILDFLAG(IS_ANDROID) #include "brave/components/ai_chat/core/browser/android/ai_chat_iap_subscription_android.h" #endif @@ -1268,6 +1273,16 @@ BraveContentBrowserClient::CreateThrottlesForNavigation( throttles.push_back(std::move(ai_chat_throttle)); } } + +#if !BUILDFLAG(IS_ANDROID) + if (auto ai_chat_brave_search_throttle = + ai_chat::AIChatBraveSearchThrottle::MaybeCreateThrottleFor( + base::BindOnce(&ai_chat::OpenAIChatForTab), handle, + ai_chat::AIChatServiceFactory::GetForBrowserContext(context), + user_prefs::UserPrefs::Get(context))) { + throttles.push_back(std::move(ai_chat_brave_search_throttle)); + } +#endif #endif // ENABLE_AI_CHAT return throttles; diff --git a/browser/resources/settings/brave_overrides/privacy_page.ts b/browser/resources/settings/brave_overrides/privacy_page.ts index 1379800d2b60..27b011b2d2ab 100644 --- a/browser/resources/settings/brave_overrides/privacy_page.ts +++ b/browser/resources/settings/brave_overrides/privacy_page.ts @@ -181,6 +181,36 @@ function InsertShieldsSubpage (pages: Element) `) } +function InsertBraveOpenAIChatSubpage (pages: Element) +{ + pages.appendChild( + html` + + `) +} + RegisterPolymerTemplateModifications({ 'settings-privacy-page': (templateContent) => { const pages = templateContent.getElementById('pages') @@ -208,6 +238,11 @@ RegisterPolymerTemplateModifications({ if (isLocalhostAccessFeatureEnabled) { InsertLocalhostAccessSubpage(pages) } + const isOpenAIChatFromBraveSearchEnabled = + loadTimeData.getBoolean('isOpenAIChatFromBraveSearchEnabled') + if (isOpenAIChatFromBraveSearchEnabled) { + InsertBraveOpenAIChatSubpage(pages) + } InsertAutoplaySubpage(pages) const isNativeBraveWalletEnabled = loadTimeData.getBoolean('isNativeBraveWalletFeatureEnabled') diff --git a/browser/resources/settings/brave_overrides/site_details.ts b/browser/resources/settings/brave_overrides/site_details.ts index 4d6f70cb96d7..06c6621063bc 100644 --- a/browser/resources/settings/brave_overrides/site_details.ts +++ b/browser/resources/settings/brave_overrides/site_details.ts @@ -99,6 +99,32 @@ RegisterPolymerTemplateModifications({ } curChild++ } + // AI Chat feature + const isOpenLeoFromBraveSearchFeatureEnabled = + loadTimeData.getBoolean('isOpenLeoFromBraveSearchFeatureEnabled') + const isOpenAIChatFromBraveSearchEnabled = + loadTimeData.getBoolean('isOpenAIChatFromBraveSearchEnabled') + if (isOpenAIChatFromBraveSearchEnabled && isOpenLeoFromBraveSearchFeatureEnabled) { + firstPermissionItem.insertAdjacentHTML( + 'beforebegin', + getTrustedHTML` + + + `) + const braveAIChatSettings = templateContent. + querySelector(`div.list-frame > site-details-permission:nth-child(${curChild})`) + if (!braveAIChatSettings) { + console.error( + '[Brave Settings Overrides] Couldn\'t find Brave AI chat settings') + } + else { + braveAIChatSettings.setAttribute( + 'label', loadTimeData.getString('siteSettingsBraveOpenAIChat')) + } + curChild++ + } const isNativeBraveWalletEnabled = loadTimeData.getBoolean('isNativeBraveWalletFeatureEnabled') if (isNativeBraveWalletEnabled) { firstPermissionItem.insertAdjacentHTML( diff --git a/browser/resources/settings/brave_overrides/site_settings_page.ts b/browser/resources/settings/brave_overrides/site_settings_page.ts index 64eaa1c2c804..24540db1216b 100644 --- a/browser/resources/settings/brave_overrides/site_settings_page.ts +++ b/browser/resources/settings/brave_overrides/site_settings_page.ts @@ -137,6 +137,21 @@ RegisterPolymerComponentReplacement( lists_.permissionsAdvanced.splice(currentIndex, 0, localhostAccessItem) } + const isOpenAIChatFromBraveSearchEnabled = + loadTimeData.getBoolean('isOpenAIChatFromBraveSearchEnabled') + if (isOpenAIChatFromBraveSearchEnabled) { + currentIndex++ + const AIChatItem = { + route: routes.SITE_SETTINGS_BRAVE_OPEN_AI_CHAT, + id: 'braveOpenAIChat', + label: 'siteSettingsBraveOpenAIChat', + icon: 'product-brave-leo', + enabledLabel: 'siteSettingsBraveOpenAIChatAsk', + disabledLabel: 'siteSettingsBraveOpenAIChatBlock' + } + lists_.permissionsAdvanced.splice(currentIndex, 0, + AIChatItem) + } const isNativeBraveWalletEnabled = loadTimeData.getBoolean('isNativeBraveWalletFeatureEnabled') if (isNativeBraveWalletEnabled) { currentIndex++ diff --git a/browser/resources/settings/brave_routes.ts b/browser/resources/settings/brave_routes.ts index 1d2106d728d4..bce4d58943d9 100644 --- a/browser/resources/settings/brave_routes.ts +++ b/browser/resources/settings/brave_routes.ts @@ -75,6 +75,10 @@ export default function addBraveRoutes(r: Partial) { r.SITE_SETTINGS_LOCALHOST_ACCESS = r.SITE_SETTINGS .createChild('localhostAccess') } + const isOpenAIChatFromBraveSearchEnabled = loadTimeData.getBoolean('isOpenAIChatFromBraveSearchEnabled') + if (isOpenAIChatFromBraveSearchEnabled) { + r.SITE_SETTINGS_BRAVE_OPEN_AI_CHAT = r.SITE_SETTINGS.createChild('braveOpenAIChat') + } const isNativeBraveWalletFeatureEnabled = loadTimeData.getBoolean('isNativeBraveWalletFeatureEnabled') if (isNativeBraveWalletFeatureEnabled) { r.SITE_SETTINGS_ETHEREUM = r.SITE_SETTINGS.createChild('ethereum') diff --git a/browser/sources.gni b/browser/sources.gni index 7eb5d58e8855..e98a3a6217e5 100644 --- a/browser/sources.gni +++ b/browser/sources.gni @@ -348,6 +348,10 @@ if (enable_ai_chat) { "//brave/components/ai_chat/core/common/mojom", ] + if (!is_android) { + brave_chrome_browser_deps += [ "//brave/browser/ui/ai_chat" ] + } + if (is_android) { brave_chrome_browser_sources += brave_browser_ai_chat_android_sources brave_chrome_browser_deps += brave_browser_ai_chat_android_deps diff --git a/browser/ui/ai_chat/BUILD.gn b/browser/ui/ai_chat/BUILD.gn index e230199538ee..04e79ce41222 100644 --- a/browser/ui/ai_chat/BUILD.gn +++ b/browser/ui/ai_chat/BUILD.gn @@ -3,6 +3,24 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at https://mozilla.org/MPL/2.0/. +import("//brave/components/ai_chat/core/common/buildflags/buildflags.gni") +assert(enable_ai_chat) + +if (!is_android) { + source_set("ai_chat") { + sources = [ + "utils.cc", + "utils.h", + ] + + deps = [ + "//chrome/browser/ui/browser_window", + "//chrome/browser/ui/tabs", + "//chrome/browser/ui/views/side_panel", + ] + } +} + source_set("unit_tests") { testonly = true sources = [ "ai_chat_tab_helper_unittest.cc" ] diff --git a/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc b/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc index 4fa38922747c..cfc84ec71659 100644 --- a/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc +++ b/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc @@ -51,6 +51,10 @@ class MockPageContentFetcher GetSearchSummarizerKey, (mojom::PageContentExtractor::GetSearchSummarizerKeyCallback), (override)); + MOCK_METHOD(void, + GetOpenAIChatButtonNonce, + (mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback), + (override)); }; class MockAssociatedContentObserver : public AssociatedContentDriver::Observer { diff --git a/browser/ui/ai_chat/utils.cc b/browser/ui/ai_chat/utils.cc new file mode 100644 index 000000000000..5c5d5053f14c --- /dev/null +++ b/browser/ui/ai_chat/utils.cc @@ -0,0 +1,28 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/ui/ai_chat/utils.h" + +#include "chrome/browser/ui/browser_window/public/browser_window_features.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/tabs/public/tab_interface.h" +#include "chrome/browser/ui/views/side_panel/side_panel_coordinator.h" +#include "chrome/browser/ui/views/side_panel/side_panel_entry_id.h" + +namespace ai_chat { + +void OpenAIChatForTab(content::WebContents* web_contents) { + if (!web_contents) { + return; + } + + auto* tab = tabs::TabInterface::GetFromContents(web_contents); + auto* coordinator = + tab->GetBrowserWindowInterface()->GetFeatures().side_panel_coordinator(); + CHECK(coordinator); + coordinator->Show(SidePanelEntryId::kChatUI); +} + +} // namespace ai_chat diff --git a/browser/ui/ai_chat/utils.h b/browser/ui/ai_chat/utils.h new file mode 100644 index 000000000000..075c08fb7b45 --- /dev/null +++ b/browser/ui/ai_chat/utils.h @@ -0,0 +1,19 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_UI_AI_CHAT_UTILS_H_ +#define BRAVE_BROWSER_UI_AI_CHAT_UTILS_H_ + +namespace content { +class WebContents; +} + +namespace ai_chat { + +void OpenAIChatForTab(content::WebContents* web_contents); + +} // namespace ai_chat + +#endif // BRAVE_BROWSER_UI_AI_CHAT_UTILS_H_ diff --git a/browser/ui/webui/settings/brave_privacy_handler.cc b/browser/ui/webui/settings/brave_privacy_handler.cc index 34abdd43200d..b7ffe98637f7 100644 --- a/browser/ui/webui/settings/brave_privacy_handler.cc +++ b/browser/ui/webui/settings/brave_privacy_handler.cc @@ -7,6 +7,7 @@ #include "base/functional/bind.h" #include "base/values.h" +#include "brave/components/ai_chat/core/common/buildflags/buildflags.h" #include "brave/components/brave_shields/core/common/features.h" #include "brave/components/constants/pref_names.h" #include "brave/components/de_amp/common/features.h" @@ -22,6 +23,11 @@ #include "content/public/browser/web_ui_data_source.h" #include "third_party/blink/public/common/peerconnection/webrtc_ip_handling_policy.h" +#if BUILDFLAG(ENABLE_AI_CHAT) +#include "brave/components/ai_chat/core/browser/utils.h" +#include "brave/components/ai_chat/core/common/features.h" +#endif + #if BUILDFLAG(ENABLE_REQUEST_OTR) #include "brave/components/request_otr/common/features.h" #endif @@ -96,6 +102,14 @@ void BravePrivacyHandler::AddLoadTimeData(content::WebUIDataSource* data_source, "isLocalhostAccessFeatureEnabled", base::FeatureList::IsEnabled( brave_shields::features::kBraveLocalhostAccessPermission)); + data_source->AddBoolean( + "isOpenAIChatFromBraveSearchEnabled", +#if BUILDFLAG(ENABLE_AI_CHAT) + ai_chat::IsAIChatEnabled(profile->GetPrefs()) && + ai_chat::features::IsOpenAIChatFromBraveSearchEnabled()); +#else + false); +#endif } void BravePrivacyHandler::SetLocalStateBooleanEnabled( diff --git a/browser/ui/webui/settings/brave_settings_localized_strings_provider.cc b/browser/ui/webui/settings/brave_settings_localized_strings_provider.cc index 8c9a3f5307a4..e3c2fdf5d762 100644 --- a/browser/ui/webui/settings/brave_settings_localized_strings_provider.cc +++ b/browser/ui/webui/settings/brave_settings_localized_strings_provider.cc @@ -105,6 +105,13 @@ void BraveAddCommonStrings(content::WebUIDataSource* html_source, {"siteSettingsGoogleSignInAllowExceptions", IDS_SETTINGS_SITE_SETTINGS_GOOGLE_SIGN_IN_ALLOW_EXCEPTIONS}, + {"siteSettingsBraveOpenAIChat", + IDS_SETTINGS_SITE_SETTINGS_BRAVE_OPEN_AI_CHAT}, + {"siteSettingsBraveOpenAIChatAsk", + IDS_SETTINGS_SITE_SETTINGS_BRAVE_OPEN_AI_CHAT_ASK}, + {"siteSettingsBraveOpenAIChatBlock", + IDS_SETTINGS_SITE_SETTINGS_BRAVE_OPEN_AI_CHAT_BLOCK}, + {"siteSettingsLocalhostAccess", IDS_SETTINGS_SITE_SETTINGS_LOCALHOST_ACCESS}, {"siteSettingsCategoryLocalhostAccess", diff --git a/chromium_src/android_webview/browser/aw_permission_manager.cc b/chromium_src/android_webview/browser/aw_permission_manager.cc index 5c7dc387e4a3..661e47662003 100644 --- a/chromium_src/android_webview/browser/aw_permission_manager.cc +++ b/chromium_src/android_webview/browser/aw_permission_manager.cc @@ -20,6 +20,7 @@ case PermissionType::BRAVE_SOLANA: \ case PermissionType::BRAVE_GOOGLE_SIGN_IN: \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ case PermissionType::NUM #include "src/android_webview/browser/aw_permission_manager.cc" diff --git a/chromium_src/chrome/browser/permissions/permission_manager_factory.cc b/chromium_src/chrome/browser/permissions/permission_manager_factory.cc index 81fe9feee122..f93f211f8e6a 100644 --- a/chromium_src/chrome/browser/permissions/permission_manager_factory.cc +++ b/chromium_src/chrome/browser/permissions/permission_manager_factory.cc @@ -10,6 +10,7 @@ #include "brave/components/permissions/brave_permission_manager.h" #include "brave/components/permissions/contexts/brave_google_sign_in_permission_context.h" #include "brave/components/permissions/contexts/brave_localhost_permission_context.h" +#include "brave/components/permissions/contexts/brave_open_ai_chat_permission_context.h" #include "brave/components/permissions/contexts/brave_wallet_permission_context.h" #include "brave/components/permissions/permission_lifetime_manager.h" #include "components/permissions/features.h" @@ -42,6 +43,8 @@ PermissionManagerFactory::BuildServiceInstanceForBrowserContext( profile); permission_contexts[ContentSettingsType::BRAVE_LOCALHOST_ACCESS] = std::make_unique(profile); + permission_contexts[ContentSettingsType::BRAVE_OPEN_AI_CHAT] = + std::make_unique(profile); if (base::FeatureList::IsEnabled( permissions::features::kPermissionLifetime)) { diff --git a/chromium_src/chrome/browser/ui/webui/settings/site_settings_helper.cc b/chromium_src/chrome/browser/ui/webui/settings/site_settings_helper.cc index e02ea6f2eecf..7b68d03b6d2e 100644 --- a/chromium_src/chrome/browser/ui/webui/settings/site_settings_helper.cc +++ b/chromium_src/chrome/browser/ui/webui/settings/site_settings_helper.cc @@ -31,6 +31,7 @@ {ContentSettingsType::BRAVE_HTTPS_UPGRADE, nullptr}, \ {ContentSettingsType::BRAVE_REMEMBER_1P_STORAGE, nullptr}, \ {ContentSettingsType::BRAVE_LOCALHOST_ACCESS, "localhostAccess"}, \ + {ContentSettingsType::BRAVE_OPEN_AI_CHAT, "braveOpenAIChat"}, \ {ContentSettingsType::BRAVE_WEBCOMPAT_NONE, nullptr}, \ {ContentSettingsType::BRAVE_WEBCOMPAT_AUDIO, nullptr}, \ {ContentSettingsType::BRAVE_WEBCOMPAT_CANVAS, nullptr}, \ @@ -87,19 +88,24 @@ namespace site_settings { bool HasRegisteredGroupName(ContentSettingsType type) { - if (type == ContentSettingsType::AUTOPLAY) + if (type == ContentSettingsType::AUTOPLAY) { return true; - if (type == ContentSettingsType::BRAVE_GOOGLE_SIGN_IN) + } + if (type == ContentSettingsType::BRAVE_GOOGLE_SIGN_IN) { return true; + } if (type == ContentSettingsType::BRAVE_LOCALHOST_ACCESS) { return true; } - if (type == ContentSettingsType::BRAVE_ETHEREUM) + if (type == ContentSettingsType::BRAVE_ETHEREUM) { return true; - if (type == ContentSettingsType::BRAVE_SOLANA) + } + if (type == ContentSettingsType::BRAVE_SOLANA) { return true; - if (type == ContentSettingsType::BRAVE_SHIELDS) + } + if (type == ContentSettingsType::BRAVE_SHIELDS) { return true; + } return HasRegisteredGroupName_ChromiumImpl(type); } @@ -112,6 +118,7 @@ std::vector GetVisiblePermissionCategories( ContentSettingsType::BRAVE_SOLANA, ContentSettingsType::BRAVE_GOOGLE_SIGN_IN, ContentSettingsType::BRAVE_LOCALHOST_ACCESS, + ContentSettingsType::BRAVE_OPEN_AI_CHAT, }; auto types = GetVisiblePermissionCategories_ChromiumImpl(origin, profile); diff --git a/chromium_src/components/content_settings/core/browser/content_settings_registry.cc b/chromium_src/components/content_settings/core/browser/content_settings_registry.cc index 047ade3d68e2..bbd7636e5c36 100644 --- a/chromium_src/components/content_settings/core/browser/content_settings_registry.cc +++ b/chromium_src/components/content_settings/core/browser/content_settings_registry.cc @@ -228,6 +228,18 @@ void ContentSettingsRegistry::BraveInit() { ContentSettingsInfo::INHERIT_IF_LESS_PERMISSIVE, ContentSettingsInfo::EXCEPTIONS_ON_SECURE_AND_INSECURE_ORIGINS); + // Register AI chat permission default value as Ask. + Register(ContentSettingsType::BRAVE_OPEN_AI_CHAT, "brave_open_ai_chat", + CONTENT_SETTING_ASK, WebsiteSettingsInfo::UNSYNCABLE, + /*allowlisted_schemes=*/{}, + /*valid_settings=*/ + {CONTENT_SETTING_ALLOW, CONTENT_SETTING_BLOCK, CONTENT_SETTING_ASK}, + WebsiteSettingsInfo::TOP_ORIGIN_ONLY_SCOPE, + WebsiteSettingsRegistry::DESKTOP | + WebsiteSettingsRegistry::PLATFORM_ANDROID, + ContentSettingsInfo::INHERIT_IF_LESS_PERMISSIVE, + ContentSettingsInfo::EXCEPTIONS_ON_SECURE_ORIGINS_ONLY); + // Disable background sync by default (brave/brave-browser#4709) content_settings_info_.erase(ContentSettingsType::BACKGROUND_SYNC); website_settings_registry_->UnRegister(ContentSettingsType::BACKGROUND_SYNC); diff --git a/chromium_src/components/content_settings/core/browser/content_settings_uma_util.cc b/chromium_src/components/content_settings/core/browser/content_settings_uma_util.cc index a85492a344e2..fd74be15d2b7 100644 --- a/chromium_src/components/content_settings/core/browser/content_settings_uma_util.cc +++ b/chromium_src/components/content_settings/core/browser/content_settings_uma_util.cc @@ -44,6 +44,7 @@ static_assert(static_cast(ContentSettingsType::kMaxValue) < {ContentSettingsType::BRAVE_HTTPS_UPGRADE, brave_value(12)}, \ {ContentSettingsType::BRAVE_REMEMBER_1P_STORAGE, brave_value(13)}, \ {ContentSettingsType::BRAVE_LOCALHOST_ACCESS, brave_value(14)}, \ + {ContentSettingsType::BRAVE_OPEN_AI_CHAT, brave_value(15)}, \ /* Begin webcompat items */ \ {ContentSettingsType::BRAVE_WEBCOMPAT_NONE, brave_value(50)}, \ {ContentSettingsType::BRAVE_WEBCOMPAT_AUDIO, brave_value(51)}, \ diff --git a/chromium_src/components/content_settings/core/common/content_settings_types.mojom b/chromium_src/components/content_settings/core/common/content_settings_types.mojom index 8f061e6343f7..9f598acb0e19 100644 --- a/chromium_src/components/content_settings/core/common/content_settings_types.mojom +++ b/chromium_src/components/content_settings/core/common/content_settings_types.mojom @@ -24,6 +24,9 @@ enum ContentSettingsType { BRAVE_HTTPS_UPGRADE, BRAVE_REMEMBER_1P_STORAGE, BRAVE_LOCALHOST_ACCESS, + // Allow a site to open AI Chat (in side panel on Desktop). + // This is limited to Brave Search only. + BRAVE_OPEN_AI_CHAT, BRAVE_WEBCOMPAT_NONE, BRAVE_WEBCOMPAT_AUDIO, diff --git a/chromium_src/components/permissions/permission_request.cc b/chromium_src/components/permissions/permission_request.cc index ff8c282d9c7f..e661dce8a9d1 100644 --- a/chromium_src/components/permissions/permission_request.cc +++ b/chromium_src/components/permissions/permission_request.cc @@ -3,13 +3,14 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ +#include "components/permissions/permission_request.h" + #include #include #include "base/containers/contains.h" #include "build/build_config.h" #include "components/grit/brave_components_strings.h" -#include "components/permissions/permission_request.h" #include "components/strings/grit/components_strings.h" #include "third_party/widevine/cdm/buildflags.h" @@ -49,6 +50,9 @@ break; \ case RequestType::kBraveLocalhostAccessPermission: \ message_id = IDS_LOCALHOST_ACCESS_PERMISSION_FRAGMENT; \ + break; \ + case RequestType::kBraveOpenAIChat: \ + message_id = IDS_OPEN_AI_CHAT_PERMISSION_FRAGMENT; \ break; #define BRAVE_ENUM_ITEMS_FOR_SWITCH_ANDROID \ @@ -58,6 +62,9 @@ break; \ case RequestType::kBraveLocalhostAccessPermission: \ message_id = IDS_LOCALHOST_ACCESS_INFOBAR_TEXT; \ + break; \ + case RequestType::kBraveOpenAIChat: \ + message_id = IDS_OPEN_AI_CHAT_INFOBAR_TEXT; \ break; namespace { @@ -154,16 +161,15 @@ PermissionRequest::GetDialogAnnotatedMessageText( #endif bool PermissionRequest::SupportsLifetime() const { - const RequestType kExcludedTypes[] = { - RequestType::kDiskQuota, - RequestType::kMultipleDownloads, + const RequestType kExcludedTypes[] = {RequestType::kDiskQuota, + RequestType::kMultipleDownloads, #if BUILDFLAG(IS_ANDROID) - RequestType::kProtectedMediaIdentifier, + RequestType::kProtectedMediaIdentifier, #else RequestType::kRegisterProtocolHandler, #endif // BUILDFLAG(IS_ANDROID) #if BUILDFLAG(ENABLE_WIDEVINE) - RequestType::kWidevine + RequestType::kWidevine #endif // BUILDFLAG(ENABLE_WIDEVINE) }; return !base::Contains(kExcludedTypes, request_type()); diff --git a/chromium_src/components/permissions/permission_request_data.cc b/chromium_src/components/permissions/permission_request_data.cc index 981030bb2b15..84b92fdfd790 100644 --- a/chromium_src/components/permissions/permission_request_data.cc +++ b/chromium_src/components/permissions/permission_request_data.cc @@ -3,10 +3,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ +#include "components/permissions/permission_request_data.h" + #include #include "components/permissions/permission_context_base.h" -#include "components/permissions/permission_request_data.h" namespace permissions { @@ -21,6 +22,8 @@ std::optional ContentSettingsTypeToRequestTypeIfExists_BraveImpl( return RequestType::kBraveGoogleSignInPermission; case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: return RequestType::kBraveLocalhostAccessPermission; + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: + return RequestType::kBraveOpenAIChat; default: return ContentSettingsTypeToRequestTypeIfExists(content_settings_type); } diff --git a/chromium_src/components/permissions/permission_uma_util.cc b/chromium_src/components/permissions/permission_uma_util.cc index d85b221cdc27..655169236821 100644 --- a/chromium_src/components/permissions/permission_uma_util.cc +++ b/chromium_src/components/permissions/permission_uma_util.cc @@ -14,15 +14,16 @@ case RequestType::kBraveSolana: \ case RequestType::kBraveGoogleSignInPermission: \ case RequestType::kBraveLocalhostAccessPermission: \ + case RequestType::kBraveOpenAIChat: \ return RequestTypeForUma::PERMISSION_VR; // These requests may be batched together, so we must handle them explicitly as // GetUmaValueForRequests expects only a few specific request types to be // batched -#define BRAVE_GET_UMA_VALUE_FOR_REQUESTS \ - if (request_type >= RequestType::kWidevine && \ - request_type <= RequestType::kBraveLocalhostAccessPermission) { \ - return GetUmaValueForRequestType(request_type); \ +#define BRAVE_GET_UMA_VALUE_FOR_REQUESTS \ + if (request_type >= RequestType::kBraveMinValue && \ + request_type <= RequestType::kBraveMaxValue) { \ + return GetUmaValueForRequestType(request_type); \ } // We do not record permissions UKM and this can save us from patching diff --git a/chromium_src/components/permissions/permission_util.cc b/chromium_src/components/permissions/permission_util.cc index 3eb284556106..0aea75c5f39a 100644 --- a/chromium_src/components/permissions/permission_util.cc +++ b/chromium_src/components/permissions/permission_util.cc @@ -4,6 +4,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "components/permissions/permission_util.h" + #include "components/permissions/permission_uma_util.h" #include "third_party/blink/public/common/permissions/permission_utils.h" @@ -35,7 +36,9 @@ case PermissionType::BRAVE_GOOGLE_SIGN_IN: \ return ContentSettingsType::BRAVE_GOOGLE_SIGN_IN; \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ - return ContentSettingsType::BRAVE_LOCALHOST_ACCESS; + return ContentSettingsType::BRAVE_LOCALHOST_ACCESS; \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ + return ContentSettingsType::BRAVE_OPEN_AI_CHAT; #include "src/components/permissions/permission_util.cc" #undef PermissionUtil @@ -55,6 +58,8 @@ std::string PermissionUtil::GetPermissionString( return "BraveGoogleSignInPermission"; case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: return "BraveLocalhostAccessPermission"; + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: + return "BraveOpenAIChatPermission"; default: return PermissionUtil_ChromiumImpl::GetPermissionString(content_type); } @@ -76,6 +81,10 @@ bool PermissionUtil::GetPermissionType(ContentSettingsType type, *out = PermissionType::BRAVE_LOCALHOST_ACCESS; return true; } + if (type == ContentSettingsType::BRAVE_OPEN_AI_CHAT) { + *out = PermissionType::BRAVE_OPEN_AI_CHAT; + return true; + } return PermissionUtil_ChromiumImpl::GetPermissionType(type, out); } @@ -87,6 +96,7 @@ bool PermissionUtil::IsPermission(ContentSettingsType type) { case ContentSettingsType::BRAVE_SOLANA: case ContentSettingsType::BRAVE_GOOGLE_SIGN_IN: case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: return true; default: return PermissionUtil_ChromiumImpl::IsPermission(type); @@ -122,6 +132,8 @@ PermissionType PermissionUtil::ContentSettingTypeToPermissionType( return PermissionType::BRAVE_GOOGLE_SIGN_IN; case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: return PermissionType::BRAVE_LOCALHOST_ACCESS; + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: + return PermissionType::BRAVE_OPEN_AI_CHAT; default: return PermissionUtil_ChromiumImpl::ContentSettingTypeToPermissionType( permission); @@ -133,8 +145,9 @@ GURL PermissionUtil::GetCanonicalOrigin(ContentSettingsType permission, const GURL& embedding_origin) { // Use requesting_origin which will have ethereum or solana address info. if (permission == ContentSettingsType::BRAVE_ETHEREUM || - permission == ContentSettingsType::BRAVE_SOLANA) + permission == ContentSettingsType::BRAVE_SOLANA) { return requesting_origin; + } return PermissionUtil_ChromiumImpl::GetCanonicalOrigin( permission, requesting_origin, embedding_origin); diff --git a/chromium_src/components/permissions/request_type.cc b/chromium_src/components/permissions/request_type.cc index 53371229d804..13843d09a0f7 100644 --- a/chromium_src/components/permissions/request_type.cc +++ b/chromium_src/components/permissions/request_type.cc @@ -3,10 +3,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ +#include "components/permissions/request_type.h" + #include #include "build/build_config.h" -#include "components/permissions/request_type.h" #if BUILDFLAG(IS_ANDROID) #include "components/resources/android/theme_resources.h" @@ -31,6 +32,7 @@ constexpr auto kAndroidStorageAccess = IDR_ANDROID_STORAGE_ACCESS; case RequestType::kBraveSolana: \ case RequestType::kBraveGoogleSignInPermission: \ case RequestType::kBraveLocalhostAccessPermission: \ + case RequestType::kBraveOpenAIChat: \ return IDR_ANDROID_INFOBAR_PERMISSION_COOKIE // Add Brave cases into GetIconIdDesktop. @@ -41,6 +43,7 @@ constexpr auto kAndroidStorageAccess = IDR_ANDROID_STORAGE_ACCESS; case RequestType::kBraveSolana: \ case RequestType::kBraveGoogleSignInPermission: \ case RequestType::kBraveLocalhostAccessPermission: \ + case RequestType::kBraveOpenAIChat: \ return vector_icons::kExtensionIcon #define BRAVE_PERMISSION_KEY_FOR_REQUEST_TYPE \ @@ -53,7 +56,9 @@ constexpr auto kAndroidStorageAccess = IDR_ANDROID_STORAGE_ACCESS; case permissions::RequestType::kBraveGoogleSignInPermission: \ return "brave_google_sign_in"; \ case permissions::RequestType::kBraveLocalhostAccessPermission: \ - return "brave_localhost_access"; + return "brave_localhost_access"; \ + case permissions::RequestType::kBraveOpenAIChat: \ + return "brave_ai_chat"; #define ContentSettingsTypeToRequestType \ ContentSettingsTypeToRequestType_ChromiumImpl @@ -85,6 +90,8 @@ RequestType ContentSettingsTypeToRequestType( return RequestType::kBraveGoogleSignInPermission; case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: return RequestType::kBraveLocalhostAccessPermission; + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: + return RequestType::kBraveOpenAIChat; case ContentSettingsType::DEFAULT: // Currently we have only one DEFAULT type that is // not mapped, which is Widevine, it's used for @@ -107,6 +114,8 @@ std::optional RequestTypeToContentSettingsType( return ContentSettingsType::BRAVE_ETHEREUM; case RequestType::kBraveSolana: return ContentSettingsType::BRAVE_SOLANA; + case RequestType::kBraveOpenAIChat: + return ContentSettingsType::BRAVE_OPEN_AI_CHAT; default: return RequestTypeToContentSettingsType_ChromiumImpl(request_type); } @@ -118,6 +127,7 @@ bool IsRequestablePermissionType(ContentSettingsType content_settings_type) { case ContentSettingsType::BRAVE_LOCALHOST_ACCESS: case ContentSettingsType::BRAVE_ETHEREUM: case ContentSettingsType::BRAVE_SOLANA: + case ContentSettingsType::BRAVE_OPEN_AI_CHAT: return true; default: return IsRequestablePermissionType_ChromiumImpl(content_settings_type); diff --git a/chromium_src/components/permissions/request_type.h b/chromium_src/components/permissions/request_type.h index db0c865a41b0..0d3cfd9bf018 100644 --- a/chromium_src/components/permissions/request_type.h +++ b/chromium_src/components/permissions/request_type.h @@ -6,9 +6,11 @@ #ifndef BRAVE_CHROMIUM_SRC_COMPONENTS_PERMISSIONS_REQUEST_TYPE_H_ #define BRAVE_CHROMIUM_SRC_COMPONENTS_PERMISSIONS_REQUEST_TYPE_H_ -#define kStorageAccess \ - kStorageAccess, kWidevine, kBraveEthereum, kBraveSolana, \ - kBraveGoogleSignInPermission, kBraveLocalhostAccessPermission +#define kStorageAccess \ + kStorageAccess, kWidevine, kBraveEthereum, kBraveSolana, kBraveOpenAIChat, \ + kBraveGoogleSignInPermission, kBraveLocalhostAccessPermission, \ + kBraveMinValue = kWidevine, \ + kBraveMaxValue = kBraveLocalhostAccessPermission #define ContentSettingsTypeToRequestType \ ContentSettingsTypeToRequestType_ChromiumImpl diff --git a/chromium_src/content/browser/permissions/permission_controller_impl.cc b/chromium_src/content/browser/permissions/permission_controller_impl.cc index d3b970d12e8b..c446b7c54a90 100644 --- a/chromium_src/content/browser/permissions/permission_controller_impl.cc +++ b/chromium_src/content/browser/permissions/permission_controller_impl.cc @@ -4,6 +4,7 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "content/browser/permissions/permission_controller_impl.h" + #include "content/browser/permissions/permission_util.h" #include "third_party/blink/public/common/permissions/permission_utils.h" @@ -21,6 +22,7 @@ case PermissionType::BRAVE_SOLANA: \ case PermissionType::BRAVE_GOOGLE_SIGN_IN: \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ case PermissionType::NUM #include "src/content/browser/permissions/permission_controller_impl.cc" diff --git a/chromium_src/content/shell/browser/shell_permission_manager.cc b/chromium_src/content/shell/browser/shell_permission_manager.cc index 020fe013b9e6..0942ae8bda57 100644 --- a/chromium_src/content/shell/browser/shell_permission_manager.cc +++ b/chromium_src/content/shell/browser/shell_permission_manager.cc @@ -20,6 +20,7 @@ case PermissionType::BRAVE_SOLANA: \ case PermissionType::BRAVE_GOOGLE_SIGN_IN: \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ case PermissionType::NUM #include "src/content/shell/browser/shell_permission_manager.cc" diff --git a/chromium_src/third_party/blink/common/permissions/permission_utils.cc b/chromium_src/third_party/blink/common/permissions/permission_utils.cc index f5baf50f24a3..049063bc445f 100644 --- a/chromium_src/third_party/blink/common/permissions/permission_utils.cc +++ b/chromium_src/third_party/blink/common/permissions/permission_utils.cc @@ -30,6 +30,8 @@ return "BraveGoogleSignInPermission"; \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ return "BraveLocalhostAccessPermission"; \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ + return "BraveOpenAIChatPermission"; \ case PermissionType::BRAVE_ETHEREUM: \ return "BraveEthereum"; \ case PermissionType::BRAVE_SOLANA: \ @@ -52,6 +54,7 @@ case PermissionType::BRAVE_SPEEDREADER: \ case PermissionType::BRAVE_GOOGLE_SIGN_IN: \ case PermissionType::BRAVE_LOCALHOST_ACCESS: \ + case PermissionType::BRAVE_OPEN_AI_CHAT: \ return std::nullopt #include "src/third_party/blink/common/permissions/permission_utils.cc" diff --git a/chromium_src/third_party/blink/public/common/permissions/permission_utils.h b/chromium_src/third_party/blink/public/common/permissions/permission_utils.h index 5e19636be6f2..350d2342197b 100644 --- a/chromium_src/third_party/blink/public/common/permissions/permission_utils.h +++ b/chromium_src/third_party/blink/public/common/permissions/permission_utils.h @@ -20,7 +20,8 @@ BRAVE_ETHEREUM, \ BRAVE_SOLANA, \ BRAVE_GOOGLE_SIGN_IN, \ - BRAVE_LOCALHOST_ACCESS, \ + BRAVE_LOCALHOST_ACCESS, \ + BRAVE_OPEN_AI_CHAT, \ NUM // clang-format on diff --git a/components/ai_chat/content/browser/BUILD.gn b/components/ai_chat/content/browser/BUILD.gn index 1e3a609d705f..5f771501d52c 100644 --- a/components/ai_chat/content/browser/BUILD.gn +++ b/components/ai_chat/content/browser/BUILD.gn @@ -10,6 +10,8 @@ assert(enable_ai_chat) static_library("browser") { sources = [ + "ai_chat_brave_search_throttle.cc", + "ai_chat_brave_search_throttle.h", "ai_chat_tab_helper.cc", "ai_chat_tab_helper.h", "ai_chat_throttle.cc", diff --git a/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc new file mode 100644 index 000000000000..921344850b89 --- /dev/null +++ b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc @@ -0,0 +1,147 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" + +#include +#include + +#include "base/check.h" +#include "base/functional/bind.h" +#include "brave/components/ai_chat/content/browser/ai_chat_tab_helper.h" +#include "brave/components/ai_chat/content/browser/page_content_fetcher.h" +#include "brave/components/ai_chat/core/browser/conversation_handler.h" +#include "brave/components/ai_chat/core/browser/utils.h" +#include "brave/components/ai_chat/core/common/features.h" +#include "brave/components/ai_chat/core/common/utils.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/navigation_handle.h" +#include "content/public/browser/permission_controller.h" +#include "content/public/browser/permission_request_description.h" +#include "content/public/browser/web_contents.h" + +namespace ai_chat { + +// static +std::unique_ptr +AIChatBraveSearchThrottle::MaybeCreateThrottleFor( + base::OnceCallback open_leo_delegate, + content::NavigationHandle* navigation_handle, + AIChatService* ai_chat_service, + PrefService* pref_service) { + auto* web_contents = navigation_handle->GetWebContents(); + if (!web_contents) { + return nullptr; + } + + if (!open_leo_delegate || !ai_chat_service || + !IsAIChatEnabled(pref_service) || + !features::IsOpenAIChatFromBraveSearchEnabled() || + !IsOpenAIChatButtonFromBraveSearchURL(navigation_handle->GetURL())) { + return nullptr; + } + + return std::make_unique( + std::move(open_leo_delegate), navigation_handle, ai_chat_service); +} + +AIChatBraveSearchThrottle::AIChatBraveSearchThrottle( + base::OnceCallback open_leo_delegate, + content::NavigationHandle* handle, + AIChatService* ai_chat_service) + : content::NavigationThrottle(handle), + open_ai_chat_delegate_(std::move(open_leo_delegate)), + ai_chat_service_(ai_chat_service) { + CHECK(open_ai_chat_delegate_); + CHECK(ai_chat_service_); +} + +AIChatBraveSearchThrottle::~AIChatBraveSearchThrottle() = default; + +AIChatBraveSearchThrottle::ThrottleCheckResult +AIChatBraveSearchThrottle::WillStartRequest() { + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents || !navigation_handle()->IsInPrimaryMainFrame() || + !IsOpenAIChatButtonFromBraveSearchURL(navigation_handle()->GetURL()) || + !IsBraveSearchURL(web_contents->GetLastCommittedURL())) { + // Uninterested navigation for this throttle. + return content::NavigationThrottle::PROCEED; + } + + // Check if nonce in HTML tag matches the one in the URL. + AIChatTabHelper::FromWebContents(web_contents) + ->GetOpenAIChatButtonNonce( + base::BindOnce(&AIChatBraveSearchThrottle::OnGetOpenAIChatButtonNonce, + weak_factory_.GetWeakPtr())); + return content::NavigationThrottle::DEFER; +} + +void AIChatBraveSearchThrottle::OpenAIChatWithStagedEntries() { + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents) { + return; + } + + ai_chat_service_->OpenConversationWithStagedEntries( + AIChatTabHelper::FromWebContents(web_contents)->GetWeakPtr(), + base::BindOnce(&AIChatBraveSearchThrottle::OnOpenAIChat, + weak_factory_.GetWeakPtr())); +} + +void AIChatBraveSearchThrottle::OnOpenAIChat() { + std::move(open_ai_chat_delegate_).Run(navigation_handle()->GetWebContents()); +} + +void AIChatBraveSearchThrottle::OnGetOpenAIChatButtonNonce( + const std::optional& nonce) { + if (!nonce || nonce->empty() || + *nonce != navigation_handle()->GetURL().ref()) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + return; + } + + // Check if the user has granted permission to open AI Chat. + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + return; + } + + content::RenderFrameHost* rfh = web_contents->GetPrimaryMainFrame(); + content::PermissionController* permission_controller = + web_contents->GetBrowserContext()->GetPermissionController(); + content::PermissionResult permission_status = + permission_controller->GetPermissionResultForCurrentDocument( + blink::PermissionType::BRAVE_OPEN_AI_CHAT, rfh); + + if (permission_status.status == content::PermissionStatus::DENIED) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + } else if (permission_status.status == content::PermissionStatus::GRANTED) { + OpenAIChatWithStagedEntries(); + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + } else { // ask + permission_controller->RequestPermissionFromCurrentDocument( + rfh, + content::PermissionRequestDescription( + blink::PermissionType::BRAVE_OPEN_AI_CHAT, /*user_gesture=*/true), + base::BindOnce(&AIChatBraveSearchThrottle::OnPermissionPromptResult, + weak_factory_.GetWeakPtr())); + } +} + +void AIChatBraveSearchThrottle::OnPermissionPromptResult( + content::PermissionStatus status) { + if (status == content::PermissionStatus::GRANTED) { + OpenAIChatWithStagedEntries(); + } + + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); +} + +const char* AIChatBraveSearchThrottle::GetNameForLogging() { + return "AIChatBraveSearchThrottle"; +} + +} // namespace ai_chat diff --git a/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h new file mode 100644 index 000000000000..d9183e2d5aac --- /dev/null +++ b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h @@ -0,0 +1,71 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ + +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "brave/components/ai_chat/core/browser/ai_chat_service.h" +#include "content/public/browser/navigation_throttle.h" +#include "content/public/browser/permission_result.h" + +namespace content { +class WebContents; +} + +class PrefService; + +namespace ai_chat { + +// A network throttle which intercepts Brave Search requests. +// Currently the only use case is to intercept requests to open Leo AI chat, so +// it is only created when navigating to open Leo button URL from Brave Search. +// It could be extended to other Brave Search URLs in the future. +// +// For Open Leo feature, we check: +// 1) If AI chat is enabled. +// 2) If the request is from Brave Search and is trying to navigate to open Leo +// button URL. +// 3) If the nonce property in the a tag element is equal to the one in url ref. +// 4) If the user has granted permission to open Leo. +// The navigation to the specific Open Leo URL will be cancelled, and Leo AI +// chat will be opened only if all the above conditions are met. +class AIChatBraveSearchThrottle : public content::NavigationThrottle { + public: + AIChatBraveSearchThrottle( + base::OnceCallback open_leo_delegate, + content::NavigationHandle* handle, + AIChatService* ai_chat_service); + ~AIChatBraveSearchThrottle() override; + + static std::unique_ptr MaybeCreateThrottleFor( + base::OnceCallback open_leo_delegate, + content::NavigationHandle* navigation_handle, + AIChatService* ai_chat_service, + PrefService* pref_service); + + ThrottleCheckResult WillStartRequest() override; + const char* GetNameForLogging() override; + + private: + void OnGetOpenAIChatButtonNonce(const std::optional& nonce); + void OnPermissionPromptResult(blink::mojom::PermissionStatus status); + void OnOpenAIChat(); + + void OpenAIChatWithStagedEntries(); + + base::OnceCallback open_ai_chat_delegate_; + const raw_ptr ai_chat_service_ = nullptr; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ diff --git a/components/ai_chat/content/browser/ai_chat_tab_helper.cc b/components/ai_chat/content/browser/ai_chat_tab_helper.cc index 46b728ea0ea4..bcead876887e 100644 --- a/components/ai_chat/content/browser/ai_chat_tab_helper.cc +++ b/components/ai_chat/content/browser/ai_chat_tab_helper.cc @@ -27,6 +27,9 @@ #include "content/public/browser/browser_context.h" #include "content/public/browser/navigation_details.h" #include "content/public/browser/navigation_entry.h" +#include "content/public/browser/permission_controller.h" +#include "content/public/browser/permission_request_description.h" +#include "content/public/browser/permission_result.h" #include "content/public/browser/scoped_accessibility_mode.h" #include "content/public/browser/storage_partition.h" #include "content/public/browser/web_contents.h" @@ -414,6 +417,21 @@ void AIChatTabHelper::GetSearchSummarizerKey( page_content_fetcher_delegate_->GetSearchSummarizerKey(std::move(callback)); } +void AIChatTabHelper::GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) { + page_content_fetcher_delegate_->GetOpenAIChatButtonNonce(std::move(callback)); +} + +bool AIChatTabHelper::HasOpenAIChatPermission() const { + content::RenderFrameHost* rfh = web_contents()->GetPrimaryMainFrame(); + content::PermissionController* permission_controller = + web_contents()->GetBrowserContext()->GetPermissionController(); + content::PermissionResult permission_status = + permission_controller->GetPermissionResultForCurrentDocument( + blink::PermissionType::BRAVE_OPEN_AI_CHAT, rfh); + return permission_status.status == content::PermissionStatus::GRANTED; +} + WEB_CONTENTS_USER_DATA_KEY_IMPL(AIChatTabHelper); } // namespace ai_chat diff --git a/components/ai_chat/content/browser/ai_chat_tab_helper.h b/components/ai_chat/content/browser/ai_chat_tab_helper.h index b671d90bed28..1c6d9d532a5f 100644 --- a/components/ai_chat/content/browser/ai_chat_tab_helper.h +++ b/components/ai_chat/content/browser/ai_chat_tab_helper.h @@ -7,8 +7,8 @@ #define BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_TAB_HELPER_H_ #include -#include #include +#include #include "base/functional/callback_forward.h" #include "base/memory/raw_ptr.h" @@ -74,6 +74,12 @@ class AIChatTabHelper : public content::WebContentsObserver, // Attempts to find a search summarizer key for the page. virtual void GetSearchSummarizerKey( GetSearchSummarizerKeyCallback callback) = 0; + + // Fetches the nonce for the OpenLeo button from the page HTML and validate + // if it matches the href URL and the passed in nonce. + virtual void GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback + callback) = 0; }; AIChatTabHelper(const AIChatTabHelper&) = delete; @@ -97,6 +103,9 @@ class AIChatTabHelper : public content::WebContentsObserver, // mojom::PageContentExtractorHost void OnInterceptedPageContentChanged() override; + void GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback); + private: friend class content::WebContentsUserData; friend class ::AIChatUIBrowserTest; @@ -157,6 +166,8 @@ class AIChatTabHelper : public content::WebContentsObserver, void GetSearchSummarizerKey(GetSearchSummarizerKeyCallback callback) override; + bool HasOpenAIChatPermission() const override; + void OnFetchPageContentComplete(GetPageContentCallback callback, std::string content, bool is_video, diff --git a/components/ai_chat/content/browser/page_content_fetcher.cc b/components/ai_chat/content/browser/page_content_fetcher.cc index 9a29de3f306a..7ceb61f9a8c0 100644 --- a/components/ai_chat/content/browser/page_content_fetcher.cc +++ b/components/ai_chat/content/browser/page_content_fetcher.cc @@ -135,9 +135,20 @@ class PageContentFetcherInternal { content_extractor_->GetSearchSummarizerKey(std::move(callback)); } - void StartGithub( - GURL patch_url, - FetchPageContentCallback callback) { + void GetOpenAIChatButtonNonce( + mojo::Remote content_extractor, + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) { + content_extractor_ = std::move(content_extractor); + if (!content_extractor_) { + DeleteSelf(); + return; + } + content_extractor_.set_disconnect_handler(base::BindOnce( + &PageContentFetcherInternal::DeleteSelf, base::Unretained(this))); + content_extractor_->GetOpenAIChatButtonNonce(std::move(callback)); + } + + void StartGithub(GURL patch_url, FetchPageContentCallback callback) { auto request = std::make_unique(); request->url = patch_url; request->load_flags = net::LOAD_DO_NOT_SAVE_COOKIES; @@ -460,4 +471,16 @@ void PageContentFetcher::GetSearchSummarizerKey( fetcher->GetSearchSummarizerKey(std::move(extractor), std::move(callback)); } +void PageContentFetcher::GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) { + auto* primary_rfh = web_contents_->GetPrimaryMainFrame(); + DCHECK(primary_rfh->IsRenderFrameLive()); + + auto* fetcher = new PageContentFetcherInternal(nullptr); + mojo::Remote extractor; + primary_rfh->GetRemoteInterfaces()->GetInterface( + extractor.BindNewPipeAndPassReceiver()); + fetcher->GetOpenAIChatButtonNonce(std::move(extractor), std::move(callback)); +} + } // namespace ai_chat diff --git a/components/ai_chat/content/browser/page_content_fetcher.h b/components/ai_chat/content/browser/page_content_fetcher.h index 18712ff096b7..1825a328e5d8 100644 --- a/components/ai_chat/content/browser/page_content_fetcher.h +++ b/components/ai_chat/content/browser/page_content_fetcher.h @@ -39,6 +39,10 @@ class PageContentFetcher : public AIChatTabHelper::PageContentFetcherDelegate { mojom::PageContentExtractor::GetSearchSummarizerKeyCallback callback) override; + void GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) + override; + void SetURLLoaderFactoryForTesting( scoped_refptr url_loader_factory) { url_loader_factory_ = url_loader_factory; diff --git a/components/ai_chat/core/browser/ai_chat_service.cc b/components/ai_chat/core/browser/ai_chat_service.cc index 80d59baddf71..f19954747b27 100644 --- a/components/ai_chat/core/browser/ai_chat_service.cc +++ b/components/ai_chat/core/browser/ai_chat_service.cc @@ -414,4 +414,21 @@ void AIChatService::OnConversationListChanged() { } } +void AIChatService::OpenConversationWithStagedEntries( + base::WeakPtr + associated_content, + base::OnceClosure open_ai_chat) { + if (!associated_content || !associated_content->HasOpenAIChatPermission()) { + return; + } + + ConversationHandler* conversation = GetOrCreateConversationHandlerForContent( + associated_content->GetContentId(), associated_content); + CHECK(conversation); + + // Open AI Chat and trigger a fetch of staged conversations from Brave Search. + std::move(open_ai_chat).Run(); + conversation->MaybeFetchOrClearContentStagedConversation(); +} + } // namespace ai_chat diff --git a/components/ai_chat/core/browser/ai_chat_service.h b/components/ai_chat/core/browser/ai_chat_service.h index fb4d16e80d31..728b3e2e7439 100644 --- a/components/ai_chat/core/browser/ai_chat_service.h +++ b/components/ai_chat/core/browser/ai_chat_service.h @@ -87,6 +87,11 @@ class AIChatService : public KeyedService, base::WeakPtr associated_content); + void OpenConversationWithStagedEntries( + base::WeakPtr + associated_content, + base::OnceClosure open_ai_chat); + // mojom::Service void MarkAgreementAccepted() override; void GetVisibleConversations( diff --git a/components/ai_chat/core/browser/ai_chat_service_unittest.cc b/components/ai_chat/core/browser/ai_chat_service_unittest.cc index 884c470db48c..a67e287fbfac 100644 --- a/components/ai_chat/core/browser/ai_chat_service_unittest.cc +++ b/components/ai_chat/core/browser/ai_chat_service_unittest.cc @@ -201,6 +201,7 @@ class MockAssociatedContent GetStagedEntriesFromContent, (ConversationHandler::GetStagedEntriesCallback), (override)); + MOCK_METHOD(bool, HasOpenAIChatPermission, (), (const, override)); base::WeakPtr GetWeakPtr() { return weak_ptr_factory_.GetWeakPtr(); @@ -495,4 +496,61 @@ TEST_P(AIChatServiceUnitTest, run_loop.Run(); } +TEST_P(AIChatServiceUnitTest, OpenConversationWithStagedEntries_NoPermission) { + NiceMock associated_content{}; + ConversationHandler* conversation = + ai_chat_service_->CreateConversationHandlerForContent( + associated_content.GetContentId(), associated_content.GetWeakPtr()); + auto conversation_client = CreateConversationClient(conversation); + + ON_CALL(associated_content, HasOpenAIChatPermission) + .WillByDefault(testing::Return(false)); + EXPECT_CALL(associated_content, GetStagedEntriesFromContent).Times(0); + + bool opened = false; + ai_chat_service_->OpenConversationWithStagedEntries( + associated_content.GetWeakPtr(), + base::BindLambdaForTesting([&]() { opened = true; })); + EXPECT_FALSE(opened); + testing::Mock::VerifyAndClearExpectations(&associated_content); +} + +TEST_P(AIChatServiceUnitTest, OpenConversationWithStagedEntries) { + NiceMock associated_content{}; + ConversationHandler* conversation = + ai_chat_service_->CreateConversationHandlerForContent( + associated_content.GetContentId(), associated_content.GetWeakPtr()); + auto conversation_client = CreateConversationClient(conversation); + + ON_CALL(associated_content, GetStagedEntriesFromContent) + .WillByDefault( + [](ConversationHandler::GetStagedEntriesCallback callback) { + std::move(callback).Run(std::vector{ + SearchQuerySummary("query", "summary")}); + }); + ON_CALL(associated_content, HasOpenAIChatPermission) + .WillByDefault(testing::Return(true)); + + // Allowed scheme to be associated with a conversation + ON_CALL(associated_content, GetURL()) + .WillByDefault(testing::Return(GURL("https://example.com"))); + + // One from setting up a connected client, one from + // OpenConversationWithStagedEntries. + EXPECT_CALL(associated_content, GetStagedEntriesFromContent).Times(2); + + bool opened = false; + ai_chat_service_->OpenConversationWithStagedEntries( + associated_content.GetWeakPtr(), + base::BindLambdaForTesting([&]() { opened = true; })); + + base::RunLoop().RunUntilIdle(); + auto& history = conversation->GetConversationHistory(); + ASSERT_EQ(history.size(), 2u); + EXPECT_EQ(history[0]->text, "query"); + EXPECT_EQ(history[1]->text, "summary"); + EXPECT_TRUE(opened); + testing::Mock::VerifyAndClearExpectations(&associated_content); +} + } // namespace ai_chat diff --git a/components/ai_chat/core/browser/associated_content_driver.cc b/components/ai_chat/core/browser/associated_content_driver.cc index a63dc2799733..5dbe90b46f67 100644 --- a/components/ai_chat/core/browser/associated_content_driver.cc +++ b/components/ai_chat/core/browser/associated_content_driver.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include "base/containers/contains.h" #include "base/containers/fixed_flat_set.h" @@ -19,9 +20,9 @@ #include "base/strings/string_util.h" #include "brave/brave_domains/service_domains.h" #include "brave/components/ai_chat/core/browser/brave_search_responses.h" -#include "brave/components/ai_chat/core/browser/constants.h" #include "brave/components/ai_chat/core/browser/conversation_handler.h" #include "brave/components/ai_chat/core/browser/utils.h" +#include "brave/components/ai_chat/core/common/constants.h" #include "net/base/url_util.h" #include "net/traffic_annotation/network_traffic_annotation.h" #include "services/network/public/cpp/shared_url_loader_factory.h" diff --git a/components/ai_chat/core/browser/constants.h b/components/ai_chat/core/browser/constants.h index 6eaab67f9f30..25ce8bca9952 100644 --- a/components/ai_chat/core/browser/constants.h +++ b/components/ai_chat/core/browser/constants.h @@ -36,8 +36,6 @@ constexpr float kMaxContentLengthThreshold = 0.6f; constexpr size_t kReservedTokensForPrompt = 300; constexpr size_t kReservedTokensForMaxNewTokens = 400; -inline constexpr char kBraveSearchURLPrefix[] = "search"; - } // namespace ai_chat #endif // BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_CONSTANTS_H_ diff --git a/components/ai_chat/core/browser/conversation_handler.cc b/components/ai_chat/core/browser/conversation_handler.cc index 7362cabc90a0..744a10afd914 100644 --- a/components/ai_chat/core/browser/conversation_handler.cc +++ b/components/ai_chat/core/browser/conversation_handler.cc @@ -63,6 +63,10 @@ void AssociatedContentDelegate::GetStagedEntriesFromContent( std::move(callback).Run(std::nullopt); } +bool AssociatedContentDelegate::HasOpenAIChatPermission() const { + return false; +} + void AssociatedContentDelegate::GetTopSimilarityWithPromptTilContextLimit( const std::string& prompt, const std::string& text, @@ -1048,23 +1052,16 @@ void ConversationHandler::MaybeFetchOrClearContentStagedConversation() { if (!can_check_for_staged_conversation) { // Clear any staged conversation entries since user might have unassociated // content with this conversation - if (chat_history_.empty()) { - return; - } - - const auto& last_turn = chat_history_.back(); - if (last_turn->from_brave_search_SERP) { - chat_history_.clear(); // Clear the staged entries. + size_t num_entries = chat_history_.size(); + std::erase_if(chat_history_, [](const mojom::ConversationTurnPtr& turn) { + return turn->from_brave_search_SERP; + }); + if (num_entries != chat_history_.size()) { OnHistoryUpdate(); } return; } - // Can only have staged entries at the start of a conversation. - if (!chat_history_.empty()) { - return; - } - associated_content_delegate_->GetStagedEntriesFromContent( base::BindOnce(&ConversationHandler::OnGetStagedEntriesFromContent, weak_ptr_factory_.GetWeakPtr())); @@ -1073,11 +1070,16 @@ void ConversationHandler::MaybeFetchOrClearContentStagedConversation() { void ConversationHandler::OnGetStagedEntriesFromContent( const std::optional>& entries) { // Check if all requirements are still met. - if (!entries || !chat_history_.empty() || !IsContentAssociationPossible() || + if (is_request_in_progress_ || !entries || !IsContentAssociationPossible() || !should_send_page_contents_ || !ai_chat_service_->HasUserOptedIn()) { return; } + // Clear previous staged entries. + std::erase_if(chat_history_, [](const mojom::ConversationTurnPtr& turn) { + return turn->from_brave_search_SERP; + }); + // Add the query & summary pairs to the conversation history and call // OnHistoryUpdate to update UI. for (const auto& entry : *entries) { diff --git a/components/ai_chat/core/browser/conversation_handler.h b/components/ai_chat/core/browser/conversation_handler.h index e5e7f4fa70b9..b8db86b65c4f 100644 --- a/components/ai_chat/core/browser/conversation_handler.h +++ b/components/ai_chat/core/browser/conversation_handler.h @@ -89,6 +89,9 @@ class ConversationHandler : public mojom::ConversationHandler, // use it to fetch search query and summary from Brave search chatllm // endpoint. virtual void GetStagedEntriesFromContent(GetStagedEntriesCallback callback); + // Signifies whether the content has permission to open a conversation's UI + // within the browser. + virtual bool HasOpenAIChatPermission() const; void GetTopSimilarityWithPromptTilContextLimit( const std::string& prompt, @@ -223,6 +226,10 @@ class ConversationHandler : public mojom::ConversationHandler, void OnFaviconImageDataChanged(); void OnUserOptedIn(); + // Some associated content may provide some conversation that the user wants + // to continue, e.g. Brave Search. + void MaybeFetchOrClearContentStagedConversation(); + base::WeakPtr GetWeakPtr() { return weak_ptr_factory_.GetWeakPtr(); } @@ -244,6 +251,10 @@ class ConversationHandler : public mojom::ConversationHandler, return associated_content_delegate_.get(); } + void SetRequestInProgressForTesting(bool in_progress) { + is_request_in_progress_ = in_progress; + } + protected: // ModelService::Observer void OnModelListUpdated() override; @@ -261,6 +272,10 @@ class ConversationHandler : public mojom::ConversationHandler, UpdateOrCreateLastAssistantEntry_NotDelta); FRIEND_TEST_ALL_PREFIXES(ConversationHandlerUnitTest, UpdateOrCreateLastAssistantEntry_NotDeltaWithSearch); + FRIEND_TEST_ALL_PREFIXES(ConversationHandlerUnitTest, + OnGetStagedEntriesFromContent); + FRIEND_TEST_ALL_PREFIXES(ConversationHandlerUnitTest, + OnGetStagedEntriesFromContent_FailedChecks); FRIEND_TEST_ALL_PREFIXES(ConversationHandlerUnitTest, SelectedLanguage); FRIEND_TEST_ALL_PREFIXES(PageContentRefineTest, LocalModelsUpdater); FRIEND_TEST_ALL_PREFIXES(PageContentRefineTest, TextEmbedder); @@ -285,9 +300,6 @@ class ConversationHandler : public mojom::ConversationHandler, bool is_video, std::string invalidation_token); - // Some associated content may provide some conversation that the user wants - // to continue, e.g. Brave Search. - void MaybeFetchOrClearContentStagedConversation(); void OnGetStagedEntriesFromContent( const std::optional>& entries); diff --git a/components/ai_chat/core/browser/conversation_handler_unittest.cc b/components/ai_chat/core/browser/conversation_handler_unittest.cc index 20377b76b5c2..975b1bb580d7 100644 --- a/components/ai_chat/core/browser/conversation_handler_unittest.cc +++ b/components/ai_chat/core/browser/conversation_handler_unittest.cc @@ -263,6 +263,32 @@ class ConversationHandlerUnitTest : public testing::Test { }); } + // Pair of text and whether it's from Brave Search SERP + void SetupHistory(std::vector> entries) { + std::vector history; + for (size_t i = 0; i < entries.size(); i++) { + bool is_human = i % 2 == 0; + + std::optional> events; + if (!is_human) { + events = std::vector(); + events->push_back(mojom::ConversationEntryEvent::NewCompletionEvent( + mojom::CompletionEvent::New(entries[i].first))); + } + + auto entry = mojom::ConversationTurn::New( + is_human ? mojom::CharacterType::HUMAN + : mojom::CharacterType::ASSISTANT, + is_human ? mojom::ActionType::QUERY : mojom::ActionType::RESPONSE, + mojom::ConversationTurnVisibility::VISIBLE, + entries[i].first /* text */, std::nullopt /* selected_text */, + std::move(events), base::Time::Now(), std::nullopt /* edits */, + entries[i].second /* from_brave_search_SERP */); + history.push_back(std::move(entry)); + } + conversation_handler_->SetChatHistoryForTesting(std::move(history)); + } + protected: base::test::TaskEnvironment task_environment_; std::unique_ptr ai_chat_service_; @@ -1007,6 +1033,108 @@ TEST_F(ConversationHandlerUnitTest, EXPECT_TRUE(conversation_handler_->GetConversationHistory().empty()); } +TEST_F( + ConversationHandlerUnitTest, + MaybeFetchOrClearContentStagedConversation_FetchStagedEntriesWithHistory) { + NiceMock client(conversation_handler_.get()); + ASSERT_TRUE(conversation_handler_->IsAnyClientConnected()); + + // MaybeFetchOrClearContentStagedConversation should clear old staged entries + // and fetch new ones. + EXPECT_CALL(*associated_content_, GetStagedEntriesFromContent).Times(1); + // One from SetupHistory and one from removing old entries and adding + // new entries in OnGetStagedEntriesFromContent. + EXPECT_CALL(client, OnConversationHistoryUpdate()).Times(2); + + // Fill history with staged and non-staged entries. + SetupHistory({{"old query" /* text */, true /*from_brave_search_SERP */}, + {"old summary", "true"}, + {"normal query", false}, + {"normal response", false}}); + + // Setting mock return values for GetStagedEntriesFromContent. + SetAssociatedContentStagedEntries(/*empty=*/false, /*multi=*/true); + + conversation_handler_->MaybeFetchOrClearContentStagedConversation(); + task_environment_.RunUntilIdle(); + + testing::Mock::VerifyAndClearExpectations(associated_content_.get()); + testing::Mock::VerifyAndClearExpectations(&client); + + auto& history = conversation_handler_->GetConversationHistory(); + ASSERT_EQ(history.size(), 6u); + EXPECT_FALSE(history[0]->from_brave_search_SERP); + EXPECT_EQ(history[0]->text, "normal query"); + EXPECT_FALSE(history[1]->from_brave_search_SERP); + EXPECT_EQ(history[1]->text, "normal response"); + EXPECT_TRUE(history[2]->from_brave_search_SERP); + EXPECT_EQ(history[2]->text, "query"); + EXPECT_TRUE(history[3]->from_brave_search_SERP); + EXPECT_EQ(history[3]->text, "summary"); + EXPECT_TRUE(history[4]->from_brave_search_SERP); + EXPECT_EQ(history[4]->text, "query2"); + EXPECT_TRUE(history[5]->from_brave_search_SERP); + EXPECT_EQ(history[5]->text, "summary2"); +} + +TEST_F(ConversationHandlerUnitTest, + OnGetStagedEntriesFromContent_FailedChecks) { + // No staged entries would be added if a request is in progress. + conversation_handler_->SetRequestInProgressForTesting(true); + std::vector entries = {{"query", "summary"}, + {"query2", "summary2"}}; + conversation_handler_->OnGetStagedEntriesFromContent(entries); + task_environment_.RunUntilIdle(); + EXPECT_EQ(conversation_handler_->GetConversationHistory().size(), 0u); + + // No staged entries if should_send_page_contents_ is false. + conversation_handler_->SetRequestInProgressForTesting(false); + conversation_handler_->SetShouldSendPageContents(false); + conversation_handler_->OnGetStagedEntriesFromContent(entries); + task_environment_.RunUntilIdle(); + EXPECT_EQ(conversation_handler_->GetConversationHistory().size(), 0u); + + // No staged entries if user opt-out. + conversation_handler_->SetShouldSendPageContents(true); + EmulateUserOptedOut(); + conversation_handler_->OnGetStagedEntriesFromContent(entries); + task_environment_.RunUntilIdle(); + EXPECT_EQ(conversation_handler_->GetConversationHistory().size(), 0u); +} + +TEST_F(ConversationHandlerUnitTest, OnGetStagedEntriesFromContent) { + NiceMock client(conversation_handler_.get()); + ASSERT_TRUE(conversation_handler_->IsAnyClientConnected()); + + EXPECT_CALL(client, OnConversationHistoryUpdate()).Times(2); + // Fill history with staged and non-staged entries. + SetupHistory({{"q1" /* text */, true /*from_brave_search_SERP */}, + {"s1", "true"}, + {"q2", false}, + {"r1", false}}); + + std::vector entries = {{"query", "summary"}, + {"query2", "summary2"}}; + conversation_handler_->OnGetStagedEntriesFromContent(entries); + task_environment_.RunUntilIdle(); + testing::Mock::VerifyAndClearExpectations(&client); + + auto& history = conversation_handler_->GetConversationHistory(); + ASSERT_EQ(history.size(), 6u); + EXPECT_FALSE(history[0]->from_brave_search_SERP); + EXPECT_EQ(history[0]->text, "q2"); + EXPECT_FALSE(history[1]->from_brave_search_SERP); + EXPECT_EQ(history[1]->text, "r1"); + EXPECT_TRUE(history[2]->from_brave_search_SERP); + EXPECT_EQ(history[2]->text, "query"); + EXPECT_TRUE(history[3]->from_brave_search_SERP); + EXPECT_EQ(history[3]->text, "summary"); + EXPECT_TRUE(history[4]->from_brave_search_SERP); + EXPECT_EQ(history[4]->text, "query2"); + EXPECT_TRUE(history[5]->from_brave_search_SERP); + EXPECT_EQ(history[5]->text, "summary2"); +} + TEST_F(ConversationHandlerUnitTest_OptedOut, MaybeFetchOrClearSearchQuerySummary_NotOptedIn) { // Content will have staged entries, but we want to make sure that @@ -1096,11 +1224,11 @@ TEST_F(ConversationHandlerUnitTest, task_environment_.RunUntilIdle(); testing::Mock::VerifyAndClearExpectations(associated_content_.get()); - // Verify that no re-fetch happens when new client connects + // Verify that fetch happens when another client connects. client.Disconnect(); task_environment_.RunUntilIdle(); EXPECT_FALSE(conversation_handler_->IsAnyClientConnected()); - EXPECT_CALL(*associated_content_, GetStagedEntriesFromContent).Times(0); + EXPECT_CALL(*associated_content_, GetStagedEntriesFromContent).Times(1); NiceMock client2(conversation_handler_.get()); task_environment_.RunUntilIdle(); testing::Mock::VerifyAndClearExpectations(associated_content_.get()); diff --git a/components/ai_chat/core/browser/utils.cc b/components/ai_chat/core/browser/utils.cc index 9df25cf759b5..1b1191aa6fe8 100644 --- a/components/ai_chat/core/browser/utils.cc +++ b/components/ai_chat/core/browser/utils.cc @@ -5,6 +5,10 @@ #include "brave/components/ai_chat/core/browser/utils.h" +#include +#include +#include + #include "base/containers/fixed_flat_set.h" #include "base/functional/bind.h" #include "base/no_destructor.h" @@ -14,6 +18,7 @@ #include "base/time/time.h" #include "brave/brave_domains/service_domains.h" #include "brave/components/ai_chat/core/browser/constants.h" +#include "brave/components/ai_chat/core/common/constants.h" #include "brave/components/ai_chat/core/common/features.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "brave/components/ai_chat/core/common/pref_names.h" diff --git a/components/ai_chat/core/common/BUILD.gn b/components/ai_chat/core/common/BUILD.gn index a43344eac582..e923371bb8cd 100644 --- a/components/ai_chat/core/common/BUILD.gn +++ b/components/ai_chat/core/common/BUILD.gn @@ -12,23 +12,31 @@ component("common") { defines = [ "IS_AI_CHAT_COMMON_IMPL" ] sources = [ + "constants.h", "features.cc", "features.h", "pref_names.cc", "pref_names.h", + "utils.cc", + "utils.h", ] deps = [ "//base", + "//brave/brave_domains", "//brave/components/ai_chat/core/common/buildflags:buildflags", "//components/prefs", + "//url", ] } if (!is_ios) { source_set("unit_tests") { testonly = true - sources = [ "pref_names_unittest.cc" ] + sources = [ + "pref_names_unittest.cc", + "utils_unittest.cc", + ] deps = [ "//base/test:test_support", diff --git a/components/ai_chat/core/common/constants.h b/components/ai_chat/core/common/constants.h new file mode 100644 index 000000000000..a3155a6088f4 --- /dev/null +++ b/components/ai_chat/core/common/constants.h @@ -0,0 +1,15 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ + +namespace ai_chat { + +inline constexpr char kBraveSearchURLPrefix[] = "search"; + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ diff --git a/components/ai_chat/core/common/features.cc b/components/ai_chat/core/common/features.cc index 5d449bb0e369..e4f34ce73686 100644 --- a/components/ai_chat/core/common/features.cc +++ b/components/ai_chat/core/common/features.cc @@ -60,4 +60,16 @@ bool IsPageContentRefineEnabled() { return base::FeatureList::IsEnabled(features::kPageContentRefine); } +BASE_FEATURE(kOpenAIChatFromBraveSearch, + "OpenAIChatFromBraveSearch", +#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS) + base::FEATURE_ENABLED_BY_DEFAULT); +#else + base::FEATURE_DISABLED_BY_DEFAULT); +#endif + +bool IsOpenAIChatFromBraveSearchEnabled() { + return base::FeatureList::IsEnabled(features::kOpenAIChatFromBraveSearch); +} + } // namespace ai_chat::features diff --git a/components/ai_chat/core/common/features.h b/components/ai_chat/core/common/features.h index c12e9b74e529..ca454658d29b 100644 --- a/components/ai_chat/core/common/features.h +++ b/components/ai_chat/core/common/features.h @@ -45,6 +45,10 @@ COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsContextMenuRewriteInPlaceEnabled(); COMPONENT_EXPORT(AI_CHAT_COMMON) BASE_DECLARE_FEATURE(kPageContentRefine); COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsPageContentRefineEnabled(); +COMPONENT_EXPORT(AI_CHAT_COMMON) +BASE_DECLARE_FEATURE(kOpenAIChatFromBraveSearch); +COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsOpenAIChatFromBraveSearchEnabled(); + } // namespace ai_chat::features #endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_FEATURES_H_ diff --git a/components/ai_chat/core/common/mojom/page_content_extractor.mojom b/components/ai_chat/core/common/mojom/page_content_extractor.mojom index ad7ec91d38dc..66f982dd274a 100644 --- a/components/ai_chat/core/common/mojom/page_content_extractor.mojom +++ b/components/ai_chat/core/common/mojom/page_content_extractor.mojom @@ -32,6 +32,11 @@ interface PageContentExtractor { // Get summarizer-key meta tag from Brave Search SERP if it exists. // This should only be called when the last commited URL is Brave Search SERP. GetSearchSummarizerKey() => (string? key); + + // Get the nonce in the href URL and the nonce attribute value in the + // continue-with-leo HTML anchor element (open Leo button). These two + // nonces value must be the same, otherwise nullopt will be returned. + GetOpenAIChatButtonNonce() => (string? nonce); }; // Allows the renderer to notify the browser process of meaningful changes to diff --git a/components/ai_chat/core/common/utils.cc b/components/ai_chat/core/common/utils.cc new file mode 100644 index 000000000000..36c2ea50b931 --- /dev/null +++ b/components/ai_chat/core/common/utils.cc @@ -0,0 +1,29 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/core/common/utils.h" + +#include "brave/brave_domains/service_domains.h" +#include "brave/components/ai_chat/core/common/constants.h" +#include "url/gurl.h" +#include "url/url_constants.h" + +namespace ai_chat { + +bool IsBraveSearchURL(const GURL& url) { + return url.is_valid() && url.SchemeIs(url::kHttpsScheme) && + url.host_piece() == + brave_domains::GetServicesDomain(kBraveSearchURLPrefix); +} + +bool IsOpenAIChatButtonFromBraveSearchURL(const GURL& url) { + // Use search.brave.com in all cases because href on search site is + // hardcoded to search.brave.com for all environments. + return url.is_valid() && url.SchemeIs(url::kHttpsScheme) && + url.host_piece() == "search.brave.com" && url.path_piece() == "/leo" && + !url.ref_piece().empty(); +} + +} // namespace ai_chat diff --git a/components/ai_chat/core/common/utils.h b/components/ai_chat/core/common/utils.h new file mode 100644 index 000000000000..7ba37be18a50 --- /dev/null +++ b/components/ai_chat/core/common/utils.h @@ -0,0 +1,23 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ + +#include "base/component_export.h" + +class GURL; + +namespace ai_chat { + +COMPONENT_EXPORT(AI_CHAT_COMMON) +bool IsBraveSearchURL(const GURL& url); + +COMPONENT_EXPORT(AI_CHAT_COMMON) +bool IsOpenAIChatButtonFromBraveSearchURL(const GURL& url); + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ diff --git a/components/ai_chat/core/common/utils_unittest.cc b/components/ai_chat/core/common/utils_unittest.cc new file mode 100644 index 000000000000..56099064a73e --- /dev/null +++ b/components/ai_chat/core/common/utils_unittest.cc @@ -0,0 +1,35 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/core/common/utils.h" + +#include "testing/gtest/include/gtest/gtest.h" +#include "url/gurl.h" + +namespace ai_chat { + +TEST(AIChatCommonUtilsUnitTest, IsBraveSearchURL) { + EXPECT_TRUE(IsBraveSearchURL(GURL("https://search.brave.com"))); + EXPECT_FALSE(IsBraveSearchURL(GURL("http://search.brave.com"))); + EXPECT_FALSE(IsBraveSearchURL(GURL("https://test.brave.com/"))); + EXPECT_FALSE(IsBraveSearchURL(GURL("https://brave.com/"))); + EXPECT_FALSE(IsBraveSearchURL(GURL())); +} + +TEST(AIChatCommonUtilsUnitTest, IsOpenAIChatButtonFromBraveSearchURL) { + EXPECT_TRUE(IsOpenAIChatButtonFromBraveSearchURL( + GURL("https://search.brave.com/leo#5566"))); + EXPECT_FALSE(IsOpenAIChatButtonFromBraveSearchURL(GURL())); + EXPECT_FALSE( + IsOpenAIChatButtonFromBraveSearchURL(GURL("https://search.brave.com"))); + EXPECT_FALSE(IsOpenAIChatButtonFromBraveSearchURL( + GURL("https://search.brave.com/leo"))); + EXPECT_FALSE(IsOpenAIChatButtonFromBraveSearchURL( + GURL("https://search.brave.com/leo#"))); + EXPECT_FALSE( + IsOpenAIChatButtonFromBraveSearchURL(GURL("https://brave.com/leo#5566"))); +} + +} // namespace ai_chat diff --git a/components/ai_chat/renderer/BUILD.gn b/components/ai_chat/renderer/BUILD.gn index b214f86b3aeb..7394baf8dd02 100644 --- a/components/ai_chat/renderer/BUILD.gn +++ b/components/ai_chat/renderer/BUILD.gn @@ -22,6 +22,7 @@ static_library("renderer") { deps = [ "//base", + "//brave/components/ai_chat/core/common", "//brave/components/ai_chat/core/common/mojom", "//content/public/renderer", "//gin", diff --git a/components/ai_chat/renderer/page_content_extractor.cc b/components/ai_chat/renderer/page_content_extractor.cc index 9444d32cef2a..e5dde35a9a0c 100644 --- a/components/ai_chat/renderer/page_content_extractor.cc +++ b/components/ai_chat/renderer/page_content_extractor.cc @@ -21,10 +21,13 @@ #include "base/containers/fixed_flat_set.h" #include "base/containers/span.h" #include "base/functional/bind.h" +#include "base/functional/callback.h" #include "base/memory/ptr_util.h" +#include "base/time/time.h" #include "base/values.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom-shared.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h" +#include "brave/components/ai_chat/core/common/utils.h" #include "brave/components/ai_chat/renderer/page_text_distilling.h" #include "brave/components/ai_chat/renderer/yt_util.h" #include "content/public/renderer/render_frame.h" @@ -318,4 +321,23 @@ void PageContentExtractor::GetSearchSummarizerKey( std::move(callback).Run(element.GetAttribute("content").Utf8()); } +void PageContentExtractor::GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) { + auto element = render_frame()->GetWebFrame()->GetDocument().GetElementById( + "continue-with-leo"); + if (element.IsNull() || !element.HasHTMLTagName("a")) { + std::move(callback).Run(std::nullopt); + return; + } + + GURL url(element.GetAttribute("href").Utf8()); + std::string nonce = element.GetAttribute("data-nonce").Utf8(); + if (!IsOpenAIChatButtonFromBraveSearchURL(url) || nonce.empty() || + url.ref_piece() != nonce) { + std::move(callback).Run(std::nullopt); + return; + } + std::move(callback).Run(nonce); +} + } // namespace ai_chat diff --git a/components/ai_chat/renderer/page_content_extractor.h b/components/ai_chat/renderer/page_content_extractor.h index d56e8889249a..dda042ae0d9e 100644 --- a/components/ai_chat/renderer/page_content_extractor.h +++ b/components/ai_chat/renderer/page_content_extractor.h @@ -59,6 +59,9 @@ class PageContentExtractor void GetSearchSummarizerKey( mojom::PageContentExtractor::GetSearchSummarizerKeyCallback callback) override; + void GetOpenAIChatButtonNonce( + mojom::PageContentExtractor::GetOpenAIChatButtonNonceCallback callback) + override; // AIChatResourceSnifferThrottleDelegate void OnInterceptedPageContentChanged( diff --git a/components/permissions/contexts/brave_open_ai_chat_permission_context.cc b/components/permissions/contexts/brave_open_ai_chat_permission_context.cc new file mode 100644 index 000000000000..5fa6211c5a35 --- /dev/null +++ b/components/permissions/contexts/brave_open_ai_chat_permission_context.cc @@ -0,0 +1,48 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "brave/components/permissions/contexts/brave_open_ai_chat_permission_context.h" + +#include + +#include "brave/brave_domains/service_domains.h" +#include "components/content_settings/core/common/content_settings.h" +#include "components/content_settings/core/common/content_settings_types.h" +#include "components/permissions/permission_request_data.h" +#include "third_party/blink/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h" +#include "url/gurl.h" +#include "url/url_constants.h" + +namespace permissions { + +BraveOpenAIChatPermissionContext::BraveOpenAIChatPermissionContext( + content::BrowserContext* browser_context) + : PermissionContextBase(browser_context, + ContentSettingsType::BRAVE_OPEN_AI_CHAT, + blink::mojom::PermissionsPolicyFeature::kNotFound) { +} + +BraveOpenAIChatPermissionContext::~BraveOpenAIChatPermissionContext() = default; + +ContentSetting BraveOpenAIChatPermissionContext::GetPermissionStatusInternal( + content::RenderFrameHost* render_frame_host, + const GURL& requesting_origin, + const GURL& embedding_origin) const { + // Check if origin is https://search.brave.com. + if (!requesting_origin.SchemeIs(url::kHttpsScheme) || + requesting_origin.host_piece() != + brave_domains::GetServicesDomain("search")) { + return ContentSetting::CONTENT_SETTING_BLOCK; + } + + return PermissionContextBase::GetPermissionStatusInternal( + render_frame_host, requesting_origin, embedding_origin); +} + +bool BraveOpenAIChatPermissionContext::IsRestrictedToSecureOrigins() const { + return true; +} + +} // namespace permissions diff --git a/components/permissions/contexts/brave_open_ai_chat_permission_context.h b/components/permissions/contexts/brave_open_ai_chat_permission_context.h new file mode 100644 index 000000000000..48915944fd55 --- /dev/null +++ b/components/permissions/contexts/brave_open_ai_chat_permission_context.h @@ -0,0 +1,36 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef BRAVE_COMPONENTS_PERMISSIONS_CONTEXTS_BRAVE_OPEN_AI_CHAT_PERMISSION_CONTEXT_H_ +#define BRAVE_COMPONENTS_PERMISSIONS_CONTEXTS_BRAVE_OPEN_AI_CHAT_PERMISSION_CONTEXT_H_ + +#include "components/permissions/permission_context_base.h" +#include "content/public/browser/browser_context.h" + +namespace permissions { + +class BraveOpenAIChatPermissionContext : public PermissionContextBase { + public: + explicit BraveOpenAIChatPermissionContext( + content::BrowserContext* browser_context); + ~BraveOpenAIChatPermissionContext() override; + + BraveOpenAIChatPermissionContext(const BraveOpenAIChatPermissionContext&) = + delete; + BraveOpenAIChatPermissionContext& operator=( + const BraveOpenAIChatPermissionContext&) = delete; + + private: + // PermissionContextBase: + ContentSetting GetPermissionStatusInternal( + content::RenderFrameHost* render_frame_host, + const GURL& requesting_origin, + const GURL& embedding_origin) const override; + bool IsRestrictedToSecureOrigins() const override; +}; + +} // namespace permissions + +#endif // BRAVE_COMPONENTS_PERMISSIONS_CONTEXTS_BRAVE_OPEN_AI_CHAT_PERMISSION_CONTEXT_H_ diff --git a/components/permissions/sources.gni b/components/permissions/sources.gni index 9d692b1d0a1d..889e225296c4 100644 --- a/components/permissions/sources.gni +++ b/components/permissions/sources.gni @@ -12,6 +12,8 @@ brave_components_permissions_sources = [ "//brave/components/permissions/contexts/brave_google_sign_in_permission_context.h", "//brave/components/permissions/contexts/brave_localhost_permission_context.cc", "//brave/components/permissions/contexts/brave_localhost_permission_context.h", + "//brave/components/permissions/contexts/brave_open_ai_chat_permission_context.cc", + "//brave/components/permissions/contexts/brave_open_ai_chat_permission_context.h", "//brave/components/permissions/contexts/brave_wallet_permission_context.cc", "//brave/components/permissions/contexts/brave_wallet_permission_context.h", "//brave/components/permissions/permission_expiration_key.cc", @@ -39,6 +41,7 @@ if (enable_widevine) { brave_components_permissions_deps = [ "//base", + "//brave/brave_domains", "//brave/components/brave_wallet/browser:permission_utils", "//brave/components/brave_wallet/common:mojom", "//brave/components/constants:constants", diff --git a/components/resources/permissions_strings.grdp b/components/resources/permissions_strings.grdp index fcdcaeabb25b..88923ee5a4e3 100644 --- a/components/resources/permissions_strings.grdp +++ b/components/resources/permissions_strings.grdp @@ -21,4 +21,10 @@ site permission + + Allow opening Leo AI conversations in Brave? + + + Allow $1search.brave.com to open Leo AI conversations in Brave? + diff --git a/ios/browser/flags/about_flags.mm b/ios/browser/flags/about_flags.mm index d30c20a840fb..8b86d6db6e4c 100644 --- a/ios/browser/flags/about_flags.mm +++ b/ios/browser/flags/about_flags.mm @@ -113,35 +113,33 @@ }) #if BUILDFLAG(ENABLE_AI_CHAT) -#define BRAVE_AI_CHAT \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat", \ - "Brave AI Chat", \ - "Summarize articles and engage in conversation with AI", \ - flags_ui::kOsIos, \ - FEATURE_VALUE_TYPE(ai_chat::features::kAIChat), \ - }) -#define BRAVE_AI_CHAT_HISTORY \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat-history", \ - "Brave AI Chat History", \ - "Enables AI Chat History persistence and management", \ - flags_ui::kOsIos, \ - FEATURE_VALUE_TYPE(ai_chat::features::kAIChatHistory), \ - }) -#define BRAVE_AI_CHAT_PAGE_CONTENT_REFINE \ - EXPAND_FEATURE_ENTRIES({ \ - "brave-ai-chat-page-content-refine", \ - "Brave AI Chat Page Content Refine", \ - "Enable local text embedding for long page content in order to find " \ - "most relevant parts to the prompt within context limit.", \ - flags_ui::kOsIos, \ - FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \ - }) +#define BRAVE_AI_CHAT_FEATURE_ENTRIES \ + EXPAND_FEATURE_ENTRIES( \ + { \ + "brave-ai-chat", \ + "Brave AI Chat", \ + "Summarize articles and engage in conversation with AI", \ + flags_ui::kOsIos, \ + FEATURE_VALUE_TYPE(ai_chat::features::kAIChat), \ + }, \ + { \ + "brave-ai-chat-history", \ + "Brave AI Chat History", \ + "Enables AI Chat History persistence and management", \ + flags_ui::kOsIos, \ + FEATURE_VALUE_TYPE(ai_chat::features::kAIChatHistory), \ + }, \ + { \ + "brave-ai-chat-page-content-refine", \ + "Brave AI Chat Page Content Refine", \ + "Enable local text embedding for long page content in order to " \ + "find " \ + "most relevant parts to the prompt within context limit.", \ + flags_ui::kOsIos, \ + FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \ + }) #else -#define BRAVE_AI_CHAT -#define BRAVE_AI_CHAT_HISTORY -#define BRAVE_AI_CHAT_PAGE_CONTENT_REFINE +#define BRAVE_AI_CHAT_FEATURE_ENTRIES #endif #define BRAVE_PLAYLIST_FEATURE_ENTRIES \ @@ -242,8 +240,6 @@ BRAVE_SHIELDS_FEATURE_ENTRIES \ BRAVE_NATIVE_WALLET_FEATURE_ENTRIES \ BRAVE_SKU_SDK_FEATURE_ENTRIES \ - BRAVE_AI_CHAT \ - BRAVE_AI_CHAT_HISTORY \ - BRAVE_AI_CHAT_PAGE_CONTENT_REFINE \ + BRAVE_AI_CHAT_FEATURE_ENTRIES \ BRAVE_PLAYLIST_FEATURE_ENTRIES \ LAST_BRAVE_FEATURE_ENTRIES_ITEM // Keep it as the last item. diff --git a/patches/chrome-browser-resources-settings-site_settings-constants.ts.patch b/patches/chrome-browser-resources-settings-site_settings-constants.ts.patch index f9b0bc8e41e9..4604536496d5 100644 --- a/patches/chrome-browser-resources-settings-site_settings-constants.ts.patch +++ b/patches/chrome-browser-resources-settings-site_settings-constants.ts.patch @@ -1,12 +1,12 @@ diff --git a/chrome/browser/resources/settings/site_settings/constants.ts b/chrome/browser/resources/settings/site_settings/constants.ts -index 4ed920a8aabf9a853713935ffaa6653a8e1360b0..0c9e64bc05960754cdbcbc7bdcf118e292586b01 100644 +index 4ed920a8aabf9a853713935ffaa6653a8e1360b0..05d12ce52618e2b42f4d4c0fe59691bbffe297b3 100644 --- a/chrome/browser/resources/settings/site_settings/constants.ts +++ b/chrome/browser/resources/settings/site_settings/constants.ts @@ -67,6 +67,7 @@ export enum ContentSettingsTypes { PDF_DOCUMENTS = 'pdfDocuments', SITE_DATA = 'site-data', OFFER_WRITING_HELP = 'offer-writing-help', -+ AUTOPLAY = 'autoplay', ETHEREUM = 'ethereum', SOLANA = 'solana', BRAVE_SHIELDS = 'braveShields', GOOGLE_SIGN_IN = 'googleSignIn', LOCALHOST_ACCESS = 'localhostAccess', ++ AUTOPLAY = 'autoplay', ETHEREUM = 'ethereum', SOLANA = 'solana', BRAVE_SHIELDS = 'braveShields', GOOGLE_SIGN_IN = 'googleSignIn', LOCALHOST_ACCESS = 'localhostAccess', BRAVE_OPEN_AI_CHAT = 'braveOpenAIChat' } /** diff --git a/patches/chrome-browser-resources-settings-site_settings-settings_category_default_radio_group.ts.patch b/patches/chrome-browser-resources-settings-site_settings-settings_category_default_radio_group.ts.patch index 7ccb9a69cf87..2746347d724d 100644 --- a/patches/chrome-browser-resources-settings-site_settings-settings_category_default_radio_group.ts.patch +++ b/patches/chrome-browser-resources-settings-site_settings-settings_category_default_radio_group.ts.patch @@ -1,12 +1,12 @@ diff --git a/chrome/browser/resources/settings/site_settings/settings_category_default_radio_group.ts b/chrome/browser/resources/settings/site_settings/settings_category_default_radio_group.ts -index f41407671c3aa79854cbf4c4adf5359278ea5f60..fa73ea59476b32d9feefe73c728f69c80669e848 100644 +index f41407671c3aa79854cbf4c4adf5359278ea5f60..fe1fe5d9527400c5a59143fafb3f17cc23a76ae8 100644 --- a/chrome/browser/resources/settings/site_settings/settings_category_default_radio_group.ts +++ b/chrome/browser/resources/settings/site_settings/settings_category_default_radio_group.ts @@ -171,6 +171,7 @@ export class SettingsCategoryDefaultRadioGroupElement extends case ContentSettingsTypes.WEB_PRINTING: // "Ask" vs "Blocked". return ContentSetting.ASK; -+ case ContentSettingsTypes.ETHEREUM: case ContentSettingsTypes.SOLANA: case ContentSettingsTypes.GOOGLE_SIGN_IN: case ContentSettingsTypes.LOCALHOST_ACCESS: return ContentSetting.ASK; case ContentSettingsTypes.AUTOPLAY: return ContentSetting.ALLOW; ++ case ContentSettingsTypes.ETHEREUM: case ContentSettingsTypes.SOLANA: case ContentSettingsTypes.GOOGLE_SIGN_IN: case ContentSettingsTypes.LOCALHOST_ACCESS: case ContentSettingsTypes.BRAVE_OPEN_AI_CHAT: return ContentSetting.ASK; case ContentSettingsTypes.AUTOPLAY: return ContentSetting.ALLOW; default: assertNotReached('Invalid category: ' + this.category); } diff --git a/test/data/leo/leo b/test/data/leo/leo new file mode 100644 index 000000000000..0354eeb28ea6 --- /dev/null +++ b/test/data/leo/leo @@ -0,0 +1,9 @@ + + + + Leo test + + +
I have spoken
+ + diff --git a/test/data/leo/open_ai_chat_button.html b/test/data/leo/open_ai_chat_button.html new file mode 100644 index 000000000000..4d6326aa59b4 --- /dev/null +++ b/test/data/leo/open_ai_chat_button.html @@ -0,0 +1,22 @@ + + + + Leo test + + + Open Leo + Open Leo +
+ Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + + diff --git a/test/data/leo/open_ai_chat_button_invalid.html b/test/data/leo/open_ai_chat_button_invalid.html new file mode 100644 index 000000000000..469d3d84ac02 --- /dev/null +++ b/test/data/leo/open_ai_chat_button_invalid.html @@ -0,0 +1,9 @@ + + + + Leo test + + + Open Leo + + diff --git a/test/data/leo/open_ai_chat_button_valid.html b/test/data/leo/open_ai_chat_button_valid.html new file mode 100644 index 000000000000..8fb463d6a769 --- /dev/null +++ b/test/data/leo/open_ai_chat_button_valid.html @@ -0,0 +1,9 @@ + + + + Leo test + + + Open Leo + +