From 384b9bbb24f54f49bb20911a479004a0be7233d6 Mon Sep 17 00:00:00 2001 From: Pete Miller Date: Sun, 12 Jan 2025 21:52:08 -0800 Subject: [PATCH] AI Chat: show web sources that were used (normally via web search) to generate the response --- .../create_sanitized_image_url.ts | 8 +++ .storybook/webpack.config.ts | 4 ++ browser/ui/BUILD.gn | 2 + .../ai_chat_untrusted_conversation_ui.cc | 24 ++++++- .../webui/untrusted_sanitized_image_source.cc | 35 ++++++++++ .../webui/untrusted_sanitized_image_source.h | 31 +++++++++ .../browser/engine/conversation_api_client.cc | 44 +++++++++++++ .../conversation_api_client_unittest.cc | 65 ++++++++++++++++++- .../ai_chat/core/common/mojom/ai_chat.mojom | 11 ++++ .../core/common/mojom/untrusted_frame.mojom | 9 +++ .../page/stories/components_panel.tsx | 60 ++++++++++------- .../components/assistant_response/index.tsx | 13 +++- .../web_sources_event.module.scss | 63 ++++++++++++++++++ .../assistant_response/web_sources_event.tsx | 58 +++++++++++++++++ .../common/create_sanitized_image_url.ts | 11 ++++ 15 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 .storybook/web-common-mock/create_sanitized_image_url.ts create mode 100644 browser/ui/webui/untrusted_sanitized_image_source.cc create mode 100644 browser/ui/webui/untrusted_sanitized_image_source.h create mode 100644 components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.module.scss create mode 100644 components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.tsx create mode 100644 components/common/create_sanitized_image_url.ts diff --git a/.storybook/web-common-mock/create_sanitized_image_url.ts b/.storybook/web-common-mock/create_sanitized_image_url.ts new file mode 100644 index 000000000000..cc9163515cf0 --- /dev/null +++ b/.storybook/web-common-mock/create_sanitized_image_url.ts @@ -0,0 +1,8 @@ +// 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/. + +export default function createSanitizedImageUrl(imageUrl: string) { + return imageUrl +} diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index 3a07b056a849..7ab680b88aeb 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -77,6 +77,10 @@ const pathMap = { // place to look. path.resolve(__dirname, 'chrome-resources-mock'), basePathMap['chrome://resources'] + ], + '$web-common': [ + path.resolve(__dirname, 'web-common-mock'), + basePathMap['$web-common'] ] } diff --git a/browser/ui/BUILD.gn b/browser/ui/BUILD.gn index e9ef106a20d8..2db51318c9a8 100644 --- a/browser/ui/BUILD.gn +++ b/browser/ui/BUILD.gn @@ -101,6 +101,8 @@ source_set("ui") { "webui/brave_webui_source.h", "webui/skus_internals_ui.cc", "webui/skus_internals_ui.h", + "webui/untrusted_sanitized_image_source.cc", + "webui/untrusted_sanitized_image_source.h", ] # It doesn't make sense to view the webcompat webui on iOS & Android. diff --git a/browser/ui/webui/ai_chat/ai_chat_untrusted_conversation_ui.cc b/browser/ui/webui/ai_chat/ai_chat_untrusted_conversation_ui.cc index fc96fa2a849f..9d3d7848d7e2 100644 --- a/browser/ui/webui/ai_chat/ai_chat_untrusted_conversation_ui.cc +++ b/browser/ui/webui/ai_chat/ai_chat_untrusted_conversation_ui.cc @@ -8,11 +8,13 @@ #include #include +#include "base/memory/weak_ptr.h" #include "base/strings/escape.h" #include "brave/browser/ai_chat/ai_chat_service_factory.h" #include "brave/browser/ai_chat/ai_chat_urls.h" #include "brave/browser/ui/side_panel/ai_chat/ai_chat_side_panel_utils.h" #include "brave/browser/ui/webui/ai_chat/ai_chat_ui.h" +#include "brave/browser/ui/webui/untrusted_sanitized_image_source.h" #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/conversation_handler.h" @@ -21,13 +23,16 @@ #include "brave/components/ai_chat/resources/grit/ai_chat_ui_generated_map.h" #include "brave/components/constants/webui_url_constants.h" #include "brave/components/l10n/common/localization_util.h" +#include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/webui/webui_util.h" #include "components/grit/brave_components_resources.h" #include "content/public/browser/render_frame_host.h" +#include "content/public/browser/url_data_source.h" #include "content/public/browser/web_contents.h" #include "content/public/browser/web_ui.h" #include "content/public/browser/web_ui_data_source.h" #include "content/public/common/url_constants.h" +#include "url/url_constants.h" #if BUILDFLAG(IS_ANDROID) #include "brave/browser/ui/android/ai_chat/brave_leo_settings_launcher_helper.h" @@ -67,6 +72,16 @@ class UIHandler : public ai_chat::mojom::UntrustedUIHandler { base::EscapeQueryParamValue(search_query, true))); } + void OpenURLFromResponse(const GURL& url) override { + if (!web_ui_->GetRenderFrameHost()->HasTransientUserActivation()) { + return; + } + if (!url.is_valid() || !url.SchemeIs(url::kHttpsScheme)) { + return; + } + OpenURL(url); + } + void BindParentPage(mojo::PendingReceiver parent_ui_frame_receiver) override { // Route the receiver to the parent frame @@ -108,6 +123,8 @@ class UIHandler : public ai_chat::mojom::UntrustedUIHandler { raw_ptr web_ui_ = nullptr; mojo::Receiver receiver_; + + base::WeakPtrFactory weak_ptr_factory_{this}; }; } // namespace @@ -159,7 +176,8 @@ AIChatUntrustedConversationUI::AIChatUntrustedConversationUI( "style-src 'self' 'unsafe-inline' chrome-untrusted://resources;"); source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::ImgSrc, - "img-src 'self' blob: chrome-untrusted://resources;"); + "img-src 'self' blob: chrome-untrusted://resources " + "chrome-untrusted://image;"); source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::FontSrc, "font-src 'self' chrome-untrusted://resources;"); @@ -168,6 +186,10 @@ AIChatUntrustedConversationUI::AIChatUntrustedConversationUI( base::StringPrintf("frame-ancestors %s;", kAIChatUIURL)); source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::TrustedTypes, "trusted-types default;"); + + Profile* profile = Profile::FromWebUI(web_ui); + content::URLDataSource::Add( + profile, std::make_unique(profile)); } AIChatUntrustedConversationUI::~AIChatUntrustedConversationUI() = default; diff --git a/browser/ui/webui/untrusted_sanitized_image_source.cc b/browser/ui/webui/untrusted_sanitized_image_source.cc new file mode 100644 index 000000000000..93e4aab4e861 --- /dev/null +++ b/browser/ui/webui/untrusted_sanitized_image_source.cc @@ -0,0 +1,35 @@ +// 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/webui/untrusted_sanitized_image_source.h" + +#include +#include + +#include "base/strings/strcat.h" +#include "chrome/common/webui_url_constants.h" + +std::string UntrustedSanitizedImageSource::GetSource() { + return base::StrCat({content::kChromeUIUntrustedScheme, + url::kStandardSchemeSeparator, + chrome::kChromeUIImageHost, "/"}); +} + +void UntrustedSanitizedImageSource::StartDataRequest( + const GURL& url, + const content::WebContents::Getter& wc_getter, + content::URLDataSource::GotDataCallback callback) { + if (!url.is_valid() || !url.SchemeIs(content::kChromeUIUntrustedScheme)) { + std::move(callback).Run(nullptr); + return; + } + + // Change scheme to ChromeUIScheme for base class + GURL::Replacements replacements; + replacements.SetSchemeStr(content::kChromeUIScheme); + + SanitizedImageSource::StartDataRequest(url.ReplaceComponents(replacements), + wc_getter, std::move(callback)); +} diff --git a/browser/ui/webui/untrusted_sanitized_image_source.h b/browser/ui/webui/untrusted_sanitized_image_source.h new file mode 100644 index 000000000000..fc9cf820dc5e --- /dev/null +++ b/browser/ui/webui/untrusted_sanitized_image_source.h @@ -0,0 +1,31 @@ +// 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_WEBUI_UNTRUSTED_SANITIZED_IMAGE_SOURCE_H_ +#define BRAVE_BROWSER_UI_WEBUI_UNTRUSTED_SANITIZED_IMAGE_SOURCE_H_ + +#include + +#include "chrome/browser/ui/webui/sanitized_image_source.h" +#include "content/public/browser/web_contents.h" +#include "url/gurl.h" + +// Uses SanitizedImageSource for chrome-untrusted:// WebUIs +class UntrustedSanitizedImageSource : public SanitizedImageSource { + public: + using SanitizedImageSource::SanitizedImageSource; + UntrustedSanitizedImageSource(const UntrustedSanitizedImageSource&) = delete; + UntrustedSanitizedImageSource& operator=(const SanitizedImageSource&) = + delete; + + // SanitizedImageSource: + std::string GetSource() override; + void StartDataRequest( + const GURL& url, + const content::WebContents::Getter& wc_getter, + content::URLDataSource::GotDataCallback callback) override; +}; + +#endif // BRAVE_BROWSER_UI_WEBUI_UNTRUSTED_SANITIZED_IMAGE_SOURCE_H_ diff --git a/components/ai_chat/core/browser/engine/conversation_api_client.cc b/components/ai_chat/core/browser/engine/conversation_api_client.cc index 39bb31e05995..c12bc9cd5535 100644 --- a/components/ai_chat/core/browser/engine/conversation_api_client.cc +++ b/components/ai_chat/core/browser/engine/conversation_api_client.cc @@ -56,6 +56,9 @@ using ConversationEvent = ConversationAPIClient::ConversationEvent; using ConversationEventType = ConversationAPIClient::ConversationEventType; constexpr char kRemotePath[] = "v1/conversation"; + +constexpr char kAllowedWebSourceFaviconHost[] = "imgs.search.brave.com"; + #if !defined(OFFICIAL_BUILD) constexpr char kAIChatServerUrl[] = "ai-chat-server-url"; #endif @@ -114,6 +117,47 @@ mojom::ConversationEntryEventPtr ParseResponseEvent( } return mojom::ConversationEntryEvent::NewSearchQueriesEvent( std::move(event)); + } else if (*type == "webSources") { + const base::Value::List* sources = response_event.FindList("sources"); + if (!sources) { + return nullptr; + } + auto event = mojom::WebSourcesEvent::New(); + for (auto& item : *sources) { + if (!item.is_dict()) { + continue; + } + const base::Value::Dict& source = item.GetDict(); + const std::string* title = source.FindString("title"); + const std::string* url = source.FindString("url"); + const std::string* favicon_url = source.FindString("favicon"); + if (!title || !url || !favicon_url) { + DVLOG(2) << "Missing required fields in web source event: " + << item.DebugString(); + continue; + } + GURL item_url(*url); + GURL item_favicon_url(*favicon_url); + if (!item_url.is_valid() || !item_favicon_url.is_valid()) { + DVLOG(2) << "Invalid URL in webSource event: " << item.DebugString(); + continue; + } + // Validate favicon is private source + if (!item_favicon_url.SchemeIs(url::kHttpsScheme) || + base::CompareCaseInsensitiveASCII(item_favicon_url.host_piece(), + kAllowedWebSourceFaviconHost) != + 0) { + DVLOG(2) << "webSource event contained disallowed host or scheme: " + << item.DebugString(); + continue; + } + event->sources.push_back( + mojom::WebSource::New(*title, item_url, item_favicon_url)); + } + if (event->sources.empty()) { + return nullptr; + } + return mojom::ConversationEntryEvent::NewSourcesEvent(std::move(event)); } else if (*type == "conversationTitle") { const std::string* title = response_event.FindString("title"); if (!title) { diff --git a/components/ai_chat/core/browser/engine/conversation_api_client_unittest.cc b/components/ai_chat/core/browser/engine/conversation_api_client_unittest.cc index 9f91122d238a..57876e53d755 100644 --- a/components/ai_chat/core/browser/engine/conversation_api_client_unittest.cc +++ b/components/ai_chat/core/browser/engine/conversation_api_client_unittest.cc @@ -244,7 +244,7 @@ TEST_F(ConversationAPIUnitTest, PerformRequest_PremiumHeaders) { "__Secure-sku#brave-leo-premium=" + expected_crediential); EXPECT_NE(headers.find("x-brave-key"), headers.end()); - // Verify body contains events in expected json format + // Verify input body contains input events in expected json format EXPECT_STREQ(GetEventsJson(body).c_str(), FormatComparableEventsJson(expected_events_body).c_str()); @@ -254,8 +254,8 @@ TEST_F(ConversationAPIUnitTest, PerformRequest_PremiumHeaders) { EXPECT_TRUE(selected_language.has_value()); EXPECT_TRUE(selected_language.value().empty()); - // Send some event responses so that we can verify it is passed - // through to the PerformRequest callbacks. + // Send some event responses so that we can verify they are passed + // through to the PerformRequest callbacks as events. { base::Value result(base::Value::Type::DICT); result.GetDict().Set("type", "isSearching"); @@ -270,6 +270,50 @@ TEST_F(ConversationAPIUnitTest, PerformRequest_PremiumHeaders) { result.GetDict().Set("queries", std::move(queries)); data_received_callback.Run(base::ok(std::move(result))); } + { + base::Value result(base::Value::Type::DICT); + result.GetDict().Set("type", "webSources"); + base::Value sources(base::Value::Type::LIST); + { + // Invalid because it doesn't contain the expected host + base::Value query(base::Value::Type::DICT); + query.GetDict().Set("title", "Star Wars"); + query.GetDict().Set("url", "https://starwars.com"); + query.GetDict().Set("favicon", "https://starwars.com/favicon"); + sources.GetList().Append(std::move(query)); + } + { + // Invalid because it doesn't contain the expected scheme + base::Value query(base::Value::Type::DICT); + query.GetDict().Set("title", "Star Wars"); + query.GetDict().Set("url", "https://starwars.com"); + query.GetDict().Set( + "favicon", "http://imgs.search.brave.com/starwars.com/favicon"); + sources.GetList().Append(std::move(query)); + } + { + // Valid + base::Value query(base::Value::Type::DICT); + query.GetDict().Set("title", "Star Wars"); + query.GetDict().Set("url", "https://starwars.com"); + query.GetDict().Set( + "favicon", + "https://imgs.search.brave.com/starwars.com/favicon"); + sources.GetList().Append(std::move(query)); + } + { + // Valid + base::Value query(base::Value::Type::DICT); + query.GetDict().Set("title", "Star Trek"); + query.GetDict().Set("url", "https://startrek.com"); + query.GetDict().Set( + "favicon", + "https://imgs.search.brave.com/startrek.com/favicon"); + sources.GetList().Append(std::move(query)); + } + result.GetDict().Set("sources", std::move(sources)); + data_received_callback.Run(base::ok(std::move(result))); + } { base::Value result(base::Value::Type::DICT); result.GetDict().Set("type", "completion"); @@ -308,6 +352,21 @@ TEST_F(ConversationAPIUnitTest, PerformRequest_PremiumHeaders) { EXPECT_EQ(queries[0], "Star Wars"); EXPECT_EQ(queries[1], "Star Trek"); }); + EXPECT_CALL(mock_callbacks, OnDataReceived(_)) + .InSequence(seq) + .WillOnce([&](mojom::ConversationEntryEventPtr event) { + EXPECT_TRUE(event->is_sources_event()); + auto& sources = event->get_sources_event()->sources; + EXPECT_EQ(sources.size(), 2u); + EXPECT_EQ(sources[0]->title, "Star Wars"); + EXPECT_EQ(sources[1]->title, "Star Trek"); + EXPECT_EQ(sources[0]->url.spec(), "https://starwars.com/"); + EXPECT_EQ(sources[1]->url.spec(), "https://startrek.com/"); + EXPECT_EQ(sources[0]->favicon_url.spec(), + "https://imgs.search.brave.com/starwars.com/favicon"); + EXPECT_EQ(sources[1]->favicon_url.spec(), + "https://imgs.search.brave.com/startrek.com/favicon"); + }); EXPECT_CALL(mock_callbacks, OnDataReceived(_)) .InSequence(seq) .WillOnce([&](mojom::ConversationEntryEventPtr event) { diff --git a/components/ai_chat/core/common/mojom/ai_chat.mojom b/components/ai_chat/core/common/mojom/ai_chat.mojom index ecaa26a9cc3a..b320425c1757 100644 --- a/components/ai_chat/core/common/mojom/ai_chat.mojom +++ b/components/ai_chat/core/common/mojom/ai_chat.mojom @@ -163,6 +163,16 @@ struct SearchStatusEvent { bool is_searching = true; }; +struct WebSource { + string title; + url.mojom.Url url; + url.mojom.Url favicon_url; +}; + +struct WebSourcesEvent { + array sources; +}; + struct CompletionEvent { string completion; }; @@ -181,6 +191,7 @@ union ConversationEntryEvent { PageContentRefineEvent page_content_refine_event; SearchQueriesEvent search_queries_event; SearchStatusEvent search_status_event; + WebSourcesEvent sources_event; // These events don't normally get added to the conversation entry // but are used in engine responses. diff --git a/components/ai_chat/core/common/mojom/untrusted_frame.mojom b/components/ai_chat/core/common/mojom/untrusted_frame.mojom index 264d5e2c3c2c..9ec4a57b7370 100644 --- a/components/ai_chat/core/common/mojom/untrusted_frame.mojom +++ b/components/ai_chat/core/common/mojom/untrusted_frame.mojom @@ -5,6 +5,8 @@ module ai_chat.mojom; +import "url/mojom/url.mojom"; + // Interfaces for communication between the untrusted content frame and both // the Browser and the parent trusted UI frame. @@ -25,7 +27,14 @@ interface ParentUIFrame { // Browser-side handler for untrusted frame that handles rendering of // conversation entries. interface UntrustedUIHandler { + // Open a URL for the web search query the assistant used for a response OpenSearchURL(string query); + OpenLearnMoreAboutBraveSearchWithLeo(); + + // Opens a URL linked from an assistant response or web search event in the + // response. Could be any http/https destination. + OpenURLFromResponse(url.mojom.Url url); + BindParentPage(pending_receiver parent_frame); }; diff --git a/components/ai_chat/resources/page/stories/components_panel.tsx b/components/ai_chat/resources/page/stories/components_panel.tsx index d8d89109b360..a356c55c275f 100644 --- a/components/ai_chat/resources/page/stories/components_panel.tsx +++ b/components/ai_chat/resources/page/stories/components_panel.tsx @@ -24,47 +24,48 @@ import styles from './style.module.scss' import StorybookConversationEntries from './story_utils/ConversationEntries' import { UntrustedConversationContext, UntrustedConversationReactContext } from '../../untrusted_conversation_frame/untrusted_conversation_context' +const eventTemplate: Mojom.ConversationEntryEvent = { + completionEvent: undefined, + pageContentRefineEvent: undefined, + searchQueriesEvent: undefined, + searchStatusEvent: undefined, + selectedLanguageEvent: undefined, + conversationTitleEvent: undefined, + sourcesEvent: undefined +} + function getCompletionEvent(text: string): Mojom.ConversationEntryEvent { return { - completionEvent: { completion: text }, - pageContentRefineEvent: undefined, - searchQueriesEvent: undefined, - searchStatusEvent: undefined, - conversationTitleEvent: undefined, - selectedLanguageEvent: undefined + ...eventTemplate, + completionEvent: { completion: text } } } function getSearchEvent(queries: string[]): Mojom.ConversationEntryEvent { return { - completionEvent: undefined, - pageContentRefineEvent: undefined, - searchQueriesEvent: { searchQueries: queries }, - searchStatusEvent: undefined, - conversationTitleEvent: undefined, - selectedLanguageEvent: undefined + ...eventTemplate, + searchQueriesEvent: { searchQueries: queries } } } function getSearchStatusEvent(): Mojom.ConversationEntryEvent { return { - completionEvent: undefined, - pageContentRefineEvent: undefined, - searchQueriesEvent: undefined, - searchStatusEvent: { isSearching: true }, - selectedLanguageEvent: undefined, - conversationTitleEvent: undefined + ...eventTemplate, + searchStatusEvent: { isSearching: true } + } +} + +function getWebSourcesEvent(sources: Mojom.WebSource[]): Mojom.ConversationEntryEvent { + return { + ...eventTemplate, + sourcesEvent: { sources } } } function getPageContentRefineEvent(): Mojom.ConversationEntryEvent { return { - completionEvent: undefined, - pageContentRefineEvent: { isRefining: true }, - searchQueriesEvent: undefined, - searchStatusEvent: undefined, - selectedLanguageEvent: undefined, - conversationTitleEvent: undefined + ...eventTemplate, + pageContentRefineEvent: { isRefining: true } } } @@ -260,7 +261,16 @@ const HISTORY: Mojom.ConversationTurn[] = [ selectedText: undefined, edits: [], createdTime: { internalValue: BigInt('13278618001000000') }, - events: [getSearchStatusEvent(), getSearchEvent(['pointer compression', 'c++ language specification']), getCompletionEvent('Pointer compression is a memory optimization technique.')], + events: [ + getSearchStatusEvent(), + getSearchEvent(['pointer compression', 'c++ language specification']), + getCompletionEvent('Pointer compression is a memory optimization technique.'), + getWebSourcesEvent([ + { url: { url: 'https://www.example.com' }, title: 'Pointer Compression', faviconUrl: { url: 'https://www.example.com/favicon.ico' } }, + { title: 'LTT Store', faviconUrl: { url: 'https://lttstore.com/favicon.ico' }, url: { url: 'https://lttstore.com' } }, + { title: 'Tesla Model Y', faviconUrl: { url: 'https://www.tesla.com/favicon.ico' }, url: { url: 'https://www.tesla.com/modely' } } + ]) + ], fromBraveSearchSERP: false }, { diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx index 25eb810714ca..b6e9edf515fc 100644 --- a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/index.tsx @@ -11,6 +11,7 @@ import { getLocale } from '$web-common/locale' import * as Mojom from '../../../common/mojom' import { useUntrustedConversationContext } from '../../untrusted_conversation_context' import MarkdownRenderer from '../markdown_renderer' +import WebSourcesEvent from './web_sources_event' import styles from './style.module.scss' function SearchSummary (props: { searchQueries: string[] }) { @@ -79,7 +80,10 @@ function AssistantEvent(props: { event: Mojom.ConversationEntryEvent, hasComplet } export default function AssistantResponse(props: { entry: Mojom.ConversationTurn, isEntryInProgress: boolean }) { + // Extract certain events which need to render at specific locations (e.g. end of the events) const searchQueriesEvent = props.entry.events?.find(event => event.searchQueriesEvent)?.searchQueriesEvent + const sourcesEvent = props.entry.events?.find(event => !!event.sourcesEvent)?.sourcesEvent + const hasCompletionStarted = !props.isEntryInProgress || (props.entry.events?.some(event => event.completionEvent) ?? false) @@ -94,8 +98,13 @@ export default function AssistantResponse(props: { entry: Mojom.ConversationTurn /> ) } - { !props.isEntryInProgress && searchQueriesEvent && - + + { + !props.isEntryInProgress && + <> + {searchQueriesEvent && } + {sourcesEvent && } + } ) } diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.module.scss b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.module.scss new file mode 100644 index 000000000000..db4ab6b87008 --- /dev/null +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.module.scss @@ -0,0 +1,63 @@ +// 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/. + +.sources { + h4 { + margin: 16px 0 6px 0; + font: var(--leo-font-small-regular); + color: var(--leo-color-text-tertiary); + + } + + ul, li { + list-style-type: none; + padding: 0; + margin: 0; + } + + ul { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 5px; + } + + li { + display: contents; + } + + a, button { + border-radius: var(--leo-radius-s); + border: 1px solid var(--leo-color-divider-subtle); + padding: 2px 6px 2px 2px; + font: var(--leo-font-small-regular); + color: var(--leo-color-text-primary); + text-decoration: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + } + + button { + appearance: none; + background: transparent; + cursor: pointer; + color: var(--leo-color-text-secondary); + } + + img, .expandIcon { + --leo-icon-size: 16px; + padding: 4px; + width: 24px; + height: 24px; + background-color: var(--leo-color-primitive-neutral-95); + border-radius: var(--leo-radius-xs); + } + + .expandIcon { + background-color: transparent; + } +} diff --git a/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.tsx b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.tsx new file mode 100644 index 000000000000..fb6b2df2e6c9 --- /dev/null +++ b/components/ai_chat/resources/untrusted_conversation_frame/components/assistant_response/web_sources_event.tsx @@ -0,0 +1,58 @@ +// 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/. + +import * as React from 'react' +import Icon from '@brave/leo/react/icon' +import createSanitizedImageUrl from '$web-common/create_sanitized_image_url' +import * as mojom from '../../../common/mojom' +import { useUntrustedConversationContext } from '../../untrusted_conversation_context' +import styles from './web_sources_event.module.scss' + +const UNEXPANDED_SOURCES_COUNT = 4; + +function WebSource (props: { source: mojom.WebSource }) { + const context = useUntrustedConversationContext() + + const { source } = props + + const handleOpenSource = (e: React.MouseEvent, source: mojom.WebSource) => { + e.preventDefault() + context.uiHandler?.openURLFromResponse(source.url) + } + + const host = new URL(source.url.url).hostname + return ( +
  • + handleOpenSource(e, source)}> + + {host} + +
  • + ) +} + +export default function WebSourcesEvent (props: { sources: mojom.WebSource[] }) { + const [isExpanded, setIsExpanded] = React.useState(false) + + const unhiddenSources = props.sources.slice(0, UNEXPANDED_SOURCES_COUNT) + const hiddenSources = props.sources.slice(UNEXPANDED_SOURCES_COUNT) + + return ( +
    +

    Sources

    +
      + {unhiddenSources.map(source => )} + {!isExpanded && hiddenSources.length > 0 && ( +
    • + +
    • + )} + {isExpanded && hiddenSources.map(source => )} +
    +
    + ) +} diff --git a/components/common/create_sanitized_image_url.ts b/components/common/create_sanitized_image_url.ts new file mode 100644 index 000000000000..00fd3d6d7280 --- /dev/null +++ b/components/common/create_sanitized_image_url.ts @@ -0,0 +1,11 @@ +// 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/. + +// This simple string concat is made to a utility so it can be overriden in +// Storybook. The C++ WebUI must add either SanitizedImageSource or +// UntrustedSanitizedImageSource. +export default function createSanitizedImageUrl(imageUrl: string) { + return `//image?url=${encodeURIComponent(imageUrl)}` +}