tor-browser

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

GenAIChild.sys.mjs (8185B)


      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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = {};
      8 XPCOMUtils.defineLazyPreferenceGetter(
      9  lazy,
     10  "shortcutsDelay",
     11  "browser.ml.chat.shortcuts.longPress"
     12 );
     13 
     14 ChromeUtils.defineESModuleGetters(lazy, {
     15  ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs",
     16 });
     17 
     18 // Events to register after shortcuts are shown
     19 const HIDE_EVENTS = ["pagehide", "resize", "scroll"];
     20 
     21 /**
     22 * JSWindowActor to detect content page events to send GenAI related data.
     23 */
     24 export class GenAIChild extends JSWindowActorChild {
     25  mouseUpTimeout = null;
     26  downSelection = null;
     27  downTimeStamp = 0;
     28  debounceDelay = 200;
     29  pendingHide = false;
     30 
     31  registerHideEvents() {
     32    this.document.addEventListener("selectionchange", this);
     33    HIDE_EVENTS.forEach(ev =>
     34      this.contentWindow.addEventListener(ev, this, true)
     35    );
     36    this.pendingHide = true;
     37  }
     38 
     39  removeHideEvents() {
     40    this.document.removeEventListener("selectionchange", this);
     41    HIDE_EVENTS.forEach(ev =>
     42      this.contentWindow?.removeEventListener(ev, this, true)
     43    );
     44    this.pendingHide = false;
     45  }
     46 
     47  handleEvent(event) {
     48    const sendHide = () => {
     49      // Only remove events and send message if shortcuts are actually visible
     50      if (this.pendingHide) {
     51        this.sendAsyncMessage("GenAI:HideShortcuts", event.type);
     52        this.removeHideEvents();
     53      }
     54    };
     55 
     56    switch (event.type) {
     57      case "mousedown":
     58        this.downSelection = this.getSelectionInfo().selection;
     59        this.downTimeStamp = event.timeStamp;
     60        sendHide();
     61        break;
     62      case "mouseup": {
     63        // Only handle plain clicks
     64        if (
     65          event.button ||
     66          event.altKey ||
     67          event.ctrlKey ||
     68          event.metaKey ||
     69          event.shiftKey
     70        ) {
     71          return;
     72        }
     73 
     74        // Clear any previously scheduled mouseup actions
     75        if (this.mouseUpTimeout) {
     76          this.contentWindow.clearTimeout(this.mouseUpTimeout);
     77        }
     78 
     79        const { screenX, screenY } = event;
     80 
     81        this.mouseUpTimeout = this.contentWindow.setTimeout(() => {
     82          const selectionInfo = this.getSelectionInfo();
     83          const delay = event.timeStamp - this.downTimeStamp;
     84 
     85          // Only send a message if there's a new selection or a long press
     86          if (
     87            (selectionInfo.selection &&
     88              selectionInfo.selection !== this.downSelection) ||
     89            delay > lazy.shortcutsDelay
     90          ) {
     91            this.sendAsyncMessage("GenAI:ShowShortcuts", {
     92              ...selectionInfo,
     93              contentType: "selection",
     94              delay,
     95              screenXDevPx: screenX * this.contentWindow.devicePixelRatio,
     96              screenYDevPx: screenY * this.contentWindow.devicePixelRatio,
     97            });
     98            this.registerHideEvents();
     99          }
    100 
    101          // Clear the timeout reference after execution
    102          this.mouseUpTimeout = null;
    103        }, this.debounceDelay);
    104 
    105        break;
    106      }
    107      case "pagehide":
    108      case "resize":
    109      case "scroll":
    110      case "selectionchange":
    111        // Hide if selection might have shifted away from shortcuts
    112        sendHide();
    113        break;
    114    }
    115  }
    116 
    117  /**
    118   * Provide the selected text and input type.
    119   *
    120   * @returns {object} selection info
    121   */
    122  getSelectionInfo() {
    123    // Handle regular selection outside of inputs
    124    const { activeElement } = this.document;
    125    const selection = this.contentWindow.getSelection()?.toString().trim();
    126    if (selection) {
    127      return {
    128        inputType: activeElement.closest("[contenteditable]")
    129          ? "contenteditable"
    130          : "",
    131        selection,
    132      };
    133    }
    134 
    135    // Selection within input elements
    136    const { selectionStart, value } = activeElement;
    137    if (selectionStart != null && value != null) {
    138      return {
    139        inputType: activeElement.localName,
    140        selection: value.slice(selectionStart, activeElement.selectionEnd),
    141      };
    142    }
    143    return { inputType: "", selection: "" };
    144  }
    145 
    146  /**
    147   * Handles incoming messages from the browser
    148   *
    149   * @param {object} message - The message object containing name
    150   * @param {string} message.name - The name of the message
    151   * @param {object} message.data - The data object of the message
    152   */
    153  async receiveMessage({ name, data }) {
    154    switch (name) {
    155      case "GetReadableText":
    156        return this.getContentText();
    157      case "AutoSubmit":
    158        return await this.autoSubmitClick(data);
    159      default:
    160        return null;
    161    }
    162  }
    163 
    164  /**
    165   * Find the prompt editable element within a timeout
    166   * Return the element or null
    167   *
    168   * @param {Window} win - the target window
    169   * @param {number} [tms=1000] - time in ms
    170   */
    171  async findTextareaEl(win, tms = 1000) {
    172    const start = win.performance.now();
    173    let el;
    174    while (
    175      !(el = win.document.querySelector(
    176        '#prompt-textarea, [contenteditable], [role="textbox"]'
    177      )) &&
    178      win.performance.now() - start < tms
    179    ) {
    180      await new Promise(r => win.requestAnimationFrame(r));
    181    }
    182    return el;
    183  }
    184 
    185  /**
    186   * Automatically submit the prompt
    187   *
    188   * @param {string} promptText - the prompt to send
    189   */
    190  async autoSubmitClick({ promptText = "" } = {}) {
    191    const win = this.contentWindow;
    192    if (!win || win._autosent) {
    193      return;
    194    }
    195 
    196    // Ensure the DOM is ready before querying elements
    197    if (win.document.readyState === "loading") {
    198      await new Promise(r =>
    199        win.addEventListener("DOMContentLoaded", r, { once: true })
    200      );
    201    }
    202 
    203    const editable = await this.findTextareaEl(win);
    204    if (!editable) {
    205      return;
    206    }
    207 
    208    if (!editable.textContent) {
    209      editable.textContent = promptText;
    210      editable.dispatchEvent(new win.InputEvent("input", { bubbles: true }));
    211    }
    212 
    213    // Explicitly wait for the button is ready
    214    await new Promise(r => win.requestAnimationFrame(r));
    215 
    216    // Simulating click to avoid SPA router rewriting (?prompt-textarea=)
    217    const submitBtn =
    218      win.document.querySelector('button[data-testid="send-button"]') ||
    219      win.document.querySelector('button[aria-label="Send prompt"]') ||
    220      win.document.querySelector('button[aria-label="Send message"]');
    221 
    222    if (submitBtn) {
    223      submitBtn.click();
    224      win._autosent = true;
    225    }
    226 
    227    // Ensure clean up textarea only for chatGPT and mochitest
    228    if (
    229      win._autosent &&
    230      (/chatgpt\.com/i.test(win.location.host) ||
    231        win.location.pathname.includes("file_chat-autosubmit.html"))
    232    ) {
    233      const container = editable.parentElement;
    234      if (!container) {
    235        return;
    236      }
    237 
    238      const observer = new win.MutationObserver(() => {
    239        // Always refetch because ChatGPT replaces editable div
    240        const currentEditable = container.querySelector(
    241          '[contenteditable="true"]'
    242        );
    243        if (!currentEditable) {
    244          return;
    245        }
    246 
    247        let hasText = currentEditable.textContent?.trim().length > 0;
    248        if (hasText) {
    249          currentEditable.textContent = "";
    250          currentEditable.dispatchEvent(
    251            new win.InputEvent("input", { bubbles: true })
    252          );
    253        }
    254      });
    255 
    256      observer.observe(container, { childList: true, subtree: true });
    257 
    258      // Disconnect once things stabilize
    259      win.setTimeout(() => observer.disconnect(), 2000);
    260    }
    261  }
    262 
    263  /**
    264   * Get readable article text or whole innerText from the content side.
    265   *
    266   * @returns {string} text from the page
    267   */
    268  async getContentText() {
    269    const win = this.browsingContext?.window;
    270    const doc = win?.document;
    271    const article = await lazy.ReaderMode.parseDocument(doc);
    272    return {
    273      readerMode: !!article?.textContent,
    274      selection: (article?.textContent || doc?.body?.innerText || "")
    275        .trim()
    276        // Replace duplicate whitespace with either a single newline or space
    277        .replace(/(\s*\n\s*)|\s{2,}/g, (_, newline) => (newline ? "\n" : " ")),
    278    };
    279  }
    280 }