GenAI.sys.mjs (43391B)
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 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 8 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 9 10 const lazy = {}; 11 ChromeUtils.defineESModuleGetters(lazy, { 12 ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", 13 ContentAnalysisUtils: "resource://gre/modules/ContentAnalysisUtils.sys.mjs", 14 EveryWindow: "resource:///modules/EveryWindow.sys.mjs", 15 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 16 PrefUtils: "moz-src:///toolkit/modules/PrefUtils.sys.mjs", 17 SidebarManager: 18 "moz-src:///browser/components/sidebar/SidebarManager.sys.mjs", 19 }); 20 ChromeUtils.defineLazyGetter( 21 lazy, 22 "l10n", 23 () => new Localization(["browser/genai.ftl"]) 24 ); 25 XPCOMUtils.defineLazyPreferenceGetter( 26 lazy, 27 "chatEnabled", 28 "browser.ml.chat.enabled", 29 null, 30 (_pref, _old, val) => onChatEnabledChange(val) 31 ); 32 XPCOMUtils.defineLazyPreferenceGetter( 33 lazy, 34 "chatHideLocalhost", 35 "browser.ml.chat.hideLocalhost", 36 null, 37 reorderChatProviders 38 ); 39 XPCOMUtils.defineLazyPreferenceGetter( 40 lazy, 41 "chatMaxLength", 42 "browser.ml.chat.maxLength" 43 ); 44 XPCOMUtils.defineLazyPreferenceGetter(lazy, "chatMenu", "browser.ml.chat.menu"); 45 XPCOMUtils.defineLazyPreferenceGetter( 46 lazy, 47 "chatNimbus", 48 "browser.ml.chat.nimbus" 49 ); 50 XPCOMUtils.defineLazyPreferenceGetter( 51 lazy, 52 "chatOpenSidebarOnProviderChange", 53 "browser.ml.chat.openSidebarOnProviderChange", 54 true 55 ); 56 XPCOMUtils.defineLazyPreferenceGetter(lazy, "chatPage", "browser.ml.chat.page"); 57 XPCOMUtils.defineLazyPreferenceGetter( 58 lazy, 59 "chatPageMenuBadge", 60 "browser.ml.chat.page.menuBadge" 61 ); 62 XPCOMUtils.defineLazyPreferenceGetter( 63 lazy, 64 "chatPromptPrefix", 65 "browser.ml.chat.prompt.prefix" 66 ); 67 XPCOMUtils.defineLazyPreferenceGetter( 68 lazy, 69 "chatProvider", 70 "browser.ml.chat.provider", 71 null, 72 (_pref, _old, val) => onChatProviderChange(val) 73 ); 74 XPCOMUtils.defineLazyPreferenceGetter( 75 lazy, 76 "chatProviders", 77 "browser.ml.chat.providers", 78 "claude,chatgpt,copilot,gemini,lechat", 79 reorderChatProviders 80 ); 81 XPCOMUtils.defineLazyPreferenceGetter( 82 lazy, 83 "chatShortcuts", 84 "browser.ml.chat.shortcuts", 85 null, 86 (_pref, _old, val) => onChatShortcutsChange(val) 87 ); 88 XPCOMUtils.defineLazyPreferenceGetter( 89 lazy, 90 "chatShortcutsCustom", 91 "browser.ml.chat.shortcuts.custom" 92 ); 93 XPCOMUtils.defineLazyPreferenceGetter( 94 lazy, 95 "chatShortcutsIgnoreFields", 96 "browser.ml.chat.shortcuts.ignoreFields", 97 "input", 98 updateIgnoredInputs 99 ); 100 XPCOMUtils.defineLazyPreferenceGetter( 101 lazy, 102 "chatSidebar", 103 "browser.ml.chat.sidebar" 104 ); 105 XPCOMUtils.defineLazyPreferenceGetter(lazy, "sidebarRevamp", "sidebar.revamp"); 106 XPCOMUtils.defineLazyPreferenceGetter( 107 lazy, 108 "sidebarTools", 109 "sidebar.main.tools" 110 ); 111 XPCOMUtils.defineLazyPreferenceGetter( 112 lazy, 113 "shortcutMouseoverCount", 114 "browser.ml.chat.shortcut.onboardingMouseoverCount", 115 0 116 ); 117 118 XPCOMUtils.defineLazyServiceGetter( 119 lazy, 120 "parserUtils", 121 "@mozilla.org/parserutils;1", 122 Ci.nsIParserUtils 123 ); 124 125 export const GenAI = { 126 // Cache of potentially localized prompt 127 chatPromptPrefix: "", 128 129 // Any chat provider can be used and those that match the URLs in this object 130 // will allow for additional UI shown such as populating dropdown with a name, 131 // showing links, and other special behaviors needed for individual providers. 132 chatProviders: new Map([ 133 [ 134 "https://claude.ai/new", 135 { 136 iconUrl: "chrome://browser/content/genai/assets/brands/claude.svg", 137 id: "claude", 138 link1: 139 "https://www.anthropic.com/legal/archive/6370fb23-12ed-41d9-a4a2-28866dee3105", 140 link2: 141 "https://www.anthropic.com/legal/archive/7197103a-5e27-4ee4-93b1-f2d4c39ba1e7", 142 link3: 143 "https://www.anthropic.com/legal/archive/628feec9-7df9-4d38-bc69-fbf104df47b0", 144 linksId: "genai-settings-chat-claude-links", 145 maxLength: 14150, 146 name: "Anthropic Claude", 147 supportAutoSubmit: true, 148 tooltipId: "genai-onboarding-claude-tooltip", 149 }, 150 ], 151 [ 152 "https://chatgpt.com", 153 { 154 iconUrl: "chrome://browser/content/genai/assets/brands/chatgpt.svg", 155 id: "chatgpt", 156 link1: "https://openai.com/terms", 157 link2: "https://openai.com/privacy", 158 linksId: "genai-settings-chat-chatgpt-links", 159 maxLength: 9350, 160 name: "ChatGPT", 161 supportAutoSubmit: true, 162 tooltipId: "genai-onboarding-chatgpt-tooltip", 163 }, 164 ], 165 [ 166 "https://copilot.microsoft.com/?form=MOZCMC", 167 { 168 iconUrl: "chrome://browser/content/genai/assets/brands/copilot.svg", 169 id: "copilot", 170 link1: "https://www.bing.com/new/termsofuse", 171 link2: "https://go.microsoft.com/fwlink/?LinkId=521839", 172 linksId: "genai-settings-chat-copilot-links", 173 maxLength: 3260, 174 name: "Copilot", 175 tooltipId: "genai-onboarding-copilot-tooltip", 176 }, 177 ], 178 [ 179 "https://gemini.google.com", 180 { 181 header: "X-Firefox-Gemini", 182 iconUrl: "chrome://browser/content/genai/assets/brands/gemini.svg", 183 id: "gemini", 184 link1: "https://policies.google.com/terms", 185 link2: "https://policies.google.com/terms/generative-ai/use-policy", 186 link3: "https://support.google.com/gemini?p=privacy_notice", 187 linksId: "genai-settings-chat-gemini-links", 188 // Max header length is around 55000, but spaces are encoded with %20 189 // for header instead of + for query parameter 190 maxLength: 45000, 191 name: "Google Gemini", 192 tooltipId: "genai-onboarding-gemini-tooltip", 193 }, 194 ], 195 [ 196 "https://huggingface.co/chat", 197 { 198 iconUrl: "chrome://browser/content/genai/assets/brands/huggingchat.svg", 199 id: "huggingchat", 200 link1: "https://huggingface.co/chat/privacy", 201 link2: "https://huggingface.co/privacy", 202 linksId: "genai-settings-chat-huggingchat-links", 203 maxLength: 8192, 204 name: "HuggingChat", 205 tooltipId: "genai-onboarding-huggingchat-tooltip", 206 }, 207 ], 208 [ 209 "https://chat.mistral.ai/chat", 210 { 211 iconUrl: "chrome://browser/content/genai/assets/brands/lechat.svg", 212 id: "lechat", 213 link1: "https://mistral.ai/terms/#terms-of-service-le-chat", 214 link2: "https://mistral.ai/terms/#privacy-policy", 215 linksId: "genai-settings-chat-lechat-links", 216 maxLength: 13350, 217 name: "Le Chat Mistral", 218 tooltipId: "genai-onboarding-lechat-tooltip", 219 }, 220 ], 221 [ 222 "http://localhost:8080", 223 { 224 id: "localhost", 225 link1: "https://llamafile.ai", 226 linksId: "genai-settings-chat-localhost-links", 227 maxLength: 8192, 228 name: "localhost", 229 }, 230 ], 231 ]), 232 233 /** 234 * Retrieves the current chat provider information based on the 235 * preference setting 236 * 237 * @returns {object} An object containing the current chat provider's 238 * information, such as name, iconUrl, etc. If no 239 * provider is set, returns an empty object. 240 */ 241 get currentChatProviderInfo() { 242 return { 243 iconUrl: "chrome://global/skin/icons/highlights.svg", 244 ...this.chatProviders.get(lazy.chatProvider), 245 }; 246 }, 247 248 /** 249 * Determine if chat entrypoints can be shown 250 * 251 * @returns {bool} can show 252 */ 253 get canShowChatEntrypoint() { 254 return ( 255 lazy.chatEnabled && 256 lazy.chatProvider != "" && 257 // Chatbot needs to be a tool if new sidebar 258 (!lazy.sidebarRevamp || lazy.sidebarTools.includes("aichat")) 259 ); 260 }, 261 262 /** 263 * Handle startup tasks like telemetry, adding listeners. 264 */ 265 init() { 266 // Allow other callers to init even though we now automatically init 267 if (this._initialized) { 268 return; 269 } 270 this._initialized = true; 271 272 // Access getters for side effects of observing pref changes 273 lazy.chatEnabled; 274 lazy.chatHideLocalhost; 275 lazy.chatProvider; 276 lazy.chatProviders; 277 lazy.chatShortcuts; 278 lazy.chatShortcutsIgnoreFields; 279 280 // Apply initial ordering of providers 281 reorderChatProviders(); 282 updateIgnoredInputs(); 283 284 // Handle nimbus feature pref setting 285 const feature = lazy.NimbusFeatures.chatbot; 286 feature.onUpdate(() => { 287 const enrollment = feature.getEnrollmentMetadata(); 288 if (!enrollment) { 289 return; 290 } 291 292 // Enforce minimum version by skipping pref changes until Firefox restarts 293 // with the appropriate version 294 if ( 295 Services.vc.compare( 296 // Support betas, e.g., 132.0b1, instead of MOZ_APP_VERSION 297 AppConstants.MOZ_APP_VERSION_DISPLAY, 298 // Check configured version or compare with unset handled as 0 299 feature.getVariable("minVersion") 300 ) < 0 301 ) { 302 return; 303 } 304 305 // Set prefs on any branch if we have a new enrollment slug, otherwise 306 // only set default branch as those only last for the session 307 const slug = enrollment.slug + ":" + enrollment.branch; 308 const newEnroll = slug != lazy.chatNimbus; 309 const setPref = ([pref, { branch = "user", value = null }]) => { 310 if (newEnroll || branch == "default") { 311 lazy.PrefUtils.setPref("browser.ml.chat." + pref, value, { branch }); 312 } 313 }; 314 setPref(["nimbus", { value: slug }]); 315 Object.entries(feature.getVariable("prefs") ?? {}).forEach(setPref); 316 317 // Show sidebar badge on new enrollment 318 if (feature.getVariable("badgeSidebar") && newEnroll) { 319 Services.prefs.setBoolPref("sidebar.notification.badge.aichat", true); 320 } 321 }); 322 323 // Record glean metrics after applying nimbus prefs 324 Glean.genaiChatbot.badges.set( 325 Object.entries({ 326 footer: "browser.ml.chat.page.footerBadge", 327 menu: "browser.ml.chat.page.menuBadge", 328 sidebar: "sidebar.notification.badge.aichat", 329 }) 330 .reduce((acc, [key, pref]) => { 331 if (Services.prefs.getBoolPref(pref)) { 332 acc.push(key); 333 } 334 return acc; 335 }, []) 336 .join(",") 337 ); 338 Glean.genaiChatbot.enabled.set(lazy.chatEnabled); 339 Glean.genaiChatbot.menu.set(lazy.chatMenu); 340 Glean.genaiChatbot.page.set(lazy.chatPage); 341 Glean.genaiChatbot.provider.set(this.getProviderId()); 342 Glean.genaiChatbot.shortcuts.set(lazy.chatShortcuts); 343 Glean.genaiChatbot.shortcutsCustom.set(lazy.chatShortcutsCustom); 344 Glean.genaiChatbot.sidebar.set(lazy.chatSidebar); 345 }, 346 347 /** 348 * Convert provider to id. 349 * 350 * @param {string} provider url defaulting to current pref 351 * @returns {string} id or custom or none 352 */ 353 getProviderId(provider = lazy.chatProvider) { 354 const { id } = this.chatProviders.get(provider) ?? {}; 355 return id ?? (provider ? "custom" : "none"); 356 }, 357 358 /** 359 * Add chat items to menu or popup. 360 * 361 * @param {MozBrowser} browser providing context 362 * @param {object} extraContext e.g., selection text 363 * @param {Function} itemAdder creates and returns the item 364 * @param {string} entry name 365 * @param {Function} cleanup optional on item activation 366 * @returns {object} context used for selecting prompts 367 */ 368 async addAskChatItems(browser, extraContext, itemAdder, entry, cleanup) { 369 // Prepare context used for both targeting and handling prompts 370 const window = browser.ownerGlobal; 371 const tab = window?.gBrowser?.getTabForBrowser(browser); 372 const uri = browser.currentURI; 373 const context = { 374 ...extraContext, 375 entry, 376 provider: lazy.chatProvider, 377 tabTitle: (tab?._labelIsContentTitle && tab?.label) || "", 378 url: uri?.asciiHost + uri?.filePath, 379 window, 380 }; 381 382 // Add items that pass along context for handling 383 (await this.getContextualPrompts(context)).forEach(promptObj => { 384 const item = itemAdder(promptObj, context); 385 item?.addEventListener("command", () => { 386 this.handleAskChat(promptObj, context); 387 cleanup?.(item); 388 }); 389 }); 390 391 return context; 392 }, 393 394 /** 395 * Setup helpers and callbacks for ai shortcut button. 396 * 397 * @param {MozButton} aiActionButton instance for the browser window 398 */ 399 initializeAIShortcut(aiActionButton) { 400 if (aiActionButton.initialized) { 401 return; 402 } 403 aiActionButton.initialized = true; 404 405 const setAIButtonAriaLabel = (chatProviderName = "localhost") => { 406 document.l10n.setAttributes(aiActionButton, "genai-shortcut-button", { 407 provider: chatProviderName, 408 }); 409 }; 410 411 const document = aiActionButton.ownerDocument; 412 const initialChatProvider = this.chatProviders.get(lazy.chatProvider); 413 setAIButtonAriaLabel(initialChatProvider?.name); 414 const buttonActiveState = "icon"; 415 const buttonDefaultState = "icon ghost"; 416 const chatShortcutsOptionsPanel = document.getElementById( 417 "chat-shortcuts-options-panel" 418 ); 419 const selectionShortcutActionPanel = document.getElementById( 420 "selection-shortcut-action-panel" 421 ); 422 aiActionButton.hide = () => { 423 chatShortcutsOptionsPanel.hidePopup(); 424 selectionShortcutActionPanel.hidePopup(); 425 }; 426 aiActionButton.iconSrc = "chrome://global/skin/icons/highlights.svg"; 427 aiActionButton.setAttribute("type", buttonDefaultState); 428 chatShortcutsOptionsPanel.addEventListener("popuphidden", () => 429 aiActionButton.setAttribute("type", buttonDefaultState) 430 ); 431 chatShortcutsOptionsPanel.firstChild.id = "ask-chat-shortcuts"; 432 433 // Helper to show rounded warning numbers 434 const roundDownToNearestHundred = number => { 435 return Math.floor(number / 100) * 100; 436 }; 437 438 /** 439 * Create a warning message bar. 440 * 441 * @param {{ 442 * name: string, 443 * maxLength: number, 444 * }} chatProvider attributes for the warning 445 * @returns { mozMessageBarEl } MozMessageBar warning message bar 446 */ 447 const createMessageBarWarning = chatProvider => { 448 const mozMessageBarEl = this.createWarningEl( 449 document, 450 "ask-chat-shortcut-warning", 451 null 452 ); 453 454 // If provider is not defined, use generic warning message 455 const translationId = chatProvider?.name 456 ? "genai-shortcuts-selected-warning" 457 : "genai-shortcuts-selected-warning-generic"; 458 459 document.l10n.setAttributes(mozMessageBarEl, translationId, { 460 provider: chatProvider?.name, 461 maxLength: roundDownToNearestHundred( 462 this.estimateSelectionLimit(chatProvider?.maxLength) 463 ), 464 selectionLength: roundDownToNearestHundred( 465 aiActionButton.data.selection.length 466 ), 467 }); 468 469 return mozMessageBarEl; 470 }; 471 472 // build the ask popup 473 const buildPopup = async () => { 474 aiActionButton.setAttribute("type", buttonActiveState); 475 const vbox = chatShortcutsOptionsPanel.querySelector("vbox"); 476 vbox.innerHTML = ""; 477 478 const showWarning = this.isContextTooLong(aiActionButton.data.selection); 479 const chatProvider = this.chatProviders.get(lazy.chatProvider); 480 481 if (initialChatProvider !== chatProvider?.name) { 482 setAIButtonAriaLabel(chatProvider?.name); 483 } 484 485 // Show warning if selection is too long 486 if (showWarning) { 487 vbox.appendChild(createMessageBarWarning(chatProvider)); 488 } 489 490 const addItem = () => { 491 const button = vbox.appendChild( 492 document.createXULElement("toolbarbutton") 493 ); 494 button.className = "subviewbutton"; 495 button.setAttribute("tabindex", "0"); 496 return button; 497 }; 498 499 const browser = document.ownerGlobal.gBrowser.selectedBrowser; 500 const context = await this.addAskChatItems( 501 browser, 502 aiActionButton.data, 503 promptObj => { 504 const button = addItem(); 505 button.textContent = promptObj.label; 506 return button; 507 }, 508 "shortcuts", 509 aiActionButton.hide 510 ); 511 512 // Add custom textarea box if configured 513 if (lazy.chatShortcutsCustom) { 514 const textAreaEl = vbox.appendChild(document.createElement("textarea")); 515 document.l10n.setAttributes( 516 textAreaEl, 517 chatProvider?.name 518 ? "genai-input-ask-provider" 519 : "genai-input-ask-generic", 520 { provider: chatProvider?.name } 521 ); 522 523 textAreaEl.className = "ask-chat-shortcuts-custom-prompt"; 524 textAreaEl.addEventListener("mouseover", () => textAreaEl.focus()); 525 textAreaEl.addEventListener("keydown", event => { 526 if (event.key == "Enter" && !event.shiftKey) { 527 this.handleAskChat({ value: textAreaEl.value }, context); 528 aiActionButton.hide(); 529 } 530 }); 531 532 // For Content Analysis, we need to specify the URL that the data is being sent to. 533 // In this case it's not the URL in the browsingContext (like it is in other cases), 534 // but the URL of the chatProvider is close enough to where the content will eventually 535 // be sent. 536 lazy.ContentAnalysisUtils.setupContentAnalysisEventsForTextElement( 537 textAreaEl, 538 browser.browsingContext, 539 Services.io.newURI(lazy.chatProvider) 540 ); 541 542 const resetHeight = () => { 543 textAreaEl.style.height = "auto"; 544 textAreaEl.style.height = textAreaEl.scrollHeight + "px"; 545 }; 546 547 textAreaEl.addEventListener("input", resetHeight); 548 chatShortcutsOptionsPanel.addEventListener("popupshown", resetHeight, { 549 once: true, 550 }); 551 } 552 553 // Allow hiding these shortcuts 554 vbox.appendChild(document.createXULElement("toolbarseparator")); 555 const hider = addItem(); 556 document.l10n.setAttributes(hider, "genai-shortcuts-hide"); 557 hider.addEventListener("command", () => { 558 Services.prefs.setBoolPref("browser.ml.chat.shortcuts", false); 559 Glean.genaiChatbot.shortcutsHideClick.record({ 560 selection: aiActionButton.data.selection.length, 561 }); 562 }); 563 564 chatShortcutsOptionsPanel.openPopup( 565 selectionShortcutActionPanel, 566 "after_start", 567 0, 568 10 569 ); 570 Glean.genaiChatbot.shortcutsExpanded.record({ 571 selection: aiActionButton.data.selection.length, 572 provider: this.getProviderId(), 573 warning: showWarning, 574 }); 575 }; 576 577 // ask popup shows on mouseover only in the first two times 578 const hasMouseoverOnPopup = () => { 579 const mouseoverCounter = lazy.shortcutMouseoverCount; 580 const maxMouseoverCount = 2; 581 582 if (mouseoverCounter >= maxMouseoverCount) { 583 return; 584 } 585 586 if (chatShortcutsOptionsPanel.state == "closed") { 587 Services.prefs.setIntPref( 588 "browser.ml.chat.shortcut.onboardingMouseoverCount", 589 mouseoverCounter + 1 590 ); 591 buildPopup(); 592 } 593 }; 594 595 aiActionButton.addEventListener("mouseover", hasMouseoverOnPopup); 596 597 // Detect click to build and toggle the popup 598 aiActionButton.addEventListener("click", async () => { 599 if (chatShortcutsOptionsPanel.state != "closed") { 600 chatShortcutsOptionsPanel.hidePopup(); 601 return; 602 } 603 buildPopup(); 604 }); 605 }, 606 607 /** 608 * Handle messages from content to show or hide shortcuts. 609 * 610 * @param {string} name of message 611 * @param {{ 612 * inputType: string, 613 * selection: string, 614 * delay: number, 615 * x: number, 616 * y: number, 617 * }} data for the message 618 * @param {MozBrowser} browser that provided the message 619 */ 620 handleShortcutsMessage(name, data, browser) { 621 const isInBrowserStack = browser?.closest(".browserStack"); 622 623 if ( 624 !isInBrowserStack || 625 !browser || 626 this.ignoredInputs.has(data.inputType) || 627 !lazy.chatShortcuts || 628 !this.canShowChatEntrypoint 629 ) { 630 return; 631 } 632 633 const window = browser.ownerGlobal; 634 const { document, devicePixelRatio } = window; 635 const aiActionButton = document.getElementById("ai-action-button"); 636 this.initializeAIShortcut(aiActionButton); 637 638 switch (name) { 639 case "GenAI:HideShortcuts": 640 aiActionButton.hide(); 641 break; 642 case "GenAI:ShowShortcuts": { 643 // Save the latest selection so it can be used by popup 644 aiActionButton.data = data; 645 646 Glean.genaiChatbot.shortcutsDisplayed.record({ 647 delay: data.delay, 648 inputType: data.inputType, 649 selection: data.selection.length, 650 }); 651 652 // Position the shortcuts relative to the browser's top-left corner 653 const screenYBase = data.screenYDevPx / devicePixelRatio; 654 const safeSpace = window.outerHeight - 40; 655 // Remove padding if the popup would be offscreen 656 const bottomPadding = screenYBase > safeSpace ? 0 : 40; 657 const screenX = data.screenXDevPx / devicePixelRatio; 658 const screenY = screenYBase + bottomPadding; 659 660 aiActionButton 661 .closest("panel") 662 .openPopup( 663 browser, 664 "before_start", 665 screenX - browser.screenX, 666 screenY - browser.screenY 667 ); 668 break; 669 } 670 } 671 }, 672 673 /** 674 * Determine whether a warning should be shown depending on provider max length 675 * 676 * @param {string} selection selected text from context 677 */ 678 isContextTooLong(selection) { 679 const chatProvider = this.chatProviders.get(lazy.chatProvider); 680 const selectionLength = selection.length; 681 682 return ( 683 this.estimateSelectionLimit(chatProvider?.maxLength) < selectionLength 684 ); 685 }, 686 687 /** 688 * Create <moz-message-bar> warning element 689 * 690 * @param {Document} document the element 691 * @param {string | null} className css class to apply 692 * @param {string | null} dismissable attribute setting 693 */ 694 createWarningEl(document, className, dismissable) { 695 const mozMessageBarEl = document.createElement("moz-message-bar"); 696 697 mozMessageBarEl.dataset.l10nAttrs = "heading,message"; 698 mozMessageBarEl.setAttribute("type", "warning"); 699 if (dismissable) { 700 mozMessageBarEl.setAttribute("dismissable", true); 701 } 702 703 if (className) { 704 mozMessageBarEl.className = className; 705 } 706 707 return mozMessageBarEl; 708 }, 709 710 /** 711 * Build prompts menu to ask chat for context menu. 712 * 713 * @param {MozMenu} menu element to update 714 * @param {object} contextMenu object containing utility and states for building the context menu 715 */ 716 async buildAskChatMenu(menu, contextMenu) { 717 const { 718 browser, 719 selectionInfo, 720 showItem = this.showItem, 721 source, 722 contextTabs = null, 723 } = contextMenu; 724 725 // DO NOT show menu when inside an extension panel 726 const uri = browser.browsingContext?.currentURI.spec; 727 if (uri?.startsWith("moz-extension:")) { 728 showItem(menu, false); 729 return; 730 } 731 732 // Page feature can be shown without provider unless disabled via menu 733 // or revamp sidebar excludes chatbot 734 const isPageFeatureAllowed = 735 lazy.chatPage && 736 (lazy.chatProvider != "" || lazy.chatMenu) && 737 (!lazy.sidebarRevamp || lazy.sidebarTools.includes("aichat")); 738 739 let canShow = false; 740 switch (source) { 741 case "page": 742 canShow = this.canShowChatEntrypoint || isPageFeatureAllowed; 743 break; 744 case "tab": 745 canShow = isPageFeatureAllowed && contextTabs?.length === 1; 746 break; 747 case "tool": 748 canShow = lazy.chatPage; 749 break; 750 } 751 if (!canShow) { 752 showItem(menu, false); 753 return; 754 } 755 756 const provider = this.chatProviders.get(lazy.chatProvider)?.name; 757 const doc = menu.ownerDocument; 758 759 // Only "page" and "tab" contexts need a <menu> submenu 760 if (source !== "tool") { 761 if (provider) { 762 doc.l10n.setAttributes(menu, "genai-menu-ask-provider-2", { provider }); 763 } else { 764 doc.l10n.setAttributes( 765 menu, 766 lazy.chatProvider 767 ? "genai-menu-ask-generic-2" 768 : "genai-menu-no-provider-2" 769 ); 770 } 771 menu.menupopup?.remove(); 772 } 773 774 // NOTE: Show the menu item synchronously, before any `await`. 775 showItem(menu, true); 776 777 // Determine if we have selection or should use page content 778 const context = { 779 contentType: "selection", 780 selection: selectionInfo?.fullText ?? "", 781 }; 782 if (lazy.chatPage && !context.selection) { 783 // Get page content for prompts when no selection 784 await this.addPageContext(browser, context); 785 } 786 const addItem = () => 787 source === "tool" 788 ? menu.appendChild(doc.createXULElement("menuitem")) 789 : menu.appendItem(""); 790 await this.addAskChatItems( 791 browser, 792 context, 793 promptObj => { 794 const { contentType, selection } = context; 795 const item = addItem(); 796 item.setAttribute("label", promptObj.label); 797 798 // Disabled menu if page is invalid 799 if (contentType === "page" && !selection) { 800 item.disabled = true; 801 } 802 if (promptObj.badge && lazy.chatPageMenuBadge) { 803 item.setAttribute("badge", promptObj.badge); 804 } 805 806 return item; 807 }, 808 source, 809 item => { 810 // Currently only summarize page shows a badge, so remove when clicked 811 if (item.hasAttribute("badge")) { 812 Services.prefs.setBoolPref("browser.ml.chat.page.menuBadge", false); 813 } 814 } 815 ); 816 817 // For page which currently only shows 1 prompt, make it less empty with an 818 // Open or Choose options depending on provider 819 if (context.contentType == "page") { 820 const openItem = addItem(); 821 if (provider) { 822 doc.l10n.setAttributes(openItem, "genai-menu-open-provider", { 823 provider, 824 }); 825 } else { 826 doc.l10n.setAttributes( 827 openItem, 828 lazy.chatProvider 829 ? "genai-menu-open-generic" 830 : "genai-menu-choose-chatbot" 831 ); 832 } 833 openItem.addEventListener("command", () => { 834 const window = browser.ownerGlobal; 835 window.SidebarController.show("viewGenaiChatSidebar"); 836 Glean.genaiChatbot.contextmenuChoose.record({ 837 provider: this.getProviderId(), 838 }); 839 }); 840 } 841 842 // Add remove provider option 843 const popup = source === "tool" ? menu : menu.menupopup; 844 popup.appendChild(doc.createXULElement("menuseparator")); 845 const removeItem = addItem(); 846 doc.l10n.setAttributes( 847 removeItem, 848 provider ? "genai-menu-remove-provider" : "genai-menu-remove-generic", 849 { provider } 850 ); 851 removeItem.addEventListener("command", () => { 852 Glean.genaiChatbot.contextmenuRemove.record({ 853 provider: this.getProviderId(), 854 }); 855 if (lazy.chatProvider) { 856 Services.prefs.clearUserPref("browser.ml.chat.provider"); 857 } else if (source === "tool") { 858 // When there's no provider set this menu should remove chatbot as a tool 859 lazy.SidebarManager.updateToolsPref("aichat", true); 860 } else { 861 Services.prefs.setBoolPref("browser.ml.chat.menu", false); 862 } 863 }); 864 }, 865 866 /** 867 * 868 * Build the buildAskChatMenu item for the tab context menu 869 * 870 * @param {MozMenu} menu the tab menu to update 871 * @param {object} tabContextMenu the tab context menu instance 872 * @returns {promise} resolve when the menu item is configured 873 */ 874 async buildTabMenu(menu, tabContextMenu) { 875 const { contextTab, contextTabs } = tabContextMenu; 876 877 const browser = contextTab?.linkedBrowser; 878 await this.buildAskChatMenu(menu, { 879 browser, 880 selectionInfo: null, 881 showItem: (item, shouldShow) => { 882 const separator = item.nextElementSibling; 883 this.showItem(item, shouldShow); 884 885 if (separator && separator.localName === "menuseparator") { 886 this.showItem(separator, shouldShow); 887 } 888 }, 889 source: "tab", 890 contextTabs, 891 }); 892 }, 893 894 /** 895 * Toggle the visibility of the chatbot menu item 896 * 897 * @param {MozMenu} item the chatbot menu item element 898 * @param {boolean} shouldShow whether to show or hide the item 899 */ 900 showItem(item, shouldShow) { 901 item.hidden = !shouldShow; 902 }, 903 904 /** 905 * Get prompts from prefs evaluated with context 906 * 907 * @param {object} context data used for targeting 908 * @returns {promise} array of matching prompt objects 909 */ 910 async getContextualPrompts(context) { 911 // Treat prompt objects as messages to reuse targeting capabilities 912 const messages = []; 913 const toFormat = []; 914 Services.prefs.getChildList("browser.ml.chat.prompts.").forEach(pref => { 915 try { 916 const promptObj = { 917 label: Services.prefs.getStringPref(pref), 918 targeting: "true", 919 value: "", 920 }; 921 try { 922 // Prompts can be JSON with label, value, targeting and other keys 923 Object.assign(promptObj, JSON.parse(promptObj.label)); 924 925 // Ignore provided id (if any) for modified prefs 926 if (Services.prefs.prefHasUserValue(pref)) { 927 promptObj.id = null; 928 } 929 } catch (ex) {} 930 messages.push(promptObj); 931 if (promptObj.l10nId) { 932 toFormat.push(promptObj); 933 } 934 } catch (ex) { 935 console.error("Failed to get prompt pref " + pref, ex); 936 } 937 }); 938 939 // Apply localized attributes for prompts 940 (await lazy.l10n.formatMessages(toFormat.map(obj => obj.l10nId))).forEach( 941 (msg, idx) => 942 msg?.attributes.forEach(attr => (toFormat[idx][attr.name] = attr.value)) 943 ); 944 945 // Specially handle page summarization prompt 946 if (context.contentType == "page") { 947 for (const promptObj of toFormat) { 948 if (promptObj.id == "summarize") { 949 const [badge, label] = await lazy.l10n.formatValues([ 950 "genai-menu-new-badge", 951 "genai-menu-summarize-page", 952 ]); 953 promptObj.badge = badge; 954 promptObj.label = label; 955 } 956 } 957 } 958 959 return lazy.ASRouterTargeting.findMatchingMessage({ 960 messages, 961 returnAll: true, 962 trigger: { context }, 963 }); 964 }, 965 966 /** 967 * Approximately adjust query limit for encoding and other text in prompt, 968 * e.g., page title, per-prompt instructions. Generally more conservative as 969 * going over the limit results in server errors. 970 * 971 * @param {number} maxLength optional of the provider request URI 972 * @returns {number} adjusted length estimate 973 */ 974 estimateSelectionLimit(maxLength = lazy.chatMaxLength) { 975 // Could try to be smarter including the selected text with URI encoding, 976 // base URI length, other parts of the prompt (especially for custom) 977 return Math.round(maxLength * 0.85) - 500; 978 }, 979 980 /** 981 * Updates chat prompt prefix. 982 */ 983 async prepareChatPromptPrefix() { 984 if ( 985 !this.chatPromptPrefix || 986 this.chatLastPrefix != lazy.chatPromptPrefix 987 ) { 988 try { 989 // Check json for localized prefix 990 const prefixObj = JSON.parse(lazy.chatPromptPrefix); 991 this.chatPromptPrefix = ( 992 await lazy.l10n.formatMessages([ 993 { 994 id: prefixObj.l10nId, 995 args: { 996 selection: `%selection|${this.estimateSelectionLimit( 997 this.chatProviders.get(lazy.chatProvider)?.maxLength 998 )}%`, 999 tabTitle: "%tabTitle|50%", 1000 url: "%url%", 1001 }, 1002 }, 1003 ]) 1004 )[0].value; 1005 } catch (ex) { 1006 // Treat as plain text prefix 1007 this.chatPromptPrefix = lazy.chatPromptPrefix; 1008 } 1009 if (this.chatPromptPrefix) { 1010 this.chatPromptPrefix += "\n\n"; 1011 } 1012 this.chatLastPrefix = lazy.chatPromptPrefix; 1013 } 1014 }, 1015 1016 /** 1017 * Build a prompt with context. 1018 * 1019 * @param {MozMenuItem} item Use value falling back to label 1020 * @param {object} context Placeholder keys with values to replace 1021 * @param {Document} document Document for sanitizing context values 1022 * @returns {string} Prompt with placeholders replaced 1023 */ 1024 buildChatPrompt(item, context = {}, document = null) { 1025 // Combine prompt prefix with the item then replace placeholders from the 1026 // original prompt (and not from context) 1027 return (this.chatPromptPrefix + (item.value || item.label)).replace( 1028 // Handle %placeholder% as key|options 1029 /\%(\w+)(?:\|([^%]+))?\%/g, 1030 (placeholder, key, options) => { 1031 // Currently only supporting numeric options for slice with `undefined` 1032 // resulting in whole string. Also remove fake int tags from untrusted content. 1033 const value = context[key]; 1034 let sanitized; 1035 1036 // Sanitize and truncate context values before sending prompt 1037 // otherwise return placeholder 1038 if (value !== undefined) { 1039 const contextElement = document.createElement("div"); 1040 sanitized = lazy.parserUtils.parseFragment( 1041 value, 1042 Ci.nsIParserUtils.SanitizerDropForms | 1043 Ci.nsIParserUtils.SanitizerDropMedia, 1044 false, 1045 Services.io.newURI("about:blank"), 1046 contextElement 1047 ).textContent; 1048 1049 if (options) { 1050 sanitized = sanitized.slice(0, Number(options)); 1051 } 1052 1053 sanitized = sanitized 1054 .replace(/&/g, "&") 1055 .replace(/</g, "<") 1056 .replace(/>/g, ">") 1057 .replace(/"/g, """) 1058 .replace(/'/g, "'"); 1059 } else { 1060 sanitized = placeholder; 1061 } 1062 1063 return `<${key}>${sanitized}</${key}>`; 1064 } 1065 ); 1066 }, 1067 1068 /** 1069 * Update context with page content. 1070 * 1071 * @param {MozBrowser} browser for the tab to get content 1072 * @param {object} context optional existing context to update 1073 * @returns {object} updated context 1074 */ 1075 async addPageContext(browser, context = {}) { 1076 context.contentType = "page"; 1077 try { 1078 Object.assign( 1079 context, 1080 await browser?.browsingContext?.currentWindowContext 1081 .getActor("GenAI") 1082 .sendQuery("GetReadableText") 1083 ); 1084 } catch (ex) { 1085 console.warn("Failed to get page content", ex); 1086 } 1087 return context; 1088 }, 1089 1090 /** 1091 * Summarize the current page content. 1092 * 1093 * @param {Window} window chrome window with tabs 1094 * @param {string} entry name 1095 */ 1096 async summarizeCurrentPage(window, entry) { 1097 const browser = window.gBrowser.selectedBrowser; 1098 await this.addAskChatItems( 1099 browser, 1100 await this.addPageContext(browser), 1101 (promptObj, context) => { 1102 if (promptObj.id === "summarize") { 1103 this.handleAskChat(promptObj, context); 1104 } 1105 }, 1106 entry 1107 ); 1108 }, 1109 1110 /** 1111 * Set up automatic prompt submission for ChatGPT and Claude 1112 * 1113 * @param {Browser} browser - current browser 1114 * @param {string} prompt - prompt text 1115 * @param {object} context of how the prompt should be handled 1116 */ 1117 setupAutoSubmit(browser, prompt, context) { 1118 const sendAutoSubmit = (br, promptText) => { 1119 const wgp = br.browsingContext?.currentWindowGlobal; 1120 const actor = wgp?.getActor("GenAI"); 1121 if (!actor) { 1122 return; 1123 } 1124 1125 try { 1126 actor.sendAsyncMessage("AutoSubmit", { 1127 promptText, 1128 }); 1129 } catch (e) { 1130 console.error("error message: ", e); 1131 } 1132 }; 1133 1134 if (lazy.chatSidebar) { 1135 const injector = { 1136 async onStateChange(_wp, _req, flags) { 1137 const stopDoc = 1138 flags & Ci.nsIWebProgressListener.STATE_STOP && 1139 flags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; 1140 if (!stopDoc) { 1141 return; 1142 } 1143 1144 const wgp = browser.browsingContext?.currentWindowGlobal; 1145 if (!wgp || wgp.isInitialDocument) { 1146 return; 1147 } 1148 1149 try { 1150 browser.webProgress?.removeProgressListener(injector); 1151 } catch {} 1152 await sendAutoSubmit(browser, prompt); 1153 }, 1154 QueryInterface: ChromeUtils.generateQI([ 1155 "nsIWebProgressListener", 1156 "nsISupportsWeakReference", 1157 ]), 1158 }; 1159 1160 browser.webProgress?.addProgressListener( 1161 injector, 1162 Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT 1163 ); 1164 } else { 1165 // Tab mode: 1166 const gBrowser = context.window.gBrowser; 1167 const targetBrowser = browser; 1168 1169 const tabListener = { 1170 async onLocationChange(br, _wp, _req, location) { 1171 if (br !== targetBrowser) { 1172 return; 1173 } 1174 1175 const spec = location?.spec || ""; 1176 if (spec === "about:blank") { 1177 return; 1178 } 1179 1180 try { 1181 gBrowser.removeTabsProgressListener(tabListener); 1182 } catch {} 1183 await sendAutoSubmit(browser, prompt); 1184 }, 1185 QueryInterface: ChromeUtils.generateQI([ 1186 "nsIwebProgressListener", 1187 "nsISupportsWeakReference", 1188 ]), 1189 }; 1190 1191 gBrowser.addTabsProgressListener(tabListener); 1192 } 1193 }, 1194 1195 /** 1196 * Handle selected prompt by opening tab or sidebar. 1197 * 1198 * @param {object} promptObj to convert to string 1199 * @param {object} context of how the prompt should be handled 1200 */ 1201 async handleAskChat(promptObj, context) { 1202 // Record up to 3 types of event telemetry for backwards compatibility 1203 const isPageSummarizeRequest = 1204 promptObj.id == "summarize" && context.contentType == "page"; 1205 if (isPageSummarizeRequest) { 1206 Glean.genaiChatbot.summarizePage.record({ 1207 provider: this.getProviderId(), 1208 reader_mode: context.readerMode, 1209 selection: context.selection?.length ?? 0, 1210 source: context.entry, 1211 }); 1212 } 1213 if (["page", "shortcuts"].includes(context.entry)) { 1214 Glean.genaiChatbot[ 1215 context.entry == "page" 1216 ? "contextmenuPromptClick" 1217 : "shortcutsPromptClick" 1218 ].record({ 1219 prompt: promptObj.id ?? "custom", 1220 provider: this.getProviderId(), 1221 selection: context.selection?.length ?? 0, 1222 }); 1223 } 1224 Glean.genaiChatbot.promptClick.record({ 1225 content_type: context.contentType, 1226 prompt: promptObj.id ?? "custom", 1227 provider: this.getProviderId(), 1228 reader_mode: context.readerMode, 1229 selection: context.selection?.length ?? 0, 1230 source: context.entry, 1231 }); 1232 1233 // If no provider is configured, open sidebar and wait once for onboarding 1234 const { SidebarController } = context.window; 1235 1236 if (!lazy.chatProvider) { 1237 await SidebarController.show("viewGenaiChatSidebar"); 1238 await SidebarController.browser.contentWindow.onboardingPromise; 1239 if (!lazy.chatProvider) { 1240 return; 1241 } 1242 } 1243 1244 // Build prompt after provider is confirmed to use correct length limits 1245 await this.prepareChatPromptPrefix(); 1246 const prompt = this.buildChatPrompt( 1247 promptObj, 1248 { 1249 ...context, 1250 }, 1251 context.window.document 1252 ); 1253 1254 // Pass the prompt via GET url ?q= param or request header 1255 const { 1256 header, 1257 queryParam = "q", 1258 supportAutoSubmit, 1259 } = this.chatProviders.get(lazy.chatProvider) ?? {}; 1260 const url = new URL(lazy.chatProvider); 1261 const options = { 1262 inBackground: false, 1263 relatedToCurrent: true, 1264 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( 1265 {} 1266 ), 1267 }; 1268 1269 if (header) { 1270 options.headers = Cc[ 1271 "@mozilla.org/io/string-input-stream;1" 1272 ].createInstance(Ci.nsIStringInputStream); 1273 options.headers.setByteStringData( 1274 `${header}: ${encodeURIComponent(prompt)}\r\n` 1275 ); 1276 } else { 1277 url.searchParams.set(queryParam, prompt); 1278 } 1279 1280 // Get the desired browser to handle the prompt url request 1281 let browser; 1282 if (lazy.chatSidebar) { 1283 await SidebarController.show("viewGenaiChatSidebar"); 1284 browser = await SidebarController.browser.contentWindow.browserPromise; 1285 if (!browser) { 1286 console.error("Failed to get chat sidebar browser"); 1287 return; 1288 } 1289 const showWarning = 1290 isPageSummarizeRequest && this.isContextTooLong(context.selection); 1291 1292 await SidebarController.browser.contentWindow.onNewPrompt({ 1293 show: showWarning, 1294 ...(showWarning 1295 ? { contextLength: context.selection?.length ?? 0 } 1296 : {}), 1297 }); 1298 } else { 1299 browser = context.window.gBrowser.addTab("", options).linkedBrowser; 1300 } 1301 browser.fixupAndLoadURIString(url, options); 1302 1303 // Run autosubmit only for chatGPT, Claude, or mochitest 1304 if ( 1305 supportAutoSubmit || 1306 lazy.chatProvider?.includes("file_chat-autosubmit.html") 1307 ) { 1308 this.setupAutoSubmit(browser, prompt, context); 1309 } 1310 }, 1311 }; 1312 1313 /** 1314 * Ensure the chat sidebar get closed. 1315 * 1316 * @param {bool} value New pref value 1317 */ 1318 function onChatEnabledChange(value) { 1319 if (!value) { 1320 lazy.EveryWindow.readyWindows.forEach(({ SidebarController }) => { 1321 if ( 1322 SidebarController.isOpen && 1323 SidebarController.currentID == "viewGenaiChatSidebar" 1324 ) { 1325 SidebarController.hide(); 1326 } 1327 }); 1328 } 1329 } 1330 1331 /** 1332 * Ensure the chat sidebar is shown to reflect changed provider. 1333 * 1334 * @param {string} value New pref value 1335 */ 1336 function onChatProviderChange(value) { 1337 if (value && lazy.chatEnabled && lazy.chatOpenSidebarOnProviderChange) { 1338 Services.wm 1339 .getMostRecentWindow("navigator:browser") 1340 ?.SidebarController.show("viewGenaiChatSidebar"); 1341 } 1342 1343 // Recalculate query limit on provider change 1344 GenAI.chatLastPrefix = null; 1345 1346 // Refreshes the sidebar icon and label for all open windows 1347 lazy.EveryWindow.readyWindows.forEach(window => { 1348 window.SidebarController.addOrUpdateExtension("viewGenaiChatSidebar", {}); 1349 }); 1350 } 1351 1352 /** 1353 * Ensure the chat shortcuts get hidden. 1354 * 1355 * @param {bool} value New pref value 1356 */ 1357 function onChatShortcutsChange(value) { 1358 if (!value) { 1359 lazy.EveryWindow.readyWindows.forEach(window => { 1360 const selectionShortcutActionPanel = window.document.getElementById( 1361 "selection-shortcut-action-panel" 1362 ); 1363 1364 selectionShortcutActionPanel.hidePopup(); 1365 }); 1366 } 1367 } 1368 1369 /** 1370 * Update the ordering of chat providers Map. 1371 */ 1372 function reorderChatProviders() { 1373 // Figure out which providers to include in order 1374 const ordered = lazy.chatProviders.split(","); 1375 if (!lazy.chatHideLocalhost) { 1376 ordered.push("localhost"); 1377 } 1378 1379 // Convert the url keys to lookup by id 1380 const idToKey = new Map([...GenAI.chatProviders].map(([k, v]) => [v.id, k])); 1381 1382 // Remove providers in the desired order and make them shown 1383 const toSet = []; 1384 ordered.forEach(id => { 1385 const key = idToKey.get(id); 1386 const val = GenAI.chatProviders.get(key); 1387 if (val) { 1388 val.hidden = false; 1389 toSet.push([key, val]); 1390 GenAI.chatProviders.delete(key); 1391 } 1392 }); 1393 1394 // Hide unremoved providers before re-adding visible ones in order 1395 GenAI.chatProviders.forEach(val => (val.hidden = true)); 1396 toSet.forEach(args => GenAI.chatProviders.set(...args)); 1397 } 1398 1399 /** 1400 * Update ignored input fields Set. 1401 */ 1402 function updateIgnoredInputs() { 1403 GenAI.ignoredInputs = new Set( 1404 // Skip empty string as no input type is "" 1405 lazy.chatShortcutsIgnoreFields.split(",").filter(v => v) 1406 ); 1407 } 1408 1409 // Initialize on first import 1410 GenAI.init();