/** * 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatEnabled", "browser.ml.chat.enabled" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatHideLocalhost", "browser.ml.chat.hideLocalhost" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatOpenSidebarOnProviderChange", "browser.ml.chat.openSidebarOnProviderChange", true ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatPromptPrefix", "browser.ml.chat.prompt.prefix" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatProvider", "browser.ml.chat.provider", null, (_pref, _old, val) => onChatProviderChange(val) ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "chatSidebar", "browser.ml.chat.sidebar" ); export const GenAI = { // Any chat provider can be used and those that match the URLs in this object // will allow for additional UI shown such as populating dropdown with a name, // showing links, and other special behaviors needed for individual providers. // The ordering of this list affects UI and currently alphabetical by name. chatProviders: new Map([ // Until bug 1903900 to better handle max length issues, track in comments // ~14k max length uri before 431 [ "https://claude.ai/new", { id: "claude", link1: "https://www.anthropic.com/legal/archive/6370fb23-12ed-41d9-a4a2-28866dee3105", link2: "https://www.anthropic.com/legal/archive/7197103a-5e27-4ee4-93b1-f2d4c39ba1e7", link3: "https://www.anthropic.com/legal/archive/628feec9-7df9-4d38-bc69-fbf104df47b0", linksId: "genai-settings-chat-claude-links", name: "Anthropic Claude", }, ], // ~14k max length uri before 431 [ "https://chatgpt.com", { id: "chatgpt", link1: "https://openai.com/terms", link2: "https://openai.com/privacy", linksId: "genai-settings-chat-chatgpt-links", name: "ChatGPT", }, ], // ~4k max length uri before 400 [ "https://www.bing.com/chat?sendquery=1", { hidden: true, id: "copilot", link1: "https://www.bing.com/new/termsofuse", link2: "https://go.microsoft.com/fwlink/?LinkId=521839", linksId: "genai-settings-chat-copilot-links", name: "Copilot", }, ], // ~20k max length uri before 400 // ~55k max header (no ?q=) before 413 [ "https://gemini.google.com", { header: "X-Firefox-Gemini", id: "gemini", link1: "https://policies.google.com/terms", link2: "https://policies.google.com/terms/generative-ai/use-policy", link3: "https://support.google.com/gemini?p=privacy_notice", linksId: "genai-settings-chat-gemini-links", name: "Google Gemini", }, ], // ~8k max length uri before 413 [ "https://huggingface.co/chat", { id: "huggingchat", link1: "https://huggingface.co/chat/privacy", link2: "https://huggingface.co/privacy", linksId: "genai-settings-chat-huggingchat-links", name: "HuggingChat", }, ], // ~4k max length uri before 502 [ "https://chat.mistral.ai/chat", { id: "lechat", link1: "https://mistral.ai/terms/#terms-of-service-le-chat", link2: "https://mistral.ai/terms/#privacy-policy", linksId: "genai-settings-chat-lechat-links", name: "Le Chat Mistral", }, ], // 8k max length uri before 414 [ "http://localhost:8080", { get hidden() { return lazy.chatHideLocalhost; }, id: "localhost", link1: "https://llamafile.ai", linksId: "genai-settings-chat-localhost-links", name: "localhost", }, ], ]), /** * Handle startup tasks like telemetry, adding listeners. */ init() { Glean.genaiChatbot.enabled.set(lazy.chatEnabled); Glean.genaiChatbot.provider.set(this.getProviderId()); Glean.genaiChatbot.sidebar.set(lazy.chatSidebar); // Access this getter for its side effect of observing provider pref change lazy.chatProvider; // Detect about:preferences to add controls Services.obs.addObserver(this, "experimental-pane-loaded"); }, /** * Convert provider to id. * * @param {string} provider url defaulting to current pref * @returns {string} id or custom or none */ getProviderId(provider = lazy.chatProvider) { const { id } = this.chatProviders.get(provider) ?? {}; return id ?? (provider ? "custom" : "none"); }, /** * Build prompts menu to ask chat for context menu or popup. * * @param {MozMenu} menu element to update * @param {nsContextMenu} nsContextMenu helpers for context menu */ async buildAskChatMenu(menu, nsContextMenu) { nsContextMenu.showItem(menu, false); if (!lazy.chatEnabled || lazy.chatProvider == "") { return; } menu.label = `Ask ${ this.chatProviders.get(lazy.chatProvider)?.name ?? "AI Chatbot" }`; menu.menupopup?.remove(); // Prepare context used for both targeting and handling prompts const window = menu.ownerGlobal; const tab = window.gBrowser.getTabForBrowser(nsContextMenu.browser); const context = { provider: lazy.chatProvider, selection: nsContextMenu.selectionInfo.fullText ?? "", tabTitle: (tab._labelIsContentTitle && tab.label) || "", window, }; // Add menu items that pass along context for handling (await this.getContextualPrompts(context)).forEach(promptObj => menu .appendItem(promptObj.label, promptObj.value) .addEventListener("command", () => this.handleAskChat(promptObj, context) ) ); nsContextMenu.showItem(menu, menu.itemCount > 0); }, /** * Get prompts from prefs evaluated with context * * @param {object} context data used for targeting * @returns {promise} array of matching prompt objects */ getContextualPrompts(context) { // Treat prompt objects as messages to reuse targeting capabilities const messages = []; Services.prefs.getChildList("browser.ml.chat.prompts.").forEach(pref => { try { const promptObj = { label: Services.prefs.getStringPref(pref), targeting: "true", value: "", }; try { // Prompts can be JSON with label, value, targeting and other keys Object.assign(promptObj, JSON.parse(promptObj.label)); // Ignore provided id (if any) for modified prefs if (Services.prefs.prefHasUserValue(pref)) { promptObj.id = null; } } catch (ex) {} messages.push(promptObj); } catch (ex) { console.error("Failed to get prompt pref " + pref, ex); } }); return lazy.ASRouterTargeting.findMatchingMessage({ messages, returnAll: true, trigger: { context }, }); }, /** * Build a prompt with context. * * @param {MozMenuItem} item Use value falling back to label * @param {object} context Placeholder keys with values to replace * @returns {string} Prompt with placeholders replaced */ buildChatPrompt(item, context = {}) { // Combine prompt prefix with the item then replace placeholders from the // original prompt (and not from context) return (lazy.chatPromptPrefix + (item.value || item.label)).replace( // Handle %placeholder% as key|options /\%(\w+)(?:\|([^%]+))?\%/g, (placeholder, key, options) => // Currently only supporting numeric options for slice with `undefined` // resulting in whole string context[key]?.slice(0, options) ?? placeholder ); }, /** * Handle selected prompt by opening tab or sidebar. * * @param {object} promptObj to convert to string * @param {object} context of how the prompt should be handled */ async handleAskChat(promptObj, context) { Glean.genaiChatbot.contextmenuPromptClick.record({ prompt: promptObj.id ?? "custom", provider: this.getProviderId(), selection: context.selection?.length ?? 0, }); const prompt = this.buildChatPrompt(promptObj, context); // Pass the prompt via GET url ?q= param or request header const { header } = this.chatProviders.get(lazy.chatProvider) ?? {}; const url = new URL(lazy.chatProvider); const options = { inBackground: false, relatedToCurrent: true, triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( {} ), }; if (header) { options.headers = Cc[ "@mozilla.org/io/string-input-stream;1" ].createInstance(Ci.nsIStringInputStream); options.headers.data = `${header}: ${encodeURIComponent(prompt)}\r\n`; } else { url.searchParams.set("q", prompt); } // Get the desired browser to handle the prompt url request let browser; if (lazy.chatSidebar) { const { SidebarController } = context.window; await SidebarController.show("viewGenaiChatSidebar"); browser = await SidebarController.browser.contentWindow.browserPromise; } else { browser = context.window.gBrowser.addTab("", options).linkedBrowser; } browser.fixupAndLoadURIString(url, options); }, /** * Build preferences for chat such as handling providers. * * @param {Window} window for about:preferences */ buildPreferences({ document, Preferences }) { const providerEl = document.getElementById("genai-chat-provider"); if (!providerEl) { return; } const enabled = Preferences.get("browser.ml.chat.enabled"); const onEnabledChange = () => { providerEl.disabled = !enabled.value; // Update enabled telemetry Glean.genaiChatbot.enabled.set(enabled.value); if (onEnabledChange.canChange) { Glean.genaiChatbot.experimentCheckboxClick.record({ enabled: enabled.value, }); } onEnabledChange.canChange = true; }; onEnabledChange(); enabled.on("change", onEnabledChange); // Populate providers and hide from list if necessary this.chatProviders.forEach((data, url) => { providerEl.appendItem(data.name, url).hidden = data.hidden ?? false; }); const provider = Preferences.add({ id: "browser.ml.chat.provider", type: "string", }); let customItem; const onProviderChange = () => { // Add/update the Custom entry if it's not a default provider entry if (provider.value && !this.chatProviders.has(provider.value)) { if (!customItem) { customItem = providerEl.appendItem(); } customItem.label = `Custom (${provider.value})`; customItem.value = provider.value; // Select the item if the preference changed not via menu providerEl.selectedItem = customItem; } // Update potentially multiple links for the provider const links = document.getElementById("genai-chat-links"); const providerData = this.chatProviders.get(provider.value); for (let i = 1; i <= 3; i++) { const name = `link${i}`; let link = links.querySelector(`[data-l10n-name=${name}]`); const href = providerData?.[name]; if (href) { if (!link) { link = links.appendChild(document.createElement("a")); link.dataset.l10nName = name; link.target = "_blank"; } link.href = href; } else { link?.remove(); } } document.l10n.setAttributes( links, providerData?.linksId ?? "genai-settings-chat-links" ); // Update provider telemetry const providerId = this.getProviderId(provider.value); Glean.genaiChatbot.provider.set(providerId); if (onProviderChange.lastId && document.hasFocus()) { Glean.genaiChatbot.providerChange.record({ current: providerId, previous: onProviderChange.lastId, surface: "settings", }); } onProviderChange.lastId = providerId; }; onProviderChange(); provider.on("change", onProviderChange); }, // nsIObserver observe(window) { this.buildPreferences(window); }, }; /** * Ensure the chat sidebar is shown to reflect changed provider. * * @param {string} value New pref value */ function onChatProviderChange(value) { if (value && lazy.chatEnabled && lazy.chatOpenSidebarOnProviderChange) { Services.wm .getMostRecentWindow("navigator:browser") ?.SidebarController.show("viewGenaiChatSidebar"); } }