Source code

Revision control

Copy as Markdown

Other Tools

/* 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<typeof UrlbarUtils.PROVIDER_TYPE>}
*/
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<boolean>} 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<void>}
* @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<string>} "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,
};
}