/* 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 http://mozilla.org/MPL/2.0/. */ import { SkippableTimer, UrlbarProvider, UrlbarUtils, } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AIWindow: "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", AIWindowUI: "moz-src:///browser/components/aiwindow/ui/modules/AIWindowUI.sys.mjs", AIWINDOW_URL: "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", IntentClassifier: "moz-src:///browser/components/aiwindow/models/IntentClassifier.sys.mjs", UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", UrlbarProviderHeuristicFallback: "moz-src:///browser/components/urlbar/UrlbarProviderHeuristicFallback.sys.mjs", UrlbarSearchUtils: "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", }); // Don't offer chat results for very short queries. const MIN_CHARS_FOR_CHAT = 3; // Debounce parameters for intent evaluation. const MAX_SEARCHSTRING_DIFF_FOR_DEBOUNCE = 3; const MAX_TIME_FOR_DEBOUNCE_MS = 200; /** * Determine if two strings are unrelated for debounce purposes. * * @param {string} str1 * @param {string} str2 * @returns {boolean} True if the strings are unrelated enough. */ function stringsAreUnrelated(str1, str2) { // Not a substring of each other. if (str1.includes(str2) || str2.includes(str1)) { return false; } // Length difference sufficient to debounce. return ( Math.abs(str1.length - str2.length) > MAX_SEARCHSTRING_DIFF_FOR_DEBOUNCE ); } /** * A provider that returns a chat result for the smart window Chat feature. */ export class UrlbarProviderAiChat extends UrlbarProvider { constructor() { super(); } // TODO (GENAI-3376): Verify if this is the expected icon for search results. static CHAT_ICON_URL = "chrome://browser/content/aiwindow/assets/ask-icon.svg"; /** * @returns {Values} */ get type() { // The behavior depends on the SAP and the user intent, thus we treat this // as an immediate heuristic provider and eventually delay later. return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; } /** * Whether this provider should be invoked for the given context. * If this method returns false, the providers manager won't start a query * with this provider, to save on resources. * * @param {UrlbarQueryContext} queryContext The query context object * @param {UrlbarController} [controller] The current controller. * @returns {Promise} True if the provider should be invoked. */ async isActive(queryContext, controller) { return ( lazy.AIWindow.isAIWindowActiveAndEnabled(controller.browserWindow) && queryContext.trimmedSearchString.length >= MIN_CHARS_FOR_CHAT && !queryContext.searchMode ); } /** * Starts querying. * * Note: Extended classes should return a Promise resolved when the provider * is done searching AND returning results. * * @param {UrlbarQueryContext} queryContext * The query context object * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback * Callback invoked by the provider to add a new result. * @returns {Promise} * @abstract */ async startQuery(queryContext, addCallback) { let instance = this.queryInstance; let canReturnHeuristicResult = queryContext.sapName != "urlbar"; if (!canReturnHeuristicResult) { // Add a delay as chat is never a heuristic result. this.logger.info("Add delay before resolving intent"); await new SkippableTimer({ name: "ProviderAiChat", time: lazy.UrlbarPrefs.get("delay"), logger: this.logger, }).promise; if (instance != this.queryInstance) { // The query was canceled while we were waiting. return; } } let intent = await this.#determineIntent(queryContext); // TODO (Bug 2008926): add controller argument to the startQuery call, and // send out a CustomEvent to tell we've determined the intent. this.logger.info("Determined intent: " + intent); if ( instance != this.queryInstance || intent == "navigate" || (intent != "chat" && queryContext.sapName == "urlbar") ) { return; } let heuristic = canReturnHeuristicResult && intent == "chat"; let result = new lazy.UrlbarResult({ heuristic, type: UrlbarUtils.RESULT_TYPE.AI_CHAT, source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, suggestedIndex: heuristic ? undefined : 1, payload: { icon: UrlbarProviderAiChat.CHAT_ICON_URL, query: queryContext.searchString, title: queryContext.searchString, }, }); addCallback(this, result); // If the result is heuristic, we also want a following non-heuristic // Search result. if (heuristic) { let engine = lazy.UrlbarSearchUtils.getDefaultEngine( queryContext.isPrivate ); let icon = await engine.getIconURL(); if (instance != this.queryInstance) { return; } let searchResult = new lazy.UrlbarResult({ type: UrlbarUtils.RESULT_TYPE.SEARCH, source: UrlbarUtils.RESULT_SOURCE.SEARCH, payload: { engine: engine.name, query: queryContext.searchString, title: queryContext.searchString, icon, }, highlights: { engine: UrlbarUtils.HIGHLIGHT.TYPED, }, }); addCallback(this, searchResult); } } async onEngagement(queryContext, controller) { let win = controller.browserWindow; if (!lazy.AIWindowUI.isSidebarOpen(win)) { await this.#openSidebarAndWaitForLoad(win); if (win.closed) { return; } } let actor = this.#getAiBrowser(win).browsingContext?.currentWindowGlobal.getActor( "AISmartBar" ); if (!actor) { this.logger.error("AISmartBar actor not found"); return; } actor.ask(queryContext.searchString); } async #openSidebarAndWaitForLoad(win) { lazy.AIWindowUI.openSidebar(win); let browser = this.#getAiBrowser(win); if (browser.currentURI?.spec !== lazy.AIWINDOW_URL) { await new Promise(resolve => { browser.addEventListener( "load", function () { if (browser.currentURI.spec === lazy.AIWINDOW_URL) { resolve(); } }, { once: true, capture: true } ); }); } } #getAiBrowser(win) { return win.document.getElementById(lazy.AIWindowUI.BROWSER_ID); } /** * Determine the user intent for the given query context. * * @param {UrlbarQueryContext} queryContext * @returns {Promise} "chat", "search", or "navigate" */ async #determineIntent(queryContext) { // If the string looks like a URL, don't show the chat result. if (lazy.UrlbarProviderHeuristicFallback.matchUnknownUrl(queryContext)) { return "navigate"; } // For performance reasons we want to debounce here, so only re-evaluate // intent after a meaningful delta from the last evaluation, otherwise keep // the last value. let intent = this.#lastIntentEvaluation.intent; if ( !intent || Date.now() - this.#lastIntentEvaluation.timestamp > MAX_TIME_FOR_DEBOUNCE_MS || stringsAreUnrelated( queryContext.searchString, this.#lastIntentEvaluation.queryString ) ) { try { intent = await lazy.IntentClassifier.getPromptIntent( queryContext.searchString ); } catch (e) { this.logger.error( "Error determining intent, defaulting to 'search': " + e ); intent = "search"; } this.#lastIntentEvaluation = { timestamp: Date.now(), queryString: queryContext.searchString, intent, }; } return intent; } // Store the last intent evaluation to debounce. #lastIntentEvaluation = { timestamp: 0, queryString: "", /** @type {?string} */ intent: null, }; }