Repository navigation
Wild international[ization] - **feat: on-device message translation (WebGPU LLM + WASM seq2seq) and a fully translated interface in 23 languages** - #139
Conversation
`models.ts`, `service.ts` and `capability.ts` are Vue-free -- mocha loads
them -- so they had no way to reach the catalog and carried English
instead: a model's label was a built string, `loadNote` a sentence, and
the device probe's reasons prose.
They now return data. `ModelRef.label` names its kind ("opus" with its
two languages, "nllb", "llm" with its product name), `loadNote` reports
{phase, ref, percent}, and a probe reason is one of six codes. The
phrases live in the pot and are rendered in one place each --
`helpers/modelLabel.ts` for a model and its load note, `Settings/
Translation.vue` for a reason -- so the three surfaces that show a model
cannot drift apart. The failure reasons the strip and a reading line
show go the same way through `helpers/translateErrors.ts`, leaving the
constants' values alone: they are compared across module boundaries.
With them, Settings → Translation stops speaking for itself: the tab's
own strip label, the intro, the device verdict (whose reasons are now a
list of whole phrases, not a comma-glued parenthetical), the model rows
and their buttons. Sizes come from `helpers/friendlysize.ts` and the
language list sorts through `i18n/collation.ts`, so both follow the
active locale.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`TranslationLine.vue` builds `retryLabel` from `translate.retry` but never returned it from setup, so the retry button on a failed translation drew an empty title and an empty aria-label and Vue warned on every render. Found by `tools/scenarios/translate-reading.mjs`, which renders the failure rows nothing in `yarn test` reaches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`restartAllReading` called `setReading(…, true)` on a channel already
reading, which returns at its no-op guard, so moving the reading language
retranslated nothing: the lines on screen kept their old target until
something else touched them. And `lastReadingLanguage` started null, so the
`first` guard swallowed the first change after a page load anyway.
The requeue body is now `requeueReading(network, channel)` -- the same one
a switch-on runs -- and the language change calls it directly; the reader
records the language it starts in when it is initialised, so the first
change counts like any other. `setReading`'s off path keeps its own
cancellation.
Two leaks beside it (F31): `arrivals`, the count of what a channel has said
since a line was queued, counted every `msg` event, so a netsplit's quits
looked like the conversation moving on -- it now counts chat lines alone
(`eligibility.ts` `isChatLine`). And the paths that drop a message's
translation for good -- the message-limit trims, `clearHistory`, a REDACT,
an edit's original -- now also drop the queue item the pipeline remembered
for it (`forgetTranslations`), which nothing could have reached again.
The browser check's language-change step asserted the old behaviour ("a
finished translation keeps its English"); it now waits for the on-screen
translation to come back in the new language and checks that nothing
finished is still in the old one. Its next step lets the requeue drain
before the next line is said, so that line is a request of its own rather
than one batched behind the fake's scripted failures.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stopword generator drops a word shared by four tables or more, which
leaves `je`, `suis` and `la` out of French while `la` survives in
Vietnamese -- so "je suis là" places as Vietnamese on one hit, and the
reader then translated it *from* Vietnamese. A narrow trigram lead does the
same ("hasta luego" → Polish at 0.213).
`Detection.weak` marks a verdict too thin to translate from: a classifier
verdict under `CHAT_STRENGTH_MIN` (2) hits, or a franc verdict whose
*measured* lead is under `WEAK_CONFIDENCE` (0.2). A declared language or
the channel's prior is never weak -- `DETECT_MIN_GAP` is how "the reader's
claim decided it" is written down, not a measurement, and marking those
weak would invert the declared-languages feature.
`detectionSkip` still reads `lang` whatever its weight, so a weak English
verdict still leaves an English line alone for an English reader. What weak
changes is the new `sourceFor(detection, from, to)`, now the one place the
reader decides what a line is translated from: a chosen source wins, a
verdict that is neither missing nor weak is the source, anything else
queues the line with no source named and `unsure` set, and the engine
places it. F34 with it: the ternary at the end of the short-line branch
tested `chat.lang` where it is known null.
On screen: a one-hit line now shows "German? → English", the chip's
existing wording for a source it only guesses at, and its request carries
`from: null` -- the browser check asserts both. The fake engine's
batch guard was keyed on the line while its failure is keyed on the
request's text, so a batch holding an already-failed `[fail]` line threw
for all its neighbours instead of falling back to singles; a requeue sends
exactly such a batch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F4 left a weak verdict with no source anywhere: `from: null` and `context.sourceHint: null`. The prompt half of that is right -- the model must not be told "probably Vietnamese" on one function-word hit -- but the router reads the same field, and `resolveRoute` skips every seq2seq candidate when no source is known. On a CPU-only device such a line resolved to NO_ROUTE: no translation at all, where before there was a wrong one. The read side now has the shape the write side already had (`outgoing.ts` `sourceHintFor`): `QueueItem.routeHint` is routing only and distinct from `context.sourceHint`. The queue routes on it and puts it in `TranslateRequest.hint` (the batch's is its head's -- only LLM items batch, and the LLM takes its source from the prompt); the prompt keeps `context.sourceHint` alone. `sourceFor` decides both, given the channel's prior: for a weak verdict the hint is the prior first and the verdict as the last resort, for everything else it is what it was. A line nothing placed at all still offers nothing -- the prior announced a Spanish line in a German channel as German. An item without `routeHint` routes on its `context.sourceHint` as before, so nothing that builds a `QueueItem` elsewhere changes meaning. Also: the scenario header names the oper credentials a rig outside the Docker default needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es after a minute A worker torn down under a running request (the pagehide teardown, an idle unload) and an abort the queue did not ask for said nothing about the engine, yet both were counted as its failures: three background/return cycles on a phone paused the engine, and every line after that was committed pending and never ran again, since only a user's retry on an already-failed line could resume it. Both now put the batch back at the front of its engine's queue, entries still pending, nothing marked failed and nothing counted — after REQUEUE_WAIT_MS, so a worker that keeps dying is retried once a second rather than spun. And a pause is a minute off rather than the end of reading: PAUSE_RESUME_MS later the engine resumes itself, failures back at zero, through the same resume() a user's retry goes through, which fires the new QueueDeps.onResume so the header's paused banner clears. A retry disarms the waiting timer; a second pause waits again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The abort listener was registered `{once: true}` while the request still
had no id, so an abort during the wait phase -- resolving the route (whose
first call asks the worker what is downloaded), a GPU model switch
settling, another model's work finishing -- cancelled nothing, and the
request was then posted to the worker, model download included, long after
the queue had given the line up.
The listener now outlives every candidate the call tries, and `aborted` is
re-read after each of those awaits, throwing ABORTED before anything is
posted. A teardown still reports itself first: a worker disposed while the
route resolved is the truer report of the two.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deleting a model from Settings unloaded the seq2seq engine at once, out from under whatever was generating on it: the GPU path has waited for its model's work since the model switch landed, the CPU path never did. It now waits the same way — `engineRunning` counts downloads too, so a delete also waits behind another seq2seq download — and re-checks afterwards, so work that started again while it waited keeps its engine and its idle clock. The files go either way; only the unload waits. With nothing running the unload is still posted in the caller's own tick, so a teardown in that tick rejects it as a disposal rather than leaving it posted to a worker that will never answer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An LLM batch is one request for several people's lines, and every line of it was given the head's context: the head's reply target stood above the whole block as "This line replies to <ada>: …", so line 3's answer was written as if it replied to whoever line 1 did. A batched request now carries `lineContexts`, one entry per line, and the prompt writes each note against its own number — "Line 3 replies to <bob>: …" — above the numbered block, never inside it, since the answer is parsed by strict numbering and an exact count. The field is optional and a batch without it reads exactly as before; the channel's own context (the earlier lines, the topic, the names, the terms) is the head's and stays where it was. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`⟦` and `⟧` are ordinary characters someone can type, and the protection numbered its own spans into the same shape: a line carrying `⟦2⟧` came back with span 2 -- a URL, a nickname, half a code span -- spliced into the middle of it. A placeholder the user typed is now the first stage of `protect()`, a span of its own before anything else is numbered, so the engine never sees the number and `restoreAll` puts the brackets back as they were written. Its new `typed` meta kind is verbatim in every restore policy but is never expanded as a nested span: its own text is placeholder-shaped, and reading it as one is the bug it is protected against. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An ACTION is prose, and the reading side translates everyone else's -- but the composer's gate stopped at the leading slash, so the one line a user writes about themselves went out in whatever language they typed it in. `draftGate` now answers with the part of the draft a translation is of: the draft itself, or what follows `/me `. The strip is keyed on that text (the watcher that invalidates it compares the same way), and what goes out is `/me <translation>` -- the command back on the front, never escaped, so `deliver`'s slash intercept sees an action rather than text. An action with nothing after it is still the command's own business, and `/mention bob` is not an action at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The queue retries all five judged failures bare (`failJudged`); the composer named four of them in two places, so a draft the model looped on in four or five repeats (DEGENERATE) got no second try while a six-repeat one (REPETITION) did, and the read-back check behaved the same way. The set is now BARE_RETRY_ERRORS in outgoing.ts, where mocha can pin it and both call sites test membership against it. Also: the router's `hint` doc says where the hint comes from (`QueueItem.routeHint` and the composer's `sourceHintFor`, as `TranslateRequest.hint`), not `PromptContext.sourceHint`, which is the one thing it is not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What the six fixes changed, in docs/resources/translation.md: a pause is a minute off rather than the end of reading, a torn-down worker or a foreign abort puts the batch back instead of counting against the engine, the service honours an abort before the request is posted, a batch's reply targets travel per line, a placeholder somebody typed is text, the composer translates a `/me`, and both sides retry the same five judged failures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Step 8 pinned "a command never translates" on `/me`, which is now the one command that does: the action's text goes through the strip and its read-back, the second Enter sends it, and the listener hears an ACTION carrying the translation. A plain command (`/topic`) keeps the old assertion that no strip appears. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On a keyboard whose Return puts its newline in the draft before the `keypress` fires -- Android's and iOS's, the case `onSubmit` already handles by stripping exactly that newline back off -- the `input` event reached the `pendingMessage` watcher first, the draft no longer matched the outgoing entry's, and the strip was cancelled in the microtask between the two. The Enter that followed then found nothing to send and translated afresh: the second Enter could never send. The watcher now tolerates one trailing newline on the gated draft, so "hello\n" and "/me hello\n" compare equal to what the strip translated. The browser check is `tools/scenarios/translate-composer.mjs`, under `--mobile`: `onEnterKey` only takes the newline-tolerating path on a touch-primary device, since with a hardware keyboard the Return is preventDefaulted and no newline is ever typed. The new case types a draft, translates it, inserts the newline through a separate `input` event, asserts the strip is still up and presses Enter -- prose and a `/me` alike. That run also needed the sheet's formality assertion scoped to the formality group: the reading switch above it is a segmented control of its own now, and the old query counted all five buttons. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion quiet while streaming
Seven findings from the wild-international review, all in the components
around translation:
- F16: Settings -> Translation rendered a `<select name="locale">` of its
own over the translation codes, while `activate()` accepts a different
set; a code in one list and not the other resolved silently to
"Automatic". The tab now renders `LanguageSelect`, the one control the
sidebar globe uses: one list, one "Automatic" label. `name` is not a
prop, so it falls through onto the `<select>` and the settings
container stores it exactly as before. `translate.settings.automatic`
loses its only call site and goes from the pot (merge + i18n:compile
carry the removal into the catalogs and call-sites.ts).
- F26: the GPU model picker was bound to the resolved choice, so
"Automatic" could never show selected -- picking it snapped straight
back to whatever it resolves to. It is bound to the raw setting now
("" is Automatic) and the "in use" tag goes on naming what runs.
- F20: the channel menu offered "Translation..." on a deploy with the
feature off and on a device that can run neither engine. It is behind
`translationAvailable()`.
- F21: the composer's strip was `aria-live="polite"` while the
translation streamed in token by token, so a screen reader read it out
again on every token. It is `off` while pending and `polite` once the
strip is done or has failed: one announcement, whole.
- F25: a chip menu opened with the keyboard positioned at 0,0 -- a
keyboard activation carries no pointer. `openMenu` anchors every way in
under the button that was pressed, off `event.currentTarget` (only one
of the three chips carries the `ref`).
- The OPUS model label named its pair in English; it names it in the
reading language, like every other translation surface.
- `reasonText`'s `default:` silently answered "no WebGPU" for any code;
every `CapabilityReason` has a case and the default is an
exhaustiveness check, so a new code is a compile error.
Browser: `tools/scenarios/translate-settings.mjs` (green, plus a new case
that picks Automatic and finds the picker still on it while the tag names
the model it resolves to), `translation-chips.mjs`, `settings-modal.mjs`
at both widths, `translate-composer.mjs` both ways.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F19: the translation surfaces were laid out with physical properties, so under an RTL interface the panel hung off the wrong edge, the strip's accent rule and its rounded corners were on the wrong side and its buttons sat at the inline start. `right` -> `inset-inline-end`, `border-right`/`border-left` -> `border-inline-end`/`border-inline-start`, `margin-left`/`margin-right` -> `margin-inline-start`/`margin-inline-end`, the chip's asymmetric padding -> `padding-block` + `padding-inline` (start first, so the order flips), the strip's four-value `border-radius` -> the two `border-*-end-radius` longhands. `transform-origin` takes no logical keywords, so the source-language picker keeps `top left` and gets a `[dir="rtl"]` mirror. Verified in a dev build under the `qqx` pseudo locale (RTL by definition): the panel hangs below its button flush to the mirrored edge, the strip's accent rule is on its right and its buttons at the inline end, and a chip's menu opens under the chip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of the previous round: the exhaustiveness check I put in `reasonText` is decorative in this pipeline. Nothing type-checks an SFC's script block here -- webpack compiles .vue through babel-loader with the types stripped, ForkTsCheckerWebpackPlugin carries no `extensions.vue`, and vue-tsc is not installed -- so "a new CapabilityReason is a compile error" was false, and the `default` I wrote returned the raw enum token into the UI: an untranslated string past t(). The `never` binding stays, so a type-checker that one day reads this block still trips over an unhandled code, but the branch returns prose again: an unknown reason reads as "no WebGPU", which is what it was before every code got a case of its own. The comment says what is actually true. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F19 missed one rule: `.translation-panel--sheet .translation-panel-chip` kept a four-value `padding` shorthand, and a shorthand resets the base rule's `padding-inline`, so on a phone under RTL the extra room before the label landed after it instead -- against the remove button, which brings its own. `padding-block` + `padding-inline: 0.625rem 0.375rem` (start first). Measured in the sheet under the qqx pseudo locale at 390x844: padding-inline-start 12.5px, -end 7.5px, resolving to padding-right 12.5px and padding-left 7.5px, which is the mirror. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tolerance the previous round added was unconditional, so it also swallowed a deliberate Shift+Enter on a hardware keyboard. There `onEnterKey` preventDefaults the Return and nothing ever strips a newline, so the draft keeps it, the strip lingers over a draft it is no longer a translation of, and the next Enter reaches `onSubmit(false)` with a gated text that still ends "\n" -- cancel and retranslate, after showing a stale strip. The tolerance is behind `hasVirtualKeyboard()`: only there does the Return put its newline in first, and only there does `onSubmit(true)` take it back off. Everywhere else a trailing newline is an edit of the draft like any other and the strip goes. `tools/scenarios/translate-composer.mjs` asserts both halves now: the `--mobile` run keeps the strip through the newline and sends on the Enter, the desktop run watches the same newline drop the strip and checks the draft kept it (`hardwareNewlineIsAnEdit`). The append is one helper shared by both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ersisted F22. `translate/index.ts` wrote the adapter's pick into `translateLlmModel`, which turned a device guess into the user's choice and defeated "an unset value follows the deploy default". The pick now stands in for the unset setting where it is read: `llmChoice(catalog, setting, capability)` resolves "" (and a stored id that is no longer a choice) to the deploy's model, else `defaultLlmForAdapter(capability)`, else the catalog default while the probe is still running. The service redoes the resolution when its probe lands, Settings and the panel read the store's capability inside their computeds, and nothing is written back. `defaultLlmForAdapter` takes the capability: with `deviceMemoryGiB` the 4B needs 6 GiB, otherwise the old buffer rule stands — Chrome clamps the addressable buffer to ~2 GiB on most desktop GPUs, so the buffer rule alone never reached the 4B there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F24. `thelounge.translate` (what each channel reads and writes, its formality, variant, languages and term memory) is a preference like the mutes and the aliases beside it, and a restore that dropped it left every channel's translation switched off. It joins BACKUP_KEYS, the module's own list of what the file holds, and the table in docs/resources/settings-backup.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F17. Two locale changes in quick succession raced on their overlay imports and whichever resolved last won, so the older pick could overwrite the language the user had just asked for. activate() takes a monotonic token, and a run whose token has moved on returns before it touches the catalog, the refs, <html lang>/<html dir>, the splash copy or the push mirror — the load is hoisted out of the try so nothing is installed before the check. The overlay fetch goes through a swappable loader (useOverlayLoader, after settingsBackup's useStorageBackend): a real dynamic import cannot be made to resolve out of order, and the test drives the shipping path rather than a copy of the guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stics fold out F13. compile.ts baked `export const DEV = …` and the qqx entry from its own NODE_ENV into two tracked generated files. `yarn watch`, `yarn test:e2e` and a bare `webpack` skip the compile, so they shipped whichever flavor was committed last, and a production build dirtied the tree. available.ts now lists every compiled catalog with qqx marked devOnly, tags.json (the pre-paint list, substituted at build time and unable to check anything at runtime) leaves the dev-only tags out, and the filtering is the bundler's own fold: DEV_I18N moves to core.ts and the selector, activate()'s resolvable set and boot's diagnostics banner read it from there. F27. The lazy locale import takes `webpackExclude: /(qqx|tags)\.json$/` on a production-only branch, so a production build emits a chunk for the 23 shipped catalogs and none for the rig or the tag list. F33. The generated call-site table (980 keys, diagnostics only) is imported in a branch webpack's NODE_ENV fold removes whole — written inline so the dependency is never recorded — and the dynamic-label check does nothing until it is in hand. Verified on a production build: no qqx/tags chunk, no call-site table in bundle.js; on a development build both are there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to F22. `llmChoice` applied the adapter pick for any probed device, but the selected GPU model also picks the route table (`service.ts` buildTable), and the two tables differ in entries that carry no `llm` candidate at all — so a cpu-tier machine with 8 GiB of RAM would have had its seq2seq routing quietly changed by a model it cannot run. The capability's `tier` now gates the pick (`AdapterMemory.tier`, absent = gpu for the pure helper's own callers); a cpu or none device keeps the catalog's default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s it
Follow-up to F13. Leaving the dev-only tags out of the generated tags.json
made it mode-independent, but it also took the qqx pick's lang/dir away
from the first paint — which tools/scenarios/i18n-language-switch.mjs
checks by attribution ("the first dir write happened at readyState
loading"). The generated file lists every tag again, and the one consumer
that cannot check at runtime — the inline script webpack bakes into
index.html — is where the filter belongs: readAvailableLocales() drops
DEV_ONLY_TAGS for a production build. tools/ is a separate TS project, so
webpack.config.ts spells the list out and the toolchain test pins the two
copies to each other, as the RTL set is pinned to index.html.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, findings 2 and 3. Two of this task's production-only behaviours were unguarded by the suite: - `webpackExclude: /(qqx|tags)\.json$/` (F27) had no test at all — test/tests/build.ts knows nothing about build modes, so the brief's other branch applies: the toolchain test asserts the magic comment and its production-only branch are in client/js/i18n/index.ts. - the pre-paint tag filter could be deleted and the suite stayed green: the old test only compared two copies of the tag list. `readAvailableLocales` moves into tools/i18n/available-locales.ts (webpack.config.ts imports it, hence the root tsconfig's `files` entry — tools/ is otherwise outside that project) and the test drives that very function over the real tags.json in both modes. `DEV_ONLY_TAGS` lives there too, and compile.ts builds the dev-only available.ts entries from it, so there is one list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, finding 4. `translateLlmModel`'s comment still described the
pre-F22 resolution ("both resolve to the catalog's default"); it now names
the order that ships: the deploy's model, else the adapter's pick on a
gpu-tier device, else the catalog default — and that nothing writes a
resolved id back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, finding 5. A restore empties the covered keys the file does not carry, so adding `thelounge.translate` to the list meant that restoring a file written before it existed wiped every channel's translation state — absence read as "this device had none", which that file could never have said. The format version goes to 2 and `KEY_SINCE` records when a key joined it: a restore only removes keys the file's own version covered. Everything version 1 did carry is still replaced exactly as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The `BrandingTranslation` block ended on a doc comment for a field that does not exist, and `strings` still claimed a "small set" of overridable keys while `KNOWN_KEYS` has been every key of the English catalog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # CLAUDE.md
# Conflicts: # CLAUDE.md
# Conflicts: # CLAUDE.md
# Conflicts: # CLAUDE.md
A second push registration of this app whose subscription the server still pushes every message to — push/<uuid>/ for a uuid no saved network has — has a worker that finds each message already shown by the live network's registration and shows nothing: on Chrome a silent push per message, and after the budget "This site has been updated in the background" with every one. Settings' network removal unsubscribes first, so it should not arise, but nothing swept one up if it did. dropStaleRegistrations runs at every boot after dropRootSubscription: every push/<uuid>/ registration under this app's base whose uuid is no saved network loses its subscription (endpoint queued for UNREGISTER on the network whose key it carries) and is unregistered; the journal says so. Another app on the origin (a Pages preview) has its own base and is never touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # CLAUDE.md
# Conflicts: # CLAUDE.md # client/components/Settings/General.vue # client/components/Settings/Navigation.vue # client/components/Windows/Changelog.vue # client/components/Windows/Help.vue # client/components/Windows/OperPanel.vue # client/components/Windows/Settings.vue # client/js/autocompletion.ts # client/js/helpers/settingsBackup.ts # package.json # test/helpers/settingsBackup.ts # webpack.config.ts # yarn.lock
…gh t(), in 23 languages The Scripts tab and its editor, the Lua API reference, the script library's errors (names, storage limits), /lua's lines and the scripting manager's lobby lines, Help's build-track sentences and the release notes' close button now go through t()/tCount(); the default script folder stays stored as "Unsorted" and is shown in the reader's words. 85 keys new or changed (backupIntro and the release notes' label reworded), translated into all 23 catalogs; slots identical to English (Scripts, Name, Folder, Error…) are left to English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rface
Under ar (and qqx) the CodeMirror field took the page's direction: code
right-aligned, the gutter on the right, "-- comment" drawn as
"comment --". The code area is dir="ltr", the API reference's code
spans are isolated LTR runs (".from" no longer becomes "from."), the
editor's physical margins are logical ones (the title's gap, Delete
pushed to the far end) and the free-text fields (folder, description,
search) take dir="auto".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Error in <event> handler: …", "Error in /<command> command: …" and "Skipped the rest of this <event> event: …" were composed inside the Lua bootstrap; it now reports host.error(id, kind, name, detail) and the host words the sentence with t() (scripting.handlerFailed, scripting.commandFailed, scripting.eventSkipped — English unchanged), in 23 languages. The interpreter-style raises (read-only seance, too many handlers, string.rep's limit, the instruction limit) stay as Lua errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Needed after merging develop: the translation sheets and #148's #148 (merged into develop as The failures are real bugs, seen on an iPhone in the iOS shell: the translation sheet's title and ✕ sit under the status bar (it is What to change (tested on an iPhone 16 in the shell, and in Chrome with the shell faked on iOS and Android):
The patch below applies cleanly to this PR's head ( Patch against this PR's headdiff --git a/client/components/TranslationPanel.vue b/client/components/TranslationPanel.vue
index 75ade5428..ade1a5173 100644
--- a/client/components/TranslationPanel.vue
+++ b/client/components/TranslationPanel.vue
@@ -1,12 +1,12 @@
<template>
- <!-- One markup, two layouts. The sheet is teleported to <body> so it can
- cover the whole screen: the desktop panel is absolutely positioned
+ <!-- One markup, two layouts. The sheet is teleported to #viewport so it can
+ cover the whole app: the desktop panel is absolutely positioned
inside the channel view, which clips its overflow. -->
- <Teleport to="body" :disabled="!phone">
+ <Teleport to="#viewport" :disabled="!phone">
<div
ref="panel"
class="translation-panel"
- :class="{'translation-panel--sheet': phone}"
+ :class="{'translation-panel--sheet screen-overlay': phone}"
role="dialog"
:aria-modal="phone ? 'true' : undefined"
:aria-label="panelLabel"
diff --git a/client/css/style.css b/client/css/style.css
index 04b74ec47..6845cb0a3 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -2460,17 +2482,18 @@ textarea.input {
sidebar and its overlay (10 and 9 inside #viewport); #context-menu-container
is 1000 but is only in the document while a menu is open, and no menu can be
opened from the sheet. */
.translation-panel--sheet {
- position: fixed;
+ /* Absolute in #viewport, not fixed: #viewport follows the visual viewport
+ (helpers/viewport.ts), which iOS shrinks for the keyboard where it never
+ shrinks the layout one, and in the shell `.screen-overlay` starts it at
+ the insets. */
+ position: absolute;
inset: 0;
z-index: 1001;
width: auto;
max-width: none;
-
- /* iOS never shrinks the layout viewport for the keyboard, so the sheet
- follows the visual viewport helpers/viewport.ts publishes. */
- height: var(--viewport-height, 100%);
+ height: auto;
max-height: none;
overflow: hidden;
gap: 0;
padding: 0;
@@ -2486,12 +2509,13 @@ textarea.input {
}
/* The sheet sits flush against the top of the page, above the sidebar's
* z-index: it needs #status-bar-tint's same reservation so the title clears
- * it on an iOS home-screen app (see #status-bar-tint above). */
+ * it on an iOS home-screen app (see #status-bar-tint above). The shell has no
+ * tint, and `.screen-overlay` already starts the sheet below the status bar. */
@media (hover: none) and (pointer: coarse) {
@supports (-webkit-touch-callout: none) {
- .translation-panel--sheet .translation-panel-title {
+ html:not([data-shell="native"]) .translation-panel--sheet .translation-panel-title {
padding-top: calc(1rem + var(--status-bar-tint-height, 0px));
}
}
}
@@ -2603,10 +2627,12 @@ textarea.input {
.translation-panel--sheet .translation-panel-foot {
flex-direction: column;
gap: 0.5rem;
- /* Above the home indicator, whatever the device says that is. */
- padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 20px));
+ /* Out of iOS's home indicator strip in the shell, as the composer is
+ (Android's bar is under the sheet's edge, which `.screen-overlay` or
+ its own rule moves). */
+ padding: 0.75rem 1rem max(0.75rem, var(--inset-inner-bottom, 0px));
border-top: 1px solid var(--window-border, rgb(128 128 128 / 30%));
}
.translation-panel--sheet .translation-panel-more {
@@ -2746,8 +2772,16 @@ textarea.input {
border-width: 1px 0 0;
border-radius: 0.75rem 0.75rem 0 0;
}
+/* Fixed to the screen's bottom edge, which in the shell is under Android's
+ * button bar: it stops above it (iOS's home indicator is reserved by the
+ * actions row); in landscape the notch is a side. */
+html[data-shell="native"] .source-language-picker--sheet {
+ bottom: var(--inset-outer-bottom);
+ padding: 0 var(--inset-right) 0 var(--inset-left);
+}
+
.source-language-picker--sheet .source-language-picker-field {
gap: 0.375rem;
padding: 1rem;
}
@@ -2764,10 +2798,12 @@ textarea.input {
font-size: 1rem;
}
.source-language-picker--sheet .source-language-picker-actions {
- /* Above the home indicator, whatever the device says that is. */
- padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 20px));
+ /* Out of iOS's home indicator strip in the shell, as the composer is
+ (Android's bar is under the sheet's edge, which `.screen-overlay` or
+ its own rule moves). */
+ padding: 0.75rem 1rem max(0.75rem, var(--inset-inner-bottom, 0px));
border-top: 1px solid var(--window-border, rgb(128 128 128 / 30%));
}
.source-language-picker--sheet .source-language-picker-actions .btn {After merging develop: the exemptiondiff --git a/test/tests/screenOverlays.ts b/test/tests/screenOverlays.ts
index 6a7d04d40..ec90a7417 100644
--- a/test/tests/screenOverlays.ts
+++ b/test/tests/screenOverlays.ts
@@ -21,6 +21,10 @@ describe("overlays carry .screen-overlay", function () {
// A popover under the @ button; its phone layout takes the insets in
// its own native rule (Mentions.vue).
"Mentions.vue": "anchored popover, own phone rule",
+ // A popover at its chip; its phone sheet sits on the bottom edge and
+ // has its own inset rule (`html[data-shell="native"]
+ // .source-language-picker--sheet`).
+ "SourceLanguagePicker.vue": "anchored popover, own sheet rule",
// The connect form is a page, not an overlay: its link-approval
// dialog is the window itself, laid out inside #viewport's padding.
"Windows/Connect.vue": "a page inside #viewport", |
…overlay) The translation sheet is teleported to #viewport and absolutely positioned, with .screen-overlay while it is the sheet, so the shell starts it below the status bar and stops it above Android's bar; the PWA tint reservation on its title is off in the shell. The source-language sheet keeps its own inset rule. Both sheets pad the bottom with --inset-inner-bottom, as the composer does. Patch from rdrake's review on PR evilnet#139, tested by him on an iPhone 16 in the shell and in Chrome with the shell faked on iOS and Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn sheet rule) From rdrake's review on PR evilnet#139. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Marian (OPUS-MT) models drop or mangle emoji: "thanks for the help 👍" came back "danke für die Hilfe", 6 of 6 emoji lines lost. spans.ts now protects each emoji sequence whole -- a pictograph or a regional-indicator pair, with its skin-tone modifiers, VS16 and ZWJ-joined pictographs -- as a verbatim span after the shortcodes, so it is put back where it stood, or appended when an engine loses its placeholder. An emoji inside a URL or a code span stays in that span; a line of emoji alone has nothing left to translate. Reported by rdrake on PR evilnet#139 with the regex this uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (sheets clear of the shell's bars, emoji kept) and evilnet#148 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks — both applied.
The description is updated with everything since 29 October, including the per-model GPU/CPU choice and the multithreaded CPU path. |
The language model's GPU copy marked down under Auto (a load failure, or the second failed generation in a row) now asks, once a session and only where its CPU copy can run (never an iPhone/iPad, the 4B, or a browser without Memory64/SIMD): "The GPU stopped" -- Use the CPU this time / Always use the CPU here / Not now, with the browser's reason quoted when the loss carried one (the user's option C). - service.ts: `ServiceDeps.askCpuFallback` and `saveLlmDevice`; the question is asked after the failed attempt's bookkeeping, every route and every concurrent failure waits on the one open question, and `loadTicks()` moves on each read while it is open so no armed deadline runs out. A yes grants the CPU copy's consent (`<id>@cpu`), puts the CPU after the GPU in the language model's Auto order for the session (`RouteInput.llmCpuAfterGpu`, `deviceOrder`) and routes the same request again; "always" also pins `translateLlmDevice` to cpu. A request carried on to the CPU under that yes moves the counter per chunk (its deadline may be the GPU's), and a batch over `CPU_BATCH_MAX_LINES` on the CPU is refused with BATCH_REFUSED so the queue sends it line by line. "Not now" is today's failure and is never asked again this session. - ConfirmDialog.vue: an optional second yes (`alt`), the three answers stacked main-first, and a `quote` line rendered through I18nFrame with the browser's text verbatim in a <bdi>. - index.ts wires the dialog and the store write; fakePort.ts gains a `[gpu-lost]` token; tools/scenarios/translate-gpu-fallback.mjs. - Six strings (translate.gpuFallback.*) in the pot and all 23 catalogs; translate.consent.notNow's context now covers both dialogs. - Docs: translation.md § Devices, translation-devices.md § Auto (amended 2026-11-01), CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one confirm dialog answers the question it replaces "no", and a download question answered that way is a session's "Not now" to downloads. The service already routes nothing while the fallback question is open; index.ts now holds the fallback question back until a download question on screen is answered. Also pins "Always" with no model chosen: the device's default stays the model and its CPU load is not retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A request carried over to the CPU by the answer keeps the GPU's two-minute deadline while a cold CPU start takes about three: until its first chunk the service moves the deadline counter every minute (`keepAlive`), never past CPU_REQUEST_TIMEOUT_MS from the answer (or from the end of a download after it). The per-chunk bump is limited to that attempt, not every later CPU request. - Ask only when the GPU ran the model: its copy on the device or its download finished, or the failure carried a GPU-loss reason. A download that broke off (network, quota, cache) is today's failure. - resolve() re-checks the open fallback question on every pass. - ConfirmDialog: a `wait` question (both translation questions) queues behind any open dialog, and one the user opens over it sends it back to wait instead of answering it no (replaces index.ts's download-question wait). role="dialog", aria-modal, aria-labelledby the title, initial focus on the main answer (a destructive dialog's cancel), also after the overlay's fade, and back where it was on close. The stacked dialog's main answer is filled. - quality.ts: a sentence after a break may open with ¿ or ¡ (es); the Spanish body is a question again, German "always" keeps "hier". - Tests: cold CPU start past 2 min completes, past CPU_REQUEST_TIMEOUT_MS times out, later CPU requests move no counter, a failed download and a first (request-class) generation failure ask nothing, a GPU-loss reason asks before the copy is on the device, the composer's deadline outlasts the dialog; the ¿/¡ verdicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
- keepAlive aims its last tick 5 s (KEEP_ALIVE_SLACK_MS) before since + CPU_REQUEST_TIMEOUT_MS - 2 x REQUEST_TIMEOUT_MS, ticking every minute until then: every deadline check for about four minutes sees a fresh tick, timers late or not, and none lets the request outlive CPU_REQUEST_TIMEOUT_MS. The second half-period tick used to miss its bound by the milliseconds the attempt started after the answer. - Any language-model attempt on the CPU under the fallback's yes moves the counter per chunk, and one that starts the model cold (not loaded and answering on the CPU) is kept alive from its own start: a reading line routed to the GPU before the answer and run after it carries the GPU's deadline too. - ConfirmDialog: a question that opened by itself (`wait`) takes no answer for 450 ms (ARM_MS, the push prompt's rule; `data-armed` after), the focus on the dialog until then and on the main answer once armed; a held Enter/Space never answers it; a held Escape's repeats answer nothing; a run of questions gives the focus back to what had it before the first. - Scenarios wait for `data-armed` before answering a translation question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
On a phone (Adreno 750, Chrome 156, Android 17, Vulkan, a 128 MiB binding)
the GPU model lost the device, and stock WebLLM answered a one-word message
but failed on a ~300-word one: one big prefill job is the likely trigger.
WebLLM 0.2.85 prefills in chunks of the library's prefill_chunk_size (1024
for these builds), and the kernels and the KV cache take any smaller chunk.
- models.ts: MOBILE_PREFILL_CHUNK = 64 and prefillChunkFor(gpu): the test
switch's value when set (0 = the library's own), else 64 on a phone or
tablet, else the library's. GpuBudget.prefillChunk carries the switch.
- engines/webllm.ts: after every GPU reload (the small-buffer build's too,
never the CPU instance) WebLlmDeps.setPrefillChunk(mlc, reloadedId,
tokens); webllm.real.ts lowers LLMChatPipeline.prefillChunkSize in
MLCEngine.loadedModelIdToPipeline to min(tokens, current), or warns once
("WebLLM internals changed: prefill chunk not set") and keeps the
library's. Never throws; a refusal does not fail the load.
- gpuPrefillChunk.ts: the switch thelounge.state.gpuPrefillChunk (a whole
number of tokens, 0 = the library's own), read with the device probe and
passed to the worker in the GPU budget; never in the backup.
- testSwitches.ts + boot.ts: ?gpuPrefillChunk=N and ?gpuTestLimitMiB=N are
stored at boot before anything probes the device (0 or empty removes,
junk changes nothing) and taken out of the address bar, every other
parameter and the hash kept.
- Settings → Translation says so while the switch is set (two strings,
the counted one plural, all 23 catalogs).
- Docs: translation.md § Phones and small GPUs, CLAUDE.md.
- Tests: test/translate/prefillChunk.ts; browser check
tools/scenarios/translate-test-switches.mjs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
The user, twice: "Marked as read" has to go by itself. On Android with no window of the app open nothing scheduled for after a push event runs — the process ends with the event and Chrome refuses a Background Sync to a worker with no window — so there (closesRunLater false) the worker gives up Chrome's count instead of leaving a card: a relay closes the last card inside its event and converts nothing, Mark read closes the last card at once, and keepChromeCount shows no stub. Each is a silent push Chrome counts against the origin's budget (about six a day for a fully engaged site), past which it shows its generic, which it deletes once the site shows two notifications. A backgrounded phone still has a window, so its sync registers and fires and the in-place "Marked as read" stays the rule there, as on every desktop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # CLAUDE.md
Branch
wild-internationalagainstorigin/develop(2401a0cba, #148). The branch already contains that commit, so it fast-forwards..pofiles plusmessages.pot: +360,413.client/locales/*.json: +64,937.client/locales/attic/: +118,407.client/locales/): 483 files, +83,054 / −3,613.Two features that grew together, because the reading language and the interface language are one setting:
client/js/translate/,TranslationLine.vue,TranslationPanel.vue,ChatInput.vueclient/js/i18n/,client/locales/, every componentae08bc46,98a1f0f0translate/consent.ts,service.ts,Settings/Translation.vuemodels.tsdeviceOrder,engines/onDevice.ts,engines/cpuLlm.ts,threads.ts,service-worker.jsengines/gpuLimits.ts,models.ts,router.ts,helpers/translateWhy.tsNothing new goes out on the wire.
caps.ts:CapResult.error(an English string) becameerrorCode, which the lobby renders through the catalog.On-device translation
docs/resources/translation.mddescribes how it works today.docs/projects/client-translation.mdis the spec, with the date of every decision.Engines and routing
js/translate-worker.js, a third webpack configuration):engines/*.real.ts, so mocha never loads them.router.ts,routes.default.ts):tools/translate-eval/results/.SUPPORTED_LANGUAGES). Four that came back under 55% from every engine were dropped.config.json(translation.routes).Reading a channel (
reader.ts)chatdetect.ts, generatedstopwords.json) runs first.francruns second, as a lazy chunk.wordlookup.ts, thewordlist.jsonchunk).Writing (
outgoing.ts,writer.ts)/meis the one command that translates.Per-channel controls
TranslationPanel.vue, opened from the header's文Abutton, holds reading on or off, the write language, formality, variant and terms.Lifecycle
Deploy knobs (
docs/resources/branding.md§ Translation)translation.enabled,modelBase(a mirror for the weights and WebLLM's compiled library),llm(model,lib),cpu(nllb,opus),routesandglossary.Internationalization
docs/resources/i18n.mdis the workflow anddocs/projects/i18n.mdthe design record.Strings and the catalog
t()/tCount():useI18n()in components;tin Vue-free modules;client/locales/messages.potis the only English copy.#.context written for translators.tools/i18n/check.ts(run inyarn test) fails when a call site and the pot disagree.{var}placeholders, never concatenated fragments.tools/i18n/plural-rules.json).FAILpayloads, X3 replies. It renders verbatim, wrapped in<bdi>inside a translated frame.Languages
translation-languages.txtis the one list.tools/i18n/sync.ts(insideyarn i18n:compile, whichyarn buildnow runs first) scaffolds a new target and archives a dropped one.client/locales/attic/for a later refill rather than deleted.e5fcf874).zh-TW,zh-HK,zh-MOandzh-Hant-*no longer fall tozh, and neither does the barezhChrome appends after them (zh-TW, zh, en-US, en). The reader gets their next preference, or English.zh-Hans-TWis Simplified,zh-Hant-CNis not.zh,zh-CN,zh-SGandzh-Hans-*keepzh;pt-PTkeeps the Brazilianpt.client/js/i18n/traditionalChinese.ts, and the pre-paint script restates it, so<html lang>is right before boot. Picking Chinese by hand still giveszh.Right to left and the
qqxpseudo-localecore.isRTL()) shared with the pre-paint script.qqxdoubles every string and flips the direction. It is a development build's rig only, and a production build has no trace of it. Verified on this build:qqx.jsoninpublic/locales/;qqxin the inline RTL regex, which is dead code there.Language selection
locale, drives the interface and the reading language.auto, fromnavigator.languages.LanguageSelect.vue). A development build adds the 🌐 globe by the logo.Filling and gating the catalogs
tools/i18n/fill.ts.tools/i18n/sweep.tsgates them. It empties any slotquality.tsslotVerdictjudges broken:IDENTICAL_EVERYWHEREjargon, and except a plural form beside a translated sibling;The review pass (
0c336d89)Every slot of every catalog was then read by a reviewing agent, one language at a time, against its English and its translator context.
Show {motd}, with the<abbr>in anI18nFrameslot.The upstream-label sweep
The 454 upstream commits merged in
d4ed1277brought their UI in plain English.ae08bc46puts every string they added throught(), about 1,220 keys:profiles/noticeText.ts);fateText);Intl.Still never translated: wire text, X3 keywords, server and O3 replies, and jargon (G-line, shun, snomask, WALLOPS, …).
English output is unchanged, except for four small corrections named in the commit.
98a1f0f0translates those keys into all 23 catalogs, in each catalog's own register: de Sie, fr vous, pt-BR, zh Simplified, and so on.GPU limits and what can run
The GPU model on phones
The problem. WebLLM 0.2.85 asks the GPU for a 1 GiB storage binding, or WebGPU's 128 MiB floor when the adapter cannot give 1 GiB, with nothing between. Qwen3 1.7B's embedding is one 148 MiB tensor. So a phone offering 512 MiB got a 128 MiB device and the model never loaded.
The fix.
engines/gpuLimits.ts(2c7553a0) wrapsGPUAdapter.prototype.requestDevicein the worker.node_modulesis patched.5dbd2bbc):models.tsLLM_MIN_BUFFER_BYTES, 148 MiB.llmFitsdecides each model.deviceMemory.Qwen3 1.7B on a 128 MiB GPU (
3e306fb4,2c1262c7)The problem. Many phones bind no more than WebGPU's 128 MiB floor in one storage buffer, and a model binds each weight tensor whole. In Qwen3 1.7B only one tensor is larger: the vocabulary table (
model.embed_tokens.q_weight, 148.4 MiB), which the output head reuses. Every other tensor is 24 MiB or less.The build.
model.embed_tokens.part0/part1, 74.2 MiB each). The lookup takes a token from its half, and the output head joins the two halves' logits in vocabulary order.bc1a904, MLC-LLM2008fe8, emsdk 4.0.23). The 73-line model patch and the recipe are intools/translate-split/.client/models/qwen3-1.7b-q4f16_1-split-c4939e7d.wasm, 5.7 MB, copied topublic/models/and never precached by the service worker.The weights are the published ones.
80b3abceofmlc-ai/Qwen3-1.7B-q4f16_1-MLC, so its cache keys are its own and its byte offsets cannot drift.tensor-cache.json, rewrites the table's records into two byte ranges of the same shard (engines/splitManifest.ts, which refuses a manifest whose shapes or dtypes differ from what the library expects), and seeds it into WebLLM'swebllm/modelcache under the URL WebLLM asks for. Nothing else is hosted.What the user sees. Settings still lists one "Qwen3 1.7B". On a GPU that cannot bind its 148.4 MiB table, the row says it runs here as a small-buffer build ("its largest buffer is 74.2 MiB"), and Download fetches that build.
models.tssmallBufferBuildFordecides, for the engine, the cache questions, Settings and the download consent alike. A deploy that mirrors the weights (modelBase) or names its own 1.7B library gets no small-buffer build.Testing on a desktop.
localStorage["thelounge.state.gpuTestLimitMiB"] = "128"makes the probe report at most 128 MiB and caps WebLLM's device request in the worker (gpuLimits.ts), so a desktop GPU loads the small-buffer build. Settings shows a line while it is on; the settings backup never carries it.How it was proven. On WebGPU over SwiftShader (headless Chromium, the f32 variant, since SwiftShader has no
shader-f16), with the device capped at 128 MiB:What can run, and why not (
c99f8541,3f61b620)One Vue-free rule answers it for every surface:
models.tsmodelBlockper model;router.tsexplainRouteper language pair.resolveRouteis now its route, so the UI disables exactly what the router would refuse.translationBlockfor translation as a whole.Where users see it:
aria-disabledinstead, and a tap opens the panel with the reason (696d7882).A GPU below WebLLM's shader limits has its own reason,
GPU_LIMITS: fewer than 10 storage buffers per shader stage, or less than 32 KiB of workgroup storage (8e25b00d).Behaviour change: the CPU models are skipped on a device without WebAssembly SIMD, even when its GPU works.
The GPU model select greys what cannot bind (
1c3d8cb6). On a device that can run a GPU model at all, a model the GPU cannot hold is a disabled option, and a line under the select names it with the buffer numbers ("Qwen3 4B can't run on this device. Needs a GPU buffer of 185.5 MiB; this device binds at most 160 MiB").Downloads ask first, and NLLB-200 is opt-in
1c3d8cb6,b32c2697,698dca20;docs/resources/translation.md§ A download asks first and § Licences.The download question
Before: the router sends a request to the best class whether its model is on the device or not, so one click on the header's translate button could start a 1–2.3 GB download.
Now the first download of a model that is not cached, not downloading and never agreed to asks once:
translate/consent.ts(Vue-free) holds the rule. The service asks inresolve(), which bothroute()andtranslate()go through, so the question comes before the reading queue or the composer starts its request timeout: a dialog left open cannot time a line out.ConfirmDialog: "Download {model}?", the size, and an extra line whennavigator.connectionreports Save-Data or a cellular link. It sits above the phone's translation sheet.thelounge.state.translateConsent. The Download button in Settings counts as consent, and deleting a model withdraws it.NLLB-200 is opt-in
translateNllb, off for new and existing users. The switch in Settings → Translation carries a line naming the CC BY-NC 4.0 licence.Every model on the GPU or the CPU
The spec is
docs/projects/translation-devices.md(with the date of every call);docs/resources/translation.md§ Devices describes it as built. Built 2026-10-29 (671090ac..fb843552), after the user's phone (Chrome, Vulkan) lost its GPU mid-translation (VK_ERROR_DEVICE_LOST).Run on: Auto, GPU or CPU, per model
ModelRef.copies.gpu/cpu), with its own download size, consent, cache state and Download/Remove line in Settings.translateLlmDevice,translateOpusDevice,translateNllbDevice, eachauto/gpu/cpu, defaultauto; an unknown value (an old or foreign backup) reads asauto. The engine switches keep their keys and become one per model:translateLlmthe language model,translateCpuOPUS-MT,translateNllbNLLB-200 (no longer gated bytranslateCpu).modelBlock(ref, device, …). A pinned device that cannot run the model says why and never falls back to the other device.models.tsdeviceOrder) is the language model on the GPU only (the CPU runs it only when the user pins it there), NLLB-200 GPU-then-CPU where its GPU copy fits, OPUS-MT CPU-first on a phone and GPU-first on a computer. It is a placeholder until someone measures on real GPUs.DownKey<candidate>@<device>): a GPU copy marked down after failures moves Auto to the other copy; a declined download of the preferred copy falls back to a downloaded one.consent.tsconsentId): the copy a model ran on before keeps its old consent id, so an existing "yes" covers exactly what it was given for; the other copy is<id>@<device>.Settings/Translation.vue): per model, the switch, a Run on radiogroup (a choice the device cannot run is disabled with its reason as visible text andaria-describedby), a line saying what Auto picks here and why, and a line per copy. Browser checks:translate-capability.mjs(seven stubbed devices, also--mobile) andtranslate-settings.mjs.Qwen3 1.7B on the CPU
unsloth/Qwen3-1.7B-GGUFQ4_K_M(1.1 GB, revision pinned). A spike chose it over transformers.js's ONNX build, which would not load at the shipped optimisation level, decoded 3× slower and peaked at 5.9 GB (tools/translate-eval/results/2026-10-29-cpu-llm.md).engines/cpuLlm.tsputs wllama behind WebLLM'sMlcLikeinterface, so the prompts, think-block handling,fitToWindowand overflow logic are shared.engines/onDevice.tsholds one device's engine per slot at a time and serialises a load against the other device's.Multithreading through the service worker
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: credentiallessto the app's documents and its own worker scripts (service-worker.jsisolate), so the page is cross-origin isolated on GitHub Pages, which cannot send headers.credentiallesskeeps link previews, media and the uploader loading without headers of their own; the app embeds no iframes and relies on nowindow.opener.credentiallesssupport): Safari and iOS stay single-threaded. No forced reload: isolation starts on the next load once the worker controls the page, and Settings says "Reload the app to use N CPU threads" when that applies (threads.tsisolationState).cpuThreads): a phone at most 4 and never more than cores − 1, a computer at most 8. Measured: 186 s on one thread → 31 s on eight (5.9×). A live run on 7 threads translated a line to Japanese in 20 s including the model load.blob:URLs, so the documented CSP and the Electron shell now allowworker-src 'self' blob:.Not in this build
webpack.config.tsaliasesonnxruntime-web/webgputo/wasm), so their GPU copies are left out of the shipped catalog (CatalogOptions.seq2seqGpu, on in the tests) and their GPU choice is shown disabled with a reason. Shipping them needs ORT's WebGPU bundle and its jsep wasm (about 26 MB, and a change to the CPU path everyone uses), a real-GPU check, and GPU-loss handling for the seq2seq engine.Since the red team
15b6837e): on a mobile device the GPU model loads with a 2048-token window (the KV cache is the memory WebKit counts), the 4B is blocked asmemory, every LLM request is fitted to the loaded window (budget.tsfitToWindow, a context overflow retried bare and alone), and only one engine stays in memory (service.tsmakeRoom).e41e3994): the 4B's "you"/"your" coin flip at temperature 0.1 is fixed with example turns for the 4B, examples in the 1.7B's system prompt, greedy decoding, and a kept question mark (tools/translate-eval/results/2026-10-28-polish.md).7ec9f3fa): the device'slostreason is recorded and a failure on a lost device reads "the GPU stopped (<the browser's reason>): …" instead of themapAsynccall that happened to be running.57fbd7a9): a pair only the language model or NLLB-200 could take, with both off, names both switches.face1cdee, from rdrake's review): the Marian models dropped them ("thanks for the help 👍" → "danke für die Hilfe"), sospans.tsprotects each emoji sequence whole (skin tones, ZWJ sequences, flags) as a verbatim span, for every engine.7a2498527,0e2e379f9, rdrake's patch): the translation sheet is in#viewportwith.screen-overlay, the source-language sheet has its own inset rule, both pad the bottom with--inset-inner-bottom;test/tests/screenOverlays.ts(Overlays keep clear of the native shell's bars by a class, not a list #148) passes.cb86bb013,c894b6d7b): Lua scripting, the help modal, the backdrop and image-viewer fixes and Overlays keep clear of the native shell's bars by a class, not a list #148. Their strings go throught()in all 23 languages (26c10d027,bc06c69a5), and the Lua editor stays left to right in a right-to-left interface (16d6a33fd).Dependencies, licences, bundle
New runtime dependencies
@huggingface/transformersonnxruntime-web1.26.0-dev (MIT, a nightly pin),onnxruntime-node1.24.3 (MIT) andsharp(Apache-2.0). Onlyonnxruntime-webreaches the browser.@mlc-ai/web-llmfrancn-gram,trigram-utilsandcollapse-white-space, all MIT.@wllama/wllamajs/wllama.wasm; both notices are injs/translate-worker.js.LICENSE.txt. Imported only by the worker.44d92d63adds both tojs/translate-worker.js.LICENSE.txt, and franc tojs/franc.js.LICENSE.txt, through the existingchunkNoticesbanner.docs/resources/translation.md§ Licences says so for deploys, which can pointtranslation.cpu.nllbat another model.yarn.lockis consistent:yarn install --frozen-lockfile --ignore-scriptschanges nothing.package.jsonchanges are the four dependencies above and thei18n:*scripts (CodeMirror and fengari come from upstream's scripting tab).Bundle (production build,
.mapfiles excluded, againstorigin/developbuilt the same way)"After" re-measured at
face1cdee(2026-11-01). "Before" isorigin/developatfc2bb3fband was not re-measured, so part of the growth is upstream's own since then (the scripting tab).Loaded on every page:
bundle.jspush.jsstyle.cssLazy, fetched only when used:
js/translate-worker.jsjs/ort/ort-wasm-simd-threaded.wasm+.mjsjs/wllama.wasmwordlist.jsfranc.jspublic/locales/*.jsonface1cdeewithout source maps (7.4 MB before the branch);js/wllama.wasmand the 5.7 MB small-buffer library are the largest additions since the last measure.21ca9bcc).tools/pages-site.mjsassemble()drops the*.mapfiles (about 18 MB a build) from the release, develop and every PR preview. Local builds and the workflow's cache keep them.bundle.jsandpush.js.isModelCache).models/never touch the fetch handler.CI
yarn i18n:compilebefore webpack (yarn build).onnxruntime-node's postinstall fetched its ~316 MB CUDA and TensorRT providers from NuGet on every uncached Linux build.44d92d63setsONNXRUNTIME_NODE_INSTALL=skipon the Pages install.?fakeTranslate/fakePort, the 🌐 globe, the 🐞 devtools button, the missing-key console warnings orqqx.44d92d63also stops copyingclient/locales/tags.jsonintopublic/locales/. Nothing fetched it, and it namedqqx.Storage
New settings, in
thelounge.settingsand carried by the settings backup:locale"auto"translateFormality"auto"translateLlmtruetranslateCputruetranslateLlmModel""translateNllbfalseNew localStorage key:
thelounge.translate.version: 2.KEY_SINCElets an older file leave the key alone.New localStorage key:
thelounge.state.translateConsent. The models the user agreed to download. Like everythelounge.state.*key, the settings backup never carries it. A yes is also kept in memory for the session, so a browser that cannot store it does not ask again on every request.IndexedDB: the push preferences (
push-prefs.ts) gainlocale, so the service worker translates a notification with no page open.Cache Storage, owned by the libraries:
transformers-cache,webllm/model,webllm/wasmandwebllm/config.What the client expects from the ircd
Nothing new.
Translation needs from the deploy:
.mjsserved astext/javascriptand.wasmasapplication/wasm. GitHub Pages and nginx do this.huggingface.co. WebLLM's compiled library also comes fromraw.githubusercontent.com, unlesstranslation.modelBasemirrors both.Testing
Measured on 2026-10-28 on a shared host (load average about 40 on 56 cores). The gate ran at
0c336d89in this order;f0e6f1f7, CSS only, came after it and was re-checked as noted below.npx tsx tools/i18n/sweep.ts --dryyarn i18n:compileen/qqx, 24 targets, 2,290 static call-site keysyarn i18n:checknpx tsc --buildyarn lint(eslint, prettier, stylelint)f0e6f1f7NODE_ENV=production yarn buildf0e6f1f7'test/**/*.ts', after that build)2c1262c7)face1cdee(2026-11-01):yarn lint,npx tsc --build,yarn i18n:check,NODE_ENV=production yarn buildrequireface1cdeetest/helpers/parseMarkdown.ts"cost of a hostile 16 KB message": 142 ms against a 120 ms budget in the full run. Outside the full suite it failed once and passed once at the same load (the pass atf0e6f1f7, run withtest/tests/build.tsandtest/themes/ps.ts: 321 passing).test/scenes/ps/legibility.ts, one 60 s timeout. Alone it timed out again (33 passing, 1 failing). It is the ps theme's CPU-heavy contrast sweep and needs a quiet machine.test/tests/build.tsincluded.Unit tests
test/translate/,test/tests/i18n-po.ts,test/tests/i18n-toolchain.ts,test/helpers/i18n.ts,test/helpers/i18nFrames.ts,test/helpers/translateWhy.ts,test/push/i18n.ts,test/push/prefs.tsandtest/client/js/i18n/datesTest.ts.test/translate/availability.tscrosses 7 devices × 4 switch states × 6 language pairs and checks the explanation always agrees with the router.test/translate/consent.tsandtest/translate/service.tscover the download question: one question per model, Not now, a storage that cannot keep a yes.test/helpers/i18n.tsruns the Traditional Chinese rule through bothbestLocaleand the pre-paint script over the same tag lists.test/translate/gpuLimits.tsruns the shim against a stand-in prototype.test/tests/service-worker.tstranslates notifications in the real worker.Browser checks
Headless Chromium has no GPU, so
navigator.gpuis stubbed by an init script. Every run passed with no console errors. Screenshots read: all ofi18n-rtl-layout.mjs(38),settings-modal.mjs(both sizes),translate-settings.mjsandtranslate-consent.mjs, the install guide's desktop steps, and the Settings, GPU-select and download-question shots oftranslate-capability.mjsfor every stand-in device; not the composer and panel shots of the capability run, nortranslate-composer.mjs's.tools/scenarios/translate-capability.mjsf0e6f1f7translate-capability.mjs --mobilef0e6f1f7translate-small-gpu.mjs(the small-buffer build, served under/seance/develop/)2c1262c7translate-small-gpu.mjs --mobile2c1262c7install-guide.mjs0c336d89install-guide.mjs --mobile0c336d89settings-modal.mjs0c336d89settings-modal.mjs --width=390 --height=844f0e6f1f7i18n-rtl-layout.mjs(every theme, desktop and phone)f0e6f1f7translate-consent.mjs(fake engine)f0e6f1f7translate-settings.mjs(?fakeTranslate)f0e6f1f7translate-composer.mjs(?fakeTranslate)f0e6f1f7translate-capability.mjsstands in six devices: no WebGPU, no 16-bit float, low shader limits (lowLimits), 128 MiB, 160 MiB, and a 2 GiB desktop.translate-consent.mjs --mobile(9) ran at698dca20.Rendered in five languages. A production build was rendered in de, ru, ja, ar and pl against the dev ircd, all five on the final CSS: the connect form, the sidebar with a channel, a channel with join and part lines, the composer with a draft, and Settings → General, Appearance and Translation, scrolled to the end; ar also with Latin text typed into both searches. Every screenshot was read. It found five things, all fixed (
db9030ac,0c336d89,f0e6f1f7):dir="auto"field turned left-to-right when empty or holding Latin text, so the user list's "1 مستخدم", the jump-to placeholder and typed text sat under the search glyph. Such a field now keeps the page's sides.<bdi>now, as the header's was.The earlier red-team rounds were also checked in a browser when they landed (at
7ada7d06):who-list.mjs(11, and 8--mobile),install-guide.mjs --mobilefor Android and iOS (30 and 30),translate-composer.mjs --mobile(92),i18n-frames.mjsin en, de, ja, ar and qqx,i18n-system-messages.mjs,translation-off-locale.mjs,translation-chips.mjs,translate-reading.mjs, andi18n-rtl-layout.mjs(with ps under RTL deferred to ps plan 5).Other scenarios the branch adds:
translate-reading.mjs,translation-chips.mjs, andtranslate-cpu-real.mjs(real CPU models,SEANCE_REAL_MODELS=1);i18n-rtl-layout.mjs,i18n-language-switch.mjs,i18n-system-messages.mjs,i18n-context-menu.mjs,i18n-dynamic-keys.mjs,i18n-warnings.mjs,sidebar-language.mjs.Not exercised here:
tools/translate-eval/.Deliberate limits and follow-ups
Devices and builds
OnDeviceare left: a load that never settles wedges a later load of the other device, and two overlapping loads on one device can leave both devices resident. Both need sequences that have not been seen.shader-f16, so the method was proven with the f32 variant only (§ Qwen3 1.7B on a 128 MiB GPU). The first real run is a desktop with the 128 MiB test switch, then a phone. The 4B has no small-buffer build, so 128 MiB phones run the 1.7B or the CPU models.'wasm-unsafe-eval'and serves.wasm(25512c5a).Interface
The catalogs are machine-filled and agent-reviewed, not native-reviewed
{Kind}titles: the kind words are genitive now ("obrazu", "dźwięku") to fit their frames, butLinkPreview.vuecapitalises the word and shows it alone as a title when there is no host, so pl shows "Obrazu" there.composer.fallbackNetworkis in the dative ("síti", "sieti") to fit "k {network}", and reads wrongly in the other frames that use it.oper.usage.*translate the command arguments, unlike the other usage lines; uk calls the oper Alerts tab and ordinary notifications both «сповіщення».sweep --pluralslists 188 plural forms that read the same for different counts (66 of them ar, then pl 27, cs 23, uk 21, ru 20, sk 19, ro 12). Most are frames where the noun does not change, such as ar's "{count} مستخدم" for 0, 1 and 100; they are a list to read, not errors.Measurement gaps
qqx's doubled strings (i18n-rtl-layout.mjs) and by rendering de, ru, ja, ar and pl in a production build (§ Testing). The other 18 languages were not rendered surface by surface, so a long label may still wrap or clip somewhere; the oper tools, Help and the dialogs were not rendered in any of them.Red-team review
Before this PR, the branch had an adversarial review on six dimensions:
Each finding was checked against the code before anything was fixed. Every fix is its own commit on this branch, with tests where mocha can reach the code.
Fixed
merge
7563e313). Before, English full timestamps came out on en-US's 12-hour clock whatever the setting said.5b815fcb, repaired in2b466a1a).5b815fcbon its own failscheck.ts.7aeb5c65).t()conversion had weakened pin upstream's English again (b149d705). That covers the upload tests, the typing sentence (moved into a Vue-freetypingSummaryhelper), psWords and own messages.i18n
system.*,condensed.*, the unread and mention badges, the connection strip and Help were rewritten by hand in all 23 catalogs, every plural slot included (7b683902,be25e531,a8c3905b,7b19b9da,aaa941f0).t()fragments around elements are now one key each, rendered byI18nFrame.vuein the translation's own placeholder order (898f0b15,0837ed34,de5b4013,486f1347).invite.vueprinted its trailing text twice, and RTL Help reversed/connect.1eb0d0db). This fixes fil, ru/uk 21/31 and fr/pt 0.upload.confirmCountis now a real plural.9ddedc57) and stray Latin tokens in CJK and Thai (2d21ec0c);sweep --pluralslists plural forms that read the same for different counts (aaa941f0);check.tsfails twot()calls placed side by side in a template (898f0b15) and a msgid that opens a parenthesis it never closes (de5b4013).a8c3905b).7f7c1079,2d21ec0c). The language-name frames in ru, uk, pl, cs, sk and hu no longer need a grammatical case (be25e531).friendlysizewrites "B" rather than "Bytes" (2ed3df16).translate
9ffbb192).94fcb62c,e4df9c5a).GPU_LIMITS(8e25b00d).e6d58239).security
10cdb654,1d4e30c4).draft/multiline, every later line the model starts with/is escaped.f36825ea): none are recorded whilekeepPrivateConversationsis off, they are forgotten when it is switched off or the query closes, and the settings backup leaves them out.'wasm-unsafe-eval', and.wasmgets its MIME type (25512c5a).index.htmlintojs/prepaint.js, so no inline script is left.test/tests/build.tsfails on any inline script that comes back.perf
protectNicksis no longer quadratic: a 400-character line against 2000 nicks went from 21.8 ms to 2.5 ms. History loads also yield to the page between lines (1a7563fa).eee89359).TranslationLineuntil it has an entry (25bd225c).1c3551e9).ux (what the user is told about which models can run)
6a7b7728). It opens on the first target that routes, and when nothing routes it says why.e0bc0ab3,0efa65ff).aria-disabled, and tapping it opens the panel that gives the reason (696d7882).17f66efb).5a497482):0b204056,7ada7d06).087c9668). The docs drop the reading-language override that no longer exists (e33a3c3f). Scenario fixes in223bf1bcandcc91aa58.Refuted, downgraded or left on purpose
mainis how stock WebLLM and transformers.js load models, and a deploy can mirror everything throughtranslation.modelBase.Open items for the maintainer's or the user's call
push.jsstill bundles the whole ofen.json.Settled since the last draft: the download now asks first and names its size, with Save-Data and cellular links called out; Traditional Chinese reads English; the Pages site carries no maps; NLLB-200 is opt-in; the GPU select disables a model that cannot bind; and fil and the rest of
translate.*had their review pass.Review hints
A branch this size reads best by directory. Three-dot line counts per area:
client/js/translate/stopwords.jsonandwordlist.jsonare generated tablesclient/js/i18n/client/js/(rest)t()conversionsclient/components/t()conversions, plusTranslationLine.vue,TranslationPanel.vue,Settings/Translation.vue,LanguageSelect.vueand the composer striptest/translate/andtest/(rest)tools/i18n/tools/translate-eval/andtools/translate-llm.tstools/scenarios/docs/client/locales/Suggested order:
docs/resources/translation.md§ "Two engines, one router" and § "What runs here, and why not";docs/resources/i18n.md;t()" convention.models.ts,router.ts,capability.ts,engines/gpuLimits.ts;reader.ts(store glue),outgoing.ts/writer.ts;client/js/i18n/core.ts.t()conversions.git diff -w origin/develop...HEAD -- client/componentsreads quickly.ChatInput.vue.Catalogs.
client/locales/*.jsonis compiled from the.pobyyarn i18n:compile.client/locales/attic/holds the 22 dropped languages' catalogs, kept for a future refill.Dev-rig files that came with the branch and are not part of the app:
tools/serve.py, a one-port HTTPS host with a WebSocket proxy;tools/iauth-agent.py, which does SASL without services for the dev ircd;/seance/tmp/…), as the existing dev-rig docs already do.🤖 Generated with Claude Code