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 };