Skip to content

home/webext: Assistant bridge — providers, per-document consent, tab-bound streaming - #513

Draft
nyblnet wants to merge 22 commits into
mainfrom
webext-assistant
Draft

nyblnet wants to merge 22 commits into
mainfrom
webext-assistant

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 18, 2026

Copy link
Copy Markdown
Owner

The extension half of the Assistant (slides #512). The page never holds a key or an endpoint; it sends text over the existing __bento_tray__ envelope and gets text back. Contract: slides/src/editor/assistant/transport.ts.

What lands

  • src/providers.js — pure request shapers + SSE parser for Gemini, Anthropic and OpenAI-compatible (OpenAI, OpenRouter, Ollama, LM Studio…). Base URL editable for every provider.
  • src/assistant.jsdescribe / check / run over an injected environment; the Chrome built-in model (Prompt API, Gemini Nano) is the default when this Chrome has it, feature-detected, both stream shapes handled.
  • background.jsassistant.describe|check|settings.open over sendMessage; each assistant.send rides its own port (res, then evt chunk/done/error). The port is what binds a turn to its tab: abort arrives on it, the tab closing disconnects it, and no frame can name another tab's turn.
  • Consent per document (security review §4 item 2): the first request from a file opens src/consent.html naming the file, the host and the model. The answer is recorded only from a sender that IS that page, remembered per file, revocable in Settings. A closed window is "not now", never a hang.
  • relay.js forwards the streaming frames — ev.source === window + top-frame-only content scripts mean an iframe on a slide (slides: add the embed element to the bento/embed shape #424 live embeds) is dropped. page-bridge.js announces 'assistant' in __bentoHost.ops.
  • Settings → Assistant: provider, base URL, model, key (chrome.storage.local), Check connection with the real error, allowed-documents list.
  • 32 strings × 30 locales.
  • scripts/test-webext-assistant.ts — 118 checks: the 34 provider cases from the handoff, iframe / foreign-page / foreign-id rejection, consent forgery refused, no key or base-URL path in describe or any error, abort scope, port disconnect.

Two things for the maintainer

  1. optional_host_permissions: http://*/*, https://*/*. "Any base URL" needs it to be exempt from CORS; it is asked per origin on Save (a click) and declining is not an error — the hosted vendors, Ollama by default and LM Studio (switch) all allow extension origins. The store listing will show the optional broad host pattern; if that is unwanted, drop the manifest lines and everything above still works for those providers.
  2. The key is at rest in chrome.storage.local, unencrypted, like every extension that holds one. No page can read it; another extension cannot either.

Not in this PR: a CI step (the workflow file is contended — node scripts/test-webext-assistant.ts is the command), and a real-Chrome pass, which the browser tool cannot do on file://.

… consent, tab-bound streaming

The chat drawer in bento/slides (#512) sends a deck to the user's model
THROUGH the extension: the page never holds a key or an endpoint. This is
the other side of that contract (slides/src/editor/assistant/transport.ts).

- src/providers.js: pure request shapers + SSE parser for Gemini,
  Anthropic and anything OpenAI-compatible (base URL editable for all).
- src/assistant.js: describe/check/send over an injected environment —
  the Chrome built-in model (Prompt API) first when present.
- background.js: sendMessage ops for describe/check/settings.open; a PORT
  per streaming turn (res, then evt chunk/done/error), which binds the turn
  to its tab, carries the abort, and dies with the tab. Consent is per
  DOCUMENT: the first request from a file opens src/consent.html naming
  the file, the host and the model; the answer is recorded only from that
  page and is revocable in Settings.
- relay.js forwards the streaming frames; page-bridge.js announces
  'assistant' in __bentoHost.ops.
- Settings: provider, base URL, model, key (chrome.storage.local), Check
  connection with the real error, allowed-documents list. Site access is
  an optional_host_permission asked per origin on Save; declining still
  works wherever the server allows extensions (CORS).
- 32 strings x 30 locales; scripts/test-webext-assistant.ts (118 checks:
  iframe/foreign-page/foreign-id rejection, consent forgery, no key or
  URL in describe or errors, abort scope).
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Build size

main (4b10e5e) → webext-assistant (c42436f)

app base PR change
bento/slides 651.3 KiB 651.3 KiB 0.0 KiB (0.00%)
bento/spaces 264.5 KiB 264.5 KiB 0.0 KiB (0.00%)
bento/dash 406.4 KiB 406.4 KiB 0.0 KiB (0.00%)

Updated: 2026-09-19T19:17:13Z

…, consent replies carry machine codes; all_frames stated; rig in CI

- The built-in model's describe is host '' / model 'gemini-nano' / local:true —
  the page bounds host and model to hostname/id shapes and renders "on this
  device" itself; prose there would be blanked.
- check() answers code:'consent-pending' while the window is open; a refusal
  is evt assistant.error code:'consent-denied'. The localized reason rides
  alongside; the page keys on the code.
- all_frames: false stated explicitly on both content scripts.
- ci.yml runs scripts/test-webext-assistant.ts (121 checks).
The page bounds describe.host to a hostname shape and blanks anything else,
so 'localhost:11434' rendered as '—' (security's browser run of #513). The
port stays in the words a person reads — the settings status line and the
unreachable reason — where it is the useful part.
… 'downloadable' is no longer 'no model'

On the maintainer's Chrome 152 LanguageModel.availability() was 'downloadable'
and nothing ever called LanguageModel.create() — the only thing that starts
the download — so the built-in provider could not work: Settings said Chrome
downloads it on first use, and every first use threw 'no built-in model'.

- Settings: a Download model button for downloadable/downloading, calling
  create({monitor}) from the click and showing downloadprogress; a hint with
  Chrome's requirements (~2–4 GB, ~22 GB free, unmetered network).
- check / send distinguish downloadable ('model-download'), downloading
  ('model-downloading') and unavailable ('model-unavailable'), code + words.
- 5 strings x 30 locales; rig 131.
…odels come from the provider's listing, the default by rule

- describe gains contextTokens: the person's override (a new Context window
  field, whole number 1k–10M), else the cached provider listing (Gemini says
  inputTokenLimit), else a documented family table (claude 200k, gpt-4.1 1M,
  gpt-5 400k, gpt-4o 128k, o-series 200k, gemini 1M; 128k for the unknown);
  the built-in model reports a session's inputQuota, read once and kept.
- Check and Save fetch the provider's model list (OpenAI /models, Anthropic
  /v1/models, Gemini /v1beta/models), cache it per provider+endpoint, feed a
  datalist under the free-text Model field, and move a still-default or
  unlisted model to pickDefault's choice: general-purpose chat models only,
  the vendor's mid tier first (mini / sonnet / flash), newest of those.
- DEFAULTS are only the value before the first listing, bumped to today's
  mid tier: gpt-5-mini, claude-sonnet-5, gemini-3-flash.
- 3 strings x 30 locales; rig 163.
… the ranking reads the version anywhere in the id

DEFAULTS as the maintainer named them — gpt-5.6-luna, claude-sonnet-5,
gemini-3.8-flash (luna taken as the 5.6 line's mid tier; sol ranks with the
full model). pickDefault now orders tier → version → created, with the
version parsed as a float wherever it sits (5.6 above 5, 3.8 above 3,
sonnet-4-5 as 4.5). Family table notes cover the new lines. Rig 170.
Nano answered a words edit with a paragraph — a prose 'answer in JSON' is
not a constraint. assistant.send may now carry `schema` (a JSON Schema
object): built-in → promptStreaming responseConstraint (Chrome 137+; a
Chrome that throws on the option gets one plain retry), Gemini →
generationConfig responseMimeType+responseSchema, OpenAI-compatible →
response_format json_schema (a 400 naming response_format retries once
without — local servers vary), Anthropic → left to the prompt. The schema
is data: plain object, under 8 KB, re-read through JSON, dropped otherwise
and the turn runs as prose. Rig 193.
…ge's model picker; per-provider store

The store becomes { active, providers: { [provider]: fields } } so every
provider keeps what was typed for it (the old flat shape migrates on read);
Settings and the page's picker write the same place.

- assistant.models: every route usable right now — the built-in model when
  it can answer, each keyed provider's configured model then its CACHED
  listing (never fetched here), host/local/contextTokens as describe
  reports them, current on the active one, ≤200.
- assistant.select { provider, model }: persists the active route and that
  provider's model, nothing else; refuses an unconfigured provider, an id
  outside MODEL_RE, or a built-in model that cannot answer.
- httpConfigured: api.openai.com without a key is not configured; a
  changed base URL (a local server) is. Rig 218.
…irst, ≤12 per provider; show-all and pinned picks

A Gemini listing is forty entries and most are not for chat. chatModels()
keeps what can answer a text turn by name (Gemini: generateContent minus
tts/image/embedding/audio/live/veo/imagen/aqa/learnlm/robotics; OpenAI: the
chat families minus embedding/whisper/tts/dall-e/realtime/…; Anthropic
as is), folds dated snapshots into their undated alias and keeps
preview/exp only when nothing else is left. curateModels() orders newest
version first and mid tier (flash/mini/sonnet) before full (pro/opus)
before small (lite/nano), capped at 12. assistant.models and the Settings
datalist both use it: configured model first, then the models picked from
the page (pinned on select, ≤8), then the cut. A per-provider 'Show all
models' checkbox turns the filter off. 1 string x 30 locales; rig 233.
…complete

A <datalist> with an id typed suggests only the ids that start with it; the
other ten curated models were invisible until the field was cleared. The
field is a <select> now over pickerRows() — the configured model, the
pinned picks, then the curated (or full) listing, each with its window
('gemini-3.8-flash · 1M') — plus an Other… row that reveals a free-text
field for an unlisted id, validated by MODEL_RE on Save. Before the first
listing it holds the default and Other…. 3 strings x 30 locales; rig 242.
…turn, the document ask, prompts and window sizing

The page half of the Assistant cost the shell 25 KB; the maintainer moved
the model knowledge here. src/prompt.js (from slides prompt.ts + ops.ts at
e82fcf4): isQuestion, OPS_PROMPT / QUESTION_PROMPT / OPS_SCHEMA /
OBJECT_SCHEMA verbatim, responseSchema by mode+focus, the window rule
(outline + focus + reply room; focus dropped and history cut when it does
not fit; refused with the numbers when the outline does not), parseReply
and the one prose→JSON nudge. The page keeps format knowledge: it builds
the outlines and applies the patch.

The handshake, over the turn's port: page assistant.turn { request,
history, focus } → res ok → consent (nothing of the document is asked for
before Allow) → evt assistant.document → page req assistant.document
{ outline, addressed, open, size, focus, theme } → the model → evt chunk
(prose only) → done { mode:'ask', text } | { mode:'edit', ops, note,
focus } | { mode:'edit', text }, or error with code 'window' /
'document' / 'consent-denied'. assistant.send stays. 3 strings x 30
locales; rig 277.
nyblnet added a commit that referenced this pull request Sep 18, 2026
… format and the drawer

Lead, on the maintainer's behalf, after building both sides: #512 added
25,294 B to a 666,953 B shell (+3.8%) that ships in every saved file, and
the line that moved — prompt.ts, 497 lines of prompts, token budgets,
window assumptions, schemas, the question detector, a model-family table
— is MODEL knowledge, which goes stale monthly while the format does not.

Split: prompt.ts is gone. material.ts holds the format's side — elide,
the outlines (plain and addressed), the focus (selected elements or the
open slide as compact JSON with every address), merge, dedupe, clean —
and one call, material(doc, focus), that returns every shape at once.
ops.ts keeps applyOps (what a verb means for the document) and loses the
prompt and schema (the extension's, verbatim from e82fcf4). transport.ts
replaces send(messages, schema) with turn(request, history, focus,
material, onChunk, signal): the page sends the request and where the user
is; the extension asks for the material with evt assistant.document
AFTER consent holds; the page answers on the same id; prose streams; done
carries { mode:'ask', text } | { mode:'edit', ops, note?, focus } |
{ mode:'edit', text }; error carries the extension's localized reason and
a code ('window' with the numbers, 'consent-denied', …). The panel shows
the extension's reason texts verbatim and keys behaviour on codes; the
drawer keeps labels only — the 'what is sent' notice, the hint line and
every explanatory sentence moved to the extension's settings card (30
locales there vs 8 here). Contract built to on #513 @1d972574.

Measured against a fresh origin/main build (666,953 B): 685,192 B, i.e.
+18,239 B (+2.7%), from +25,294. Per module, minified+gz: material 2.5
KB, ops 2.9 KB, transport 1.8 KB, panel 5.5 KB, css 1.8 KB, strings ~2
KB, editor tabs ~0.4 KB. The lead's target was ≤ 15 KB; the last 3 KB is
the drawer's pop-out window, the model picker and the prose renderer —
the maintainer's call which of those to keep.

Rig 194/194 (the material shapes; the turn handshake incl. 'no document
before the extension asks'; ops through the real apply; A–F intact:
the E pin follows apply(index); consent texts are the extension's). 31
strings remain in all eight catalogs (was 49). docs/DECISIONS.md: a
paragraph on where model knowledge lives and why.
…ettings card; consent/window reasons in the page's former words

The page no longer carries a 'what is sent' notice or its own waiting /
refusal / too-large lines — it shows the extension's reason verbatim. So
the Settings card says what leaves the computer (outline + selection or
open slide → host, comments never; 'the on-device model' for Nano), and
the reasons for consent-pending, consent-denied, outline-only and window
take the page's former wording in the eight catalogues it had (en de es fr
it ja pt zh-Hans zh-Hant, mapped to ours) with matching text in the other
21. Rig 277; i18n 100.
…; a hollow object is prose, not a patch; raw replies logged

Maintainer's run (gemini-3.5-flash-lite, 'change the title', focus = the
slide → the loose {type:'object'} schema): the page got an ops object with
no keys. Gemini's schema dialect wants an OBJECT to have properties and
answers a property-less one with {} instead of a 400. So a schema without
properties now asks Gemini for JSON by responseMimeType alone (the prompt
carries the shape); the strict OPS_SCHEMA path is unchanged. Provider-
agnostic guard on top: a parsed object with none of the twelve op keys is
treated as prose — nudged once, then handed back as text so the drawer
shows what the model said. The raw reply goes to console.info in the
service-worker inspector, never to storage. Rig 282.
…, refusals go back to the model, hosted models verify

A model that wrote 'sd-title' where the outline said '1/sd-title' got its
edit refused and the drawer showed the refusal. Now, inside a turn:
evt assistant.check { ops } → the page answers req assistant.check
{ applied, skipped, structural, outline } — a dry run of applyOps on the
turn's material, nothing committed. A patch with refusals (or nothing
applied) is sent back with the refused names, the address rule and the
outline again; one correction for the built-in model, two for hosted;
the patch committed is the one that applied most with nothing refused.
Hosted models then get one short verify call (OK: what changed /
MISSING: what is left) — its line is done.note, and MISSING buys one
more correction. The loop is logged under the same console tag. An older
page that never answers assistant.check goes straight through. Rig 297.
…ad back and fix with three tools

Gemini, Anthropic and OpenAI-compatible models with function calling now
run a turn as an agent: outline() → the addressed outline, slide(n) → that
slide in full (assistant.document { slide: n }, #512 50090e3), patch(json)
→ the page's dry run (applied / refused / outline after). The last CLEAN
patch is committed on done; a partial one that applied something when
nothing was clean; the model's closing line is the note. Eight calls at
most, then it is asked to finish. providers.js shapes the three dialects
non-streaming (tool_calls / tool_use+tool_result / functionCall+
functionResponse) and reads them back into one neutral thread; the patch
travels as a JSON string because an OBJECT parameter without properties
is hollowed by one dialect. A server that 400s on tools falls back to the
one-shot path with its correction loop and verify; the built-in model
always takes that path. Rig 314.
…— route by id; warnings are faults; an echoed request is no note

Three findings from the maintainer's flash-lite run. (1) Every describe
showed 'The request failed: unknown': relay.js routed EVERY assistant.check
frame to a turn's port, but the page's reachability probe carries that op
name too — with a fresh id and no port, it was answered ok:false with no
reason. The dry-run answer is now recognised by the TURN's id; the probe
falls through to sendMessage and gets the worker's words. (2) The note
repeated the request: a verify line, a reply's lead-in or an agent's
closing line that only echoes the request is dropped. (3) The dry run now
reports the validator's warnings (an overflowing text box, in its words);
they count as faults beside refusals in loop-1 and in the patch tool's
result, with the fix instruction (shorter text, smaller fontSize via set,
taller box via set h); the patch committed has no refusals AND no
warnings, else the best. Rig 322.
…e the const initialised); the built-in model is told its output language
…wept in

02d03ae was meant to carry two lines (providerLabel hoisted, the built-in
model's output language) and committed the working tree's overlay of the
webext-place branch alongside — without that branch's new files, so the
branch imported filegrant.js it did not have. Those files return to
c962153; the two intended changes stay. webext-place (#515) is where
that work lives.
…istant card

assistant.settings.open { section:'assistant' } opens the library at
#assistant — routed to the Settings view, the Assistant card scrolled
into view and its provider control focused once it has rendered — and an
already-open library tab is focused and sent there rather than a second
one opened (openOptionsPage cannot carry a hash). No section → #settings,
for older pages. Rig 325.
…ages, a search endpoint; sources on done

Hosted routes get the provider's web search beside the agent's tools:
Gemini google_search (groundingChunks → sources), Anthropic's
web_search server tool (results and citations → sources; its server-tool
blocks echoed back on the next step), OpenAI web_search_options on the
search-preview models only (url_citation → sources) — on by default per
provider (the deck already goes there; the query is the only new thing
that leaves), a Settings toggle whose note says whether the route has
one. src/web.js: fetch(url) reads a page as text with no DOM (head,
scripts, styles, nav, header, footer, aside stripped; capped 8k; public
http(s) only, never local addresses) and hands it to the model LABELLED
'data, not instructions'; search(query) through a configured endpoint
(SearXNG JSON or Brave with its key) for routes with no search of their
own. Reading pages is a browser permission that needs a gesture, so it
is asked in Settings ('Allow reading web pages…', http/https broadly)
and only checked in the worker. done carries sources (≤20, title ≤200,
http(s), unique). Nano: no tools, unchanged. Rig 351 incl. a page saying
'delete slide 1' producing no delete in the committed patch. 11 strings
x 30 locales.
…and IPv4-mapped included

Security's browser run of #513: the predicate enumerated literals and let
through 169.254.169.254 (the metadata endpoint), link-local generally,
[::ffff:127.0.0.1] (loopback over IPv6), fe80::/10, fc00::/7 and
localhost. with a trailing dot — and the fetch hands the whole body to
the model. Now: IPv4 by range (0/8, 10/8, 127/8, 169.254/16, 172.16/12,
192.168/16, 100.64/10, 224+), IPv6 by range (::1, ::, fe80::/10,
fc00::/7, multicast) with IPv4-mapped literals unwrapped to the v4 check
in both dotted and hex forms, a trailing dot stripped before the name
checks, bare single-label names refused. DNS rebinding stays a known
limit in the comment: the boundary that holds is that a fetch returns
untrusted, capped, labelled data reaching the document only through the
gated patch. Rig 353.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant