tor-browser

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

smart-assist.mjs (11883B)


      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 } 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  AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
     14  SmartAssistEngine:
     15    "moz-src:///browser/components/genai/SmartAssistEngine.sys.mjs",
     16  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     17  SpecialMessageActions:
     18    "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
     19  AIWindowUI:
     20    "moz-src:///browser/components/aiwindow/ui/modules/AIWindowUI.sys.mjs",
     21 });
     22 
     23 const FULL_PAGE_URL = "chrome://browser/content/genai/smartAssistPage.html";
     24 const ACTION_CHAT = "chat";
     25 const ACTION_SEARCH = "search";
     26 
     27 /**
     28 * A custom element for managing the smart assistant sidebar.
     29 */
     30 export class SmartAssist extends MozLitElement {
     31  static properties = {
     32    userPrompt: { type: String },
     33    aiResponse: { type: String },
     34    conversationState: { type: Array },
     35    logState: { type: Array },
     36    mode: { type: String }, // "tab" | "sidebar"
     37    overrideNewTab: { type: Boolean },
     38    showLog: { type: Boolean },
     39    actionKey: { type: String }, // "chat" | "search"
     40  };
     41 
     42  constructor() {
     43    super();
     44    this.userPrompt = "";
     45    // TODO the conversation state will evenually need to be stored in a "higher" location
     46    // then just the state of this lit component. This is a Stub to get the convo started for now
     47    this.conversationState = [
     48      { role: "system", content: "You are a helpful assistant" },
     49    ];
     50    this.logState = [];
     51    this.showLog = false;
     52    this.mode = "sidebar";
     53    this.overrideNewTab = Services.prefs.getBoolPref(
     54      "browser.ml.smartAssist.overrideNewTab"
     55    );
     56    this.actionKey = ACTION_CHAT;
     57    this._actions = {
     58      [ACTION_CHAT]: {
     59        label: "Submit",
     60        icon: "chrome://global/skin/icons/arrow-right.svg",
     61        run: this._actionChat,
     62      },
     63      [ACTION_SEARCH]: {
     64        label: "Search",
     65        icon: "chrome://global/skin/icons/search-glass.svg",
     66        run: this._actionSearch,
     67      },
     68    };
     69  }
     70 
     71  connectedCallback() {
     72    super.connectedCallback();
     73    if (this.mode === "sidebar" && this.overrideNewTab) {
     74      this._applyNewTabOverride(true);
     75    }
     76  }
     77 
     78  /**
     79   * Adds a new message to the conversation history.
     80   *
     81   * @param {object} chatEntry - A message object to add to the conversation
     82   * @param {("system"|"user"|"assistant")} chatEntry.role - The role of the message sender
     83   * @param {string} chatEntry.content - The text content of the message
     84   */
     85  _updateConversationState = chatEntry => {
     86    this.conversationState = [...this.conversationState, chatEntry];
     87  };
     88 
     89  _updatelogState = chatEntry => {
     90    const entryWithDate = { ...chatEntry, date: new Date().toLocaleString() };
     91    this.logState = [...this.logState, entryWithDate];
     92  };
     93 
     94  _handlePromptInput = async e => {
     95    try {
     96      const value = e.target.value;
     97      this.userPrompt = value;
     98 
     99      const intent = await lazy.SmartAssistEngine.getPromptIntent(value);
    100      this.actionKey = [ACTION_CHAT, ACTION_SEARCH].includes(intent)
    101        ? intent
    102        : ACTION_CHAT;
    103    } catch (error) {
    104      // Default to chat on error
    105      this.actionKey = ACTION_CHAT;
    106      console.error("Error determining prompt intent:", error);
    107    }
    108  };
    109 
    110  /**
    111   * Returns the current action object based on the actionKey
    112   */
    113 
    114  get inputAction() {
    115    return this._actions[this.actionKey];
    116  }
    117 
    118  _actionSearch = async () => {
    119    const searchTerms = (this.userPrompt || "").trim();
    120    if (!searchTerms) {
    121      return;
    122    }
    123 
    124    const isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
    125    const engine = isPrivate
    126      ? await Services.search.getDefaultPrivate()
    127      : await Services.search.getDefault();
    128 
    129    const submission = engine.getSubmission(searchTerms); // default to SEARCH (text/html)
    130 
    131    // getSubmission can return null if the engine doesn't have a URL
    132    // with a text/html response type. This is unlikely (since
    133    // SearchService._addEngineToStore() should fail for such an engine),
    134    // but let's be on the safe side.
    135    if (!submission) {
    136      return;
    137    }
    138 
    139    const triggeringPrincipal =
    140      Services.scriptSecurityManager.createNullPrincipal({});
    141 
    142    window.browsingContext.topChromeWindow.openLinkIn(
    143      submission.uri.spec,
    144      "current",
    145      {
    146        private: isPrivate,
    147        postData: submission.postData,
    148        inBackground: false,
    149        relatedToCurrent: true,
    150        triggeringPrincipal,
    151        policyContainer: null,
    152        targetBrowser: null,
    153        globalHistoryOptions: {
    154          triggeringSearchEngine: engine.name,
    155        },
    156      }
    157    );
    158  };
    159 
    160  _actionChat = async () => {
    161    const formattedPrompt = (this.userPrompt || "").trim();
    162    if (!formattedPrompt) {
    163      return;
    164    }
    165 
    166    // Push user prompt
    167    this._updateConversationState({ role: "user", content: formattedPrompt });
    168    this.userPrompt = "";
    169 
    170    // Create an empty assistant placeholder.
    171    this._updateConversationState({ role: "assistant", content: "" });
    172    const latestAssistantMessageIndex = this.conversationState.length - 1;
    173 
    174    let acc = "";
    175    try {
    176      const stream = lazy.SmartAssistEngine.fetchWithHistory(
    177        this.conversationState
    178      );
    179 
    180      for await (const chunk of stream) {
    181        // Check to see if chunk is special tool calling log and add to logState
    182        if (chunk.type === "tool_call_log") {
    183          this._updatelogState({
    184            content: chunk.content,
    185            result: chunk.result || "No result",
    186          });
    187          continue;
    188        }
    189        acc += chunk;
    190        // append to the latest assistant message
    191 
    192        this.conversationState[latestAssistantMessageIndex] = {
    193          ...this.conversationState[latestAssistantMessageIndex],
    194          content: acc,
    195        };
    196        this.requestUpdate?.();
    197      }
    198    } catch (e) {
    199      this.conversationState[latestAssistantMessageIndex] = {
    200        role: "assistant",
    201        content: `There was an error`,
    202      };
    203      this.requestUpdate?.();
    204    }
    205  };
    206 
    207  /**
    208   * Mock Functionality to open full page UX
    209   *
    210   * @param {boolean} enable
    211   * Whether or not to override the new tab page.
    212   */
    213  _applyNewTabOverride(enable) {
    214    try {
    215      enable
    216        ? (lazy.AboutNewTab.newTabURL = FULL_PAGE_URL)
    217        : lazy.AboutNewTab.resetNewTabURL();
    218    } catch (e) {
    219      console.error("Failed to toggle new tab override:", e);
    220    }
    221  }
    222 
    223  _onToggleFullPage(e) {
    224    const isChecked = e.target.checked;
    225    Services.prefs.setBoolPref(
    226      "browser.ml.smartAssist.overrideNewTab",
    227      isChecked
    228    );
    229    this.overrideNewTab = isChecked;
    230    this._applyNewTabOverride(isChecked);
    231  }
    232 
    233  /**
    234   * Initiates the Firefox Account sign-in flow for MLPA authentication.
    235   */
    236 
    237  _signIn() {
    238    lazy.SpecialMessageActions.handleAction(
    239      {
    240        type: "FXA_SIGNIN_FLOW",
    241        data: {
    242          entrypoint: "aiwindow",
    243          extraParams: {
    244            service: "aiwindow",
    245          },
    246        },
    247      },
    248      window.browsingContext.topChromeWindow.gBrowser.selectedBrowser
    249    );
    250  }
    251 
    252  _toggleAIWindowSidebar() {
    253    lazy.AIWindowUI.toggleSidebar(window.browsingContext.topChromeWindow);
    254  }
    255 
    256  render() {
    257    const iconSrc = this.showLog
    258      ? "chrome://global/skin/icons/arrow-down.svg"
    259      : "chrome://global/skin/icons/arrow-up.svg";
    260 
    261    return html`
    262      <link
    263        rel="stylesheet"
    264        href="chrome://browser/content/genai/content/smart-assist.css"
    265      />
    266      <div class="wrapper">
    267        ${
    268          this.mode === "sidebar"
    269            ? html` <sidebar-panel-header
    270                data-l10n-id="genai-smart-assist-sidebar-title"
    271                data-l10n-attrs="heading"
    272                view="viewGenaiSmartAssistSidebar"
    273              ></sidebar-panel-header>`
    274            : ""
    275        }
    276 
    277        <div>
    278 
    279          <!-- Conversation Panel -->
    280          <div>
    281            ${this.conversationState
    282              .filter(msg => msg.role !== "system")
    283              .map(
    284                msg =>
    285                  html`<div class="message ${msg.role}">
    286                    <strong>${msg.role}:</strong> ${msg.content}
    287                    ${msg.role === "assistant" && msg.content.length === 0
    288                      ? html`<span>Thinking</span>`
    289                      : ""}
    290                  </div>`
    291              )}
    292          </div>
    293 
    294          <!-- Log Panel -->
    295          ${
    296            this.logState.length !== 0
    297              ? html` <div class="log-panel">
    298                  <div class="log-header">
    299                    <span class="log-title">Log</span>
    300                    <moz-button
    301                      type="ghost"
    302                      iconSrc=${iconSrc}
    303                      @click=${() => {
    304                        this.showLog = !this.showLog;
    305                      }}
    306                    >
    307                    </moz-button>
    308                  </div>
    309                  ${this.showLog
    310                    ? html` <div class="log-entries">
    311                        ${this.logState.map(
    312                          data =>
    313                            html`<div class="log-entry">
    314                              <div><b>Message</b> : ${data.content}</div>
    315                              <div><b>Date</b> : ${data.date}</div>
    316                              <div>
    317                                <b>Tool Response</b> :
    318                                ${JSON.stringify(data.result)}
    319                              </div>
    320                            </div>`
    321                        )}
    322                      </div>`
    323                    : html``}
    324                </div>`
    325              : html``
    326          }
    327          </div>
    328 
    329          <!-- User Input -->
    330          <textarea
    331            .value=${this.userPrompt}
    332            class="prompt-textarea"
    333            @input=${e => this._handlePromptInput(e)}
    334          ></textarea>
    335          <moz-button
    336            iconSrc=${this.inputAction.icon}
    337            id="submit-user-prompt-btn"
    338            type="primary"
    339            size="small"
    340            @click=${this.inputAction.run}
    341            iconPosition="end"
    342          >
    343            ${this.inputAction.label}
    344          </moz-button>
    345          <hr/>
    346          <h3>The following Elements are for testing purposes</h3>
    347 
    348          <p>Sign in for MLPA authentication.</p>
    349          <moz-button
    350            type="primary"
    351            size="small"
    352            @click=${this._signIn}
    353          >
    354            Sign in
    355          </moz-button>
    356          <!-- Footer - New Tab Override -->
    357          ${
    358            this.mode === "sidebar"
    359              ? html`<div class="footer">
    360                  <moz-checkbox
    361                    type="checkbox"
    362                    label="Mock Full Page Experience"
    363                    @change=${e => this._onToggleFullPage(e)}
    364                    ?checked=${this.overrideNewTab}
    365                  ></moz-checkbox>
    366                </div>`
    367              : ""
    368          }
    369 
    370          ${
    371            this.mode === "tab"
    372              ? html`
    373                  <div class="footer">
    374                    <moz-button
    375                      type="primary"
    376                      size="small"
    377                      @click=${this._toggleAIWindowSidebar}
    378                    >
    379                      Open AI Window Sidebar
    380                    </moz-button>
    381                  </div>
    382                `
    383              : ""
    384          }
    385        </div>
    386      </div>
    387    `;
    388  }
    389 }
    390 
    391 customElements.define("smart-assist", SmartAssist);