LinkPreviewModel.sys.mjs (18455B)
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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 // On average, each token represents about 4 characters. A factor of 3.5 is used 9 // instead of 4 to account for edge cases. 10 const CHARACTERS_PER_TOKEN = 3.5; 11 // On average, one token corresponds to approximately 4 characters, meaning 0.25 12 // times the character count would suffice under normal conditions. To ensure 13 // robustness and handle edge cases, we use a more conservative factor of 0.69. 14 const CONTEXT_SIZE_MULTIPLIER = 0.69; 15 const DEFAULT_INPUT_SENTENCES = 6; 16 const MIN_SENTENCE_LENGTH = 14; 17 const MIN_WORD_COUNT = 5; 18 const DEFAULT_INPUT_PROMPT = 19 "You're an AI assistant for text re-writing and summarization. Rewrite the input text focusing on the main key point in at most three very short sentences."; 20 21 // All tokens taken from the model's vocabulary at https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct/raw/main/vocab.json 22 // Token id for end of text 23 const END_OF_TEXT_TOKEN = 0; 24 // Token id for beginning of sequence 25 const BOS_TOKEN = 1; 26 // Token id for end of sequence 27 const EOS_TOKEN = 2; 28 29 const lazy = {}; 30 ChromeUtils.defineESModuleGetters(lazy, { 31 createEngine: "chrome://global/content/ml/EngineProcess.sys.mjs", 32 Progress: "chrome://global/content/ml/Utils.sys.mjs", 33 BlockListManager: "chrome://global/content/ml/Utils.sys.mjs", 34 RemoteSettingsManager: "chrome://global/content/ml/Utils.sys.mjs", 35 }); 36 XPCOMUtils.defineLazyPreferenceGetter( 37 lazy, 38 "config", 39 "browser.ml.linkPreview.config", 40 "{}" 41 ); 42 XPCOMUtils.defineLazyPreferenceGetter( 43 lazy, 44 "inputSentences", 45 "browser.ml.linkPreview.inputSentences" 46 ); 47 XPCOMUtils.defineLazyPreferenceGetter( 48 lazy, 49 "outputSentences", 50 "browser.ml.linkPreview.outputSentences" 51 ); 52 XPCOMUtils.defineLazyPreferenceGetter( 53 lazy, 54 "prompt", 55 "browser.ml.linkPreview.prompt" 56 ); 57 XPCOMUtils.defineLazyPreferenceGetter( 58 lazy, 59 "blockListEnabled", 60 "browser.ml.linkPreview.blockListEnabled" 61 ); 62 XPCOMUtils.defineLazyPreferenceGetter( 63 lazy, 64 "preUserPrompt", 65 "browser.ml.linkPreview.preUserPrompt", 66 "" 67 ); 68 XPCOMUtils.defineLazyPreferenceGetter( 69 lazy, 70 "postUserPrompt", 71 "browser.ml.linkPreview.postUserPrompt", 72 "" 73 ); 74 75 XPCOMUtils.defineLazyPreferenceGetter( 76 lazy, 77 "penalizedTokens", 78 "browser.ml.linkPreview.penalizedTokens", 79 // default (when PREF_INVALID) 80 // Tokens with newlines for the default link preview model, based on the model's vocab: https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct/raw/main/vocab.json 81 JSON.stringify([ 82 198, 448, 466, 472, 629, 945, 1004, 1047, 1116, 1410, 1927, 2367, 2738, 83 2830, 2953, 3136, 3299, 3337, 3354, 3558, 3717, 3805, 3914, 4602, 4767, 84 5952, 7116, 7209, 7338, 7396, 8301, 8500, 8821, 8866, 9198, 9225, 9343, 85 9694, 10459, 11181, 11259, 11539, 11813, 12350, 13002, 13272, 13280, 13596, 86 13617, 13809, 14436, 14446, 15111, 15182, 15290, 15537, 16140, 16299, 16390, 87 16506, 16871, 16980, 16997, 18682, 18850, 18864, 19014, 19145, 19993, 20098, 88 20370, 20793, 21193, 21377, 21941, 22342, 22369, 23004, 23386, 23499, 23799, 89 24112, 24205, 25457, 25576, 26675, 26886, 26925, 27536, 27924, 28577, 29306, 90 29866, 30314, 30544, 30799, 31464, 32057, 32315, 32829, 34344, 34356, 35163, 91 35988, 36176, 36286, 36328, 36489, 36496, 36804, 37468, 38028, 38031, 39014, 92 39843, 39892, 40677, 40944, 42057, 42617, 43784, 43902, 44064, 46778, 47213, 93 47647, 48259, 48279, 48818, 94 ]), 95 null, // no onUpdate callback 96 rawValue => { 97 if (!rawValue) { 98 return []; 99 } 100 return JSON.parse(rawValue); 101 } 102 ); 103 104 XPCOMUtils.defineLazyPreferenceGetter( 105 lazy, 106 "minWordsPerOutputSentences", 107 "browser.ml.linkPreview.minWordsPerOutputSentences", 108 0 109 ); 110 111 // End of generation tokens. 112 XPCOMUtils.defineLazyPreferenceGetter( 113 lazy, 114 "stopTokens", 115 "browser.ml.linkPreview.stopTokens", 116 // default (when PREF_INVALID) 117 JSON.stringify([END_OF_TEXT_TOKEN, BOS_TOKEN, EOS_TOKEN]), 118 null, // no onUpdate callback 119 rawValue => { 120 if (!rawValue) { 121 return []; 122 } 123 return JSON.parse(rawValue); 124 } 125 ); 126 127 export const LinkPreviewModel = { 128 /** 129 * Manager for the block list. If null, no block list is applied. 130 * 131 * @type {BlockListManager} 132 */ 133 blockListManager: null, 134 135 /** 136 * Blocked token list 137 * 138 * @returns {Array<number>} block token list 139 */ 140 getBlockTokenList() { 141 return lazy.penalizedTokens; 142 }, 143 /** 144 * Extracts sentences from a given text. 145 * 146 * @param {string} text text to process 147 * @returns {Array<string>} sentences 148 */ 149 getSentences(text) { 150 const abbreviations = [ 151 "Mr.", 152 "Mrs.", 153 "Ms.", 154 "Dr.", 155 "Prof.", 156 "Inc.", 157 "Ltd.", 158 "Jr.", 159 "Sr.", 160 "St.", 161 "e.g.", 162 "i.e.", 163 "U.S.A", 164 "D.C.", 165 "U.K.", 166 "etc.", 167 "a.m.", 168 "p.m.", 169 "D.", 170 "Mass.", 171 "Sen.", 172 "Rep.", 173 "No.", 174 "Fig.", 175 "vs.", 176 "Mx.", 177 "Ph.D.", 178 "M.D.", 179 "D.D.S.", 180 "B.A.", 181 "M.A.", 182 "LL.B.", 183 "LL.M.", 184 "J.D.", 185 "D.O.", 186 "D.V.M.", 187 "Psy.D.", 188 "Ed.D.", 189 "Eng.", 190 "Co.", 191 "Corp.", 192 "Mt.", 193 "Ft.", 194 "U.S.", 195 "U.S.A.", 196 "E.U.", 197 "et al.", 198 "Nos.", 199 "pp.", 200 "Vol.", 201 "Rev.", 202 "Gen.", 203 "Lt.", 204 "Col.", 205 "Maj.", 206 "Capt.", 207 "Sgt.", 208 "Cpl.", 209 "Pvt.", 210 "Adm.", 211 "Cmdr.", 212 "Ave.", 213 "Blvd.", 214 "Rd.", 215 "Ln.", 216 "Jan.", 217 "Feb.", 218 "Mar.", 219 "Apr.", 220 "May.", 221 "Jun.", 222 "Jul.", 223 "Aug.", 224 "Sep.", 225 "Sept.", 226 "Oct.", 227 "Nov.", 228 "Dec.", 229 "Mon.", 230 "Tue.", 231 "Tues.", 232 "Wed.", 233 "Thu.", 234 "Thur.", 235 "Thurs.", 236 "Fri.", 237 "Sat.", 238 "Sun.", 239 "Dept.", 240 "Univ.", 241 "Est.", 242 "Calif.", 243 "Fla.", 244 "N.Y.", 245 "Conn.", 246 "Va.", 247 "Ill.", 248 "Assoc.", 249 "Bros.", 250 "Dist.", 251 "Msgr.", 252 "S.P.", 253 "P.S.", 254 "U.S.S.R.", 255 "Mlle.", 256 "Mme.", 257 "Hon.", 258 "Messrs.", 259 "Mmes.", 260 "v.", 261 "vs.", 262 ]; 263 264 // Replace periods in abbreviations with a placeholder. 265 let modifiedText = text; 266 const placeholder = "∯"; 267 268 abbreviations.forEach(abbrev => { 269 const escapedAbbrev = abbrev 270 .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 271 .replace(/\\\./g, "\\."); 272 const regex = new RegExp(escapedAbbrev, "g"); 273 const abbrevWithPlaceholder = abbrev.replace(/\./g, placeholder); 274 modifiedText = modifiedText.replace(regex, abbrevWithPlaceholder); 275 }); 276 277 const segmenter = new Intl.Segmenter("en", { 278 granularity: "sentence", 279 }); 280 const segments = segmenter.segment(modifiedText); 281 let sentences = Array.from(segments, segment => segment.segment); 282 283 // Restore the periods in abbreviations. 284 return sentences.map(sentence => 285 sentence.replace(new RegExp(placeholder, "g"), ".") 286 ); 287 }, 288 289 /** 290 * Clean up text for text generation AI. 291 * 292 * @param {string} text to process 293 * @param {number} maxNumSentences - Max number of sentences to return. 294 * @returns {string} cleaned up text 295 */ 296 preprocessText( 297 text, 298 maxNumSentences = lazy.inputSentences ?? DEFAULT_INPUT_SENTENCES 299 ) { 300 // Filter out emoji characters. The `u` flag is for unicode and `g` for global. 301 // Use `Emoji_Presentation` to avoid removing numbers and other symbols. 302 const textWithoutEmoji = text.replace(/\p{Emoji_Presentation}/gu, ""); 303 return ( 304 this.getSentences(textWithoutEmoji) 305 .map(s => 306 // trim and replace consecutive blank by a single one. 307 s.trim().replace( 308 /(\s*\n\s*)|\s{2,}/g, 309 // (\s*\n\s*) -> Matches a newline (`\n`) surrounded by optional whitespace. 310 // \s{2,} -> Matches two or more consecutive spaces. 311 // g -> Global flag to replace all occurrences in the string. 312 313 (_, newline) => (newline ? "\n" : " ") 314 // Callback function: 315 // `_` -> First argument (full match) is ignored. 316 // `newline` -> If the first capturing group (\s*\n\s*) matched, `newline` is truthy. 317 // If `newline` exists, it replaces the match with a single newline ("\n"). 318 // Otherwise, it replaces the match (extra spaces) with a single space (" "). 319 ) 320 ) 321 // Remove sentences that are too short without punctuation. 322 .filter( 323 s => 324 s.length >= MIN_SENTENCE_LENGTH && 325 s.split(" ").length >= MIN_WORD_COUNT && 326 /\p{P}$/u.test(s) 327 ) 328 .slice(0, maxNumSentences) 329 .join(" ") 330 ); 331 }, 332 333 /** 334 * Creates a new ML engine instance with the provided options for link preview. 335 * 336 * @param {object} options - Configuration options for the ML engine. 337 * @param {?function(ProgressAndStatusCallbackParams):void} notificationsCallback A function to call to indicate notifications. 338 * @param {AbortSignal} abortSignal - The signal to abort the download. 339 * @returns {Promise<MLEngine>} - A promise that resolves to the ML engine instance. 340 */ 341 async createEngine(options, notificationsCallback = null, abortSignal) { 342 return lazy.createEngine(options, notificationsCallback, abortSignal); 343 }, 344 345 /** 346 * Generate summary text using AI. 347 * 348 * @param {string} inputText 349 * @param {object} callbacks for progress and error 350 * @param {AbortSignal} callbacks.abortSignal - The signal to abort the download. 351 * @param {Function} callbacks.onDownload optional for download active 352 * @param {Function} callbacks.onText optional for text chunks 353 * @param {Function} callbacks.onError optional for error 354 */ 355 async generateTextAI( 356 inputText, 357 { onDownload, onText, onError, abortSignal } = {} 358 ) { 359 // Get updated options from remote settings. No failure if no record exists 360 const remoteRequestRecord = await lazy.RemoteSettingsManager.getRemoteData({ 361 collectionName: "ml-inference-request-options", 362 filters: { featureId: "link-preview" }, 363 majorVersion: 1, 364 }).catch(() => { 365 console.error( 366 "Error retrieving request options from remote settings, will use default options." 367 ); 368 return { options: "{}" }; 369 }); 370 371 let remoteRequestOptions = {}; 372 373 try { 374 remoteRequestOptions = remoteRequestRecord?.options 375 ? JSON.parse(remoteRequestRecord.options) 376 : {}; 377 } catch (error) { 378 console.error( 379 "Error parsing the remote settings request options, will use default options.", 380 error 381 ); 382 } 383 384 // TODO: Unit test that order of preference is correctly respected. 385 const processedInput = this.preprocessText( 386 inputText, 387 lazy.inputSentences ?? 388 remoteRequestOptions?.inputSentences ?? 389 DEFAULT_INPUT_SENTENCES 390 ); 391 392 // Asssume generated text is approximately the same length as the input. 393 const nPredict = Math.ceil(processedInput.length / CHARACTERS_PER_TOKEN); 394 const systemPrompt = 395 lazy.prompt ?? remoteRequestOptions?.systemPrompt ?? DEFAULT_INPUT_PROMPT; 396 // Estimate an upper bound for the required number of tokens. This estimate 397 // must be large enough to include prompt tokens, input tokens, and 398 // generated tokens. 399 const numContext = 400 Math.ceil( 401 (processedInput.length + systemPrompt.length) * CONTEXT_SIZE_MULTIPLIER 402 ) + nPredict; 403 404 let engine; 405 try { 406 engine = await this.createEngine( 407 { 408 backend: "best-llama", 409 engineId: "wllamapreview", 410 kvCacheDtype: "q8_0", 411 modelFile: "smollm2-360m-instruct-q8_0.gguf", 412 modelHubRootUrl: "https://model-hub.mozilla.org", 413 modelId: "HuggingFaceTB/SmolLM2-360M-Instruct-GGUF", 414 modelRevision: "main", 415 numBatch: numContext, 416 numContext, 417 numUbatch: numContext, 418 taskName: "wllama-text-generation", 419 timeoutMS: -1, 420 useMlock: false, 421 useMmap: true, 422 ...JSON.parse(lazy.config), 423 }, 424 data => { 425 if (data.type == lazy.Progress.ProgressType.DOWNLOAD) { 426 onDownload?.( 427 data.statusText != lazy.Progress.ProgressStatusText.DONE, 428 Math.round((100 * data.totalLoaded) / data.total) 429 ); 430 } 431 }, 432 abortSignal 433 ); 434 435 const postProcessor = await SentencePostProcessor.initialize(); 436 const blockedTokens = this.getBlockTokenList(); 437 for await (const val of engine.runWithGenerator({ 438 nPredict, 439 stopTokens: lazy.stopTokens, 440 logit_bias_toks: blockedTokens, 441 logit_bias_vals: Array(blockedTokens.length).fill(-Infinity), 442 prompt: [ 443 { role: "system", content: systemPrompt }, 444 { 445 role: "user", 446 content: lazy.preUserPrompt + processedInput + lazy.postUserPrompt, 447 }, 448 ], 449 })) { 450 const { sentence, abort } = postProcessor.put(val.text); 451 if (sentence) { 452 onText?.(sentence); 453 } else if (!val.text) { 454 const remaining = postProcessor.flush(); 455 if (remaining) { 456 onText?.(remaining); 457 } 458 } 459 460 if (abort) { 461 break; 462 } 463 } 464 } catch (error) { 465 onError?.(error); 466 } finally { 467 await engine?.terminate(); 468 } 469 }, 470 }; 471 472 /** 473 * A class for processing streaming text to detect and extract complete 474 * sentences. It buffers incoming text and periodically checks for new sentences 475 * based on punctuation and character count limits. 476 * 477 * This class is useful for incremental sentence processing in NLP tasks. 478 */ 479 export class SentencePostProcessor { 480 /** 481 * The maximum number of sentences to output before truncating the buffer. 482 * Use -1 for unlimited. 483 * 484 * @type {number} 485 */ 486 maxNumOutputSentences = -1; 487 488 /** 489 * Stores the current text being processed. 490 * 491 * @type {string} 492 */ 493 currentText = ""; 494 495 /** 496 * Tracks the number of sentences processed so far. 497 * 498 * @type {number} 499 */ 500 currentNumSentences = 0; 501 502 /** 503 * Manager for the block list. If null, no block list is applied. 504 * 505 * @type {BlockListManager} 506 */ 507 blockListManager = null; 508 509 /** 510 * Create an instance of the sentence postprocessor. 511 * 512 * @param {object} config - Configuration object. 513 * @param {number} config.maxNumOutputSentences - The maximum number of sentences to 514 * output before truncating the buffer. 515 * @param {BlockListManager | null} config.blockListManager - Manager for the block list 516 */ 517 constructor({ 518 maxNumOutputSentences = lazy.outputSentences, 519 blockListManager, 520 } = {}) { 521 this.maxNumOutputSentences = maxNumOutputSentences; 522 this.blockListManager = blockListManager; 523 } 524 525 /** 526 * @param {object} config - Configuration object. 527 * @param {number} config.maxNumOutputSentences - The maximum number of sentences to 528 * output before truncating the buffer. 529 * @param {boolean} config.blockListEnabled - Wether to enable block list. If enabled, we 530 * don't return the sentence that has a blocked word along with any sentences coming after. 531 * @returns {SentencePostProcessor} - An instance of SentencePostProcessor 532 */ 533 static async initialize({ 534 maxNumOutputSentences = lazy.outputSentences, 535 blockListEnabled = lazy.blockListEnabled, 536 } = {}) { 537 if (!blockListEnabled) { 538 LinkPreviewModel.blockListManager = null; 539 } else if (!LinkPreviewModel.blockListManager) { 540 LinkPreviewModel.blockListManager = 541 await lazy.BlockListManager.initializeFromRemoteSettings({ 542 blockListName: "link-preview-test-en", 543 language: "en", 544 fallbackToDefault: true, 545 majorVersion: 1, 546 }); 547 } 548 549 return new SentencePostProcessor({ 550 maxNumOutputSentences, 551 blockListManager: LinkPreviewModel.blockListManager, 552 }); 553 } 554 555 /** 556 * Processes incoming text, checking if a full sentence has been completed. If 557 * a full sentence is detected, it returns the first complete sentence. 558 * Otherwise, it returns an empty string. 559 * 560 * @param {string} text to process 561 * @returns {{ text: string, abort: boolean }} An object containing: 562 * - `{string} sentence`: The first complete sentence if available, otherwise an empty string. 563 * - `{boolean} abort`: `true` if generation should be aborted early, `false` otherwise. 564 */ 565 put(text) { 566 if (this.currentNumSentences == this.maxNumOutputSentences) { 567 return { sentence: "", abort: true }; 568 } 569 this.currentText += text; 570 571 // We need to ensure that the current sentence is complete and the next 572 // has started before reporting that a sentence is ready. 573 const sentences = LinkPreviewModel.getSentences(this.currentText); 574 let sentence = ""; 575 let abort = false; 576 if (sentences.length >= 2) { 577 this.currentText = sentences.slice(1).join(""); 578 // simple way to get number of word ignoring non-whitespaces chatacters 579 const isValidSentence = 580 sentences[0].trim().split(/\p{White_Space}+/u).length >= 581 lazy.minWordsPerOutputSentences; 582 583 if (isValidSentence) { 584 this.currentNumSentences += 1; 585 } 586 587 if (this.currentNumSentences == this.maxNumOutputSentences) { 588 this.currentText = ""; 589 abort = true; 590 } 591 if (isValidSentence) { 592 sentence = sentences[0]; 593 } 594 595 // If the sentence contains a block word, abort 596 if ( 597 this.blockListManager && 598 this.blockListManager.matchAtWordBoundary({ 599 // Blocklist is always lowercase 600 text: sentence.toLowerCase(), 601 }) 602 ) { 603 sentence = ""; 604 abort = true; 605 this.currentNumSentences = this.maxNumOutputSentences; 606 } 607 } 608 609 return { sentence, abort }; 610 } 611 612 /** 613 * Flushes the remaining text buffer. This ensures that any last remaining 614 * sentence is returned. 615 * 616 * @returns {string} remaining text that hasn't been processed yet 617 */ 618 flush() { 619 return this.currentText; 620 } 621 }