GenAIChild.sys.mjs (8185B)
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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 XPCOMUtils.defineLazyPreferenceGetter( 9 lazy, 10 "shortcutsDelay", 11 "browser.ml.chat.shortcuts.longPress" 12 ); 13 14 ChromeUtils.defineESModuleGetters(lazy, { 15 ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs", 16 }); 17 18 // Events to register after shortcuts are shown 19 const HIDE_EVENTS = ["pagehide", "resize", "scroll"]; 20 21 /** 22 * JSWindowActor to detect content page events to send GenAI related data. 23 */ 24 export class GenAIChild extends JSWindowActorChild { 25 mouseUpTimeout = null; 26 downSelection = null; 27 downTimeStamp = 0; 28 debounceDelay = 200; 29 pendingHide = false; 30 31 registerHideEvents() { 32 this.document.addEventListener("selectionchange", this); 33 HIDE_EVENTS.forEach(ev => 34 this.contentWindow.addEventListener(ev, this, true) 35 ); 36 this.pendingHide = true; 37 } 38 39 removeHideEvents() { 40 this.document.removeEventListener("selectionchange", this); 41 HIDE_EVENTS.forEach(ev => 42 this.contentWindow?.removeEventListener(ev, this, true) 43 ); 44 this.pendingHide = false; 45 } 46 47 handleEvent(event) { 48 const sendHide = () => { 49 // Only remove events and send message if shortcuts are actually visible 50 if (this.pendingHide) { 51 this.sendAsyncMessage("GenAI:HideShortcuts", event.type); 52 this.removeHideEvents(); 53 } 54 }; 55 56 switch (event.type) { 57 case "mousedown": 58 this.downSelection = this.getSelectionInfo().selection; 59 this.downTimeStamp = event.timeStamp; 60 sendHide(); 61 break; 62 case "mouseup": { 63 // Only handle plain clicks 64 if ( 65 event.button || 66 event.altKey || 67 event.ctrlKey || 68 event.metaKey || 69 event.shiftKey 70 ) { 71 return; 72 } 73 74 // Clear any previously scheduled mouseup actions 75 if (this.mouseUpTimeout) { 76 this.contentWindow.clearTimeout(this.mouseUpTimeout); 77 } 78 79 const { screenX, screenY } = event; 80 81 this.mouseUpTimeout = this.contentWindow.setTimeout(() => { 82 const selectionInfo = this.getSelectionInfo(); 83 const delay = event.timeStamp - this.downTimeStamp; 84 85 // Only send a message if there's a new selection or a long press 86 if ( 87 (selectionInfo.selection && 88 selectionInfo.selection !== this.downSelection) || 89 delay > lazy.shortcutsDelay 90 ) { 91 this.sendAsyncMessage("GenAI:ShowShortcuts", { 92 ...selectionInfo, 93 contentType: "selection", 94 delay, 95 screenXDevPx: screenX * this.contentWindow.devicePixelRatio, 96 screenYDevPx: screenY * this.contentWindow.devicePixelRatio, 97 }); 98 this.registerHideEvents(); 99 } 100 101 // Clear the timeout reference after execution 102 this.mouseUpTimeout = null; 103 }, this.debounceDelay); 104 105 break; 106 } 107 case "pagehide": 108 case "resize": 109 case "scroll": 110 case "selectionchange": 111 // Hide if selection might have shifted away from shortcuts 112 sendHide(); 113 break; 114 } 115 } 116 117 /** 118 * Provide the selected text and input type. 119 * 120 * @returns {object} selection info 121 */ 122 getSelectionInfo() { 123 // Handle regular selection outside of inputs 124 const { activeElement } = this.document; 125 const selection = this.contentWindow.getSelection()?.toString().trim(); 126 if (selection) { 127 return { 128 inputType: activeElement.closest("[contenteditable]") 129 ? "contenteditable" 130 : "", 131 selection, 132 }; 133 } 134 135 // Selection within input elements 136 const { selectionStart, value } = activeElement; 137 if (selectionStart != null && value != null) { 138 return { 139 inputType: activeElement.localName, 140 selection: value.slice(selectionStart, activeElement.selectionEnd), 141 }; 142 } 143 return { inputType: "", selection: "" }; 144 } 145 146 /** 147 * Handles incoming messages from the browser 148 * 149 * @param {object} message - The message object containing name 150 * @param {string} message.name - The name of the message 151 * @param {object} message.data - The data object of the message 152 */ 153 async receiveMessage({ name, data }) { 154 switch (name) { 155 case "GetReadableText": 156 return this.getContentText(); 157 case "AutoSubmit": 158 return await this.autoSubmitClick(data); 159 default: 160 return null; 161 } 162 } 163 164 /** 165 * Find the prompt editable element within a timeout 166 * Return the element or null 167 * 168 * @param {Window} win - the target window 169 * @param {number} [tms=1000] - time in ms 170 */ 171 async findTextareaEl(win, tms = 1000) { 172 const start = win.performance.now(); 173 let el; 174 while ( 175 !(el = win.document.querySelector( 176 '#prompt-textarea, [contenteditable], [role="textbox"]' 177 )) && 178 win.performance.now() - start < tms 179 ) { 180 await new Promise(r => win.requestAnimationFrame(r)); 181 } 182 return el; 183 } 184 185 /** 186 * Automatically submit the prompt 187 * 188 * @param {string} promptText - the prompt to send 189 */ 190 async autoSubmitClick({ promptText = "" } = {}) { 191 const win = this.contentWindow; 192 if (!win || win._autosent) { 193 return; 194 } 195 196 // Ensure the DOM is ready before querying elements 197 if (win.document.readyState === "loading") { 198 await new Promise(r => 199 win.addEventListener("DOMContentLoaded", r, { once: true }) 200 ); 201 } 202 203 const editable = await this.findTextareaEl(win); 204 if (!editable) { 205 return; 206 } 207 208 if (!editable.textContent) { 209 editable.textContent = promptText; 210 editable.dispatchEvent(new win.InputEvent("input", { bubbles: true })); 211 } 212 213 // Explicitly wait for the button is ready 214 await new Promise(r => win.requestAnimationFrame(r)); 215 216 // Simulating click to avoid SPA router rewriting (?prompt-textarea=) 217 const submitBtn = 218 win.document.querySelector('button[data-testid="send-button"]') || 219 win.document.querySelector('button[aria-label="Send prompt"]') || 220 win.document.querySelector('button[aria-label="Send message"]'); 221 222 if (submitBtn) { 223 submitBtn.click(); 224 win._autosent = true; 225 } 226 227 // Ensure clean up textarea only for chatGPT and mochitest 228 if ( 229 win._autosent && 230 (/chatgpt\.com/i.test(win.location.host) || 231 win.location.pathname.includes("file_chat-autosubmit.html")) 232 ) { 233 const container = editable.parentElement; 234 if (!container) { 235 return; 236 } 237 238 const observer = new win.MutationObserver(() => { 239 // Always refetch because ChatGPT replaces editable div 240 const currentEditable = container.querySelector( 241 '[contenteditable="true"]' 242 ); 243 if (!currentEditable) { 244 return; 245 } 246 247 let hasText = currentEditable.textContent?.trim().length > 0; 248 if (hasText) { 249 currentEditable.textContent = ""; 250 currentEditable.dispatchEvent( 251 new win.InputEvent("input", { bubbles: true }) 252 ); 253 } 254 }); 255 256 observer.observe(container, { childList: true, subtree: true }); 257 258 // Disconnect once things stabilize 259 win.setTimeout(() => observer.disconnect(), 2000); 260 } 261 } 262 263 /** 264 * Get readable article text or whole innerText from the content side. 265 * 266 * @returns {string} text from the page 267 */ 268 async getContentText() { 269 const win = this.browsingContext?.window; 270 const doc = win?.document; 271 const article = await lazy.ReaderMode.parseDocument(doc); 272 return { 273 readerMode: !!article?.textContent, 274 selection: (article?.textContent || doc?.body?.innerText || "") 275 .trim() 276 // Replace duplicate whitespace with either a single newline or space 277 .replace(/(\s*\n\s*)|\s{2,}/g, (_, newline) => (newline ? "\n" : " ")), 278 }; 279 } 280 }