From 9b8e446aeef76b4e369a16391677a32015f7c360 Mon Sep 17 00:00:00 2001 From: Jay Harris Date: Fri, 10 Jan 2025 15:11:53 +1300 Subject: [PATCH] [AI Chat]: Add TabInformer so the front end can be aware of open tabs --- browser/brave_content_browser_client.cc | 4 +- browser/resources/settings/sources.gni | 1 + browser/ui/BUILD.gn | 2 + browser/ui/ai_chat/tab_informer.cc | 125 ++++++++++++++++++ browser/ui/ai_chat/tab_informer.h | 66 +++++++++ browser/ui/webui/ai_chat/ai_chat_ui.cc | 10 ++ browser/ui/webui/ai_chat/ai_chat_ui.h | 7 + .../webui/ai_chat/ai_chat_ui_page_handler.cc | 68 ++++++++++ .../webui/ai_chat/ai_chat_ui_page_handler.h | 2 + .../core/browser/ai_chat_database_unittest.cc | 18 +-- .../ai_chat/core/browser/ai_chat_service.cc | 23 +++- .../ai_chat/core/browser/ai_chat_service.h | 3 + .../core/browser/conversation_handler.cc | 3 + .../core/browser/conversation_handler.h | 4 + components/ai_chat/core/common/constants.h | 9 ++ components/ai_chat/core/common/mojom/BUILD.gn | 1 + .../ai_chat/core/common/mojom/ai_chat.mojom | 5 + .../core/common/mojom/tab_informer.mojom | 30 +++++ components/ai_chat/resources/common/mojom.ts | 1 + .../ai_chat/resources/page/api/index.ts | 26 +++- .../page/stories/components_panel.tsx | 1 + 21 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 browser/ui/ai_chat/tab_informer.cc create mode 100644 browser/ui/ai_chat/tab_informer.h create mode 100644 components/ai_chat/core/common/mojom/tab_informer.mojom 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..1a2c1a3f0491 100644 --- a/browser/ui/BUILD.gn +++ b/browser/ui/BUILD.gn @@ -106,6 +106,8 @@ source_set("ui") { # It doesn't make sense to view the webcompat webui on iOS & Android. if (!is_android && !is_ios) { sources += [ + "ai_chat/tab_informer.cc", + "ai_chat/tab_informer.h", "webui/webcompat_reporter/webcompat_reporter_ui.cc", "webui/webcompat_reporter/webcompat_reporter_ui.h", ] 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..065218844bcc --- /dev/null +++ b/browser/ui/ai_chat/tab_informer.h @@ -0,0 +1,66 @@ +// 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: + 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/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..dee6dcc61382 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,46 @@ constexpr char kURLManagePremium[] = "https://account.brave.com/"; namespace ai_chat { +namespace { + +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 +278,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_unittest.cc b/components/ai_chat/core/browser/ai_chat_database_unittest.cc index b14343299231..d419b38f7fdb 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, std::nullopt, 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, std::nullopt, + 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, std::nullopt, + 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..40f720c4610b 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,8 @@ 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, std::nullopt, std::nullopt, 0, false, + false)); conversations_.insert_or_assign(conversation_uuid, std::move(conversation)); } mojom::Conversation* conversation = @@ -543,7 +543,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 +948,17 @@ void AIChatService::OpenConversationWithStagedEntries( conversation->MaybeFetchOrClearContentStagedConversation(); } +void AIChatService::AssociateContent( + ConversationHandler::AssociatedContentDelegate* content, + const std::string& conversation_uuid) { + CHECK(content); + + 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..24bea002f3f0 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 = std::nullopt; 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/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..0977bf05a9be 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,7 @@ struct SiteInfo { // Web page specific fields, if available and allowed string? title; string? hostname; + int32? content_id; url.mojom.Url? url; // Percentage of the content that has been utilized by remote LLM (0-100) @@ -382,6 +384,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..679faa5a5369 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,10 +35,11 @@ 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 -class PageAPI extends API { +class PageAPI extends API implements Mojom.TabListenerInterface { public service: Mojom.ServiceRemote = Mojom.Service.getRemote() @@ -53,6 +55,11 @@ class PageAPI extends API { public conversationEntriesFrameObserver: Mojom.ParentUIFrameCallbackRouter = new Mojom.ParentUIFrameCallbackRouter() + public tabInformer: Mojom.TabInformerRemote + = Mojom.TabInformer.getRemote() + + private tabListenerReceiver = new Mojom.TabListenerReceiver(this) + constructor() { super(defaultUIState) this.initialize() @@ -86,6 +93,11 @@ class PageAPI extends API { allActions }) + // If we're in standalone mode, listen for tab changes so we can show a picker. + if (isStandalone) { + this.tabInformer.addListener(this.tabListenerReceiver.$.bindNewPipeAndPassRemote()) + } + this.observer.onStateChanged.addListener((state: Mojom.ServiceState) => { this.setPartialState(state) }) @@ -116,6 +128,12 @@ class PageAPI extends API { }) } + tabsChanged(windows: Mojom.Window[]) { + this.setPartialState({ + windows + }) + } + private async getCurrentPremiumStatus() { const { status } = await this.service.getPremiumStatus() return { diff --git a/components/ai_chat/resources/page/stories/components_panel.tsx b/components/ai_chat/resources/page/stories/components_panel.tsx index d8d89109b360..f37c152f9468 100644 --- a/components/ai_chat/resources/page/stories/components_panel.tsx +++ b/components/ai_chat/resources/page/stories/components_panel.tsx @@ -534,6 +534,7 @@ function StoryContext(props: React.PropsWithChildren<{args: CustomArgs, setArgs: isHistoryFeatureEnabled: options.args.isHistoryEnabled, isStandalone: options.args.isStandalone, allActions: ACTIONS_LIST, + windows: [], goPremium: () => {}, managePremium: () => {}, handleAgreeClick: () => {},