@arraypress/extension-kit 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -20
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +17 -5
- package/src/scripting/tab.js +15 -7
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +56 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.js +2 -0
package/README.md
CHANGED
|
@@ -74,9 +74,12 @@ Other libraries are copied the same way, by name (a path ending in `/` copies a
|
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
`npm install some-package` doesn't run the extension's own `postinstall`, so a copy can go stale
|
|
77
|
-
without a word. Each copy is stamped with its package's version
|
|
78
|
-
zip), and `extension-kit vendor-check` fails, naming the copy,
|
|
79
|
-
|
|
77
|
+
without a word. Each copy is stamped with its package's version, what was copied and a hash of it
|
|
78
|
+
(`vendored.json`, left out of the zip), and `extension-kit vendor-check` fails, naming the copy,
|
|
79
|
+
until `npm run postinstall` remakes it: a copy of another version, one edited by hand, one whose
|
|
80
|
+
package's files changed under the same version (a library copied into `node_modules` to try it
|
|
81
|
+
before it's published), or a vendor folder with no copies in it at all. A copy is never made into
|
|
82
|
+
a folder that holds a `manifest.json`, a `package.json` or a `.git` (the folder is emptied first). From a script: `vendorPackage(name, files, dest)`, `checkVendored(dir)`.
|
|
80
83
|
|
|
81
84
|
### An extension with a bundler
|
|
82
85
|
|
|
@@ -116,10 +119,12 @@ saver.follow((settings) => show(settings), chrome.storage.onChanged);
|
|
|
116
119
|
|
|
117
120
|
| Store member | What it does |
|
|
118
121
|
|---|---|
|
|
119
|
-
| `load(storage)` | Every setting: sync, then held refusals, then local, normalised. |
|
|
122
|
+
| `load(storage)` | Every setting: sync, then held refusals (the ones still newer than what sync holds), then local, normalised. |
|
|
120
123
|
| `save(storage, changes)` | Writes only the settings named, so another computer's change to a different one survives. Resolves to `{settings, synced}`. |
|
|
121
|
-
| `writeSynced(storage, values)` | One sync write; what sync refuses (rate limit, an item over 8,192 bytes) is kept in local under `unsyncedKey` and retried with the next write. |
|
|
124
|
+
| `writeSynced(storage, values)` | One sync write; what sync refuses (rate limit, an item over 8,192 bytes) is kept in local under `unsyncedKey` and retried with the next write. Items refused together are tried one at a time, and a held change is dropped once another computer has changed that setting since. Holding runs under a Web Lock, so two writers at once lose nothing. |
|
|
122
125
|
| `retryUnsynced(storage)` | Try the held refusals again. |
|
|
126
|
+
| `retryable(storage)` | Whether trying again could help (something held is still the newest and fits one sync item). |
|
|
127
|
+
| `syncItemBytes(key, value)` (an export) | What Chrome Sync counts for one item: the key plus the value as Chrome's JSON writer spells it (`<` is six bytes). |
|
|
123
128
|
| `changed(changes, area)` | The settings a `chrome.storage.onChanged` event touches. |
|
|
124
129
|
| `normalize(raw)`, `defaults`, `localKeys`, `syncedKeys`, `unsyncedKey` | The schema. |
|
|
125
130
|
|
|
@@ -248,7 +253,7 @@ chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab
|
|
|
248
253
|
// In the panel: every frame of the tab, or why it can't be read.
|
|
249
254
|
const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
|
|
250
255
|
if (reading.state === 'read') show(reading.frames); // top frame first
|
|
251
|
-
else showMessage(reading.state); // 'browser' (Chrome's own page)
|
|
256
|
+
else showMessage(reading.state); // 'browser' (Chrome's own page), 'no-access', 'failed' or 'timeout'
|
|
252
257
|
|
|
253
258
|
// In the content script: a layer the page's styles can't touch (a closed shadow root). The host's
|
|
254
259
|
// own styles go in hostStyle: its inline `all: initial !important` reset beats a `:host` rule.
|
|
@@ -259,10 +264,28 @@ const { root, remove } = pageOverlay(document, {
|
|
|
259
264
|
});
|
|
260
265
|
|
|
261
266
|
// “Show on page”: boxes in DevTools' blue, kept on the elements as the page scrolls; Esc clears.
|
|
262
|
-
|
|
267
|
+
// onClear says why they went, so the panel hears of the ones it didn't ask for.
|
|
268
|
+
const highlight = createHighlighter(document, {
|
|
269
|
+
root: () => root,
|
|
270
|
+
onClear: (reason) => { if (reason === 'escape' || reason === 'pagehide') chrome.runtime.sendMessage({ type: 'boxes-cleared' }); },
|
|
271
|
+
});
|
|
263
272
|
highlight.show(elements, { reveal: true });
|
|
273
|
+
|
|
274
|
+
// In the panel: which item is shown, whatever ends it.
|
|
275
|
+
const onPage = createShowOnPage({ show: (ids, frame) => callPage('highlight', [ids, frame]), onChange: press });
|
|
276
|
+
button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
|
|
277
|
+
chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
|
|
278
|
+
addEventListener('pagehide', () => onPage.reset()); // and `await onPage.reset()` before a rescan
|
|
264
279
|
```
|
|
265
280
|
|
|
281
|
+
A page can load a web-accessible module by the extension's address, and so tell the extension
|
|
282
|
+
is installed. `"use_dynamic_url": true` stops that (the page's `import()` and `fetch()` are
|
|
283
|
+
refused) and `callInPage`'s own import still works, but **only for a module with no static
|
|
284
|
+
imports**: Chrome refuses a dynamic-URL module's `import … from './part.js'` (checked in Chromium
|
|
285
|
+
156, `tests/chrome-chromium.test.js`). The family's page modules import their parts and the kit
|
|
286
|
+
statically and have no bundler, so they are listed without it; a single-file page module (a
|
|
287
|
+
bundled one) can use it.
|
|
288
|
+
|
|
266
289
|
`callInPage(scripting, target, module, name, args, {immediately})` resolves to each frame's
|
|
267
290
|
`{frameId, result}` (frames Chrome couldn't run in left out) and rejects when it can run in none;
|
|
268
291
|
`immediately` (default true) doesn't wait for the page to be idle. `openSidePanel` sets the tab's
|
|
@@ -270,14 +293,20 @@ panel to `path?tab=<id>` (`sidePanelPath`) and opens it in the same moment: Chro
|
|
|
270
293
|
only while the click counts. `callInTab(edges, tabId, module, name, args)` calls it in every frame
|
|
271
294
|
and gives `{state: 'read', frames}` (frames that answered, top first), or `{state: 'browser'}` for
|
|
272
295
|
a page Chrome keeps to itself (`isBrowserPage(url)`: its own pages, the Web Store) or
|
|
273
|
-
`{state: 'no-access'}` (the tab went to another site since the click)
|
|
296
|
+
`{state: 'no-access'}` (the tab went to another site since the click), `{state: 'failed', error}`
|
|
297
|
+
(the page module didn't load or threw in every frame; Chrome alone reports that as null) or, with
|
|
298
|
+
`{timeoutMs}`, `{state: 'timeout'}` (Chrome answers only once every frame has). `pageOverlay` puts a host
|
|
274
299
|
element of its own name on the root element, replacing one a run before left; `root` is its closed
|
|
275
300
|
shadow root, `css` first. The host is reset inline (`all: initial !important`) so the page can't
|
|
276
301
|
move or hide it; that also beats a `:host { … }` rule and `host.style.x = …`, so the host's own
|
|
277
|
-
styles go in `hostStyle` (set `!important` after the reset). `
|
|
302
|
+
styles go in `hostStyle` (set `!important` after the reset). `topLayer: true` makes the host a
|
|
303
|
+
manual popover, drawn over the page's modal dialogs and fullscreen element and put back on top
|
|
304
|
+
(`raise()`) when the page opens one after it; a modal dialog still makes it inert, so it shows but
|
|
305
|
+
can't be clicked while the dialog is open. `createHighlighter(doc, {root, onClear,
|
|
278
306
|
max})` boxes up to 500 elements (`MAX_BOXES`) in the overlay (`HIGHLIGHT_CSS` styles them), only
|
|
279
|
-
those in the window, following the page as it scrolls; `show` replaces the set, `clear
|
|
280
|
-
ends it
|
|
307
|
+
those in the window, following the page as it scrolls; `show` replaces the set, `clear`, Esc or
|
|
308
|
+
leaving the page ends it, and `onClear(reason)` hears which (`'replaced'`, `'asked'`, `'escape'`,
|
|
309
|
+
`'pagehide'`). `createShowOnPage({show, onChange})` is the panel's side: `toggle`, `reset`, `cleared`.
|
|
281
310
|
|
|
282
311
|
```js
|
|
283
312
|
// A page's elements in a line, and copying from a click (the copy command where the API can't).
|
|
@@ -294,6 +323,16 @@ can call it from a menu click or a shortcut (Chrome lets the focused active tab
|
|
|
294
323
|
`noticeCard({icon, tone, title, text, buttons, actions})` is a side panel's notice (`.sp-notice`:
|
|
295
324
|
frames from other sites to allow); `counted(count, one, many, {t, lang})` a count in words.
|
|
296
325
|
|
|
326
|
+
## The right-click menu
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
// Built on install and on browser start; after a Chrome update both come in one run, and two
|
|
330
|
+
// builds side by side would create every item twice. Through the builder, one waits for the other.
|
|
331
|
+
const buildMenu = contextMenuBuilder(chrome);
|
|
332
|
+
chrome.runtime.onInstalled.addListener(() => buildMenu(menuItems(t)));
|
|
333
|
+
chrome.runtime.onStartup.addListener(() => buildMenu(menuItems(t)));
|
|
334
|
+
```
|
|
335
|
+
|
|
297
336
|
## A content script on every site
|
|
298
337
|
|
|
299
338
|
```js
|
|
@@ -334,6 +373,34 @@ first session after Chrome starts can take ~18 s while the model loads: a questi
|
|
|
334
373
|
limit comes back `'late'`, and the session goes on loading for the next one. Nothing loads it ahead
|
|
335
374
|
of time (that would load the model on every start). `release()` lets the base session go.
|
|
336
375
|
|
|
376
|
+
A base session Chrome has let go since (the model was updated, its process ended) is made again
|
|
377
|
+
when its `clone()` fails.
|
|
378
|
+
|
|
379
|
+
**In a service worker.** Chrome's documentation says the Prompt API isn't in workers; Chrome 157
|
|
380
|
+
and Chromium 156 do have `LanguageModel` in an extension's service worker (checked in both, with
|
|
381
|
+
an unpacked extension: `'function'` in the worker, an extension page and an offscreen document).
|
|
382
|
+
Since neither is promised, a worker uses `createHostedModelSession`: the same `ModelSession`,
|
|
383
|
+
asking the worker's own `LanguageModel` when there is one and otherwise the extension's offscreen
|
|
384
|
+
document, over `chrome.runtime` messages.
|
|
385
|
+
|
|
386
|
+
```js
|
|
387
|
+
// background.js
|
|
388
|
+
const words = createHostedModelSession({
|
|
389
|
+
name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
|
|
390
|
+
runtime: chrome.runtime,
|
|
391
|
+
open: () => watchFromOffscreen(chrome, undefined, 'Reads light or dark mode for the toolbar icon, and runs Chrome’s on-device model.'),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// offscreen/offscreen.js: the one offscreen document an extension gets does both jobs.
|
|
395
|
+
watchSchemeOffscreen(chrome.runtime);
|
|
396
|
+
serveModelOffscreen(chrome.runtime);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The document is told each session's options and instructions with the request, keeps one base
|
|
400
|
+
session per `name`, keeps the time limit, and stops an answer when the caller's signal aborts. It
|
|
401
|
+
ignores requests from content scripts and other extensions. The schema for an answer crosses as
|
|
402
|
+
JSON (an object, not a RegExp).
|
|
403
|
+
|
|
337
404
|
`modelAvailability(api, options)` never throws (`'missing'` without a Prompt API).
|
|
338
405
|
`downloadModel(api, options, {onProgress})` is the only `create()` that may download: call it
|
|
339
406
|
first in a click's handler, with nothing awaited before it. The settings row:
|
|
@@ -385,7 +452,9 @@ hands the main thread back whenever a slice has run its time, so a 2,000-item li
|
|
|
385
452
|
freezes. `eachInSlices(items, work, {sliceMs, pause})` does the same for any list (each element's
|
|
386
453
|
style read, each text measured): `work(item, index)`. `watchChanges` hands over what was added or changed a little after the page stops
|
|
387
454
|
changing (at most `maxWaitMs` while it keeps on); an element with a few children (`regroupChildren`,
|
|
388
|
-
default 6) whose children changed is handed over whole
|
|
455
|
+
default 6) whose children changed is handed over whole; what changes inside the extension's own
|
|
456
|
+
elements (`ignore`, asked of a change's target and its ancestors) sets nothing off. `inSlices` cuts
|
|
457
|
+
the page as the work goes and leaves out scripts, styles and templates. Both are pure DOM, so a content script
|
|
389
458
|
imports them directly (list them in `web_accessible_resources`).
|
|
390
459
|
|
|
391
460
|
`renderedElements(doc, {skip, limit})` (`content/elements.js`) lists every element Chrome draws,
|
|
@@ -439,7 +508,10 @@ It opens modally over its button, as Chrome's does (its top at the button's top,
|
|
|
439
508
|
button's end), moved to stay 8px inside the window. A click outside, Esc, Tab or the window losing
|
|
440
509
|
focus closes it; ↑/↓ (wrapping, past disabled items), Home and End move; focus goes back to the
|
|
441
510
|
button (or `focusTo`); an item runs once the menu has closed. `createMenu({id, placement: 'below'})`
|
|
442
|
-
opens it below the button instead. `menuPosition` and `isInside` are the pure geometry.
|
|
511
|
+
opens it below the button instead. `menuPosition` and `isInside` are the pure geometry. In a
|
|
512
|
+
content script, give it the overlay's shadow root (`createMenu({root})`, a closed one too): the
|
|
513
|
+
keys it handles don't reach the page's own shortcuts, and `destroy()` takes it off the page with
|
|
514
|
+
its window listener. On a right-to-left page it lines up with its button's other edge.
|
|
443
515
|
|
|
444
516
|
`moreActionsButton(name, options)` is the 32px ⋮ icon button, labelled "More actions for “name”"
|
|
445
517
|
(`label` for a translation), with `aria-haspopup="menu"`; its icon is the stylesheet's
|
|
@@ -578,16 +650,16 @@ except those listed, each with a reason:
|
|
|
578
650
|
|
|
579
651
|
| Export | What it does |
|
|
580
652
|
|---|---|
|
|
581
|
-
| `fakeLanguageModel(state)` | A fake Prompt API (`LanguageModel`): `availability`, `create` (download progress, a cold start, `failDownload`), sessions that `clone`, answer (`answerMs`, `answer(prompt)`, `failPrompt`) and stop on their signal; `log` of what it was asked, `set` to change it. Browser-safe, so a test build can put it on `globalThis`. |
|
|
582
|
-
| `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage
|
|
653
|
+
| `fakeLanguageModel(state)` | A fake Prompt API (`LanguageModel`): `availability`, `create` (download progress, a cold start, `failDownload`), sessions that `clone`, answer (`answerMs`, `answer(prompt)`, `failPrompt`) and stop on their signal, a destroyed session that answers nothing more, a download refused without a click (`userActivation`); `log` of what it was asked, `set` to change it. Browser-safe, so a test build can put it on `globalThis`. |
|
|
654
|
+
| `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`, each point checked against Chromium: values as Chrome stores them (`undefined`, functions and `NaN` dropped, a `Date` is `{}`), `get` with a key, keys, defaults or nothing, `clear`, `getBytesInUse`, `onChanged` (the area's own too, `removeListener`) for real changes only, whatever order an object's keys come in. Sync's quotas with Chrome's own errors: 8,192 bytes an item counted as Chrome counts them (`syncItemBytes`), 102,400 bytes (`SYNC_QUOTA_BYTES`) and 512 items (`SYNC_MAX_ITEMS`) in all, 120 calls a minute and 1,800 an hour (`SYNC_WRITES_PER_MINUTE`, `SYNC_WRITES_PER_HOUR`) of each of `set`, `remove` and `clear`; local and session hold 10 MB (`LOCAL_QUOTA_BYTES`). `settled()` waits for listeners. |
|
|
583
655
|
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
|
|
584
656
|
| `launchDownloading(chromium, dir, options)`, `filesIn(dir)` | An extension in a browser that downloads for real: `{…launchExtension's, downloads}`, the profile's own Downloads folder (Playwright otherwise saves downloads under made-up names no extension sees); the files under a folder, downloads still running left out. |
|
|
585
657
|
| `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
|
|
586
658
|
| `runChecks(runner, body, cleanUps)` | A script's checks: an error outside any check fails, the clean-ups always run (each within 30 s), then the tally. |
|
|
587
|
-
| `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales
|
|
659
|
+
| `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales`, as Chromium answers: a locale over en, names in any case, named placeholders and `$1`–`$9` filled, `$$` a dollar sign; a message naming a placeholder it doesn't define throws (Chrome won't load that extension). For unit tests. |
|
|
588
660
|
| `heartbeat(page)` | A timer beating in a page: `stop()` gives the longest the main thread was held (ms), for performance checks. |
|
|
589
|
-
| `playPermissionPrompt(answers)` | Chrome's permission prompt played in a page (`page.evaluate` or `addInitScript`): each request recorded on `window.asked` with whether a click was behind it, answered from `answers
|
|
590
|
-
| `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()
|
|
661
|
+
| `playPermissionPrompt(answers)` | Chrome's permission prompt played in a page (`page.evaluate` or `addInitScript`): each request recorded on `window.asked` with whether a click was behind it, answered from `answers`; one with no click behind it is refused, as Chrome refuses it. |
|
|
662
|
+
| `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()` (exits 1 on a failure, or when no check ran at all). A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
|
|
591
663
|
| `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
|
|
592
664
|
| `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
|
|
593
665
|
| `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
|
|
@@ -599,16 +671,21 @@ except those listed, each with a reason:
|
|
|
599
671
|
| `until(fn, what, timeout?)` | Poll until truthy. |
|
|
600
672
|
| `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
|
|
601
673
|
| `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
|
|
674
|
+
| `stopWorker(launched, {start})` | Stop the service worker as Chrome does when it's idle (memory gone, storage kept) and start it again, or leave it for the next event: for checks that state survives a restart. Playwright's debugger otherwise keeps the worker alive for the whole run. |
|
|
602
675
|
| `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
|
|
603
676
|
| `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
|
|
604
677
|
| `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
|
|
605
678
|
| `parseLiveOptions(argv, env, {usage})`, `parseProxy(address)` | A live checker's command line (`--only`, `--category`, `--concurrency`, `--proxy` or `HTTPS_PROXY`, `--locale`, `--timezone`, `--out`, `--help`), and a proxy address for Playwright with its password hidden for the output. |
|
|
606
|
-
| `runLiveCheck({chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult})`, `liveUsage(categories, n)` | A live checker's whole run: its command line (`parseLiveOptions`, usage from the categories), the sites chosen, the exit point printed, each site visited on a fresh page in a browser with the extension (one a slot, at most 4, passing for a person's), within `pageMs` (a browser whose page ran out of time replaced), Ctrl-C closing them all; `{dir, started, finished, exit, browser, results}` for the checker's report. |
|
|
679
|
+
| `runLiveCheck({chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult})`, `liveUsage(categories, n)` | A live checker's whole run: its command line (`parseLiveOptions`, usage from the categories), the sites chosen, the exit point printed, each site visited on a fresh page in a browser with the extension (one a slot, at most 4, passing for a person's), within `pageMs` (a browser whose page ran out of time replaced), Ctrl-C closing them all; `{dir, started, finished, exit, browser, results}` for the checker's report. A visit that throws, runs out of time or loses its browser is `failed(site, why)` and the run goes on with a new browser. The report folder is emptied only if the kit made it (`reportFolder`, `REPORT_MARKER`): `--out` on a folder with other files in it is refused. |
|
|
607
680
|
| `exitPoint(request)` | Where the run's traffic leaves for the internet (`{ip, country, where, source}`), from a keyless lookup service through the context's proxy: sites decide what to show by it. |
|
|
608
|
-
| `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names
|
|
681
|
+
| `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names is a file the zip holds (one left out of the zip is as missing as one that isn't there): imports (`from`, `import()`, a bare `import './x.js'`), `getURL`, `src`/`href`, a stylesheet's `url()` and `@import`, and the manifest's worker, pages, icons, content scripts, default locale's messages, rule files and web-accessible resources. A `#fragment` or `?query` is ignored (`referencedPath`). |
|
|
609
682
|
|
|
610
683
|
## Stylesheet classes
|
|
611
684
|
|
|
685
|
+
The stylesheet uses logical properties (and mirrors the few things that need it: a select's arrow,
|
|
686
|
+
the switch's knob, the link-row arrow, the drawer), so a page with `dir="rtl"` reads as Chrome's
|
|
687
|
+
own pages do in a right-to-left language.
|
|
688
|
+
|
|
612
689
|
| Piece | Markup |
|
|
613
690
|
|---|---|
|
|
614
691
|
| Page column (680px, 96%) | `<main class="page">` |
|
package/bin/extension-kit.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* extension-kit vendor <dir> <package> <path>... copy files of an installed package into <dir>
|
|
7
7
|
* (a path ending in / copies its contents)
|
|
8
8
|
* extension-kit vendor-check [dir] fail when a copy in [dir] (default extension/vendor)
|
|
9
|
-
* no longer matches the installed package
|
|
9
|
+
* no longer matches the installed package (another
|
|
10
|
+
* version, other files, edited since), or there's none
|
|
10
11
|
* extension-kit icons [dir] render [dir]/icon.svg (default extension/icons)
|
|
11
12
|
* extension-kit toolbar-icons [dir] render the toolbar glyph (default extension/icons/toolbar)
|
|
12
13
|
* extension-kit duplication fail on copied code jscpd finds, less .jscpd-allowlist.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Shared pieces for Chrome extensions: Chrome's own page styles and components, a Chrome Sync settings store, the light/dark toolbar glyph, Chrome's on-device model, content-script helpers, About links, and build and test helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/ai/host.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's on-device model for a service worker, wherever Chrome puts the
|
|
3
|
+
* Prompt API. Chrome's documentation says `LanguageModel` isn't in workers
|
|
4
|
+
* (https://developer.chrome.com/docs/ai/prompt-api); Chrome 157 and Chromium
|
|
5
|
+
* 156 do have it in an extension's service worker (checked in both: an
|
|
6
|
+
* unpacked extension, `typeof LanguageModel` in the worker, an extension page
|
|
7
|
+
* and an offscreen document, `'function'` in all three). So neither is
|
|
8
|
+
* promised, and a worker that uses the model shouldn't depend on which holds:
|
|
9
|
+
* {@link createHostedModelSession} is a `ModelSession` that asks the worker's
|
|
10
|
+
* own `LanguageModel` when there is one, and otherwise the extension's
|
|
11
|
+
* offscreen document, which has a page's Prompt API and answers over
|
|
12
|
+
* `chrome.runtime` messages ({@link serveModelOffscreen}).
|
|
13
|
+
*
|
|
14
|
+
* The offscreen document is the one the worker already keeps for the toolbar
|
|
15
|
+
* glyph (an extension gets only one): its script calls both
|
|
16
|
+
* `watchSchemeOffscreen` and `serveModelOffscreen`. It's told the session's
|
|
17
|
+
* options and instructions with each request, so it needs none of the
|
|
18
|
+
* extension's own code.
|
|
19
|
+
*
|
|
20
|
+
* @module ai/host
|
|
21
|
+
*/
|
|
22
|
+
import type { InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelSession } from './model.js';
|
|
23
|
+
/** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
|
|
24
|
+
/** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
|
|
25
|
+
export declare const MODEL_HOST = "model-host";
|
|
26
|
+
/** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
|
|
27
|
+
export declare const HOST_GRACE_MS = 1000;
|
|
28
|
+
export type HostRequest = {
|
|
29
|
+
/**
|
|
30
|
+
* The message type.
|
|
31
|
+
*/
|
|
32
|
+
type: typeof MODEL_HOST;
|
|
33
|
+
/**
|
|
34
|
+
* What's asked.
|
|
35
|
+
*/
|
|
36
|
+
method: 'availability' | 'ask' | 'abort' | 'release';
|
|
37
|
+
/**
|
|
38
|
+
* The session's name (one base session each).
|
|
39
|
+
*/
|
|
40
|
+
name: string;
|
|
41
|
+
/**
|
|
42
|
+
* What its sessions are made with.
|
|
43
|
+
*/
|
|
44
|
+
options?: LanguageModelOptions;
|
|
45
|
+
/**
|
|
46
|
+
* Its instructions and examples.
|
|
47
|
+
*/
|
|
48
|
+
initialPrompts?: InitialPrompt[];
|
|
49
|
+
/**
|
|
50
|
+
* A question's id, for `abort`.
|
|
51
|
+
*/
|
|
52
|
+
id?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The question.
|
|
55
|
+
*/
|
|
56
|
+
prompt?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Its time limit.
|
|
59
|
+
*/
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* The answer's JSON Schema.
|
|
63
|
+
*/
|
|
64
|
+
responseConstraint?: object;
|
|
65
|
+
};
|
|
66
|
+
export type HostSender = {
|
|
67
|
+
sendMessage(message: unknown): Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
export type HostRuntime = {
|
|
70
|
+
/**
|
|
71
|
+
* This extension's id: requests from anyone else are ignored.
|
|
72
|
+
*/
|
|
73
|
+
id: string;
|
|
74
|
+
/**
|
|
75
|
+
* `chrome.runtime.onMessage`.
|
|
76
|
+
*/
|
|
77
|
+
onMessage: {
|
|
78
|
+
addListener(listener: (message: unknown, sender: {
|
|
79
|
+
id?: string;
|
|
80
|
+
tab?: unknown;
|
|
81
|
+
}, respond: (answer: unknown) => void) => boolean): void;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
export type HostedSessionSetup = {
|
|
85
|
+
/**
|
|
86
|
+
* The session's name, its own among the extension's sessions ('topics').
|
|
87
|
+
*/
|
|
88
|
+
name: string;
|
|
89
|
+
/**
|
|
90
|
+
* What sessions are made (and availability asked) with.
|
|
91
|
+
*/
|
|
92
|
+
options: LanguageModelOptions;
|
|
93
|
+
/**
|
|
94
|
+
* The instructions and examples every question starts from.
|
|
95
|
+
*/
|
|
96
|
+
initialPrompts?: InitialPrompt[];
|
|
97
|
+
/**
|
|
98
|
+
* `chrome.runtime`.
|
|
99
|
+
*/
|
|
100
|
+
runtime: HostSender;
|
|
101
|
+
/**
|
|
102
|
+
* Makes sure the offscreen document is open
|
|
103
|
+
* (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
|
|
104
|
+
*/
|
|
105
|
+
open: () => Promise<unknown>;
|
|
106
|
+
/**
|
|
107
|
+
* The worker's own Prompt API, read when used.
|
|
108
|
+
* Default Chrome's.
|
|
109
|
+
*/
|
|
110
|
+
model?: () => LanguageModelStatic | undefined;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* A request to the offscreen document.
|
|
114
|
+
*
|
|
115
|
+
* @typedef {object} HostRequest
|
|
116
|
+
* @property {typeof MODEL_HOST} type The message type.
|
|
117
|
+
* @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
|
|
118
|
+
* @property {string} name The session's name (one base session each).
|
|
119
|
+
* @property {LanguageModelOptions} [options] What its sessions are made with.
|
|
120
|
+
* @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
|
|
121
|
+
* @property {string} [id] A question's id, for `abort`.
|
|
122
|
+
* @property {string} [prompt] The question.
|
|
123
|
+
* @property {number} [timeoutMs] Its time limit.
|
|
124
|
+
* @property {object} [responseConstraint] The answer's JSON Schema.
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* The parts of `chrome.runtime` the worker's side uses.
|
|
128
|
+
*
|
|
129
|
+
* @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* The parts of `chrome.runtime` the offscreen document's side uses.
|
|
133
|
+
*
|
|
134
|
+
* @typedef {object} HostRuntime
|
|
135
|
+
* @property {string} id This extension's id: requests from anyone else are ignored.
|
|
136
|
+
* @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
|
|
137
|
+
* `chrome.runtime.onMessage`.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} HostedSessionSetup
|
|
141
|
+
* @property {string} name The session's name, its own among the extension's sessions ('topics').
|
|
142
|
+
* @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
|
|
143
|
+
* @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
|
|
144
|
+
* @property {HostSender} runtime `chrome.runtime`.
|
|
145
|
+
* @property {() => Promise<unknown>} open Makes sure the offscreen document is open
|
|
146
|
+
* (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
|
|
147
|
+
* @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
|
|
148
|
+
* Default Chrome's.
|
|
149
|
+
*/
|
|
150
|
+
/**
|
|
151
|
+
* A model session for a service worker: the worker's own Prompt API when it
|
|
152
|
+
* has one, else the offscreen document's. The same `ModelSession` either way.
|
|
153
|
+
* An answer's JSON Schema crosses to the offscreen document as JSON, so it
|
|
154
|
+
* must be a plain object there (not a RegExp).
|
|
155
|
+
*
|
|
156
|
+
* @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
|
|
157
|
+
* @returns {ModelSession} The session.
|
|
158
|
+
* @example
|
|
159
|
+
* // background.js
|
|
160
|
+
* const words = createHostedModelSession({
|
|
161
|
+
* name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
|
|
162
|
+
* runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
|
|
163
|
+
* });
|
|
164
|
+
* const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
|
|
165
|
+
*/
|
|
166
|
+
export declare function createHostedModelSession({ name, options, initialPrompts, runtime, open, model }: HostedSessionSetup): ModelSession;
|
|
167
|
+
/**
|
|
168
|
+
* Answer the worker's model requests, in the offscreen document: one base
|
|
169
|
+
* session per name, made with the options and instructions the request
|
|
170
|
+
* carries (and again if they change). Requests from another extension, or
|
|
171
|
+
* from a content script (a web page's side), are ignored.
|
|
172
|
+
*
|
|
173
|
+
* @param {HostRuntime} runtime - `chrome.runtime`.
|
|
174
|
+
* @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
|
|
175
|
+
* @returns {void}
|
|
176
|
+
* @example
|
|
177
|
+
* // offscreen/offscreen.js
|
|
178
|
+
* watchSchemeOffscreen(chrome.runtime);
|
|
179
|
+
* serveModelOffscreen(chrome.runtime);
|
|
180
|
+
*/
|
|
181
|
+
export declare function serveModelOffscreen(runtime: HostRuntime, { model }?: {
|
|
182
|
+
model?: () => LanguageModelStatic | undefined;
|
|
183
|
+
}): void;
|
package/src/ai/host.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's on-device model for a service worker, wherever Chrome puts the
|
|
3
|
+
* Prompt API. Chrome's documentation says `LanguageModel` isn't in workers
|
|
4
|
+
* (https://developer.chrome.com/docs/ai/prompt-api); Chrome 157 and Chromium
|
|
5
|
+
* 156 do have it in an extension's service worker (checked in both: an
|
|
6
|
+
* unpacked extension, `typeof LanguageModel` in the worker, an extension page
|
|
7
|
+
* and an offscreen document, `'function'` in all three). So neither is
|
|
8
|
+
* promised, and a worker that uses the model shouldn't depend on which holds:
|
|
9
|
+
* {@link createHostedModelSession} is a `ModelSession` that asks the worker's
|
|
10
|
+
* own `LanguageModel` when there is one, and otherwise the extension's
|
|
11
|
+
* offscreen document, which has a page's Prompt API and answers over
|
|
12
|
+
* `chrome.runtime` messages ({@link serveModelOffscreen}).
|
|
13
|
+
*
|
|
14
|
+
* The offscreen document is the one the worker already keeps for the toolbar
|
|
15
|
+
* glyph (an extension gets only one): its script calls both
|
|
16
|
+
* `watchSchemeOffscreen` and `serveModelOffscreen`. It's told the session's
|
|
17
|
+
* options and instructions with each request, so it needs none of the
|
|
18
|
+
* extension's own code.
|
|
19
|
+
*
|
|
20
|
+
* @module ai/host
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { within } from '../time/time.js';
|
|
24
|
+
import { chromeLanguageModel, createModelSession } from './model.js';
|
|
25
|
+
|
|
26
|
+
/** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
|
|
27
|
+
|
|
28
|
+
/** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
|
|
29
|
+
export const MODEL_HOST = 'model-host';
|
|
30
|
+
|
|
31
|
+
/** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
|
|
32
|
+
export const HOST_GRACE_MS = 1000;
|
|
33
|
+
|
|
34
|
+
/** The model's states, to check an answer that came over a message. */
|
|
35
|
+
const STATES = ['available', 'downloadable', 'downloading', 'unavailable', 'missing'];
|
|
36
|
+
|
|
37
|
+
/** Why a question got no answer, to check the same. */
|
|
38
|
+
const REASONS = ['unavailable', 'late', 'aborted', 'failed'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A request to the offscreen document.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {object} HostRequest
|
|
44
|
+
* @property {typeof MODEL_HOST} type The message type.
|
|
45
|
+
* @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
|
|
46
|
+
* @property {string} name The session's name (one base session each).
|
|
47
|
+
* @property {LanguageModelOptions} [options] What its sessions are made with.
|
|
48
|
+
* @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
|
|
49
|
+
* @property {string} [id] A question's id, for `abort`.
|
|
50
|
+
* @property {string} [prompt] The question.
|
|
51
|
+
* @property {number} [timeoutMs] Its time limit.
|
|
52
|
+
* @property {object} [responseConstraint] The answer's JSON Schema.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The parts of `chrome.runtime` the worker's side uses.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The parts of `chrome.runtime` the offscreen document's side uses.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} HostRuntime
|
|
65
|
+
* @property {string} id This extension's id: requests from anyone else are ignored.
|
|
66
|
+
* @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
|
|
67
|
+
* `chrome.runtime.onMessage`.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} HostedSessionSetup
|
|
72
|
+
* @property {string} name The session's name, its own among the extension's sessions ('topics').
|
|
73
|
+
* @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
|
|
74
|
+
* @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
|
|
75
|
+
* @property {HostSender} runtime `chrome.runtime`.
|
|
76
|
+
* @property {() => Promise<unknown>} open Makes sure the offscreen document is open
|
|
77
|
+
* (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
|
|
78
|
+
* @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
|
|
79
|
+
* Default Chrome's.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A model session for a service worker: the worker's own Prompt API when it
|
|
84
|
+
* has one, else the offscreen document's. The same `ModelSession` either way.
|
|
85
|
+
* An answer's JSON Schema crosses to the offscreen document as JSON, so it
|
|
86
|
+
* must be a plain object there (not a RegExp).
|
|
87
|
+
*
|
|
88
|
+
* @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
|
|
89
|
+
* @returns {ModelSession} The session.
|
|
90
|
+
* @example
|
|
91
|
+
* // background.js
|
|
92
|
+
* const words = createHostedModelSession({
|
|
93
|
+
* name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
|
|
94
|
+
* runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
|
|
95
|
+
* });
|
|
96
|
+
* const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
|
|
97
|
+
*/
|
|
98
|
+
export function createHostedModelSession({ name, options, initialPrompts = [], runtime, open, model = chromeLanguageModel }) {
|
|
99
|
+
const local = createModelSession({ model, options, initialPrompts });
|
|
100
|
+
let asked = 0;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ask the offscreen document.
|
|
104
|
+
*
|
|
105
|
+
* @param {HostRequest['method']} method
|
|
106
|
+
* @param {Partial<HostRequest>} [more]
|
|
107
|
+
* @returns {Promise<unknown>} Its answer; rejects when there's no document to hear it.
|
|
108
|
+
*/
|
|
109
|
+
const send = async (method, more = {}) => {
|
|
110
|
+
await open();
|
|
111
|
+
return runtime.sendMessage(/** @type {HostRequest} */ ({ type: MODEL_HOST, method, name, options, initialPrompts, ...more }));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
async availability() {
|
|
116
|
+
if (model()) return local.availability();
|
|
117
|
+
const state = await send('availability').catch(() => 'missing');
|
|
118
|
+
return STATES.includes(/** @type {string} */ (state)) ? /** @type {Availability} */ (state) : 'missing';
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async ask(prompt, askOptions) {
|
|
122
|
+
if (model()) return local.ask(prompt, askOptions);
|
|
123
|
+
const { timeoutMs, signal, responseConstraint } = askOptions;
|
|
124
|
+
if (signal?.aborted) return { text: null, reason: 'aborted' };
|
|
125
|
+
const id = `${name}:${Date.now()}:${asked++}`;
|
|
126
|
+
const onAbort = () => { runtime.sendMessage({ type: MODEL_HOST, method: 'abort', name, id }).catch(() => {}); };
|
|
127
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
128
|
+
try {
|
|
129
|
+
// The document keeps the time limit; the worker's own is for a document that never answers.
|
|
130
|
+
const answer = /** @type {Partial<ModelAnswer> | 'gone' | 'late' | undefined} */ (await within(
|
|
131
|
+
send('ask', { id, prompt, timeoutMs, ...(responseConstraint && { responseConstraint }) }).catch(() => 'gone'),
|
|
132
|
+
timeoutMs + HOST_GRACE_MS,
|
|
133
|
+
'late',
|
|
134
|
+
));
|
|
135
|
+
if (answer === 'late') {
|
|
136
|
+
onAbort();
|
|
137
|
+
return { text: null, reason: signal?.aborted ? 'aborted' : 'late' };
|
|
138
|
+
}
|
|
139
|
+
if (answer && typeof answer === 'object') {
|
|
140
|
+
if (typeof answer.text === 'string' && answer.reason === 'answered') return { text: answer.text, reason: 'answered' };
|
|
141
|
+
if (REASONS.includes(/** @type {string} */ (answer.reason))) return { text: null, reason: /** @type {Exclude<ModelAnswer['reason'], 'answered'>} */ (answer.reason) };
|
|
142
|
+
}
|
|
143
|
+
return { text: null, reason: signal?.aborted ? 'aborted' : 'unavailable' };
|
|
144
|
+
} finally {
|
|
145
|
+
signal?.removeEventListener('abort', onAbort);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
release() {
|
|
150
|
+
local.release();
|
|
151
|
+
if (!model()) runtime.sendMessage({ type: MODEL_HOST, method: 'release', name }).catch(() => {});
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Answer the worker's model requests, in the offscreen document: one base
|
|
158
|
+
* session per name, made with the options and instructions the request
|
|
159
|
+
* carries (and again if they change). Requests from another extension, or
|
|
160
|
+
* from a content script (a web page's side), are ignored.
|
|
161
|
+
*
|
|
162
|
+
* @param {HostRuntime} runtime - `chrome.runtime`.
|
|
163
|
+
* @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
|
|
164
|
+
* @returns {void}
|
|
165
|
+
* @example
|
|
166
|
+
* // offscreen/offscreen.js
|
|
167
|
+
* watchSchemeOffscreen(chrome.runtime);
|
|
168
|
+
* serveModelOffscreen(chrome.runtime);
|
|
169
|
+
*/
|
|
170
|
+
export function serveModelOffscreen(runtime, { model = chromeLanguageModel } = {}) {
|
|
171
|
+
/** Each name's session, and the setup it was made with. @type {Map<string, {setup: string, session: ModelSession}>} */
|
|
172
|
+
const sessions = new Map();
|
|
173
|
+
/** The questions being answered, to stop one. @type {Map<string, AbortController>} */
|
|
174
|
+
const asking = new Map();
|
|
175
|
+
|
|
176
|
+
/** @param {HostRequest} request @returns {ModelSession} */
|
|
177
|
+
const sessionFor = ({ name, options = {}, initialPrompts = [] }) => {
|
|
178
|
+
const setup = JSON.stringify([options, initialPrompts]);
|
|
179
|
+
const kept = sessions.get(name);
|
|
180
|
+
if (kept?.setup === setup) return kept.session;
|
|
181
|
+
kept?.session.release();
|
|
182
|
+
const session = createModelSession({ model, options, initialPrompts });
|
|
183
|
+
sessions.set(name, { setup, session });
|
|
184
|
+
return session;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** @param {HostRequest} request @returns {Promise<unknown>} */
|
|
188
|
+
const answer = async (request) => {
|
|
189
|
+
switch (request.method) {
|
|
190
|
+
case 'availability':
|
|
191
|
+
return sessionFor(request).availability();
|
|
192
|
+
case 'ask': {
|
|
193
|
+
const stop = new AbortController();
|
|
194
|
+
const id = String(request.id);
|
|
195
|
+
asking.set(id, stop);
|
|
196
|
+
try {
|
|
197
|
+
return await sessionFor(request).ask(String(request.prompt ?? ''), /** @type {AskOptions} */ ({
|
|
198
|
+
timeoutMs: Number(request.timeoutMs) || 0,
|
|
199
|
+
signal: stop.signal,
|
|
200
|
+
...(request.responseConstraint && { responseConstraint: request.responseConstraint }),
|
|
201
|
+
}));
|
|
202
|
+
} finally {
|
|
203
|
+
asking.delete(id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
case 'abort':
|
|
207
|
+
asking.get(String(request.id))?.abort();
|
|
208
|
+
return null;
|
|
209
|
+
case 'release':
|
|
210
|
+
sessions.get(request.name)?.session.release();
|
|
211
|
+
sessions.delete(request.name);
|
|
212
|
+
return null;
|
|
213
|
+
default:
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
runtime.onMessage.addListener((message, sender, respond) => {
|
|
219
|
+
const request = /** @type {HostRequest | null | undefined} */ (message);
|
|
220
|
+
if (request?.type !== MODEL_HOST || typeof request.name !== 'string') return false;
|
|
221
|
+
if (sender.id !== runtime.id || sender.tab) return false;
|
|
222
|
+
answer(request).then(respond, () => respond(null));
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
}
|