tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

chat.js (17684B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const { topChromeWindow } = window.browsingContext;
      6 
      7 const lazy = {};
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  GenAI: "resource:///modules/GenAI.sys.mjs",
     10  SpecialMessageActions:
     11    "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
     12 });
     13 const { XPCOMUtils } = ChromeUtils.importESModule(
     14  "resource://gre/modules/XPCOMUtils.sys.mjs"
     15 );
     16 
     17 // Define actions for onboarding and chatbot
     18 const ACTIONS = Object.freeze({
     19  CHATBOT_PERSIST: "chatbot:persist",
     20  CHATBOT_REVERT: "chatbot:revert",
     21  CHATBOT_SELECT: "chatbot:select",
     22  CHATBOT_SUPPORT: "chatbot:support",
     23  OPEN_URL: "OPEN_URL",
     24 });
     25 
     26 XPCOMUtils.defineLazyPreferenceGetter(
     27  lazy,
     28  "providerPref",
     29  "browser.ml.chat.provider",
     30  null,
     31  renderProviders
     32 );
     33 XPCOMUtils.defineLazyPreferenceGetter(
     34  lazy,
     35  "shortcutsPref",
     36  "browser.ml.chat.shortcuts"
     37 );
     38 XPCOMUtils.defineLazyPreferenceGetter(
     39  lazy,
     40  "sidebarRevampPref",
     41  "sidebar.revamp"
     42 );
     43 XPCOMUtils.defineLazyPreferenceGetter(
     44  lazy,
     45  "onboardingConfig",
     46  "browser.ml.chat.onboarding.config",
     47  JSON.stringify({
     48    id: "chatbot",
     49    template: "multistage",
     50    transitions: true,
     51    screens: [
     52      {
     53        id: "chat_pick",
     54        content: {
     55          fullscreen: true,
     56          hide_secondary_section: "responsive",
     57          narrow: true,
     58          position: "split",
     59 
     60          title: {
     61            fontWeight: 400,
     62            string_id: "genai-onboarding-choose-header",
     63          },
     64          cta_paragraph: {
     65            text: {
     66              string_id: "genai-onboarding-choose-description",
     67              string_name: "learn-more",
     68            },
     69            action: {
     70              type: ACTIONS.CHATBOT_SUPPORT,
     71            },
     72          },
     73          above_button_content: [
     74            // Placeholder to inject on provider change
     75            {
     76              text: " ",
     77              type: "text",
     78            },
     79          ],
     80          primary_button: {
     81            action: {
     82              navigate: true,
     83              type: ACTIONS.CHATBOT_PERSIST,
     84            },
     85            label: { string_id: "genai-onboarding-primary" },
     86          },
     87          additional_button: {
     88            action: { dismiss: true, type: ACTIONS.CHATBOT_REVERT },
     89            label: { string_id: "genai-onboarding-secondary" },
     90            style: "link",
     91          },
     92          progress_bar: true,
     93        },
     94      },
     95    ],
     96  })
     97 );
     98 
     99 ChromeUtils.defineLazyGetter(
    100  lazy,
    101  "supportLink",
    102  () =>
    103    Services.urlFormatter.formatURLPref("app.support.baseURL") + "ai-chatbot"
    104 );
    105 
    106 const node = {};
    107 
    108 function closeSidebar() {
    109  topChromeWindow.SidebarController.hide();
    110 }
    111 
    112 function openLink(url) {
    113  topChromeWindow.openLinkIn(url, "tabshifted", {
    114    triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
    115  });
    116 }
    117 
    118 function request(url = lazy.providerPref) {
    119  try {
    120    node.chat.fixupAndLoadURIString(url, {
    121      triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
    122        {}
    123      ),
    124    });
    125  } catch (ex) {
    126    console.error("Failed to request chat provider", ex);
    127  }
    128 }
    129 
    130 function renderChat() {
    131  const browser = document.createXULElement("browser");
    132  const browserContainer = document.getElementById("browser-container");
    133  browser.setAttribute("disableglobalhistory", "true");
    134  browser.setAttribute("maychangeremoteness", "true");
    135  browser.setAttribute("nodefaultsrc", "true");
    136  browser.setAttribute("remote", "true");
    137  browser.setAttribute("type", "content");
    138  return browserContainer.appendChild(browser);
    139 }
    140 
    141 async function renderProviders() {
    142  // Skip potential pref change callback when unloading
    143  if ((await document.visibilityState) == "hidden") {
    144    return null;
    145  }
    146 
    147  const select = document.getElementById("provider");
    148  select.innerHTML = "";
    149  let selected = false;
    150 
    151  const addOption = (text = "", val = "") => {
    152    const option = select.appendChild(document.createElement("option"));
    153    option.textContent = text;
    154    option.value = val;
    155    return option;
    156  };
    157 
    158  // Add the known providers in order while looking for current selection
    159  lazy.GenAI.chatProviders.forEach((data, url) => {
    160    const option = addOption(data.name, url);
    161    if (lazy.providerPref == url) {
    162      option.selected = true;
    163      selected = true;
    164    } else if (data.hidden) {
    165      option.hidden = true;
    166    }
    167  });
    168 
    169  // Must be a custom preference if provider wasn't found
    170  if (!selected) {
    171    const option = addOption(lazy.providerPref, lazy.providerPref);
    172    option.selected = true;
    173    if (!lazy.providerPref) {
    174      showOnboarding();
    175    }
    176  }
    177 
    178  // Clear warning message from different provider
    179  clearWarningMessage();
    180 
    181  // Add extra controls after the providers
    182  select.appendChild(document.createElement("hr"));
    183  document.l10n.setAttributes(addOption(), "genai-provider-view-details");
    184 
    185  // Update provider telemetry
    186  const providerId = lazy.GenAI.getProviderId(lazy.providerPref);
    187  Glean.genaiChatbot.provider.set(providerId);
    188  if (renderProviders.lastId && document.hasFocus()) {
    189    Glean.genaiChatbot.providerChange.record({
    190      current: providerId,
    191      previous: renderProviders.lastId,
    192      surface: "panel",
    193    });
    194  }
    195  renderProviders.lastId = providerId;
    196 
    197  // Load the requested provider
    198  request();
    199  return select;
    200 }
    201 
    202 function renderMore() {
    203  const button = document.getElementById("header-more");
    204  button.addEventListener("click", () => {
    205    const topDoc = topChromeWindow.document;
    206    let menu = topDoc.getElementById("chatbot-menupopup");
    207    if (!menu) {
    208      menu = topDoc
    209        .getElementById("mainPopupSet")
    210        .appendChild(topDoc.createXULElement("menupopup"));
    211      menu.id = "chatbot-menupopup";
    212      node.menu = menu;
    213      menu.addEventListener("popuphidden", () => {
    214        button.setAttribute("aria-expanded", false);
    215      });
    216    }
    217    menu.innerHTML = "";
    218 
    219    const provider = lazy.GenAI.chatProviders.get(lazy.providerPref)?.name;
    220    const providerId = lazy.GenAI.getProviderId();
    221    [
    222      [
    223        "menuitem",
    224        [
    225          provider
    226            ? "genai-options-reload-provider"
    227            : "genai-options-reload-generic",
    228          { provider },
    229        ],
    230        function reload() {
    231          request();
    232        },
    233      ],
    234      ["menuseparator"],
    235      [
    236        "menuitem",
    237        ["genai-options-show-shortcut"],
    238        function show_shortcuts() {
    239          Services.prefs.setBoolPref("browser.ml.chat.shortcuts", true);
    240        },
    241        lazy.shortcutsPref,
    242      ],
    243      [
    244        "menuitem",
    245        ["genai-options-hide-shortcut"],
    246        function hide_shortcuts() {
    247          Services.prefs.setBoolPref("browser.ml.chat.shortcuts", false);
    248        },
    249        !lazy.shortcutsPref,
    250      ],
    251      ["menuseparator"],
    252      [
    253        "menuitem",
    254        ["genai-options-about-chatbot"],
    255        function about() {
    256          openLink(lazy.supportLink);
    257        },
    258      ],
    259    ].forEach(([type, l10n, command, checked]) => {
    260      const item = menu.appendChild(topDoc.createXULElement(type));
    261      if (type != "menuitem") {
    262        return;
    263      }
    264      document.l10n.setAttributes(item, ...l10n);
    265      item.addEventListener("command", () => {
    266        command();
    267        Glean.genaiChatbot.sidebarMoreMenuClick.record({
    268          action: command.name,
    269          provider: providerId,
    270        });
    271      });
    272      if (checked !== undefined) {
    273        item.setAttribute("type", "checkbox");
    274        if (checked) {
    275          item.setAttribute("checked", true);
    276        }
    277      }
    278    });
    279    menu.openPopup(button, "after_start");
    280    button.setAttribute("aria-expanded", true);
    281    Glean.genaiChatbot.sidebarMoreMenuDisplay.record({ provider: providerId });
    282  });
    283 }
    284 
    285 function handleChange({ target }) {
    286  const { value } = target;
    287  switch (target) {
    288    case node.provider:
    289      // Special behavior to show first screen of onboarding
    290      if (value == "") {
    291        target.value = lazy.providerPref;
    292        showOnboarding(1);
    293        Glean.genaiChatbot.sidebarProviderMenuClick.record({
    294          action: "details",
    295          provider: lazy.GenAI.getProviderId(),
    296        });
    297      } else {
    298        Services.prefs.setStringPref("browser.ml.chat.provider", value);
    299      }
    300      break;
    301  }
    302 }
    303 addEventListener("change", handleChange);
    304 
    305 // Expose a promise for loading and rendering the chat browser element
    306 var browserPromise = new Promise((resolve, reject) => {
    307  addEventListener("load", async () => {
    308    try {
    309      node.chat = renderChat();
    310      node.provider = await renderProviders();
    311      renderMore();
    312      resolve(node.chat);
    313      document.getElementById("header-close").addEventListener("click", () => {
    314        closeSidebar();
    315        Glean.genaiChatbot.sidebarCloseClick.record({
    316          provider: lazy.GenAI.getProviderId(),
    317        });
    318      });
    319      document
    320        .getElementById("summarize-button")
    321        .addEventListener("click", async () => {
    322          const badgeKey = "browser.ml.chat.page.footerBadge";
    323          const newBadgePref = Services.prefs.getBoolPref(badgeKey);
    324 
    325          if (newBadgePref) {
    326            Services.prefs.setBoolPref(badgeKey, false);
    327          }
    328          await lazy.GenAI.summarizeCurrentPage(topChromeWindow, "footer");
    329        });
    330    } catch (ex) {
    331      console.error("Failed to render on load", ex);
    332      reject(ex);
    333    }
    334 
    335    Glean.genaiChatbot.sidebarToggle.record({
    336      opened: true,
    337      provider: lazy.GenAI.getProviderId(),
    338      reason: "load",
    339      version: lazy.sidebarRevampPref ? "new" : "old",
    340    });
    341  });
    342 });
    343 
    344 addEventListener("unload", () => {
    345  node.menu?.remove();
    346  Glean.genaiChatbot.sidebarToggle.record({
    347    opened: false,
    348    provider: lazy.GenAI.getProviderId(),
    349    reason: "unload",
    350    version: lazy.sidebarRevampPref ? "new" : "old",
    351  });
    352 });
    353 
    354 /**
    355 * Show onboarding screens
    356 *
    357 * @param {number} length optional show fewer screens
    358 */
    359 function showOnboarding(length) {
    360  // Insert onboarding container and render with script
    361  const root = document.createElement("div");
    362  root.id = "multi-stage-message-root";
    363  document.getElementById(root.id)?.remove();
    364  document.body.prepend(root);
    365  history.replaceState("", "");
    366  const script = document.head.appendChild(document.createElement("script"));
    367  script.src = "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js";
    368 
    369  // Convert provider data for lookup by id
    370  const providerConfigs = new Map();
    371  lazy.GenAI.chatProviders.forEach((data, url) => {
    372    if (!data.hidden) {
    373      providerConfigs.set(data.id, { ...data, url });
    374    }
    375  });
    376 
    377  // Define various AW* functions to control aboutwelcome bundle behavior
    378  Object.assign(window, {
    379    AWEvaluateScreenTargeting(screens) {
    380      return screens;
    381    },
    382    AWFinish() {
    383      if (lazy.providerPref == "") {
    384        closeSidebar();
    385      }
    386      root.remove();
    387 
    388      // Indicate onboarding finished and allow another
    389      showOnboarding.resolve();
    390      onboardingPromise = new Promise(resolve => {
    391        showOnboarding.resolve = resolve;
    392      });
    393    },
    394    AWGetFeatureConfig() {
    395      const onboarding = JSON.parse(lazy.onboardingConfig);
    396      const providerTiles = {
    397        action: { picker: "<event>" },
    398        data: [...providerConfigs.values()].map(config => ({
    399          action: { type: ACTIONS.CHATBOT_SELECT, config },
    400          id: config.id,
    401          label: config.name,
    402          tooltip: { string_id: config.tooltipId },
    403        })),
    404        // Default to nothing selected
    405        selected: " ",
    406        type: "single-select",
    407      };
    408      // Insert provider tiles on the first screen
    409      onboarding.screens[0].content.tiles = providerTiles;
    410      // Remove extra screens if any
    411      onboarding.screens = onboarding.screens.slice(0, length);
    412      return onboarding;
    413    },
    414    AWGetInstalledAddons() {},
    415    AWGetSelectedTheme() {
    416      const primary = document.querySelector(".primary");
    417      if (primary) {
    418        primary.disabled = true;
    419      }
    420    },
    421    AWSendEventTelemetry({ event, event_context: { source } }) {
    422      const { provider } = window.AWSendEventTelemetry;
    423      const step = 1;
    424      switch (true) {
    425        case event == "IMPRESSION":
    426          Glean.genaiChatbot.onboardingProviderChoiceDisplayed.record({
    427            provider: lazy.GenAI.getProviderId(lazy.providerPref),
    428            step,
    429          });
    430          break;
    431        case source == "cta_paragraph":
    432          Glean.genaiChatbot.onboardingLearnMore.record({ provider, step });
    433          break;
    434        case source == "primary_button":
    435          Glean.genaiChatbot.onboardingFinish.record({ provider, step });
    436          break;
    437        case source == "additional_button":
    438          Glean.genaiChatbot.onboardingClose.record({ provider, step });
    439          break;
    440        case source.startsWith("link"):
    441          Glean.genaiChatbot.onboardingProviderTerms.record({
    442            provider,
    443            step,
    444            text: source,
    445          });
    446          break;
    447        // Assume generic click not yet handled above single select of provider
    448        case event == "CLICK_BUTTON":
    449          window.AWSendEventTelemetry.provider = source;
    450          Glean.genaiChatbot.onboardingProviderSelection.record({
    451            provider: source,
    452            step,
    453          });
    454          break;
    455      }
    456    },
    457    AWSendToParent(_message, action) {
    458      switch (action.type) {
    459        case ACTIONS.OPEN_URL:
    460          lazy.SpecialMessageActions.handleAction(action, topChromeWindow);
    461          return;
    462        case ACTIONS.CHATBOT_PERSIST: {
    463          const { value } = document.querySelector(
    464            "label:has(.selected) input"
    465          );
    466          Services.prefs.setStringPref(
    467            "browser.ml.chat.provider",
    468            providerConfigs.get(value).url
    469          );
    470          break;
    471        }
    472        case ACTIONS.CHATBOT_REVERT: {
    473          request();
    474          break;
    475        }
    476        // Handle single select provider choice
    477        case ACTIONS.CHATBOT_SELECT: {
    478          const { config } = action;
    479          if (!config) {
    480            break;
    481          }
    482 
    483          request(config.url);
    484          document.querySelector(".primary").disabled = false;
    485 
    486          // Set max-height to trigger transition
    487          document.querySelectorAll("label .text div").forEach(div => {
    488            const selected =
    489              div.closest("label").querySelector("input").value == config.id;
    490            div.style.maxHeight = selected ? div.scrollHeight + "px" : 0;
    491            const a = div.querySelector("a");
    492            if (a) {
    493              a.tabIndex = selected ? 0 : -1;
    494            }
    495          });
    496 
    497          // Update potentially multiple links for the provider
    498          const links = document.querySelector(".link-paragraph");
    499          if (links && links.dataset.l10nId != config.linksId) {
    500            links.innerHTML = "";
    501            for (let i = 1; i <= 3; i++) {
    502              const link = links.appendChild(document.createElement("a"));
    503              const name = (link.dataset.l10nName = `link${i}`);
    504              link.href = config[name];
    505              link.setAttribute("value", name);
    506            }
    507            document.l10n.setAttributes(links, config.linksId);
    508 
    509            const handleLink = ev => {
    510              const { href } = ev.target;
    511              if (href) {
    512                ev.preventDefault();
    513                openLink(href);
    514              }
    515            };
    516 
    517            if (!links._listenerAdded) {
    518              links?.addEventListener("click", handleLink);
    519              links._listenerAdded = true;
    520            }
    521          }
    522 
    523          break;
    524        }
    525        case ACTIONS.CHATBOT_SUPPORT:
    526          openLink(lazy.supportLink);
    527          break;
    528      }
    529    },
    530  });
    531 }
    532 
    533 // Expose a promise for onboarding finishing
    534 var onboardingPromise = new Promise(resolve => {
    535  showOnboarding.resolve = resolve;
    536 });
    537 
    538 /**
    539 * Clear message if present
    540 *
    541 */
    542 function clearWarningMessage() {
    543  const messageContainer = document.getElementById("message-container");
    544 
    545  if (messageContainer?.hasChildNodes()) {
    546    messageContainer.replaceChildren();
    547  }
    548 }
    549 
    550 /**
    551 * Display a warning message in the sidebar chatbot panel when context is too long
    552 *
    553 * @param {number} length context length for a request
    554 */
    555 async function showSummarizeWarning(length) {
    556  // if previous request showed the message clear previous message
    557  clearWarningMessage();
    558 
    559  const messageContainer = document.getElementById("message-container");
    560  const warningEl = lazy.GenAI.createWarningEl(document, null, true);
    561 
    562  if (!messageContainer) {
    563    return;
    564  }
    565 
    566  const provider = lazy.GenAI.getProviderId();
    567  const type = "page_summarization";
    568  document.l10n.setAttributes(warningEl, "genai-page-warning");
    569  messageContainer.hidden = false;
    570  messageContainer.appendChild(warningEl);
    571 
    572  // Warning message bar impression event
    573  Glean.genaiChatbot.lengthDisclaimer.record({
    574    type,
    575    length,
    576    provider,
    577  });
    578 
    579  await customElements.whenDefined("moz-message-bar");
    580  const dismissButton = warningEl.shadowRoot.querySelector(".close");
    581  dismissButton?.addEventListener("click", () => {
    582    Glean.genaiChatbot.lengthDisclaimerDismissed.record({
    583      type,
    584      provider,
    585    });
    586    messageContainer.hidden = true;
    587  });
    588 }
    589 
    590 /**
    591 * Expose Sidebar entry for new prompt
    592 *
    593 * @param {object} opt for new prompt
    594 * @param {boolean} [opt.show]
    595 * @param {number} [opt.contextLength]
    596 */
    597 window.onNewPrompt = async function (opt = {}) {
    598  if (opt.show) {
    599    await showSummarizeWarning(opt.contextLength);
    600  } else {
    601    clearWarningMessage();
    602  }
    603 };