tor-browser

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

SmartAssistEngine.sys.mjs (8292B)


      1 /**
      2 * This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
      5 */
      6 
      7 const lazy = {};
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
     10 });
     11 
     12 /* eslint-disable-next-line mozilla/reject-import-system-module-from-non-system */
     13 import { createEngine } from "chrome://global/content/ml/EngineProcess.sys.mjs";
     14 import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs";
     15 import {
     16  OAUTH_CLIENT_ID,
     17  SCOPE_PROFILE,
     18 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     19 
     20 const toolsConfig = [
     21  {
     22    type: "function",
     23    function: {
     24      name: "search_open_tabs",
     25      description:
     26        "Searches the user's open tabs for tabs that match the given type",
     27      parameters: {
     28        type: "object",
     29        properties: {
     30          type: {
     31            type: "string",
     32            description:
     33              "the type of tabs I am looking for ie news, sports, etc",
     34          },
     35        },
     36        required: ["type"],
     37      },
     38    },
     39  },
     40 ];
     41 
     42 /**
     43 * Searches the user's open tabs for tabs that match the given type
     44 *
     45 * @param {object}  args.type - type of tabs to search for
     46 * @returns
     47 */
     48 
     49 const search_open_tabs = ({ type }) => {
     50  let win = lazy.BrowserWindowTracker.getTopWindow();
     51  let gBrowser = win.gBrowser;
     52  let tabs = gBrowser.tabs;
     53  const tabData = tabs.map(tab => {
     54    return {
     55      title: tab.label,
     56      url: tab.linkedBrowser.currentURI.spec,
     57    };
     58  });
     59 
     60  return {
     61    query: type,
     62    allTabs: tabData,
     63  };
     64 };
     65 
     66 /**
     67 * Smart Assist Engine
     68 */
     69 export const SmartAssistEngine = {
     70  toolMap: {
     71    search_open_tabs,
     72  },
     73 
     74  /**
     75   * Exposing createEngine for testing purposes.
     76   */
     77 
     78  _createEngine: createEngine,
     79 
     80  async _getFxAccountToken() {
     81    try {
     82      const fxAccounts = getFxAccountsSingleton();
     83      const token = await fxAccounts.getOAuthToken({
     84        scope: SCOPE_PROFILE,
     85        client_id: OAUTH_CLIENT_ID,
     86      });
     87      return token;
     88    } catch (error) {
     89      console.warn("Error obtaining FxA token:", error);
     90      return null;
     91    }
     92  },
     93 
     94  /**
     95   * Creates an OpenAI engine instance configured with Smart Assists preferences.
     96   *
     97   * @returns {Promise<object>} The configured engine instance
     98   */
     99  async createOpenAIEngine() {
    100    try {
    101      const engineInstance = await this._createEngine({
    102        apiKey: Services.prefs.getStringPref("browser.ml.smartAssist.apiKey"),
    103        backend: "openai",
    104        baseURL: Services.prefs.getStringPref(
    105          "browser.ml.smartAssist.endpoint"
    106        ),
    107        modelId: Services.prefs.getStringPref("browser.ml.smartAssist.model"),
    108        modelRevision: "main",
    109        taskName: "text-generation",
    110      });
    111      return engineInstance;
    112    } catch (error) {
    113      console.error("Failed to create OpenAI engine:", error);
    114      throw error;
    115    }
    116  },
    117 
    118  /**
    119   * Stream assistant output with tool-call support.
    120   * Yields assistant text chunks as they arrive. If the model issues tool calls,
    121   * we execute them locally, append results to the conversation, and continue
    122   * streaming the model’s follow-up answer. Repeats until no more tool calls.
    123   *
    124   * @param {Array<{role:string, content?:string, tool_call_id?:string, tool_calls?:any}>} messages
    125   * @yields {string} Assistant text chunks
    126   */
    127  async *fetchWithHistory(messages) {
    128    const engineInstance = await this.createOpenAIEngine();
    129    const fxAccountToken = await this._getFxAccountToken();
    130 
    131    // We'll mutate a local copy of the thread as we loop
    132    // We also filter out empty assistant messages because
    133    //  these kinds of messages can produce unexpected model responses
    134    let convo = Array.isArray(messages)
    135      ? messages.filter(msg => !(msg.role == "assistant" && !msg.content))
    136      : [];
    137 
    138    // Helper to run the model once (streaming) on current convo
    139    const streamModelResponse = () =>
    140      engineInstance.runWithGenerator({
    141        streamOptions: { enabled: true },
    142        fxAccountToken,
    143        tool_choice: "auto",
    144        tools: toolsConfig,
    145        args: convo,
    146      });
    147 
    148    // Keep calling until the model finishes without requesting tools
    149    while (true) {
    150      let pendingToolCalls = null;
    151 
    152      // 1) First pass: stream tokens; capture any toolCalls
    153      for await (const chunk of streamModelResponse()) {
    154        // Stream assistant text to the UI
    155        if (chunk?.text) {
    156          yield chunk.text;
    157        }
    158 
    159        // Capture tool calls (do not echo raw tool plumbing to the user)
    160        if (chunk?.toolCalls?.length) {
    161          pendingToolCalls = chunk.toolCalls;
    162        }
    163      }
    164 
    165      // 2) Watch for tool calls; if none, we are done
    166      if (!pendingToolCalls || pendingToolCalls.length === 0) {
    167        return;
    168      }
    169 
    170      // 3) Build the assistant tool_calls message exactly as expected by the API
    171      const assistantToolMsg = {
    172        role: "assistant",
    173        tool_calls: pendingToolCalls.map(toolCall => ({
    174          id: toolCall.id,
    175          type: "function",
    176          function: {
    177            name: toolCall.function.name,
    178            arguments: toolCall.function.arguments,
    179          },
    180        })),
    181      };
    182 
    183      // 4) Execute each tool locally and create a tool message with the result
    184      const toolResultMessages = [];
    185      for (const toolCall of pendingToolCalls) {
    186        const { id, function: functionSpec } = toolCall;
    187        const name = functionSpec?.name || "";
    188        let toolParams = {};
    189 
    190        try {
    191          toolParams = functionSpec?.arguments
    192            ? JSON.parse(functionSpec.arguments)
    193            : {};
    194        } catch {
    195          toolResultMessages.push({
    196            role: "tool",
    197            tool_call_id: id,
    198            content: JSON.stringify({ error: "Invalid JSON arguments" }),
    199          });
    200          continue;
    201        }
    202 
    203        let result;
    204        try {
    205          // Call the appropriate tool by name
    206          const toolFunc = this.toolMap[name];
    207          if (typeof toolFunc !== "function") {
    208            throw new Error(`No such tool: ${name}`);
    209          }
    210 
    211          result = await toolFunc(toolParams);
    212 
    213          // Create special tool call log message to show in the UI log panel
    214          const assistantToolCallLogMsg = {
    215            role: "assistant",
    216            content: `Tool Call: ${name} with parameters: ${JSON.stringify(
    217              toolParams
    218            )}`,
    219            type: "tool_call_log",
    220            result,
    221          };
    222          convo.push(assistantToolCallLogMsg);
    223          yield assistantToolCallLogMsg;
    224        } catch (e) {
    225          result = { error: `Tool execution failed: ${String(e)}` };
    226        }
    227 
    228        toolResultMessages.push({
    229          role: "tool",
    230          tool_call_id: id,
    231          content: typeof result === "string" ? result : JSON.stringify(result),
    232        });
    233      }
    234 
    235      convo = [...convo, assistantToolMsg, ...toolResultMessages];
    236    }
    237  },
    238 
    239  /**
    240   * Gets the intent of the prompt using a text classification model.
    241   *
    242   * @param {string} prompt
    243   * @returns {string} "search" | "chat"
    244   */
    245 
    246  async getPromptIntent(query) {
    247    try {
    248      const engine = await this._createEngine({
    249        featureId: "smart-intent",
    250        modelId: "mozilla/mobilebert-query-intent-detection",
    251        modelRevision: "v0.2.0",
    252        taskName: "text-classification",
    253      });
    254      const threshold = 0.6;
    255      const cleanedQuery = this._preprocessQuery(query);
    256      const resp = await engine.run({ args: [[cleanedQuery]] });
    257      // resp example: [{ label: "chat", score: 0.95 }, { label: "search", score: 0.04 }]
    258      if (
    259        resp[0].label.toLowerCase() === "chat" &&
    260        resp[0].score >= threshold
    261      ) {
    262        return "chat";
    263      }
    264      return "search";
    265    } catch (error) {
    266      console.error("Error using intent detection model:", error);
    267      throw error;
    268    }
    269  },
    270 
    271  // Helper function for preprocessing text input
    272  _preprocessQuery(query) {
    273    if (typeof query !== "string") {
    274      throw new TypeError(
    275        `Expected a string for query preprocessing, but received ${typeof query}`
    276      );
    277    }
    278    return query.replace(/\?/g, "").trim();
    279  },
    280 };