Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / genai / chat.js
blob2fdc7ff62e9ff204366a9072187f0a60e7c6fc9c
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/. */
5 const { topChromeWindow } = window.browsingContext;
7 const lazy = {};
8 ChromeUtils.defineESModuleGetters(lazy, {
9 GenAI: "resource:///modules/GenAI.sys.mjs",
10 LightweightThemeConsumer:
11 "resource://gre/modules/LightweightThemeConsumer.sys.mjs",
12 SpecialMessageActions:
13 "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
14 });
15 const { XPCOMUtils } = ChromeUtils.importESModule(
16 "resource://gre/modules/XPCOMUtils.sys.mjs"
19 XPCOMUtils.defineLazyPreferenceGetter(
20 lazy,
21 "providerPref",
22 "browser.ml.chat.provider",
23 null,
24 renderProviders
26 XPCOMUtils.defineLazyPreferenceGetter(
27 lazy,
28 "shortcutsPref",
29 "browser.ml.chat.shortcuts"
31 XPCOMUtils.defineLazyPreferenceGetter(
32 lazy,
33 "sidebarRevampPref",
34 "sidebar.revamp"
37 ChromeUtils.defineLazyGetter(
38 lazy,
39 "supportLink",
40 () =>
41 Services.urlFormatter.formatURLPref("app.support.baseURL") + "ai-chatbot"
44 const node = {};
46 function closeSidebar() {
47 topChromeWindow.SidebarController.hide();
50 function openLink(url) {
51 topChromeWindow.openLinkIn(url, "tabshifted", {
52 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
53 });
56 function request(url = lazy.providerPref) {
57 try {
58 node.chat.fixupAndLoadURIString(url, {
59 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
62 });
63 } catch (ex) {
64 console.error("Failed to request chat provider", ex);
68 function renderChat() {
69 const browser = document.createXULElement("browser");
70 browser.setAttribute("disableglobalhistory", "true");
71 browser.setAttribute("maychangeremoteness", "true");
72 browser.setAttribute("nodefaultsrc", "true");
73 browser.setAttribute("remote", "true");
74 browser.setAttribute("type", "content");
75 return document.body.appendChild(browser);
78 async function renderProviders() {
79 // Skip potential pref change callback when unloading
80 if ((await document.visibilityState) == "hidden") {
81 return null;
84 const select = document.getElementById("provider");
85 select.innerHTML = "";
86 let selected = false;
88 const addOption = (text = "", val = "") => {
89 const option = select.appendChild(document.createElement("option"));
90 option.textContent = text;
91 option.value = val;
92 return option;
95 // Add the known providers in order while looking for current selection
96 lazy.GenAI.chatProviders.forEach((data, url) => {
97 const option = addOption(data.name, url);
98 if (lazy.providerPref == url) {
99 option.selected = true;
100 selected = true;
101 } else if (data.hidden) {
102 option.hidden = true;
106 // Must be a custom preference if provider wasn't found
107 if (!selected) {
108 const option = addOption(lazy.providerPref, lazy.providerPref);
109 option.selected = true;
110 if (!lazy.providerPref) {
111 showOnboarding();
115 // Add extra controls after the providers
116 select.appendChild(document.createElement("hr"));
117 document.l10n.setAttributes(addOption(), "genai-provider-view-details");
119 // Update provider telemetry
120 const providerId = lazy.GenAI.getProviderId(lazy.providerPref);
121 Glean.genaiChatbot.provider.set(providerId);
122 if (renderProviders.lastId && document.hasFocus()) {
123 Glean.genaiChatbot.providerChange.record({
124 current: providerId,
125 previous: renderProviders.lastId,
126 surface: "panel",
129 renderProviders.lastId = providerId;
131 // Load the requested provider
132 request();
133 return select;
136 function renderMore() {
137 const button = document.getElementById("header-more");
138 button.addEventListener("click", () => {
139 const topDoc = topChromeWindow.document;
140 let menu = topDoc.getElementById("chatbot-menupopup");
141 if (!menu) {
142 menu = topDoc
143 .getElementById("mainPopupSet")
144 .appendChild(topDoc.createXULElement("menupopup"));
145 menu.id = "chatbot-menupopup";
146 node.menu = menu;
147 menu.addEventListener("popuphidden", () => {
148 button.setAttribute("aria-expanded", false);
151 menu.innerHTML = "";
153 const provider = lazy.GenAI.chatProviders.get(lazy.providerPref)?.name;
154 const providerId = lazy.GenAI.getProviderId();
157 "menuitem",
159 provider
160 ? "genai-options-reload-provider"
161 : "genai-options-reload-generic",
162 { provider },
164 function reload() {
165 request();
168 ["menuseparator"],
170 "menuitem",
171 ["genai-options-show-shortcut"],
172 function show_shortcuts() {
173 Services.prefs.setBoolPref("browser.ml.chat.shortcuts", true);
175 lazy.shortcutsPref,
178 "menuitem",
179 ["genai-options-hide-shortcut"],
180 function hide_shortcuts() {
181 Services.prefs.setBoolPref("browser.ml.chat.shortcuts", false);
183 !lazy.shortcutsPref,
185 ["menuseparator"],
187 "menuitem",
188 ["genai-options-about-chatbot"],
189 function about() {
190 openLink(lazy.supportLink);
193 ].forEach(([type, l10n, command, checked]) => {
194 const item = menu.appendChild(topDoc.createXULElement(type));
195 if (type == "menuitem") {
196 document.l10n.setAttributes(item, ...l10n);
197 item.addEventListener("command", () => {
198 command();
199 Glean.genaiChatbot.sidebarMoreMenuClick.record({
200 action: command.name,
201 provider: providerId,
204 if (checked) {
205 item.setAttribute("checked", true);
209 menu.openPopup(button, "after_start");
210 button.setAttribute("aria-expanded", true);
211 Glean.genaiChatbot.sidebarMoreMenuDisplay.record({ provider: providerId });
215 function handleChange({ target }) {
216 const { value } = target;
217 switch (target) {
218 case node.provider:
219 // Special behavior to show first screen of onboarding
220 if (value == "") {
221 target.value = lazy.providerPref;
222 showOnboarding(1);
223 Glean.genaiChatbot.sidebarProviderMenuClick.record({
224 action: "details",
225 provider: lazy.GenAI.getProviderId(),
227 } else {
228 Services.prefs.setStringPref("browser.ml.chat.provider", value);
230 break;
233 addEventListener("change", handleChange);
235 // Expose a promise for loading and rendering the chat browser element
236 var browserPromise = new Promise((resolve, reject) => {
237 addEventListener("load", async () => {
238 new lazy.LightweightThemeConsumer(document);
239 try {
240 node.chat = renderChat();
241 node.provider = await renderProviders();
242 renderMore();
243 resolve(node.chat);
244 document.getElementById("header-close").addEventListener("click", () => {
245 closeSidebar();
246 Glean.genaiChatbot.sidebarCloseClick.record({
247 provider: lazy.GenAI.getProviderId(),
250 } catch (ex) {
251 console.error("Failed to render on load", ex);
252 reject(ex);
255 Glean.genaiChatbot.sidebarToggle.record({
256 opened: true,
257 provider: lazy.GenAI.getProviderId(),
258 reason: "load",
259 version: lazy.sidebarRevampPref ? "new" : "old",
264 addEventListener("unload", () => {
265 node.menu?.remove();
266 Glean.genaiChatbot.sidebarToggle.record({
267 opened: false,
268 provider: lazy.GenAI.getProviderId(),
269 reason: "unload",
270 version: lazy.sidebarRevampPref ? "new" : "old",
275 * Show onboarding screens
277 * @param {number} length optional show fewer screens
279 function showOnboarding(length) {
280 const ACTIONS = Object.freeze({
281 OPEN_URL: "OPEN_URL",
282 CHATBOT_SELECT: "chatbot:select",
283 CHATBOT_PERSIST: "chatbot:persist",
284 CHATBOT_REVERT: "chatbot:revert",
287 // Insert onboarding container and render with script
288 const root = document.createElement("div");
289 root.id = "multi-stage-message-root";
290 document.getElementById(root.id)?.remove();
291 document.body.prepend(root);
292 history.replaceState("", "");
293 const script = document.head.appendChild(document.createElement("script"));
294 script.src = "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js";
296 // Convert provider data for lookup by id
297 const providerConfigs = new Map();
298 lazy.GenAI.chatProviders.forEach((data, url) => {
299 if (!data.hidden) {
300 providerConfigs.set(data.id, { ...data, url });
304 // Define various AW* functions to control aboutwelcome bundle behavior
305 Object.assign(window, {
306 AWEvaluateScreenTargeting(screens) {
307 return screens;
309 AWFinish() {
310 if (lazy.providerPref == "") {
311 closeSidebar();
313 root.remove();
315 AWGetFeatureConfig() {
316 return {
317 id: "chatbot",
318 template: "multistage",
319 transitions: true,
320 screens: [
322 id: "chat_pick",
323 content: {
324 fullscreen: true,
325 hide_secondary_section: "responsive",
326 narrow: true,
327 position: "split",
329 title: {
330 fontWeight: 400,
331 string_id: "genai-onboarding-header",
333 cta_paragraph: {
334 text: {
335 string_id: "genai-onboarding-description",
336 string_name: "learn-more",
338 action: {
339 data: {
340 args: lazy.supportLink,
341 where: "tabshifted",
343 type: ACTIONS.OPEN_URL,
346 tiles: {
347 action: { picker: "<event>" },
348 data: [...providerConfigs.values()].map(config => ({
349 action: { type: ACTIONS.CHATBOT_SELECT, config },
350 id: config.id,
351 label: config.name,
352 tooltip: { string_id: config.tooltipId },
353 })),
354 // Default to nothing selected
355 selected: " ",
356 type: "single-select",
358 above_button_content: [
359 // Placeholder to inject on provider change
361 text: " ",
362 type: "text",
365 primary_button: {
366 action: {
367 navigate: true,
368 type: ACTIONS.CHATBOT_PERSIST,
370 label: { string_id: "genai-onboarding-primary" },
372 additional_button: {
373 action: { dismiss: true, type: ACTIONS.CHATBOT_REVERT },
374 label: { string_id: "genai-onboarding-secondary" },
375 style: "link",
377 progress_bar: true,
381 id: "chat_suggest",
382 content: {
383 fullscreen: true,
384 hide_secondary_section: "responsive",
385 narrow: true,
386 position: "split",
388 title: {
389 fontWeight: 400,
390 string_id: "genai-onboarding-select-header",
392 subtitle: { string_id: "genai-onboarding-select-description" },
393 above_button_content: [
395 height: "172px",
396 type: "image",
397 width: "307px",
400 text: " ",
401 type: "text",
404 primary_button: {
405 action: { navigate: true },
406 label: { string_id: "genai-onboarding-select-primary" },
408 progress_bar: true,
411 ].slice(0, length),
414 AWGetInstalledAddons() {},
415 AWGetSelectedTheme() {
416 document.querySelector(".primary").disabled = true;
418 // Specially handle links to open out of the sidebar
419 const handleLink = ev => {
420 const { href } = ev.target;
421 if (href) {
422 ev.preventDefault();
423 openLink(href);
426 const links = document.querySelector(".link-paragraph");
427 links.addEventListener("click", handleLink);
429 [...document.querySelectorAll("fieldset label")].forEach(label => {
430 // Add content that is hidden with 0 height until selected
431 const div = label
432 .querySelector(".text")
433 .appendChild(document.createElement("div"));
434 div.style.maxHeight = 0;
435 div.tabIndex = -1;
436 const ul = div.appendChild(document.createElement("ul"));
437 const config = providerConfigs.get(label.querySelector("input").value);
438 config.choiceIds?.forEach(id => {
439 const li = ul.appendChild(document.createElement("li"));
440 document.l10n.setAttributes(li, id);
442 if (config.learnLink && config.learnId) {
443 const a = div.appendChild(document.createElement("a"));
444 a.href = config.learnLink;
445 a.tabIndex = -1;
446 a.addEventListener("click", ev => {
447 handleLink(ev);
448 Glean.genaiChatbot.onboardingProviderLearn.record({
449 provider: config.id,
450 step: 1,
453 document.l10n.setAttributes(a, config.learnId);
457 AWSendEventTelemetry({ event, event_context: { source }, message_id }) {
458 const { provider } = window.AWSendEventTelemetry;
459 const step = message_id.match(/chat_pick/) ? 1 : 2;
460 switch (true) {
461 case step == 1 && event == "IMPRESSION":
462 Glean.genaiChatbot.onboardingProviderChoiceDisplayed.record({
463 provider: lazy.GenAI.getProviderId(lazy.providerPref),
464 step,
466 break;
467 case step == 1 && source == "cta_paragraph":
468 Glean.genaiChatbot.onboardingLearnMore.record({ provider, step });
469 break;
470 case step == 1 && source == "primary_button":
471 Glean.genaiChatbot.onboardingContinue.record({ provider, step });
472 break;
473 case step == 1 && source == "additional_button":
474 Glean.genaiChatbot.onboardingClose.record({ provider, step });
475 break;
476 case step == 1 && source.startsWith("link"):
477 Glean.genaiChatbot.onboardingProviderTerms.record({
478 provider,
479 step,
480 text: source,
482 break;
483 // Assume generic click not yet handled above single select of provider
484 case step == 1 && event == "CLICK_BUTTON":
485 window.AWSendEventTelemetry.provider = source;
486 Glean.genaiChatbot.onboardingProviderSelection.record({
487 provider: source,
488 step,
490 break;
491 case step == 2 && event == "IMPRESSION":
492 Glean.genaiChatbot.onboardingTextHighlightDisplayed.record({
493 provider,
494 step,
496 break;
497 case step == 2 && source == "primary_button":
498 Glean.genaiChatbot.onboardingFinish.record({ provider, step });
499 break;
502 AWSendToParent(_message, action) {
503 switch (action.type) {
504 case ACTIONS.OPEN_URL:
505 lazy.SpecialMessageActions.handleAction(action, topChromeWindow);
506 return;
507 case ACTIONS.CHATBOT_PERSIST: {
508 const { value } = document.querySelector(
509 "label:has(.selected) input"
511 Services.prefs.setStringPref(
512 "browser.ml.chat.provider",
513 providerConfigs.get(value).url
515 break;
517 case ACTIONS.CHATBOT_REVERT: {
518 request();
519 break;
521 // Handle single select provider choice
522 case ACTIONS.CHATBOT_SELECT: {
523 const { config } = action;
524 if (!config) {
525 break;
528 request(config.url);
529 document.querySelector(".primary").disabled = false;
531 // Set max-height to trigger transition
532 document.querySelectorAll("label .text div").forEach(div => {
533 const selected =
534 div.closest("label").querySelector("input").value == config.id;
535 div.style.maxHeight = selected ? div.scrollHeight + "px" : 0;
536 const a = div.querySelector("a");
537 if (a) {
538 a.tabIndex = selected ? 0 : -1;
542 // Update potentially multiple links for the provider
543 const links = document.querySelector(".link-paragraph");
544 if (links.dataset.l10nId != config.linksId) {
545 links.innerHTML = "";
546 for (let i = 1; i <= 3; i++) {
547 const link = links.appendChild(document.createElement("a"));
548 const name = (link.dataset.l10nName = `link${i}`);
549 link.href = config[name];
550 link.setAttribute("value", name);
552 document.l10n.setAttributes(links, config.linksId);
555 break;