chat.js (17684B)
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 const { topChromeWindow } = window.browsingContext; 6 7 const lazy = {}; 8 ChromeUtils.defineESModuleGetters(lazy, { 9 GenAI: "resource:///modules/GenAI.sys.mjs", 10 SpecialMessageActions: 11 "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", 12 }); 13 const { XPCOMUtils } = ChromeUtils.importESModule( 14 "resource://gre/modules/XPCOMUtils.sys.mjs" 15 ); 16 17 // Define actions for onboarding and chatbot 18 const ACTIONS = Object.freeze({ 19 CHATBOT_PERSIST: "chatbot:persist", 20 CHATBOT_REVERT: "chatbot:revert", 21 CHATBOT_SELECT: "chatbot:select", 22 CHATBOT_SUPPORT: "chatbot:support", 23 OPEN_URL: "OPEN_URL", 24 }); 25 26 XPCOMUtils.defineLazyPreferenceGetter( 27 lazy, 28 "providerPref", 29 "browser.ml.chat.provider", 30 null, 31 renderProviders 32 ); 33 XPCOMUtils.defineLazyPreferenceGetter( 34 lazy, 35 "shortcutsPref", 36 "browser.ml.chat.shortcuts" 37 ); 38 XPCOMUtils.defineLazyPreferenceGetter( 39 lazy, 40 "sidebarRevampPref", 41 "sidebar.revamp" 42 ); 43 XPCOMUtils.defineLazyPreferenceGetter( 44 lazy, 45 "onboardingConfig", 46 "browser.ml.chat.onboarding.config", 47 JSON.stringify({ 48 id: "chatbot", 49 template: "multistage", 50 transitions: true, 51 screens: [ 52 { 53 id: "chat_pick", 54 content: { 55 fullscreen: true, 56 hide_secondary_section: "responsive", 57 narrow: true, 58 position: "split", 59 60 title: { 61 fontWeight: 400, 62 string_id: "genai-onboarding-choose-header", 63 }, 64 cta_paragraph: { 65 text: { 66 string_id: "genai-onboarding-choose-description", 67 string_name: "learn-more", 68 }, 69 action: { 70 type: ACTIONS.CHATBOT_SUPPORT, 71 }, 72 }, 73 above_button_content: [ 74 // Placeholder to inject on provider change 75 { 76 text: " ", 77 type: "text", 78 }, 79 ], 80 primary_button: { 81 action: { 82 navigate: true, 83 type: ACTIONS.CHATBOT_PERSIST, 84 }, 85 label: { string_id: "genai-onboarding-primary" }, 86 }, 87 additional_button: { 88 action: { dismiss: true, type: ACTIONS.CHATBOT_REVERT }, 89 label: { string_id: "genai-onboarding-secondary" }, 90 style: "link", 91 }, 92 progress_bar: true, 93 }, 94 }, 95 ], 96 }) 97 ); 98 99 ChromeUtils.defineLazyGetter( 100 lazy, 101 "supportLink", 102 () => 103 Services.urlFormatter.formatURLPref("app.support.baseURL") + "ai-chatbot" 104 ); 105 106 const node = {}; 107 108 function closeSidebar() { 109 topChromeWindow.SidebarController.hide(); 110 } 111 112 function openLink(url) { 113 topChromeWindow.openLinkIn(url, "tabshifted", { 114 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}), 115 }); 116 } 117 118 function request(url = lazy.providerPref) { 119 try { 120 node.chat.fixupAndLoadURIString(url, { 121 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( 122 {} 123 ), 124 }); 125 } catch (ex) { 126 console.error("Failed to request chat provider", ex); 127 } 128 } 129 130 function renderChat() { 131 const browser = document.createXULElement("browser"); 132 const browserContainer = document.getElementById("browser-container"); 133 browser.setAttribute("disableglobalhistory", "true"); 134 browser.setAttribute("maychangeremoteness", "true"); 135 browser.setAttribute("nodefaultsrc", "true"); 136 browser.setAttribute("remote", "true"); 137 browser.setAttribute("type", "content"); 138 return browserContainer.appendChild(browser); 139 } 140 141 async function renderProviders() { 142 // Skip potential pref change callback when unloading 143 if ((await document.visibilityState) == "hidden") { 144 return null; 145 } 146 147 const select = document.getElementById("provider"); 148 select.innerHTML = ""; 149 let selected = false; 150 151 const addOption = (text = "", val = "") => { 152 const option = select.appendChild(document.createElement("option")); 153 option.textContent = text; 154 option.value = val; 155 return option; 156 }; 157 158 // Add the known providers in order while looking for current selection 159 lazy.GenAI.chatProviders.forEach((data, url) => { 160 const option = addOption(data.name, url); 161 if (lazy.providerPref == url) { 162 option.selected = true; 163 selected = true; 164 } else if (data.hidden) { 165 option.hidden = true; 166 } 167 }); 168 169 // Must be a custom preference if provider wasn't found 170 if (!selected) { 171 const option = addOption(lazy.providerPref, lazy.providerPref); 172 option.selected = true; 173 if (!lazy.providerPref) { 174 showOnboarding(); 175 } 176 } 177 178 // Clear warning message from different provider 179 clearWarningMessage(); 180 181 // Add extra controls after the providers 182 select.appendChild(document.createElement("hr")); 183 document.l10n.setAttributes(addOption(), "genai-provider-view-details"); 184 185 // Update provider telemetry 186 const providerId = lazy.GenAI.getProviderId(lazy.providerPref); 187 Glean.genaiChatbot.provider.set(providerId); 188 if (renderProviders.lastId && document.hasFocus()) { 189 Glean.genaiChatbot.providerChange.record({ 190 current: providerId, 191 previous: renderProviders.lastId, 192 surface: "panel", 193 }); 194 } 195 renderProviders.lastId = providerId; 196 197 // Load the requested provider 198 request(); 199 return select; 200 } 201 202 function renderMore() { 203 const button = document.getElementById("header-more"); 204 button.addEventListener("click", () => { 205 const topDoc = topChromeWindow.document; 206 let menu = topDoc.getElementById("chatbot-menupopup"); 207 if (!menu) { 208 menu = topDoc 209 .getElementById("mainPopupSet") 210 .appendChild(topDoc.createXULElement("menupopup")); 211 menu.id = "chatbot-menupopup"; 212 node.menu = menu; 213 menu.addEventListener("popuphidden", () => { 214 button.setAttribute("aria-expanded", false); 215 }); 216 } 217 menu.innerHTML = ""; 218 219 const provider = lazy.GenAI.chatProviders.get(lazy.providerPref)?.name; 220 const providerId = lazy.GenAI.getProviderId(); 221 [ 222 [ 223 "menuitem", 224 [ 225 provider 226 ? "genai-options-reload-provider" 227 : "genai-options-reload-generic", 228 { provider }, 229 ], 230 function reload() { 231 request(); 232 }, 233 ], 234 ["menuseparator"], 235 [ 236 "menuitem", 237 ["genai-options-show-shortcut"], 238 function show_shortcuts() { 239 Services.prefs.setBoolPref("browser.ml.chat.shortcuts", true); 240 }, 241 lazy.shortcutsPref, 242 ], 243 [ 244 "menuitem", 245 ["genai-options-hide-shortcut"], 246 function hide_shortcuts() { 247 Services.prefs.setBoolPref("browser.ml.chat.shortcuts", false); 248 }, 249 !lazy.shortcutsPref, 250 ], 251 ["menuseparator"], 252 [ 253 "menuitem", 254 ["genai-options-about-chatbot"], 255 function about() { 256 openLink(lazy.supportLink); 257 }, 258 ], 259 ].forEach(([type, l10n, command, checked]) => { 260 const item = menu.appendChild(topDoc.createXULElement(type)); 261 if (type != "menuitem") { 262 return; 263 } 264 document.l10n.setAttributes(item, ...l10n); 265 item.addEventListener("command", () => { 266 command(); 267 Glean.genaiChatbot.sidebarMoreMenuClick.record({ 268 action: command.name, 269 provider: providerId, 270 }); 271 }); 272 if (checked !== undefined) { 273 item.setAttribute("type", "checkbox"); 274 if (checked) { 275 item.setAttribute("checked", true); 276 } 277 } 278 }); 279 menu.openPopup(button, "after_start"); 280 button.setAttribute("aria-expanded", true); 281 Glean.genaiChatbot.sidebarMoreMenuDisplay.record({ provider: providerId }); 282 }); 283 } 284 285 function handleChange({ target }) { 286 const { value } = target; 287 switch (target) { 288 case node.provider: 289 // Special behavior to show first screen of onboarding 290 if (value == "") { 291 target.value = lazy.providerPref; 292 showOnboarding(1); 293 Glean.genaiChatbot.sidebarProviderMenuClick.record({ 294 action: "details", 295 provider: lazy.GenAI.getProviderId(), 296 }); 297 } else { 298 Services.prefs.setStringPref("browser.ml.chat.provider", value); 299 } 300 break; 301 } 302 } 303 addEventListener("change", handleChange); 304 305 // Expose a promise for loading and rendering the chat browser element 306 var browserPromise = new Promise((resolve, reject) => { 307 addEventListener("load", async () => { 308 try { 309 node.chat = renderChat(); 310 node.provider = await renderProviders(); 311 renderMore(); 312 resolve(node.chat); 313 document.getElementById("header-close").addEventListener("click", () => { 314 closeSidebar(); 315 Glean.genaiChatbot.sidebarCloseClick.record({ 316 provider: lazy.GenAI.getProviderId(), 317 }); 318 }); 319 document 320 .getElementById("summarize-button") 321 .addEventListener("click", async () => { 322 const badgeKey = "browser.ml.chat.page.footerBadge"; 323 const newBadgePref = Services.prefs.getBoolPref(badgeKey); 324 325 if (newBadgePref) { 326 Services.prefs.setBoolPref(badgeKey, false); 327 } 328 await lazy.GenAI.summarizeCurrentPage(topChromeWindow, "footer"); 329 }); 330 } catch (ex) { 331 console.error("Failed to render on load", ex); 332 reject(ex); 333 } 334 335 Glean.genaiChatbot.sidebarToggle.record({ 336 opened: true, 337 provider: lazy.GenAI.getProviderId(), 338 reason: "load", 339 version: lazy.sidebarRevampPref ? "new" : "old", 340 }); 341 }); 342 }); 343 344 addEventListener("unload", () => { 345 node.menu?.remove(); 346 Glean.genaiChatbot.sidebarToggle.record({ 347 opened: false, 348 provider: lazy.GenAI.getProviderId(), 349 reason: "unload", 350 version: lazy.sidebarRevampPref ? "new" : "old", 351 }); 352 }); 353 354 /** 355 * Show onboarding screens 356 * 357 * @param {number} length optional show fewer screens 358 */ 359 function showOnboarding(length) { 360 // Insert onboarding container and render with script 361 const root = document.createElement("div"); 362 root.id = "multi-stage-message-root"; 363 document.getElementById(root.id)?.remove(); 364 document.body.prepend(root); 365 history.replaceState("", ""); 366 const script = document.head.appendChild(document.createElement("script")); 367 script.src = "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js"; 368 369 // Convert provider data for lookup by id 370 const providerConfigs = new Map(); 371 lazy.GenAI.chatProviders.forEach((data, url) => { 372 if (!data.hidden) { 373 providerConfigs.set(data.id, { ...data, url }); 374 } 375 }); 376 377 // Define various AW* functions to control aboutwelcome bundle behavior 378 Object.assign(window, { 379 AWEvaluateScreenTargeting(screens) { 380 return screens; 381 }, 382 AWFinish() { 383 if (lazy.providerPref == "") { 384 closeSidebar(); 385 } 386 root.remove(); 387 388 // Indicate onboarding finished and allow another 389 showOnboarding.resolve(); 390 onboardingPromise = new Promise(resolve => { 391 showOnboarding.resolve = resolve; 392 }); 393 }, 394 AWGetFeatureConfig() { 395 const onboarding = JSON.parse(lazy.onboardingConfig); 396 const providerTiles = { 397 action: { picker: "<event>" }, 398 data: [...providerConfigs.values()].map(config => ({ 399 action: { type: ACTIONS.CHATBOT_SELECT, config }, 400 id: config.id, 401 label: config.name, 402 tooltip: { string_id: config.tooltipId }, 403 })), 404 // Default to nothing selected 405 selected: " ", 406 type: "single-select", 407 }; 408 // Insert provider tiles on the first screen 409 onboarding.screens[0].content.tiles = providerTiles; 410 // Remove extra screens if any 411 onboarding.screens = onboarding.screens.slice(0, length); 412 return onboarding; 413 }, 414 AWGetInstalledAddons() {}, 415 AWGetSelectedTheme() { 416 const primary = document.querySelector(".primary"); 417 if (primary) { 418 primary.disabled = true; 419 } 420 }, 421 AWSendEventTelemetry({ event, event_context: { source } }) { 422 const { provider } = window.AWSendEventTelemetry; 423 const step = 1; 424 switch (true) { 425 case event == "IMPRESSION": 426 Glean.genaiChatbot.onboardingProviderChoiceDisplayed.record({ 427 provider: lazy.GenAI.getProviderId(lazy.providerPref), 428 step, 429 }); 430 break; 431 case source == "cta_paragraph": 432 Glean.genaiChatbot.onboardingLearnMore.record({ provider, step }); 433 break; 434 case source == "primary_button": 435 Glean.genaiChatbot.onboardingFinish.record({ provider, step }); 436 break; 437 case source == "additional_button": 438 Glean.genaiChatbot.onboardingClose.record({ provider, step }); 439 break; 440 case source.startsWith("link"): 441 Glean.genaiChatbot.onboardingProviderTerms.record({ 442 provider, 443 step, 444 text: source, 445 }); 446 break; 447 // Assume generic click not yet handled above single select of provider 448 case event == "CLICK_BUTTON": 449 window.AWSendEventTelemetry.provider = source; 450 Glean.genaiChatbot.onboardingProviderSelection.record({ 451 provider: source, 452 step, 453 }); 454 break; 455 } 456 }, 457 AWSendToParent(_message, action) { 458 switch (action.type) { 459 case ACTIONS.OPEN_URL: 460 lazy.SpecialMessageActions.handleAction(action, topChromeWindow); 461 return; 462 case ACTIONS.CHATBOT_PERSIST: { 463 const { value } = document.querySelector( 464 "label:has(.selected) input" 465 ); 466 Services.prefs.setStringPref( 467 "browser.ml.chat.provider", 468 providerConfigs.get(value).url 469 ); 470 break; 471 } 472 case ACTIONS.CHATBOT_REVERT: { 473 request(); 474 break; 475 } 476 // Handle single select provider choice 477 case ACTIONS.CHATBOT_SELECT: { 478 const { config } = action; 479 if (!config) { 480 break; 481 } 482 483 request(config.url); 484 document.querySelector(".primary").disabled = false; 485 486 // Set max-height to trigger transition 487 document.querySelectorAll("label .text div").forEach(div => { 488 const selected = 489 div.closest("label").querySelector("input").value == config.id; 490 div.style.maxHeight = selected ? div.scrollHeight + "px" : 0; 491 const a = div.querySelector("a"); 492 if (a) { 493 a.tabIndex = selected ? 0 : -1; 494 } 495 }); 496 497 // Update potentially multiple links for the provider 498 const links = document.querySelector(".link-paragraph"); 499 if (links && links.dataset.l10nId != config.linksId) { 500 links.innerHTML = ""; 501 for (let i = 1; i <= 3; i++) { 502 const link = links.appendChild(document.createElement("a")); 503 const name = (link.dataset.l10nName = `link${i}`); 504 link.href = config[name]; 505 link.setAttribute("value", name); 506 } 507 document.l10n.setAttributes(links, config.linksId); 508 509 const handleLink = ev => { 510 const { href } = ev.target; 511 if (href) { 512 ev.preventDefault(); 513 openLink(href); 514 } 515 }; 516 517 if (!links._listenerAdded) { 518 links?.addEventListener("click", handleLink); 519 links._listenerAdded = true; 520 } 521 } 522 523 break; 524 } 525 case ACTIONS.CHATBOT_SUPPORT: 526 openLink(lazy.supportLink); 527 break; 528 } 529 }, 530 }); 531 } 532 533 // Expose a promise for onboarding finishing 534 var onboardingPromise = new Promise(resolve => { 535 showOnboarding.resolve = resolve; 536 }); 537 538 /** 539 * Clear message if present 540 * 541 */ 542 function clearWarningMessage() { 543 const messageContainer = document.getElementById("message-container"); 544 545 if (messageContainer?.hasChildNodes()) { 546 messageContainer.replaceChildren(); 547 } 548 } 549 550 /** 551 * Display a warning message in the sidebar chatbot panel when context is too long 552 * 553 * @param {number} length context length for a request 554 */ 555 async function showSummarizeWarning(length) { 556 // if previous request showed the message clear previous message 557 clearWarningMessage(); 558 559 const messageContainer = document.getElementById("message-container"); 560 const warningEl = lazy.GenAI.createWarningEl(document, null, true); 561 562 if (!messageContainer) { 563 return; 564 } 565 566 const provider = lazy.GenAI.getProviderId(); 567 const type = "page_summarization"; 568 document.l10n.setAttributes(warningEl, "genai-page-warning"); 569 messageContainer.hidden = false; 570 messageContainer.appendChild(warningEl); 571 572 // Warning message bar impression event 573 Glean.genaiChatbot.lengthDisclaimer.record({ 574 type, 575 length, 576 provider, 577 }); 578 579 await customElements.whenDefined("moz-message-bar"); 580 const dismissButton = warningEl.shadowRoot.querySelector(".close"); 581 dismissButton?.addEventListener("click", () => { 582 Glean.genaiChatbot.lengthDisclaimerDismissed.record({ 583 type, 584 provider, 585 }); 586 messageContainer.hidden = true; 587 }); 588 } 589 590 /** 591 * Expose Sidebar entry for new prompt 592 * 593 * @param {object} opt for new prompt 594 * @param {boolean} [opt.show] 595 * @param {number} [opt.contextLength] 596 */ 597 window.onNewPrompt = async function (opt = {}) { 598 if (opt.show) { 599 await showSummarizeWarning(opt.contextLength); 600 } else { 601 clearWarningMessage(); 602 } 603 };