diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index a855e1ee0e45..8eb9e19fdd80 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -51,6 +51,7 @@ #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" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom.h" #include "brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom.h" #include "brave/components/ai_rewriter/common/buildflags/buildflags.h" #include "brave/components/body_sniffer/body_sniffer_throttle.h" @@ -624,7 +625,8 @@ void BraveContentBrowserClient::RegisterWebUIInterfaceBrokers( if (ai_chat::features::IsAIChatEnabled()) { registry.ForWebUI() .Add() - .Add(); + .Add() + .Add(); registry.ForWebUI() .Add() .Add(); diff --git a/browser/resources/settings/sources.gni b/browser/resources/settings/sources.gni index ec018c77a291..f5c701ec8f48 100644 --- a/browser/resources/settings/sources.gni +++ b/browser/resources/settings/sources.gni @@ -181,6 +181,7 @@ brave_settings_ts_extra_deps = brave_settings_mojo_files = [ "$root_gen_dir/brave/components/ai_chat/core/common/mojom/settings_helper.mojom-webui.ts", "$root_gen_dir/brave/components/ai_chat/core/common/mojom/ai_chat.mojom-webui.ts", + "$root_gen_dir/brave/components/ai_chat/core/common/mojom/tab_informer.mojom-webui.ts", "$root_gen_dir/brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom-webui.ts", ] diff --git a/browser/ui/BUILD.gn b/browser/ui/BUILD.gn index e9ef106a20d8..9bbd60c07433 100644 --- a/browser/ui/BUILD.gn +++ b/browser/ui/BUILD.gn @@ -156,6 +156,8 @@ source_set("ui") { if (!is_android) { sources += [ + "ai_chat/tab_informer.cc", + "ai_chat/tab_informer.h", "bookmark/bookmark_helper.cc", "bookmark/bookmark_helper.h", "bookmark/bookmark_prefs_service.cc", diff --git a/browser/ui/ai_chat/BUILD.gn b/browser/ui/ai_chat/BUILD.gn index 08fe8f927b1c..429b16e73a3a 100644 --- a/browser/ui/ai_chat/BUILD.gn +++ b/browser/ui/ai_chat/BUILD.gn @@ -35,3 +35,26 @@ source_set("unit_tests") { "//testing/gtest:gtest", ] } + +source_set("browser_tests") { + testonly = true + + sources = [] + + if (!is_android) { + sources += [ "tab_informer_browsertest.cc" ] + } + + defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ] + + deps = [ + "//base/test:test_support", + "//brave/components/ai_chat/content/browser", + "//brave/components/ai_chat/core/browser", + "//brave/components/ai_chat/core/common", + "//brave/components/ai_chat/core/common/mojom", + "//chrome/browser/ui", + "//chrome/test:test_support", + "//testing/gtest", + ] +} diff --git a/browser/ui/ai_chat/tab_informer.cc b/browser/ui/ai_chat/tab_informer.cc new file mode 100644 index 000000000000..7f715ae373b9 --- /dev/null +++ b/browser/ui/ai_chat/tab_informer.cc @@ -0,0 +1,125 @@ +// Copyright (c) 2025 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/tab_informer.h" + +#include +#include +#include + +#include "base/strings/utf_string_conversions.h" +#include "brave/components/ai_chat/core/common/constants.h" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_finder.h" +#include "chrome/browser/ui/browser_list.h" +#include "chrome/browser/ui/tabs/tab_model.h" +#include "content/public/browser/navigation_entry.h" +#include "content/public/browser/web_contents.h" +#include "mojo/public/cpp/bindings/receiver.h" + +namespace ai_chat { + +TabInformer::TabInformer(mojo::PendingReceiver receiver, + content::WebContents* owner_contents) + : owner_contents_(owner_contents), receiver_(this, std::move(receiver)) { + tracker_.Init(); +} + +TabInformer::~TabInformer() = default; + +content::WebContents* TabInformer::GetFromTab(const mojom::TabPtr& mojom_tab) { + const tabs::TabHandle handle = tabs::TabHandle(mojom_tab->id); + tabs::TabInterface* const tab = handle.Get(); + if (!tab) { + return nullptr; + } + return tab->GetContents(); +} + +void TabInformer::AddListener( + mojo::PendingRemote listener) { + auto id = listeners_.Add(std::move(listener)); + + NotifyListener(listeners_.Get(id), GetState()); +} + +void TabInformer::OnTabStripModelChanged( + TabStripModel* tab_strip_model, + const TabStripModelChange& change, + const TabStripSelectionChange& selection) { + NotifyListeners(); +} + +void TabInformer::TabChangedAt(content::WebContents* contents, + int index, + TabChangeType change_type) { + NotifyListeners(); +} + +bool TabInformer::ShouldTrackBrowser(Browser* browser) { + return browser->is_type_normal() && + browser->profile() == + Profile::FromBrowserContext(owner_contents_->GetBrowserContext()); +} + +TabInformer::State TabInformer::GetState() { + State state; + + // Used to determine whether a given window is active. + auto* active = chrome::FindLastActive(); + + for (Browser* browser : *BrowserList::GetInstance()) { + if (!ShouldTrackBrowser(browser)) { + continue; + } + auto window = mojom::Window::New(); + window->is_active = browser == active; + + auto* tab_strip_model = browser->tab_strip_model(); + for (int i = 0; i < tab_strip_model->count(); ++i) { + auto* contents = tab_strip_model->GetWebContentsAt(i); + if (!contents) { + continue; + } + + if (!ai_chat::kAllowedContentSchemes.contains( + contents->GetLastCommittedURL().scheme())) { + continue; + } + + auto tab_state = mojom::Tab::New(); + tab_state->id = tab_strip_model->GetTabHandleAt(i).raw_value(); + tab_state->title = base::UTF16ToUTF8(contents->GetTitle()); + tab_state->url = contents->GetVisibleURL(); + tab_state->content_id = + contents->GetController().GetVisibleEntry()->GetUniqueID(); + window->tabs.push_back(std::move(tab_state)); + } + + state.push_back(std::move(window)); + } + return state; +} + +void TabInformer::NotifyListeners() { + if (listeners_.empty()) { + return; + } + auto state = GetState(); + for (auto& listener : listeners_) { + State clone; + std::ranges::transform(state, std::back_inserter(clone), + [](auto& window) { return window.Clone(); }); + NotifyListener(listener.get(), std::move(clone)); + } +} + +void TabInformer::NotifyListener(mojom::TabListener* listener, State state) { + listener->TabsChanged(std::move(state)); +} + +} // namespace ai_chat diff --git a/browser/ui/ai_chat/tab_informer.h b/browser/ui/ai_chat/tab_informer.h new file mode 100644 index 000000000000..f54c43a709ef --- /dev/null +++ b/browser/ui/ai_chat/tab_informer.h @@ -0,0 +1,68 @@ +// Copyright (c) 2025 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_TAB_INFORMER_H_ +#define BRAVE_BROWSER_UI_AI_CHAT_TAB_INFORMER_H_ + +#include +#include + +#include "base/memory/raw_ptr.h" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom-forward.h" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom.h" +#include "chrome/browser/ui/browser_tab_strip_tracker.h" +#include "chrome/browser/ui/browser_tab_strip_tracker_delegate.h" +#include "chrome/browser/ui/tabs/tab_strip_model_observer.h" +#include "content/public/browser/web_contents.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/remote_set.h" + +namespace ai_chat { + +class TabInformer : public mojom::TabInformer, + public BrowserTabStripTrackerDelegate, + public TabStripModelObserver { + public: + using State = std::vector; + // Note: |TabInformer| should not outlive |owner_contents_|. + TabInformer(mojo::PendingReceiver receiver, + content::WebContents* owner_contents); + ~TabInformer() override; + + static content::WebContents* GetFromTab(const mojom::TabPtr& tab); + + // mojom::TabInformer + void AddListener(mojo::PendingRemote listener) override; + + // TabStripModelObserver: + void OnTabStripModelChanged( + TabStripModel* tab_strip_model, + const TabStripModelChange& change, + const TabStripSelectionChange& selection) override; + void TabChangedAt(content::WebContents* contents, + int index, + TabChangeType change_type) override; + + // BrowserTabStripTrackerDelegate: + bool ShouldTrackBrowser(Browser* browser) override; + + private: + friend class TabInformerBrowserTest; + + State GetState(); + void NotifyListeners(); + void NotifyListener(mojom::TabListener* listener, State state); + + raw_ptr owner_contents_; + + mojo::Receiver receiver_; + mojo::RemoteSet listeners_; + BrowserTabStripTracker tracker_{this, this}; +}; + +} // namespace ai_chat + +#endif // BRAVE_BROWSER_UI_AI_CHAT_TAB_INFORMER_H_ diff --git a/browser/ui/ai_chat/tab_informer_browsertest.cc b/browser/ui/ai_chat/tab_informer_browsertest.cc new file mode 100644 index 000000000000..ab51d99668e2 --- /dev/null +++ b/browser/ui/ai_chat/tab_informer_browsertest.cc @@ -0,0 +1,211 @@ +// Copyright (c) 2025 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/tab_informer.h" + +#include +#include + +#include "base/functional/callback_forward.h" +#include "base/run_loop.h" +#include "base/test/bind.h" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom-forward.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_commands.h" +#include "chrome/browser/ui/browser_tabstrip.h" +#include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "chrome/test/base/ui_test_utils.h" +#include "content/public/test/browser_test.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/self_owned_receiver.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace ai_chat { + +namespace { + +using Predicate = + base::RepeatingCallback&)>; + +class UpdateTracker : public mojom::TabListener { + public: + UpdateTracker() = default; + ~UpdateTracker() override = default; + + const std::vector& GetLastWindows() const { + CHECK(last_windows_.has_value()); + return last_windows_.value(); + } + + void WaitFor(Predicate predicate) { + if (last_windows_ && predicate.Run(last_windows_.value())) { + return; + } + + base::RunLoop waiter; + on_change_ = base::BindLambdaForTesting([&]() { + if (!last_windows_) { + return; + } + if (predicate.Run(last_windows_.value())) { + waiter.Quit(); + on_change_.Reset(); + } + }); + + waiter.Run(); + } + + private: + void TabsChanged(std::vector windows) override { + last_windows_ = std::move(windows); + + if (!on_change_.is_null()) { + on_change_.Run(); + } + } + + base::RepeatingClosure on_change_; + std::optional> last_windows_; + std::unique_ptr run_loop_; +}; + +} // namespace + +class TabInformerBrowserTest : public InProcessBrowserTest { + public: + TabInformerBrowserTest() = default; + ~TabInformerBrowserTest() override = default; + + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + + mojo::PendingReceiver receiver; + tab_informer_ = std::make_unique( + std::move(receiver), + browser()->tab_strip_model()->GetActiveWebContents()); + + auto listener = std::make_unique(); + tracker_ = listener.get(); + + mojo::PendingRemote pending_remote; + mojo::MakeSelfOwnedReceiver( + std::move(listener), pending_remote.InitWithNewPipeAndPassReceiver()); + tab_informer_->AddListener(std::move(pending_remote)); + } + + void TearDownOnMainThread() override { + tracker_ = nullptr; + tab_informer_.reset(); + + InProcessBrowserTest::SetUpOnMainThread(); + } + + const std::vector& GetLastWindows() const { + return tracker_->GetLastWindows(); + } + + void WaitFor(Predicate predicate) { tracker_->WaitFor(std::move(predicate)); } + + protected: + void AppendTab(std::string url) { + chrome::AddTabAt(browser(), GURL(url), -1, false); + } + + std::unique_ptr tab_informer_; + raw_ptr tracker_; +}; + +IN_PROC_BROWSER_TEST_F(TabInformerBrowserTest, GetsInitialTabs) { + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 1; + })); + + const auto& windows = GetLastWindows(); + + // Should be have no tabs because we exclude the owner tab + EXPECT_EQ(windows[0]->tabs.size(), 0u); +} + +IN_PROC_BROWSER_TEST_F(TabInformerBrowserTest, TabsChange) { + AppendTab("https://google.com"); + AppendTab("https://brave.com"); + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 1 && windows.at(0)->tabs.size() == 2; + })); + + { + const auto& windows = GetLastWindows(); + EXPECT_EQ(windows[0]->tabs[0]->url, GURL("https://google.com")); + EXPECT_EQ(windows[0]->tabs[1]->url, GURL("https://brave.com")); + } + + // Close all tabs but the one we're bound to + chrome::CloseOtherTabs(browser()); + + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 1 && windows.at(0)->tabs.size() == 0; + })); + + { + const auto& updated_windows = GetLastWindows(); + EXPECT_EQ(updated_windows[0]->tabs.size(), 0u); + } +} + +IN_PROC_BROWSER_TEST_F(TabInformerBrowserTest, MultipleWindows) { + AppendTab("https://topos.nz"); + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 1 && windows.at(0)->tabs.size() == 1; + })); + + { + const auto& windows = GetLastWindows(); + EXPECT_EQ(windows[0]->tabs[0]->url, GURL("https://topos.nz")); + } + + chrome::NewWindow(browser()); + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 2 && windows.at(0)->tabs.size() == 1 && + windows.at(1)->tabs.size() == 0; + })); + + AppendTab("https://brave.com"); + AppendTab("https://readr.nz"); + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + return windows.size() == 2 && windows.at(0)->tabs.size() == 3 && + windows.at(1)->tabs.size() == 0; + })); + + chrome::MoveTabsToNewWindow(browser(), {2, 3}); + WaitFor(base::BindLambdaForTesting( + [](const std::vector& windows) { + LOG(ERROR) << "Window 0: " << windows.at(0)->tabs.size() + << ", Window 1: " << windows.at(1)->tabs.size(); + return windows.size() == 3 && windows.at(0)->tabs.size() == 1 && + windows.at(1)->tabs.size() == 0 && + windows.at(2)->tabs.size() == 2; + })); + + { + const auto& windows = GetLastWindows(); + + // Just topos.nz - first tab is bound to the TabInformer + EXPECT_EQ(windows[0]->tabs[0]->url, GURL("https://topos.nz")); + + // readr.nz, brave.com + EXPECT_EQ(windows[2]->tabs[1]->url, GURL("https://readr.nz")); + EXPECT_EQ(windows[2]->tabs[0]->url, GURL("https://brave.com")); + } +} + +} // namespace ai_chat diff --git a/browser/ui/webui/ai_chat/ai_chat_ui.cc b/browser/ui/webui/ai_chat/ai_chat_ui.cc index efc256cb001f..8e5416a4abd3 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui.cc @@ -5,6 +5,7 @@ #include "brave/browser/ui/webui/ai_chat/ai_chat_ui.h" +#include #include #include "brave/browser/ai_chat/ai_chat_service_factory.h" @@ -14,6 +15,7 @@ #include "brave/components/ai_chat/core/browser/ai_chat_service.h" #include "brave/components/ai_chat/core/browser/constants.h" #include "brave/components/ai_chat/core/browser/utils.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" @@ -162,6 +164,14 @@ void AIChatUI::BindInterface( std::move(parent_ui_frame_receiver)); } +void AIChatUI::BindInterface( + mojo::PendingReceiver tab_informer_receiver) { +#if !BUILDFLAG(IS_ANDROID) + tab_informer_ = std::make_unique( + std::move(tab_informer_receiver), web_ui()->GetWebContents()); +#endif +} + bool AIChatUIConfig::IsWebUIEnabled(content::BrowserContext* browser_context) { return ai_chat::IsAIChatEnabled( user_prefs::UserPrefs::Get(browser_context)) && diff --git a/browser/ui/webui/ai_chat/ai_chat_ui.h b/browser/ui/webui/ai_chat/ai_chat_ui.h index 67c26ed7403a..7d914d4b4ce4 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui.h +++ b/browser/ui/webui/ai_chat/ai_chat_ui.h @@ -11,12 +11,14 @@ #include "brave/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" +#include "brave/components/ai_chat/core/common/mojom/tab_informer.mojom.h" #include "chrome/browser/ui/webui/top_chrome/top_chrome_web_ui_controller.h" #include "content/public/browser/web_ui_controller.h" #include "ui/webui/mojo_web_ui_controller.h" #include "ui/webui/untrusted_web_ui_controller.h" #if !BUILDFLAG(IS_ANDROID) +#include "brave/browser/ui/ai_chat/tab_informer.h" #include "chrome/browser/ui/webui/top_chrome/top_chrome_webui_config.h" #else #include "content/public/browser/webui_config.h" @@ -40,6 +42,8 @@ class AIChatUI : public ui::MojoWebUIController { void BindInterface(mojo::PendingReceiver receiver); void BindInterface(mojo::PendingReceiver parent_ui_frame_receiver); + void BindInterface( + mojo::PendingReceiver tab_informer_receiver); // Set by WebUIContentsWrapperT. TopChromeWebUIController provides default // implementation for this but we don't use it. @@ -52,6 +56,9 @@ class AIChatUI : public ui::MojoWebUIController { private: std::unique_ptr page_handler_; +#if !BUILDFLAG(IS_ANDROID) + std::unique_ptr tab_informer_; +#endif base::WeakPtr embedder_; raw_ptr profile_ = nullptr; diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc index ee70c6c8a56f..df64ff619df9 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc @@ -10,8 +10,11 @@ #include #include +#include "base/functional/bind.h" +#include "base/functional/callback_forward.h" #include "brave/browser/ai_chat/ai_chat_service_factory.h" #include "brave/browser/ai_chat/ai_chat_urls.h" +#include "brave/browser/ui/ai_chat/tab_informer.h" #include "brave/browser/ui/side_panel/ai_chat/ai_chat_side_panel_utils.h" #include "brave/components/ai_chat/core/browser/ai_chat_service.h" #include "brave/components/ai_chat/core/browser/constants.h" @@ -25,6 +28,7 @@ #include "chrome/browser/ui/singleton_tabs.h" #include "components/favicon/core/favicon_service.h" #include "content/public/browser/browser_context.h" +#include "content/public/browser/navigation_entry.h" #include "content/public/browser/storage_partition.h" #include "content/public/browser/web_contents.h" #include "content/public/browser/web_contents_observer.h" @@ -52,6 +56,52 @@ constexpr char kURLManagePremium[] = "https://account.brave.com/"; namespace ai_chat { +namespace { + +// Invokes a callback when the WebContents has finished loading. Note: If the +// WebContents is destroyed before loading is completed, the callback will not +// be invoked. +// The lifetime of this class is tied to the WebContents it is observing - it +// will be destroyed when |WebContentsDestroyed| is called, or when the +// Navigation finishes, whichever happens first. +class WaitForCommit : public content::WebContentsObserver { + public: + WaitForCommit( + content::WebContents* contents, + base::OnceCallback on_loaded) + : WebContentsObserver(contents), on_loaded_(std::move(on_loaded)) {} + ~WaitForCommit() override = default; + + void DidFinishNavigation(content::NavigationHandle* handle) override { + if (handle->IsInMainFrame() && handle->HasCommitted()) { + std::move(on_loaded_).Run(web_contents()); + delete this; + } + } + + void WebContentsDestroyed() override { delete this; } + + private: + base::OnceCallback on_loaded_; +}; + +// Note: After session restore we need to ensure the WebContents is loaded +// before associating content with a conversation. +void EnsureWebContentsLoaded( + content::WebContents* contents, + base::OnceCallback on_loaded) { + if (!contents->GetController().NeedsReload()) { + std::move(on_loaded).Run(contents); + return; + } + + // Deletes when the load completes or the WebContents is destroyed + new WaitForCommit(contents, std::move(on_loaded)); + contents->GetController().LoadIfNecessary(); +} + +} // namespace + using mojom::CharacterType; using mojom::ConversationTurn; using mojom::ConversationTurnVisibility; @@ -234,6 +284,30 @@ void AIChatUIPageHandler::BindRelatedConversation( conversation->Bind(std::move(receiver), std::move(conversation_ui_handler)); } +void AIChatUIPageHandler::AssociateTab(mojom::TabPtr tab, + const std::string& conversation_uuid) { + auto* contents = ai_chat::TabInformer::GetFromTab(tab); + if (!contents) { + return; + } + + EnsureWebContentsLoaded( + contents, base::BindOnce( + [](const std::string& conversation_uuid, + content::WebContents* contents) { + auto* tab_helper = + ai_chat::AIChatTabHelper::FromWebContents(contents); + if (!tab_helper) { + return; + } + + AIChatServiceFactory::GetForBrowserContext( + contents->GetBrowserContext()) + ->AssociateContent(tab_helper, conversation_uuid); + }, + conversation_uuid)); +} + void AIChatUIPageHandler::NewConversation( mojo::PendingReceiver receiver, mojo::PendingRemote conversation_ui_handler) { diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h index a8fa5d418829..63279dbb593f 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h @@ -60,6 +60,8 @@ class AIChatUIPageHandler : public mojom::AIChatUIHandler, mojo::PendingReceiver receiver, mojo::PendingRemote conversation_ui_handler) override; + void AssociateTab(mojom::TabPtr tab, + const std::string& conversation_uuid) override; void NewConversation( mojo::PendingReceiver receiver, mojo::PendingRemote conversation_ui_handler) diff --git a/components/ai_chat/core/browser/ai_chat_database.cc b/components/ai_chat/core/browser/ai_chat_database.cc index 6a4d2602f941..6fd65cdbaf47 100644 --- a/components/ai_chat/core/browser/ai_chat_database.cc +++ b/components/ai_chat/core/browser/ai_chat_database.cc @@ -163,6 +163,7 @@ std::vector AIChatDatabase::GetAllConversations() { conversation->has_content = true; conversation->associated_content = mojom::SiteInfo::New(); + conversation->associated_content->content_id = -1; if (statement.GetColumnType(index) != sql::ColumnType::kNull) { DVLOG(1) << __func__ << " got associated content"; diff --git a/components/ai_chat/core/browser/ai_chat_database_unittest.cc b/components/ai_chat/core/browser/ai_chat_database_unittest.cc index b14343299231..c1f448d91ebf 100644 --- a/components/ai_chat/core/browser/ai_chat_database_unittest.cc +++ b/components/ai_chat/core/browser/ai_chat_database_unittest.cc @@ -129,10 +129,10 @@ TEST_P(AIChatDatabaseTest, AddAndGetConversationAndEntries) { has_content ? mojom::SiteInfo::New( content_uuid, mojom::ContentType::PageContent, "page title", - page_url.host(), page_url, 62, true, true) + page_url.host(), 1, page_url, 62, true, true) : mojom::SiteInfo::New( std::nullopt, mojom::ContentType::PageContent, std::nullopt, - std::nullopt, std::nullopt, 0, false, false); + std::nullopt, -1, std::nullopt, 0, false, false); const mojom::ConversationPtr metadata = mojom::Conversation::New(uuid, "title", now - base::Hours(2), true, std::nullopt, std::move(associated_content)); @@ -266,8 +266,8 @@ TEST_P(AIChatDatabaseTest, UpdateConversationTitle) { mojom::ConversationPtr metadata = mojom::Conversation::New( uuid, initial_title, base::Time::Now(), true, std::nullopt, mojom::SiteInfo::New(std::nullopt, mojom::ContentType::PageContent, - std::nullopt, std::nullopt, std::nullopt, 0, false, - false)); + std::nullopt, std::nullopt, -1, std::nullopt, 0, + false, false)); // Persist the first entry (and get the response ready) const auto history = CreateSampleChatHistory(1u); @@ -298,7 +298,7 @@ TEST_P(AIChatDatabaseTest, AddOrUpdateAssociatedContent) { mojom::ConversationPtr metadata = mojom::Conversation::New( uuid, "title", base::Time::Now() - base::Hours(2), true, std::nullopt, mojom::SiteInfo::New(content_uuid, mojom::ContentType::PageContent, - "page title", page_url.host(), page_url, 62, true, + "page title", page_url.host(), 1, page_url, 62, true, true)); auto history = CreateSampleChatHistory(1u); @@ -341,8 +341,8 @@ TEST_P(AIChatDatabaseTest, DeleteAllData) { mojom::ConversationPtr metadata = mojom::Conversation::New( uuid, "title", base::Time::Now() - base::Hours(2), true, std::nullopt, mojom::SiteInfo::New(std::nullopt, mojom::ContentType::PageContent, - std::nullopt, std::nullopt, std::nullopt, 0, false, - false)); + std::nullopt, std::nullopt, -1, std::nullopt, 0, + false, false)); auto history = CreateSampleChatHistory(1u); @@ -380,12 +380,12 @@ TEST_P(AIChatDatabaseTest, DeleteAssociatedWebContent) { mojom::ConversationPtr metadata_first = mojom::Conversation::New( "first", "title", base::Time::Now() - base::Hours(2), true, std::nullopt, mojom::SiteInfo::New("first-content", mojom::ContentType::PageContent, - "page title", page_url.host(), page_url, 62, true, + "page title", page_url.host(), 1, page_url, 62, true, true)); mojom::ConversationPtr metadata_second = mojom::Conversation::New( "second", "title", base::Time::Now() - base::Hours(1), true, "model-2", mojom::SiteInfo::New("second-content", mojom::ContentType::PageContent, - "page title", page_url.host(), page_url, 62, true, + "page title", page_url.host(), 2, page_url, 62, true, true)); auto history_first = CreateSampleChatHistory(1u, -2); diff --git a/components/ai_chat/core/browser/ai_chat_service.cc b/components/ai_chat/core/browser/ai_chat_service.cc index b4d449e50d90..fee41287acb5 100644 --- a/components/ai_chat/core/browser/ai_chat_service.cc +++ b/components/ai_chat/core/browser/ai_chat_service.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ #include "brave/components/ai_chat/core/browser/conversation_handler.h" #include "brave/components/ai_chat/core/browser/model_service.h" #include "brave/components/ai_chat/core/browser/utils.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-forward.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-shared.h" @@ -57,9 +59,6 @@ namespace { constexpr base::FilePath::StringPieceType kDBFileName = FILE_PATH_LITERAL("AIChat"); -constexpr auto kAllowedSchemes = base::MakeFixedFlatSet( - {url::kHttpsScheme, url::kHttpScheme, url::kFileScheme, url::kDataScheme}); - std::vector FilterVisibleConversations( std::map>& conversations_map) { @@ -160,7 +159,7 @@ ConversationHandler* AIChatService::CreateConversation() { conversation_uuid, "", base::Time::Now(), false, std::nullopt, mojom::SiteInfo::New(base::Uuid::GenerateRandomV4().AsLowercaseString(), mojom::ContentType::PageContent, std::nullopt, - std::nullopt, std::nullopt, 0, false, false)); + std::nullopt, -1, std::nullopt, 0, false, false)); conversations_.insert_or_assign(conversation_uuid, std::move(conversation)); } mojom::Conversation* conversation = @@ -543,7 +542,7 @@ void AIChatService::MaybeAssociateContentWithConversation( base::WeakPtr associated_content) { if (associated_content && - kAllowedSchemes.contains(associated_content->GetURL().scheme())) { + kAllowedContentSchemes.contains(associated_content->GetURL().scheme())) { conversation->SetAssociatedContentDelegate(associated_content); } // Record that this is the latest conversation for this content. Even @@ -948,4 +947,21 @@ void AIChatService::OpenConversationWithStagedEntries( conversation->MaybeFetchOrClearContentStagedConversation(); } +void AIChatService::AssociateContent( + ConversationHandler::AssociatedContentDelegate* content, + const std::string& conversation_uuid) { + CHECK(content); + + // Note: As we're using the non-async version of GetConversation, this will + // only work when the conversation is already loaded. + // If we ever need to associate content with a conversation that is not + // loaded, we'll need to use the async version of GetConversation. + auto* conversation = GetConversation(conversation_uuid); + if (!conversation) { + return; + } + + MaybeAssociateContentWithConversation(conversation, content->GetContentId(), + content->GetWeakPtr()); +} } // 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 3515ae66aa53..40f9b1416123 100644 --- a/components/ai_chat/core/browser/ai_chat_service.h +++ b/components/ai_chat/core/browser/ai_chat_service.h @@ -137,6 +137,9 @@ class AIChatService : public KeyedService, associated_content, base::OnceClosure open_ai_chat); + void AssociateContent(ConversationHandler::AssociatedContentDelegate* content, + const std::string& conversation_uuid); + // mojom::Service void MarkAgreementAccepted() override; void EnableStoragePref() override; diff --git a/components/ai_chat/core/browser/conversation_handler.cc b/components/ai_chat/core/browser/conversation_handler.cc index 53e985d25aa7..e94bd93be9f3 100644 --- a/components/ai_chat/core/browser/conversation_handler.cc +++ b/components/ai_chat/core/browser/conversation_handler.cc @@ -1629,6 +1629,8 @@ void ConversationHandler::BuildAssociatedContentInfo() { const GURL url = associated_content_delegate_->GetURL(); metadata_->associated_content->hostname = url.host(); metadata_->associated_content->url = url; + metadata_->associated_content->content_id = + associated_content_delegate_->GetContentId(); metadata_->associated_content->content_used_percentage = GetContentUsedPercentage(); metadata_->associated_content->is_content_refined = is_content_refined_; @@ -1637,6 +1639,7 @@ void ConversationHandler::BuildAssociatedContentInfo() { metadata_->associated_content->title = std::nullopt; metadata_->associated_content->hostname = std::nullopt; metadata_->associated_content->url = std::nullopt; + metadata_->associated_content->content_id = -1; metadata_->associated_content->is_content_association_possible = false; } } diff --git a/components/ai_chat/core/browser/conversation_handler.h b/components/ai_chat/core/browser/conversation_handler.h index d40f1c66ab0c..c2ea57fd0f79 100644 --- a/components/ai_chat/core/browser/conversation_handler.h +++ b/components/ai_chat/core/browser/conversation_handler.h @@ -128,6 +128,10 @@ class ConversationHandler : public mojom::ConversationHandler, } TextEmbedder* GetTextEmbedderForTesting() { return text_embedder_.get(); } + base::WeakPtr GetWeakPtr() { + return weak_ptr_factory_.GetWeakPtr(); + } + protected: // Content has navigated virtual void OnNewPage(int64_t navigation_id); diff --git a/components/ai_chat/core/browser/conversation_handler_unittest.cc b/components/ai_chat/core/browser/conversation_handler_unittest.cc index d90d053db228..3e5180407138 100644 --- a/components/ai_chat/core/browser/conversation_handler_unittest.cc +++ b/components/ai_chat/core/browser/conversation_handler_unittest.cc @@ -209,7 +209,7 @@ class ConversationHandlerUnitTest : public testing::Test { mojom::SiteInfoPtr non_content = mojom::SiteInfo::New( std::nullopt, mojom::ContentType::PageContent, std::nullopt, - std::nullopt, std::nullopt, 0, false, false); + std::nullopt, -1, std::nullopt, 0, false, false); conversation_ = mojom::Conversation::New("uuid", "title", base::Time::Now(), false, std::nullopt, std::move(non_content)); diff --git a/components/ai_chat/core/common/constants.h b/components/ai_chat/core/common/constants.h index a3155a6088f4..12938818da8f 100644 --- a/components/ai_chat/core/common/constants.h +++ b/components/ai_chat/core/common/constants.h @@ -6,10 +6,19 @@ #ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ #define BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ +#include + +#include "base/containers/fixed_flat_set.h" +#include "url/url_constants.h" namespace ai_chat { inline constexpr char kBraveSearchURLPrefix[] = "search"; +inline constexpr auto kAllowedContentSchemes = + base::MakeFixedFlatSet( + {url::kHttpsScheme, url::kHttpScheme, url::kFileScheme, + url::kDataScheme}); + } // namespace ai_chat #endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ diff --git a/components/ai_chat/core/common/mojom/BUILD.gn b/components/ai_chat/core/common/mojom/BUILD.gn index 646ac80617bc..20bf2a052a7c 100644 --- a/components/ai_chat/core/common/mojom/BUILD.gn +++ b/components/ai_chat/core/common/mojom/BUILD.gn @@ -17,6 +17,7 @@ mojom_component("mojom") { "ai_chat.mojom", "page_content_extractor.mojom", "settings_helper.mojom", + "tab_informer.mojom", "untrusted_frame.mojom", ] diff --git a/components/ai_chat/core/common/mojom/ai_chat.mojom b/components/ai_chat/core/common/mojom/ai_chat.mojom index ecaa26a9cc3a..1aaef88da91f 100644 --- a/components/ai_chat/core/common/mojom/ai_chat.mojom +++ b/components/ai_chat/core/common/mojom/ai_chat.mojom @@ -6,6 +6,7 @@ module ai_chat.mojom; import "brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom"; +import "brave/components/ai_chat/core/common/mojom/tab_informer.mojom"; import "mojo/public/mojom/base/time.mojom"; import "url/mojom/url.mojom"; @@ -87,6 +88,11 @@ struct SiteInfo { // Web page specific fields, if available and allowed string? title; string? hostname; + + // Note: iOS doesn't support optional int32s un mojom, so we use -1 to + // indicate that the content id is not set. + int32 content_id; + url.mojom.Url? url; // Percentage of the content that has been utilized by remote LLM (0-100) @@ -382,6 +388,9 @@ interface AIChatUIHandler { pending_receiver conversation, pending_remote conversation_ui); + // Associates an open tab with a conversation. + AssociateTab(Tab tab, string conversation_uuid); + // Make a completely new conversation, which may // be bound to the associated conversation, if this UI has a target NewConversation(pending_receiver conversation, diff --git a/components/ai_chat/core/common/mojom/tab_informer.mojom b/components/ai_chat/core/common/mojom/tab_informer.mojom new file mode 100644 index 000000000000..78a7a09eb0a1 --- /dev/null +++ b/components/ai_chat/core/common/mojom/tab_informer.mojom @@ -0,0 +1,30 @@ +// Copyright (c) 2025 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/. + +module ai_chat.mojom; + +import "url/mojom/url.mojom"; + +struct Window { + array tabs; + string name; + bool is_active; +}; + +struct Tab { + int32 id; + int32 content_id; + string title; + url.mojom.Url url; +}; + +interface TabListener { + // If we find we need it, we can make this more granular + TabsChanged(array windows); +}; + +interface TabInformer { + AddListener(pending_remote listener); +}; diff --git a/components/ai_chat/resources/common/mojom.ts b/components/ai_chat/resources/common/mojom.ts index 5b70af960e3a..4cce9d047be2 100644 --- a/components/ai_chat/resources/common/mojom.ts +++ b/components/ai_chat/resources/common/mojom.ts @@ -5,3 +5,4 @@ export * from 'gen/brave/components/ai_chat/core/common/mojom/ai_chat.mojom.m.js' export * from 'gen/brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom.m.js' +export * from 'gen/brave/components/ai_chat/core/common/mojom/tab_informer.mojom.m.js' diff --git a/components/ai_chat/resources/page/api/index.ts b/components/ai_chat/resources/page/api/index.ts index 6e03ccab4cc4..1b18c437548c 100644 --- a/components/ai_chat/resources/page/api/index.ts +++ b/components/ai_chat/resources/page/api/index.ts @@ -3,9 +3,9 @@ * 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 { loadTimeData } from '$web-common/loadTimeData' - import API from '../../common/api' - import * as Mojom from '../../common/mojom' +import { loadTimeData } from '$web-common/loadTimeData' +import API from '../../common/api' +import * as Mojom from '../../common/mojom' // State that is owned by this class because it is global to the UI // (loadTimeData / Service / UIHandler). @@ -19,6 +19,7 @@ export type State = Mojom.ServiceState & { isMobile: boolean isHistoryFeatureEnabled: boolean allActions: Mojom.ActionGroup[] + windows: Mojom.Window[] } export const defaultUIState: State = { @@ -34,6 +35,7 @@ export const defaultUIState: State = { isMobile: loadTimeData.getBoolean('isMobile'), isHistoryFeatureEnabled: loadTimeData.getBoolean('isHistoryEnabled'), allActions: [], + windows: [] } // Owns connections to the browser via mojom as well as global state @@ -53,6 +55,9 @@ class PageAPI extends API { public conversationEntriesFrameObserver: Mojom.ParentUIFrameCallbackRouter = new Mojom.ParentUIFrameCallbackRouter() + public tabObserver: Mojom.TabListenerCallbackRouter + = new Mojom.TabListenerCallbackRouter() + constructor() { super(defaultUIState) this.initialize() @@ -86,6 +91,16 @@ class PageAPI extends API { allActions }) + // If we're in standalone mode, listen for tab changes so we can show a picker. + if (isStandalone) { + Mojom.TabInformer.getRemote().addListener(this.tabObserver.$.bindNewPipeAndPassRemote()) + this.tabObserver.tabsChanged.addListener((windows: Mojom.Window[]) => { + this.setPartialState({ + windows + }) + }) + } + this.observer.onStateChanged.addListener((state: Mojom.ServiceState) => { this.setPartialState(state) }) diff --git a/components/ai_chat/resources/page/stories/components_panel.tsx b/components/ai_chat/resources/page/stories/components_panel.tsx index d8d89109b360..19dfa3907ca9 100644 --- a/components/ai_chat/resources/page/stories/components_panel.tsx +++ b/components/ai_chat/resources/page/stories/components_panel.tsx @@ -77,6 +77,7 @@ const associatedContentNone: Mojom.SiteInfo = { title: undefined, hostname: undefined, url: undefined, + contentId: -1, } const CONVERSATIONS: Mojom.Conversation[] = [ @@ -389,6 +390,7 @@ const SITE_INFO: Mojom.SiteInfo = { hostname: 'www.example.com', url: { url: 'https://www.example.com/a' }, isContentRefined: false, + contentId: -1, } type CustomArgs = { @@ -534,6 +536,7 @@ function StoryContext(props: React.PropsWithChildren<{args: CustomArgs, setArgs: isHistoryFeatureEnabled: options.args.isHistoryEnabled, isStandalone: options.args.isStandalone, allActions: ACTIONS_LIST, + windows: [], goPremium: () => {}, managePremium: () => {}, handleAgreeClick: () => {}, diff --git a/ios/browser/api/ai_chat/BUILD.gn b/ios/browser/api/ai_chat/BUILD.gn index d7752a042108..fc973d0765c4 100644 --- a/ios/browser/api/ai_chat/BUILD.gn +++ b/ios/browser/api/ai_chat/BUILD.gn @@ -69,6 +69,7 @@ ios_objc_mojom_wrappers("ai_chat_mojom_wrappers") { mojom_target = "//brave/components/ai_chat/core/common/mojom" sources = [ "//brave/components/ai_chat/core/common/mojom/ai_chat.mojom", + "//brave/components/ai_chat/core/common/mojom/tab_informer.mojom", "//brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom", ] output_dir = "$root_gen_dir/brave/components/ai_chat/core/common/mojom/ios" @@ -83,6 +84,7 @@ ios_objc_mojom_wrappers("ai_chat_mojom_wrappers") { "ConversationEntriesState", "ParentUIFrame", "Service", + "TabInformer", "UntrustedConversationHandler", "UntrustedConversationUI", "UntrustedUIHandler", diff --git a/test/BUILD.gn b/test/BUILD.gn index 2605215d0c6c..972431215b12 100644 --- a/test/BUILD.gn +++ b/test/BUILD.gn @@ -866,6 +866,7 @@ test("brave_browser_tests") { "//brave/browser/test:browser_tests", "//brave/browser/themes", "//brave/browser/ui:browser_tests", + "//brave/browser/ui/ai_chat:browser_tests", "//brave/browser/ui/tabs/test:browser_tests", "//brave/browser/ui/views/tabs:browser_tests", "//brave/browser/ui/webui:browser_tests",