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