page-assist.mjs (9989B)
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, ifDefined } 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 PageAssist: "moz-src:///browser/components/genai/PageAssist.sys.mjs", 14 AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", 15 }); 16 17 import MozInputText from "chrome://global/content/elements/moz-input-text.mjs"; 18 19 /** 20 * A custom element for managing the page assistant input. 21 */ 22 export class PageAssistInput extends MozInputText { 23 static properties = { 24 class: { type: String, reflect: true }, 25 }; 26 27 inputTemplate() { 28 return html` 29 <link 30 rel="stylesheet" 31 href="chrome://browser/content/genai/content/page-assist.css" 32 /> 33 <input 34 id="input" 35 class=${"with-icon " + ifDefined(this.class)} 36 name=${this.name} 37 .value=${this.value || ""} 38 ?disabled=${this.disabled || this.parentDisabled} 39 accesskey=${ifDefined(this.accessKey)} 40 placeholder=${ifDefined(this.placeholder)} 41 aria-label=${ifDefined(this.ariaLabel ?? undefined)} 42 aria-describedby="description" 43 @input=${this.handleInput} 44 @change=${this.redispatchEvent} 45 /> 46 `; 47 } 48 } 49 customElements.define("page-assists-input", PageAssistInput); 50 51 /** 52 * A custom element for managing the page assistant sidebar. 53 */ 54 export class PageAssist extends MozLitElement { 55 _progressListener = null; 56 _onTabSelect = null; 57 _onReaderModeChange = null; 58 _onUnload = null; 59 60 static properties = { 61 userPrompt: { type: String }, 62 aiResponse: { type: String }, 63 isCurrentPageReaderable: { type: Boolean }, 64 matchCountQty: { type: Number }, 65 currentMatchIndex: { type: Number }, 66 highlightAll: { type: Boolean }, 67 snippets: { type: Array }, 68 }; 69 70 constructor() { 71 super(); 72 this.userPrompt = ""; 73 this.aiResponse = ""; 74 this.isCurrentPageReaderable = true; 75 this.matchCountQty = 0; 76 this.currentMatchIndex = 0; 77 this.highlightAll = true; 78 this.snippets = []; 79 } 80 81 get _browserWin() { 82 return this.ownerGlobal?.browsingContext?.topChromeWindow || null; 83 } 84 get _gBrowser() { 85 return this._browserWin?.gBrowser || null; 86 } 87 88 connectedCallback() { 89 super.connectedCallback(); 90 this._attachReaderModeListener(); 91 this._initURLChange(); 92 this._onUnload = () => this._cleanup(); 93 this._setupFinder(); 94 this.ownerGlobal.addEventListener("unload", this._onUnload, { once: true }); 95 } 96 97 disconnectedCallback() { 98 // Clean up finder listener 99 if (this.browser && this.browser.finder) { 100 this.browser.finder.removeResultListener(this); 101 } 102 103 if (this._onUnload) { 104 this.ownerGlobal.removeEventListener("unload", this._onUnload); 105 this._onUnload = null; 106 } 107 this._cleanup(); 108 super.disconnectedCallback(); 109 } 110 111 _setupFinder() { 112 const gBrowser = this._gBrowser; 113 114 if (!gBrowser) { 115 console.warn("No gBrowser found."); 116 return; 117 } 118 119 const selected = gBrowser.selectedBrowser; 120 121 // If already attached to this browser, skip 122 if (this.browser === selected) { 123 return; 124 } 125 126 // Clean up old listener if needed 127 if (this.browser && this.browser.finder) { 128 this.browser.finder.removeResultListener(this); 129 } 130 131 this.browser = selected; 132 133 if (this.browser && this.browser.finder) { 134 this.browser.finder.addResultListener(this); 135 } else { 136 console.warn("PageAssist: no finder on selected browser."); 137 } 138 } 139 140 _cleanup() { 141 try { 142 const gBrowser = this._gBrowser; 143 if (gBrowser && this._progressListener) { 144 gBrowser.removeTabsProgressListener(this._progressListener); 145 } 146 if (gBrowser?.tabContainer && this._onTabSelect) { 147 gBrowser.tabContainer.removeEventListener( 148 "TabSelect", 149 this._onTabSelect 150 ); 151 } 152 if (this._onReaderModeChange) { 153 lazy.AboutReaderParent.removeMessageListener( 154 "Reader:UpdateReaderButton", 155 this._onReaderModeChange 156 ); 157 } 158 } catch (e) { 159 console.error("PageAssist cleanup failed:", e); 160 } finally { 161 this._progressListener = null; 162 this._onTabSelect = null; 163 this._onReaderModeChange = null; 164 } 165 } 166 167 _attachReaderModeListener() { 168 this._onReaderModeChange = { 169 receiveMessage: msg => { 170 // AboutReaderParent.callListeners sets msg.target = the <browser> element 171 const browser = msg?.target; 172 const selected = this._gBrowser?.selectedBrowser; 173 if (!browser || browser !== selected) { 174 return; // only care about the active tab 175 } 176 // AboutReaderParent already set browser.isArticle for this message. 177 this.isCurrentPageReaderable = !!browser.isArticle; 178 }, 179 }; 180 181 lazy.AboutReaderParent.addMessageListener( 182 "Reader:UpdateReaderButton", 183 this._onReaderModeChange 184 ); 185 } 186 187 /** 188 * Initialize URL change detection 189 */ 190 _initURLChange() { 191 const { gBrowser } = this._gBrowser; 192 if (!gBrowser) { 193 return; 194 } 195 196 this._onTabSelect = () => { 197 this._setupFinder(); 198 const browser = gBrowser.selectedBrowser; 199 this.isCurrentPageReaderable = !!browser?.isArticle; 200 }; 201 gBrowser.tabContainer.addEventListener("TabSelect", this._onTabSelect); 202 203 this._progressListener = { 204 onLocationChange: (browser, webProgress) => { 205 if (!webProgress?.isTopLevel) { 206 return; 207 } 208 this.isCurrentPageReaderable = !!browser?.isArticle; 209 }, 210 }; 211 gBrowser.addTabsProgressListener(this._progressListener); 212 213 // Initial check 214 this._onTabSelect(); 215 } 216 217 /** 218 * Fetch Page Data 219 * 220 * @returns {Promise<null| 221 * { 222 * url: string, 223 * title: string, 224 * content: string, 225 * textContent: string, 226 * excerpt: string, 227 * isReaderable: boolean 228 * }>} 229 */ 230 async _fetchPageData() { 231 const gBrowser = this._gBrowser; 232 233 const windowGlobal = 234 gBrowser?.selectedBrowser?.browsingContext?.currentWindowGlobal; 235 236 if (!windowGlobal) { 237 return null; 238 } 239 240 // Get the parent actor instance 241 const actor = windowGlobal.getActor("PageAssist"); 242 return await actor.fetchPageData(); 243 } 244 245 _clearFinder() { 246 if (this.browser?.finder) { 247 this.browser.finder.removeSelection(); 248 this.browser.finder.highlight(false, "", false); 249 } 250 this.matchCountQty = 0; 251 this.currentMatchIndex = 0; 252 this.snippets = []; 253 } 254 255 _handlePromptInput = e => { 256 const value = e.target.value; 257 this.userPrompt = value; 258 259 // If input is empty, clear values 260 if (!value) { 261 this._clearFinder(); 262 return; 263 } 264 265 // Perform the search 266 this.browser.finder.fastFind(value, false, false); 267 268 if (this.highlightAll) { 269 // Todo this also needs to take contextRange. 270 this.browser.finder.highlight(true, value, false); 271 } 272 273 // Request match count - this method will trigger onMatchesCountResult callback 274 this.browser.finder.requestMatchesCount(value, { 275 linksOnly: false, 276 contextRange: 30, 277 }); 278 }; 279 280 onMatchesCountResult(result) { 281 this.matchCountQty = result.total; 282 this.currentMatchIndex = result.current; 283 this.snippets = result.snippets || []; 284 } 285 286 // Abstract method need to be implemented or it will error 287 onHighlightFinished() { 288 // Noop. 289 } 290 291 // Finder result listener methods 292 onFindResult(result) { 293 switch (result.result) { 294 case Ci.nsITypeAheadFind.FIND_NOTFOUND: 295 this.matchCountQty = 0; 296 this.currentMatchIndex = 0; 297 this.snippets = []; 298 break; 299 300 default: 301 break; 302 } 303 } 304 305 _handleSubmit = async () => { 306 const pageData = await this._fetchPageData(); 307 if (!pageData) { 308 this.aiResponse = "No page data"; 309 return; 310 } 311 const aiResponse = await lazy.PageAssist.fetchAiResponse( 312 this.userPrompt, 313 pageData 314 ); 315 this.aiResponse = aiResponse ?? "No response"; 316 }; 317 318 render() { 319 return html` 320 <link 321 rel="stylesheet" 322 href="chrome://browser/content/genai/content/page-assist.css" 323 /> 324 <div> 325 <sidebar-panel-header 326 data-l10n-id="genai-page-assist-sidebar-title" 327 data-l10n-attrs="heading" 328 view="viewGenaiPageAssistSidebar" 329 ></sidebar-panel-header> 330 <div class="wrapper"> 331 ${this.aiResponse 332 ? html`<div class="ai-response">${this.aiResponse}</div>` 333 : ""} 334 <div> 335 <page-assists-input 336 class="find-input" 337 type="text" 338 placeholder="Find in page..." 339 .value=${this.userPrompt} 340 @input=${this._handlePromptInput} 341 ></page-assists-input> 342 <moz-button 343 id="submit-user-prompt-btn" 344 type="primary" 345 size="small" 346 @click=${this._handleSubmit} 347 > 348 Submit 349 </moz-button> 350 </div> 351 352 <div> 353 ${this.snippets.length 354 ? html`<div class="snippets"> 355 <h3>Snippets</h3> 356 <ul> 357 ${this.snippets.map( 358 snippet => 359 html`<li> 360 ${snippet.before}<b>${snippet.match}</b>${snippet.after} 361 </li>` 362 )} 363 </ul> 364 </div>` 365 : ""} 366 </div> 367 </div> 368 </div> 369 `; 370 } 371 } 372 373 customElements.define("page-assist", PageAssist);