Skip to content

Commit f867226

Browse files
authored
Improve browser extension sample/template (huggingface#196)
* Update extension to be module * Update example extension * Allow user to specify a custom cache system * Implement custom cache system Emulates the Web Cache API using chrome's local storage API * Use custom cache system in extension * Fix serialization * Remove old folders * Update extension readme * Add note about JSON requirement for local storage
1 parent 2fde656 commit f867226

File tree

12 files changed

+271
-97
lines changed

12 files changed

+271
-97
lines changed

examples/extension/README.md

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,11 @@ An example project to show how to run 🤗 Transformers in a browser extension.
1414
npm install
1515
```
1616

17-
1. Add your model files to `./public/models/`. For this demo, we use [distilbert-base-uncased-finetuned-sst-2-english](https://huggingface.co/distilbert-base-uncased-finetuned-sst-2-english/tree/main) from the Hugging Face Hub. It should look something like this:
18-
```
19-
distilbert-base-uncased-finetuned-sst-2-english/
20-
├── config.json
21-
├── tokenizer.json
22-
├── tokenizer_config.json
23-
└── onnx/
24-
├── model.onnx
25-
└── model_quantized.onnx
26-
```
27-
28-
1. Add the WASM files to `./public/wasm/`. You can download them from the jsDelivr CDN [here](https://www.jsdelivr.com/package/npm/@xenova/transformers?tab=files&path=dist):
29-
```
30-
ort-wasm.wasm
31-
ort-wasm-simd.wasm
32-
ort-wasm-simd-threaded.wasm
33-
ort-wasm-threaded.wasm
34-
```
3517
1. Build the project:
3618
```bash
3719
npm run build
3820
```
21+
3922
1. Add the extension to your browser. To do this, go to `chrome://extensions/`, enable developer mode (top right), and click "Load unpacked". Select the `build` directory from the dialog which appears and click "Select Folder".
4023

4124
1. That's it! You should now be able to open the extenion's popup and use the model in your browser!
@@ -45,5 +28,8 @@ An example project to show how to run 🤗 Transformers in a browser extension.
4528
We recommend running `npm run dev` while editing the template as it will rebuild the project when changes are made.
4629

4730
All source code can be found in the `./src/` directory:
48-
- `background.js` - contains the service worker code which runs in the background. It handles all the requests from the UI, does processing on a separate thread, then returns the result. You will need to reload the extension (by visiting `chrome://extensions/` and clicking the refresh button) after editing this file for changes to be visible in the extension.
49-
- `popup.html`, `popup.css`, `popup.js` - contains the code for the popup which is visible to the user when they click the extension's icon from the extensions bar. For development, we recommend opening the `popup.html` file in its own tab by visiting `chrome-extension://<ext_id>/popup.html` (remember to replace `<ext_id>` with the extension's ID). You will need to refresh the page while you develop to see the changes you make.
31+
- `background.js` ([service worker](https://developer.chrome.com/docs/extensions/mv3/service_workers/)) - handles all the requests from the UI, does processing in the background, then returns the result. You will need to reload the extension (by visiting `chrome://extensions/` and clicking the refresh button) after editing this file for changes to be visible in the extension.
32+
33+
- `content.js` ([content script](https://developer.chrome.com/docs/extensions/mv3/content_scripts/)) - contains the code which is injected into every page the user visits. You can use the `sendMessage` api to make requests to the background script. Similarly, you will need to reload the extension after editing this file for changes to be visible in the extension.
34+
35+
- `popup.html`, `popup.css`, `popup.js` ([toolbar action](https://developer.chrome.com/docs/extensions/reference/action/)) - contains the code for the popup which is visible to the user when they click the extension's icon from the extensions bar. For development, we recommend opening the `popup.html` file in its own tab by visiting `chrome-extension://<ext_id>/popup.html` (remember to replace `<ext_id>` with the extension's ID). You will need to refresh the page while you develop to see the changes you make.

examples/extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"build": "webpack",
77
"dev": "webpack --watch"
88
},
9+
"type": "module",
910
"author": "Xenova",
1011
"license": "MIT",
1112
"devDependencies": {

examples/extension/public/manifest.json

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,43 @@
33
"name": "extension",
44
"description": "Transformers.js | Sample browser extension",
55
"version": "0.0.1",
6-
"permissions": [],
6+
"permissions": [
7+
"activeTab",
8+
"scripting",
9+
"contextMenus",
10+
"storage",
11+
"unlimitedStorage"
12+
],
713
"background": {
8-
"service_worker": "background.js"
14+
"service_worker": "background.js",
15+
"type": "module"
916
},
17+
"content_scripts": [
18+
{
19+
"matches": [
20+
"<all_urls>"
21+
],
22+
"js": [
23+
"content.js"
24+
]
25+
}
26+
],
1027
"minimum_chrome_version": "92",
1128
"action": {
12-
"default_icon": "icons/icon.png",
13-
"default_title": "extension",
29+
"default_icon": {
30+
"16": "icons/icon.png",
31+
"24": "icons/icon.png",
32+
"32": "icons/icon.png"
33+
},
34+
"default_title": "Transformers.js",
1435
"default_popup": "popup.html"
1536
},
1637
"content_security_policy": {
1738
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'"
39+
},
40+
"icons": {
41+
"16": "icons/icon.png",
42+
"48": "icons/icon.png",
43+
"128": "icons/icon.png"
1844
}
1945
}

examples/extension/public/models/.gitignore

Lines changed: 0 additions & 15 deletions
This file was deleted.

examples/extension/public/wasm/.gitignore

Lines changed: 0 additions & 11 deletions
This file was deleted.

examples/extension/src/background.js

Lines changed: 95 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,102 @@
1-
// background.js - Handles requests from the frontend, runs the model, then sends back a response
2-
// TODO - make persistent (i.e., do not close after inactivity)
3-
4-
if (typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope) {
5-
// Load the library
6-
const { pipeline, env } = require('@xenova/transformers');
7-
8-
// Set environment variables to only use local models.
9-
env.useBrowserCache = false;
10-
env.remoteModels = false;
11-
env.localModelPath = chrome.runtime.getURL('models/')
12-
env.backends.onnx.wasm.wasmPaths = chrome.runtime.getURL('wasm/')
13-
env.backends.onnx.wasm.numThreads = 1;
14-
15-
// TODO: Replace this with your own task and model
16-
const task = 'text-classification';
17-
const model = 'distilbert-base-uncased-finetuned-sst-2-english';
18-
19-
// Load model, storing the promise that is returned from the pipeline function.
20-
// Doing it this way will load the model in the background as soon as the worker is created.
21-
// To actually use the model, you must call `await modelPromise` to get the actual classifier.
22-
const modelPromise = pipeline(task, model, {
23-
progress_callback: (data) => {
24-
// If you would like to add a progress bar for model loading,
25-
// you can send `data` back to the UI.
1+
// background.js - Handles requests from the UI, runs the model, then sends back a response
2+
3+
import { pipeline, env } from '@xenova/transformers';
4+
import { CustomCache } from "./cache.js";
5+
6+
// Define caching parameters
7+
env.useBrowserCache = false;
8+
env.useCustomCache = true;
9+
env.customCache = new CustomCache('transformers-cache');
10+
11+
// Skip initial check for local models, since we are not loading any local models.
12+
env.allowLocalModels = false;
13+
14+
// Due to a bug in onnxruntime-web, we must disable multithreading for now.
15+
// See https://github.com/microsoft/onnxruntime/issues/14445 for more information.
16+
env.backends.onnx.wasm.numThreads = 1;
17+
18+
19+
class PipelineSingleton {
20+
static task = 'text-classification';
21+
static model = 'Xenova/distilbert-base-uncased-finetuned-sst-2-english';
22+
static instance = null;
23+
24+
static async getInstance(progress_callback = null) {
25+
if (this.instance === null) {
26+
this.instance = pipeline(this.task, this.model, { progress_callback });
2627
}
28+
29+
return this.instance;
30+
}
31+
}
32+
33+
// Create generic classify function, which will be reused for the different types of events.
34+
const classify = async (text) => {
35+
// Get the pipeline instance. This will load and build the model when run for the first time.
36+
let model = await PipelineSingleton.getInstance((data) => {
37+
// You can track the progress of the pipeline creation here.
38+
// e.g., you can send `data` back to the UI to indicate a progress bar
39+
// console.log('progress', data)
2740
});
2841

42+
// Actually run the model on the input text
43+
let result = await model(text);
44+
return result;
45+
};
46+
47+
////////////////////// 1. Context Menus //////////////////////
48+
//
49+
// Add a listener to create the initial context menu items,
50+
// context menu items only need to be created at runtime.onInstalled
51+
chrome.runtime.onInstalled.addListener(function () {
52+
// Register a context menu item that will only show up for selection text.
53+
chrome.contextMenus.create({
54+
id: 'classify-selection',
55+
title: 'Classify "%s"',
56+
contexts: ['selection'],
57+
});
58+
});
2959

30-
// Listen for messages from the UI, process it, and send the result back.
31-
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
60+
// Perform inference when the user clicks a context menu
61+
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
62+
// Ignore context menu clicks that are not for classifications (or when there is no input)
63+
if (info.menuItemId !== 'classify-selection' || !info.selectionText) return;
3264

33-
// Run model prediction asynchronously
34-
(async function () {
35-
let model = await modelPromise; // 1. Load model if not already loaded
36-
let result = await model(message); // 2. Run model prediction
37-
sendResponse(result); // 3. Send response back to UI
38-
})();
65+
// Perform classification on the selected text
66+
let result = await classify(info.selectionText);
3967

40-
// return true to indicate we will send a response asynchronously
41-
// see https://stackoverflow.com/a/46628145 for more information
42-
return true;
68+
// Do something with the result
69+
chrome.scripting.executeScript({
70+
target: { tabId: tab.id }, // Run in the tab that the user clicked in
71+
args: [result], // The arguments to pass to the function
72+
function: (result) => { // The function to run
73+
// NOTE: This function is run in the context of the web page, meaning that `document` is available.
74+
console.log('result', result)
75+
console.log('document', document)
76+
},
4377
});
44-
}
78+
});
79+
//////////////////////////////////////////////////////////////
80+
81+
////////////////////// 2. Message Events /////////////////////
82+
//
83+
// Listen for messages from the UI, process it, and send the result back.
84+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
85+
console.log('sender', sender)
86+
if (message.action !== 'classify') return; // Ignore messages that are not meant for classification.
87+
88+
// Run model prediction asynchronously
89+
(async function () {
90+
// Perform classification
91+
let result = await classify(message.text);
92+
93+
// Send response back to UI
94+
sendResponse(result);
95+
})();
96+
97+
// return true to indicate we will send a response asynchronously
98+
// see https://stackoverflow.com/a/46628145 for more information
99+
return true;
100+
});
101+
//////////////////////////////////////////////////////////////
102+

examples/extension/src/cache.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Design a caching API to be used by the extension which implements the same interface as
2+
// the browser's native Cache API (https://developer.mozilla.org/en-US/docs/Web/API/Cache)
3+
// but uses the browser's local storage API (https://developer.chrome.com/docs/extensions/reference/storage/).
4+
//
5+
// Since the local storage API requires all data to be stored as JSON (which doesn't allow some ASCII chars),
6+
// one of the better approaches is to store the response body as a base64-encoded string. This is not ideal,
7+
// as it increases the size of the response body by ~33%, but it's the best we can do with the local storage API.
8+
// See https://stackoverflow.com/a/1443240/13989043 for more information about this.
9+
//
10+
// For serialization (arraybuffer -> string) and unserialization (string -> arraybuffer),
11+
// use the `FileReader` and `Blob` APIs. Although other options are also possible, this approach
12+
// is considered to be better for larger files (like models).
13+
//
14+
// Other references:
15+
// - https://developer.chrome.com/docs/extensions/reference/storage/#property-local
16+
// - https://stackoverflow.com/questions/6965107/converting-between-strings-and-arraybuffers
17+
18+
export class CustomCache {
19+
/**
20+
* Instantiate a `CustomCache` object.
21+
* @param {string} path
22+
*/
23+
constructor(cacheName) {
24+
this.cacheName = cacheName;
25+
}
26+
27+
/**
28+
* Checks whether the given request is in the cache.
29+
* @param {Request|string} request
30+
* @returns {Promise<Response | undefined>}
31+
*/
32+
async match(request) {
33+
const url = request instanceof Request ? request.url : request;
34+
const cached = await chrome.storage.local.get([url]);
35+
36+
if (cached[url]) {
37+
return await fetch(cached[url]._body);
38+
} else {
39+
return undefined;
40+
}
41+
}
42+
43+
/**
44+
* Adds the given response to the cache.
45+
* @param {Request|string} request
46+
* @param {Response} response
47+
* @returns {Promise<void>}
48+
*/
49+
async put(request, response) {
50+
const url = request instanceof Request ? request.url : request;
51+
const buffer = await response.arrayBuffer();
52+
53+
const body = await new Promise((resolve, reject) => {
54+
const reader = new FileReader();
55+
reader.onload = e => resolve(e.target.result);
56+
reader.onerror = e => reject(e.target.error);
57+
reader.readAsDataURL(new Blob([buffer], { type: 'application/octet-stream' }));
58+
});
59+
60+
try {
61+
await chrome.storage.local.set({
62+
[url]: {
63+
_body: body,
64+
65+
// Save original response in case
66+
status: response.status,
67+
statusText: response.statusText,
68+
headers: Object.fromEntries(response.headers.entries()),
69+
url: response.url,
70+
redirected: response.redirected,
71+
type: response.type,
72+
ok: response.ok,
73+
}
74+
});
75+
76+
} catch (err) {
77+
console.warn('An error occurred while writing the file to cache:', err)
78+
}
79+
}
80+
}

examples/extension/src/content.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// content.js - the content scripts which is run in the context of web pages, and has access
2+
// to the DOM and other web APIs.
3+
4+
// Example usage:
5+
// const message = {
6+
// action: 'classify',
7+
// text: 'text to classify',
8+
// }
9+
// chrome.runtime.sendMessage(message, (response) => {
10+
// console.log('received user data', response)
11+
// });

examples/extension/src/popup.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
// This script handles interaction with the user interface, as well as handling
2-
// the communication between the main thread (UI) and the background thread (processing).
1+
// popup.js - handles interaction with the extension's popup, sends requests to the
2+
// service worker (background.js), and updates the popup's UI (popup.html) on completion.
33

44
const inputElement = document.getElementById('text');
55
const outputElement = document.getElementById('output');
66

7-
// 1. Send input data to the worker thread when it changes.
7+
// Listen for changes made to the textbox.
88
inputElement.addEventListener('input', (event) => {
9-
chrome.runtime.sendMessage(event.target.value, (response) => {
10-
// 2. Handle results returned by the service worker (`background.js`) and update the UI.
9+
10+
// Bundle the input data into a message.
11+
const message = {
12+
action: 'classify',
13+
text: event.target.value,
14+
}
15+
16+
// Send this message to the service worker.
17+
chrome.runtime.sendMessage(message, (response) => {
18+
// Handle results returned by the service worker (`background.js`) and update the popup's UI.
1119
outputElement.innerText = JSON.stringify(response, null, 2);
1220
});
1321
});

0 commit comments

Comments
 (0)