blob: e02e01d5228a7a2b29b0aa88c6c84964e6dcf5e4 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/omnibox/browser/contextual_search_provider.h"
#include <stddef.h>
#include <cmath>
#include <optional>
#include <string>
#include <utility>
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/user_metrics.h"
#include "base/notreached.h"
#include "base/strings/escape.h"
#include "base/strings/string_util.h"
#include "base/time/time.h"
#include "base/trace_event/trace_event.h"
#include "build/build_config.h"
#include "components/omnibox/browser/actions/contextual_search_action.h"
#include "components/omnibox/browser/autocomplete_input.h"
#include "components/omnibox/browser/autocomplete_match.h"
#include "components/omnibox/browser/autocomplete_match_classification.h"
#include "components/omnibox/browser/autocomplete_provider_client.h"
#include "components/omnibox/browser/autocomplete_provider_debouncer.h"
#include "components/omnibox/browser/autocomplete_provider_listener.h"
#include "components/omnibox/browser/lens_suggest_inputs_utils.h"
#include "components/omnibox/browser/match_compare.h"
#include "components/omnibox/browser/page_classification_functions.h"
#include "components/omnibox/browser/remote_suggestions_service.h"
#include "components/omnibox/browser/search_suggestion_parser.h"
#include "components/omnibox/browser/zero_suggest_provider.h"
#include "components/omnibox/common/omnibox_feature_configs.h"
#include "components/omnibox/common/omnibox_features.h"
#include "components/search_engines/search_engine_type.h"
#include "components/search_engines/template_url_service.h"
#include "components/strings/grit/components_strings.h"
#include "components/url_formatter/url_formatter.h"
#include "services/network/public/cpp/simple_url_loader.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "third_party/metrics_proto/omnibox_event.pb.h"
#include "third_party/metrics_proto/omnibox_focus_type.pb.h"
#include "third_party/metrics_proto/omnibox_input_type.pb.h"
#include "ui/base/l10n/l10n_util.h"
#include "url/gurl.h"
namespace {
// Relevance for pedal-like action matches to be provided when not in keyword
// mode and input is empty.
constexpr int kAdvertActionRelevance = 10000;
// The internal default verbatim match relevance.
constexpr int kDefaultMatchRelevance = 1500;
// Relevance value to use if it was not set explicitly by the server.
constexpr int kDefaultSuggestResultRelevance = 100;
// Populates |results| with the response if it can be successfully parsed for
// |input|. Returns true if the response can be successfully parsed.
bool ParseRemoteResponse(const std::string& response_json,
AutocompleteProviderClient* client,
const AutocompleteInput& input,
SearchSuggestionParser::Results* results) {
DCHECK(results);
if (response_json.empty()) {
return false;
}
std::optional<base::Value::List> response_data =
SearchSuggestionParser::DeserializeJsonData(response_json);
if (!response_data) {
return false;
}
return SearchSuggestionParser::ParseSuggestResults(
*response_data, input, client->GetSchemeClassifier(),
/*default_result_relevance=*/kDefaultSuggestResultRelevance,
/*is_keyword_result=*/true, results);
}
} // namespace
void ContextualSearchProvider::Start(
const AutocompleteInput& autocomplete_input,
bool minimal_changes) {
TRACE_EVENT0("omnibox", "ContextualSearchProvider::Start");
// Clear the cached results to remove the page search action matches. Also,
// matches the behavior of the `ZeroSuggestProvider`.
Stop(/*clear_cached_results=*/true, /*due_to_user_inactivity=*/false);
if (client()->IsOffTheRecord()) {
done_ = true;
return;
}
const auto [input, starter_pack_engine] = AdjustInputForStarterPackKeyword(
autocomplete_input, client()->GetTemplateURLService());
if (!starter_pack_engine) {
// Only surface the action matches that help the user find their way into
// the '@page' scope. Requirements: web, non-SRP, non-NTP, with empty input.
// TODO(crbug.com/406276335): Move and condition on zero suggest response to
// the ZeroSuggestProvider so it can inhibit the ad actions for some pages.
if (omnibox::IsOtherWebPage(input.current_page_classification()) &&
input.current_url().SchemeIsHTTPOrHTTPS() &&
(input.IsZeroSuggest() ||
input.type() == metrics::OmniboxInputType::EMPTY)) {
AddPageSearchActionMatches();
}
return;
}
input_keyword_ = starter_pack_engine->keyword();
AddDefaultVerbatimMatch(input);
// Exit early if the input is not in ZPS keyword mode or the autocomplete
// input is not allowed to make asynchronous requests.
if (!input.text().empty() || autocomplete_input.omit_asynchronous_matches()) {
done_ = true;
return;
}
done_ = false;
StartSuggestRequest(std::move(input));
}
void ContextualSearchProvider::Stop(bool clear_cached_results,
bool due_to_user_inactivity) {
if (!due_to_user_inactivity) {
// Stop the pending request if the sotp is not due to user inactivity. If
// it is due to user inactivity, the request will continue so the
// suggestions can be shown when they are ready.
AutocompleteProvider::Stop(clear_cached_results, due_to_user_inactivity);
lens_suggest_inputs_subscription_ = {};
loader_.reset();
input_keyword_.clear();
}
}
void ContextualSearchProvider::AddProviderInfo(
ProvidersInfo* provider_info) const {
BaseSearchProvider::AddProviderInfo(provider_info);
if (!matches().empty()) {
provider_info->back().set_times_returned_results_in_session(1);
}
}
ContextualSearchProvider::ContextualSearchProvider(
AutocompleteProviderClient* client,
AutocompleteProviderListener* listener)
: BaseSearchProvider(AutocompleteProvider::TYPE_CONTEXTUAL_SEARCH, client) {
AddListener(listener);
}
ContextualSearchProvider::~ContextualSearchProvider() = default;
bool ContextualSearchProvider::ShouldAppendExtraParams(
const SearchSuggestionParser::SuggestResult& result) const {
// We always use the default provider for search, so append the params.
return true;
}
void ContextualSearchProvider::StartSuggestRequest(AutocompleteInput input) {
if (AreLensSuggestInputsReady(input.lens_overlay_suggest_inputs()) ||
!omnibox_feature_configs::ContextualSearch::Get()
.csp_async_suggest_inputs) {
// If the suggest inputs are ready, make the suggest request immediately.
// Also, skip the async wait if the feature is disabled.
MakeSuggestRequest(std::move(input));
return;
}
// Wait for the suggest inputs to be generated and then make the suggest
// request. Safe to use base::Unretained(this) because the subscription is
// reset and cancelled if this provider is destroyed.
lens_suggest_inputs_subscription_ = client()->GetLensSuggestInputsWhenReady(
base::BindOnce(&ContextualSearchProvider::OnLensSuggestInputsReady,
base::Unretained(this), std::move(input)));
}
void ContextualSearchProvider::OnLensSuggestInputsReady(
AutocompleteInput input,
std::optional<lens::proto::LensOverlaySuggestInputs> lens_suggest_inputs) {
CHECK(!AreLensSuggestInputsReady(input.lens_overlay_suggest_inputs()));
if (lens_suggest_inputs) {
input.set_lens_overlay_suggest_inputs(*lens_suggest_inputs);
}
MakeSuggestRequest(std::move(input));
}
void ContextualSearchProvider::MakeSuggestRequest(AutocompleteInput input) {
TemplateURLRef::SearchTermsArgs search_terms_args;
// TODO(crbug.com/404608703): Consider new types or taking from `input`.
search_terms_args.page_classification =
metrics::OmniboxEventProto::CONTEXTUAL_SEARCHBOX;
search_terms_args.request_source =
SearchTermsData::RequestSource::LENS_OVERLAY;
search_terms_args.focus_type = input.focus_type();
search_terms_args.current_page_url = input.current_url().spec();
search_terms_args.lens_overlay_suggest_inputs =
input.lens_overlay_suggest_inputs();
// Make the request and store the loader to keep it alive. Destroying the
// loader will cancel the request. Safe to use base::Unretained(this) because
// the loader is reset and destroyed if this provider is destroyed.
loader_ =
client()
->GetRemoteSuggestionsService(/*create_if_necessary=*/true)
->StartZeroPrefixSuggestionsRequest(
RemoteRequestType::kZeroSuggest, client()->IsOffTheRecord(),
client()->GetTemplateURLService()->GetDefaultSearchProvider(),
search_terms_args,
client()->GetTemplateURLService()->search_terms_data(),
base::BindOnce(&ContextualSearchProvider::SuggestRequestCompleted,
base::Unretained(this), std::move(input)));
}
void ContextualSearchProvider::SuggestRequestCompleted(
AutocompleteInput input,
const network::SimpleURLLoader* source,
const int response_code,
std::unique_ptr<std::string> response_body) {
DCHECK(!done_);
DCHECK_EQ(loader_.get(), source);
if (response_code != 200) {
loader_.reset();
done_ = true;
return;
}
// Some match must be available in order to stay in keyword mode,
// but an empty result set is possible. The default match will
// always be added first for a consistent keyword experience.
matches_.clear();
suggestion_groups_map_.clear();
AddDefaultVerbatimMatch(input);
// Note: Queries are not yet supported. If it is kept, the current behavior
// will be to mismatch between input text and query empty string, failing
// the parse early in SearchSuggestionParser::ParseSuggestResults.
input.UpdateText(u"", 0u, {});
SearchSuggestionParser::Results results;
if (!ParseRemoteResponse(SearchSuggestionParser::ExtractJsonData(
source, std::move(response_body)),
client(), input, &results)) {
loader_.reset();
done_ = true;
return;
}
loader_.reset();
done_ = true;
// Convert the results into |matches_| and notify the listeners.
ConvertSuggestResultsToAutocompleteMatches(results, input);
NotifyListeners(/*updated_matches=*/true);
}
void ContextualSearchProvider::ConvertSuggestResultsToAutocompleteMatches(
const SearchSuggestionParser::Results& results,
const AutocompleteInput& input) {
const TemplateURL* template_url = GetKeywordTemplateURL();
if (!template_url) {
return;
}
// Add all the SuggestResults to the map. We display all ZeroSuggest search
// suggestions as unbolded.
MatchMap map;
for (size_t i = 0; i < results.suggest_results.size(); ++i) {
AddMatchToMap(results.suggest_results[i], input, template_url,
client()->GetTemplateURLService()->search_terms_data(), i,
/*mark_as_deletable*/ false, /*in_keyword_mode=*/true, &map);
}
const int num_query_results = map.size();
const int num_nav_results = results.navigation_results.size();
const int num_results = num_query_results + num_nav_results;
if (num_results == 0) {
return;
}
for (MatchMap::const_iterator it(map.begin()); it != map.end(); ++it) {
matches_.push_back(it->second);
}
const SearchSuggestionParser::NavigationResults& nav_results(
results.navigation_results);
for (const auto& nav_result : nav_results) {
matches_.push_back(
ZeroSuggestProvider::NavigationToMatch(this, client(), nav_result));
}
// Update the suggestion groups information from the server response.
for (const auto& entry : results.suggestion_groups_map) {
suggestion_groups_map_[entry.first].MergeFrom(entry.second);
}
}
void ContextualSearchProvider::AddPageSearchActionMatches() {
// These matches are effectively pedals that don't require any query matching.
AutocompleteMatch match(this, kAdvertActionRelevance, false,
AutocompleteMatchType::PEDAL);
match.contents_class = {{0, ACMatchClassification::NONE}};
match.transition = ui::PAGE_TRANSITION_GENERATED;
match.suggest_type = omnibox::SuggestType::TYPE_NATIVE_CHROME;
match.suggestion_group_id =
omnibox::GroupId::GROUP_ZERO_SUGGEST_IN_PRODUCT_HELP;
auto add_action = [&](auto action) {
match.relevance--;
match.takeover_action = std::move(action);
match.contents = match.takeover_action->GetLabelStrings().hint;
matches_.push_back(match);
};
if (omnibox_feature_configs::ContextualSearch::Get().single_lens_action) {
add_action(base::MakeRefCounted<ContextualSearchOpenLensAction>());
} else {
add_action(base::MakeRefCounted<ContextualSearchAskAboutPageAction>());
add_action(base::MakeRefCounted<ContextualSearchSelectRegionAction>());
}
}
void ContextualSearchProvider::AddDefaultVerbatimMatch(
const AutocompleteInput& input) {
const TemplateURL* template_url = GetKeywordTemplateURL();
std::u16string text = base::CollapseWhitespace(input.text(), false);
AutocompleteMatch match(this, kDefaultMatchRelevance, false,
AutocompleteMatchType::SEARCH_WHAT_YOU_TYPED);
if (text.empty()) {
// Inert/static keyword mode helper text match for empty input. This match
// doesn't commit the omnibox when selected, it just stands in to inform the
// user about how to use the keyword scope they've entered.
match.contents =
l10n_util::GetStringUTF16(IDS_STARTER_PACK_PAGE_EMPTY_QUERY_MATCH_TEXT);
match.contents_class = {{0, ACMatchClassification::DIM}};
// These are necessary to avoid the omnibox dropping out of keyword mode.
if (template_url) {
match.keyword = template_url->keyword();
}
match.transition = ui::PAGE_TRANSITION_KEYWORD;
match.allowed_to_be_default_match = true;
} else {
// Verbatim search suggestion, using the keyword `template_url` (@page)
// instead of default search engine, for a more consistent keyword UX.
// Note, the SUBTYPE_CONTEXTUAL_SEARCH subtype will cause this match
// to be fulfilled via ContextualSearchFulfillmentAction `takeover_action`.
SearchSuggestionParser::SuggestResult verbatim(
/*suggestion=*/text,
/*type=*/AutocompleteMatchType::SEARCH_WHAT_YOU_TYPED,
/*suggest_type=*/omnibox::TYPE_NATIVE_CHROME,
/*subtypes=*/{omnibox::SUBTYPE_CONTEXTUAL_SEARCH},
/*from_keyword=*/true,
/*navigational_intent=*/omnibox::NAV_INTENT_NONE,
/*relevance=*/kDefaultMatchRelevance,
/*relevance_from_server=*/false,
/*input_text=*/text);
match = CreateSearchSuggestion(
this, input, /*in_keyword_mode=*/true, verbatim, template_url,
client()->GetTemplateURLService()->search_terms_data(),
/*accepted_suggestion=*/0, ShouldAppendExtraParams(verbatim));
}
matches_.push_back(match);
}
const TemplateURL* ContextualSearchProvider::GetKeywordTemplateURL() const {
return client()->GetTemplateURLService()->GetTemplateURLForKeyword(
input_keyword_);
}