tor-browser

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

page-assist.mjs (9989B)


      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 { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
      6 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
      7 
      8 // eslint-disable-next-line import/no-unassigned-import
      9 import "chrome://browser/content/sidebar/sidebar-panel-header.mjs";
     10 
     11 const lazy = {};
     12 ChromeUtils.defineESModuleGetters(lazy, {
     13  PageAssist: "moz-src:///browser/components/genai/PageAssist.sys.mjs",
     14  AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
     15 });
     16 
     17 import MozInputText from "chrome://global/content/elements/moz-input-text.mjs";
     18 
     19 /**
     20 * A custom element for managing the page assistant input.
     21 */
     22 export class PageAssistInput extends MozInputText {
     23  static properties = {
     24    class: { type: String, reflect: true },
     25  };
     26 
     27  inputTemplate() {
     28    return html`
     29      <link
     30        rel="stylesheet"
     31        href="chrome://browser/content/genai/content/page-assist.css"
     32      />
     33      <input
     34        id="input"
     35        class=${"with-icon " + ifDefined(this.class)}
     36        name=${this.name}
     37        .value=${this.value || ""}
     38        ?disabled=${this.disabled || this.parentDisabled}
     39        accesskey=${ifDefined(this.accessKey)}
     40        placeholder=${ifDefined(this.placeholder)}
     41        aria-label=${ifDefined(this.ariaLabel ?? undefined)}
     42        aria-describedby="description"
     43        @input=${this.handleInput}
     44        @change=${this.redispatchEvent}
     45      />
     46    `;
     47  }
     48 }
     49 customElements.define("page-assists-input", PageAssistInput);
     50 
     51 /**
     52 * A custom element for managing the page assistant sidebar.
     53 */
     54 export class PageAssist extends MozLitElement {
     55  _progressListener = null;
     56  _onTabSelect = null;
     57  _onReaderModeChange = null;
     58  _onUnload = null;
     59 
     60  static properties = {
     61    userPrompt: { type: String },
     62    aiResponse: { type: String },
     63    isCurrentPageReaderable: { type: Boolean },
     64    matchCountQty: { type: Number },
     65    currentMatchIndex: { type: Number },
     66    highlightAll: { type: Boolean },
     67    snippets: { type: Array },
     68  };
     69 
     70  constructor() {
     71    super();
     72    this.userPrompt = "";
     73    this.aiResponse = "";
     74    this.isCurrentPageReaderable = true;
     75    this.matchCountQty = 0;
     76    this.currentMatchIndex = 0;
     77    this.highlightAll = true;
     78    this.snippets = [];
     79  }
     80 
     81  get _browserWin() {
     82    return this.ownerGlobal?.browsingContext?.topChromeWindow || null;
     83  }
     84  get _gBrowser() {
     85    return this._browserWin?.gBrowser || null;
     86  }
     87 
     88  connectedCallback() {
     89    super.connectedCallback();
     90    this._attachReaderModeListener();
     91    this._initURLChange();
     92    this._onUnload = () => this._cleanup();
     93    this._setupFinder();
     94    this.ownerGlobal.addEventListener("unload", this._onUnload, { once: true });
     95  }
     96 
     97  disconnectedCallback() {
     98    // Clean up finder listener
     99    if (this.browser && this.browser.finder) {
    100      this.browser.finder.removeResultListener(this);
    101    }
    102 
    103    if (this._onUnload) {
    104      this.ownerGlobal.removeEventListener("unload", this._onUnload);
    105      this._onUnload = null;
    106    }
    107    this._cleanup();
    108    super.disconnectedCallback();
    109  }
    110 
    111  _setupFinder() {
    112    const gBrowser = this._gBrowser;
    113 
    114    if (!gBrowser) {
    115      console.warn("No gBrowser found.");
    116      return;
    117    }
    118 
    119    const selected = gBrowser.selectedBrowser;
    120 
    121    // If already attached to this browser, skip
    122    if (this.browser === selected) {
    123      return;
    124    }
    125 
    126    // Clean up old listener if needed
    127    if (this.browser && this.browser.finder) {
    128      this.browser.finder.removeResultListener(this);
    129    }
    130 
    131    this.browser = selected;
    132 
    133    if (this.browser && this.browser.finder) {
    134      this.browser.finder.addResultListener(this);
    135    } else {
    136      console.warn("PageAssist: no finder on selected browser.");
    137    }
    138  }
    139 
    140  _cleanup() {
    141    try {
    142      const gBrowser = this._gBrowser;
    143      if (gBrowser && this._progressListener) {
    144        gBrowser.removeTabsProgressListener(this._progressListener);
    145      }
    146      if (gBrowser?.tabContainer && this._onTabSelect) {
    147        gBrowser.tabContainer.removeEventListener(
    148          "TabSelect",
    149          this._onTabSelect
    150        );
    151      }
    152      if (this._onReaderModeChange) {
    153        lazy.AboutReaderParent.removeMessageListener(
    154          "Reader:UpdateReaderButton",
    155          this._onReaderModeChange
    156        );
    157      }
    158    } catch (e) {
    159      console.error("PageAssist cleanup failed:", e);
    160    } finally {
    161      this._progressListener = null;
    162      this._onTabSelect = null;
    163      this._onReaderModeChange = null;
    164    }
    165  }
    166 
    167  _attachReaderModeListener() {
    168    this._onReaderModeChange = {
    169      receiveMessage: msg => {
    170        // AboutReaderParent.callListeners sets msg.target = the <browser> element
    171        const browser = msg?.target;
    172        const selected = this._gBrowser?.selectedBrowser;
    173        if (!browser || browser !== selected) {
    174          return; // only care about the active tab
    175        }
    176        // AboutReaderParent already set browser.isArticle for this message.
    177        this.isCurrentPageReaderable = !!browser.isArticle;
    178      },
    179    };
    180 
    181    lazy.AboutReaderParent.addMessageListener(
    182      "Reader:UpdateReaderButton",
    183      this._onReaderModeChange
    184    );
    185  }
    186 
    187  /**
    188   * Initialize URL change detection
    189   */
    190  _initURLChange() {
    191    const { gBrowser } = this._gBrowser;
    192    if (!gBrowser) {
    193      return;
    194    }
    195 
    196    this._onTabSelect = () => {
    197      this._setupFinder();
    198      const browser = gBrowser.selectedBrowser;
    199      this.isCurrentPageReaderable = !!browser?.isArticle;
    200    };
    201    gBrowser.tabContainer.addEventListener("TabSelect", this._onTabSelect);
    202 
    203    this._progressListener = {
    204      onLocationChange: (browser, webProgress) => {
    205        if (!webProgress?.isTopLevel) {
    206          return;
    207        }
    208        this.isCurrentPageReaderable = !!browser?.isArticle;
    209      },
    210    };
    211    gBrowser.addTabsProgressListener(this._progressListener);
    212 
    213    // Initial check
    214    this._onTabSelect();
    215  }
    216 
    217  /**
    218   * Fetch Page Data
    219   *
    220   * @returns {Promise<null|
    221   * {
    222   *  url: string,
    223   *  title: string,
    224   *  content: string,
    225   *  textContent: string,
    226   *  excerpt: string,
    227   *  isReaderable: boolean
    228   * }>}
    229   */
    230  async _fetchPageData() {
    231    const gBrowser = this._gBrowser;
    232 
    233    const windowGlobal =
    234      gBrowser?.selectedBrowser?.browsingContext?.currentWindowGlobal;
    235 
    236    if (!windowGlobal) {
    237      return null;
    238    }
    239 
    240    // Get the parent actor instance
    241    const actor = windowGlobal.getActor("PageAssist");
    242    return await actor.fetchPageData();
    243  }
    244 
    245  _clearFinder() {
    246    if (this.browser?.finder) {
    247      this.browser.finder.removeSelection();
    248      this.browser.finder.highlight(false, "", false);
    249    }
    250    this.matchCountQty = 0;
    251    this.currentMatchIndex = 0;
    252    this.snippets = [];
    253  }
    254 
    255  _handlePromptInput = e => {
    256    const value = e.target.value;
    257    this.userPrompt = value;
    258 
    259    // If input is empty, clear values
    260    if (!value) {
    261      this._clearFinder();
    262      return;
    263    }
    264 
    265    // Perform the search
    266    this.browser.finder.fastFind(value, false, false);
    267 
    268    if (this.highlightAll) {
    269      // Todo this also needs to take contextRange.
    270      this.browser.finder.highlight(true, value, false);
    271    }
    272 
    273    // Request match count - this method will trigger onMatchesCountResult callback
    274    this.browser.finder.requestMatchesCount(value, {
    275      linksOnly: false,
    276      contextRange: 30,
    277    });
    278  };
    279 
    280  onMatchesCountResult(result) {
    281    this.matchCountQty = result.total;
    282    this.currentMatchIndex = result.current;
    283    this.snippets = result.snippets || [];
    284  }
    285 
    286  // Abstract method need to be implemented or it will error
    287  onHighlightFinished() {
    288    // Noop.
    289  }
    290 
    291  // Finder result listener methods
    292  onFindResult(result) {
    293    switch (result.result) {
    294      case Ci.nsITypeAheadFind.FIND_NOTFOUND:
    295        this.matchCountQty = 0;
    296        this.currentMatchIndex = 0;
    297        this.snippets = [];
    298        break;
    299 
    300      default:
    301        break;
    302    }
    303  }
    304 
    305  _handleSubmit = async () => {
    306    const pageData = await this._fetchPageData();
    307    if (!pageData) {
    308      this.aiResponse = "No page data";
    309      return;
    310    }
    311    const aiResponse = await lazy.PageAssist.fetchAiResponse(
    312      this.userPrompt,
    313      pageData
    314    );
    315    this.aiResponse = aiResponse ?? "No response";
    316  };
    317 
    318  render() {
    319    return html`
    320      <link
    321        rel="stylesheet"
    322        href="chrome://browser/content/genai/content/page-assist.css"
    323      />
    324      <div>
    325        <sidebar-panel-header
    326          data-l10n-id="genai-page-assist-sidebar-title"
    327          data-l10n-attrs="heading"
    328          view="viewGenaiPageAssistSidebar"
    329        ></sidebar-panel-header>
    330        <div class="wrapper">
    331          ${this.aiResponse
    332            ? html`<div class="ai-response">${this.aiResponse}</div>`
    333            : ""}
    334          <div>
    335            <page-assists-input
    336              class="find-input"
    337              type="text"
    338              placeholder="Find in page..."
    339              .value=${this.userPrompt}
    340              @input=${this._handlePromptInput}
    341            ></page-assists-input>
    342            <moz-button
    343              id="submit-user-prompt-btn"
    344              type="primary"
    345              size="small"
    346              @click=${this._handleSubmit}
    347            >
    348              Submit
    349            </moz-button>
    350          </div>
    351 
    352          <div>
    353            ${this.snippets.length
    354              ? html`<div class="snippets">
    355                  <h3>Snippets</h3>
    356                  <ul>
    357                    ${this.snippets.map(
    358                      snippet =>
    359                        html`<li>
    360                          ${snippet.before}<b>${snippet.match}</b>${snippet.after}
    361                        </li>`
    362                    )}
    363                  </ul>
    364                </div>`
    365              : ""}
    366          </div>
    367        </div>
    368      </div>
    369    `;
    370  }
    371 }
    372 
    373 customElements.define("page-assist", PageAssist);