feat(chat): all-language on-device translation (M2M100 + Basque Opus)

- M2M100 418M (MIT, 617 MB q8) covers 23 app languages; Basque uses converted
  Opus eu-en/en-eu q8 packs; all self-hosted under relay.skate-spots.org/models.
- Worker uses M2M100 directly and pivots Basque through English; UI targets all
  languages; old pilot restriction/note removed; memory-only + guard unchanged.
- Verified en<->es, ca<->gl, hi<->en from the host. Basque packs are converted
  and hosted; the final MarianTokenizer/ONNX runtime check is still pending.
- 138 tests, 24 locales, build and smoke green. SpotChat deliberately skipped.
This commit is contained in:
SkateSpots 2026-10-27 13:06:04 +07:00
parent 67bc89861d
commit e85b9aacb6
6 changed files with 109 additions and 74 deletions

View file

@ -2432,7 +2432,7 @@ Rules:
`~/.bash_history`; clear any open shell's in-memory history with
`history -c; history -w` if it predates this.
### Message translation — on-device pilot live (2026-10-27)
### Message translation — on-device, all languages (2026-10-27)
- **Detector (all 24):** `src/lib/detectLanguage.ts` — **tinyld** (MIT,
lazy-loaded, local) + script rules (Devanagari→hi, Thai→th, kana→ja,
Hangul→ko, Han→zh-CN/TW, Greek→el) + a weighted **Catalan/Galician/Basque/
@ -2446,30 +2446,43 @@ Rules:
**No Chrome Translator, no server, no AI proxy/OpenRouter, no third-party
API.** Model files are public bytes; only the model is downloaded, never the
message.
- **Pilot (English ↔ Spanish) is live:**
- Models: `Xenova/opus-mt-en-es` + `es-en` (Helsinki Opus-MT, ONNX q8) hosted
on the relay VPS at
`https://relay.skate-spots.org/models/Xenova/<model>/resolve/main/…`
(235 MB total; Caddy `handle_path /models/*` → `/srv/models`, CORS `*`;
backup `/etc/caddy/Caddyfile.bak-…`). Both directions verified from the
hosted files ("Hello, how are you?" → "Hola, ¿cómo estás?" and back).
- **All-language runtime (M2M100 + Basque Opus):**
- `Xenova/m2m100_418M` (MIT, **617 MB** q8) covers 23 app languages
(en/es/ca/gl/hi/th/ja/pt/fr/de/it/nl/pl/ru/ko/zh/uk/tr/vi/id/sv/cs/ro/el).
Basque is not in M2M100, so `Xenova/opus-mt-eu-en` + `en-eu` (Apache-2.0,
converted q8; ~374 MB each) pivot through English.
- Hosted at `https://relay.skate-spots.org/models/…` (Caddy
`handle_path /models/*` → `/srv/models`, CORS `*`; backup
`/etc/caddy/Caddyfile.bak-…`).
- **Verified from the host:** en↔es, ca↔gl, hi↔en. The Basque packs are
converted and hosted (ONNX q8 + generated `tokenizer.json`), but the final
in-browser MarianTokenizer/ONNX runtime verification is **still pending**
(a Marian/ONNX runtime error in the standalone check).
- Runtime: **`@xenova/transformers` 2.17.2** + ONNX Runtime **1.14** WASM
self-hosted in `public/ort/` (37 MB, copied from `onnxruntime-web`), loaded
from `../ort/`; worker `src/workers/translate.worker.ts`.
from `../ort/`; worker `src/workers/translate.worker.ts`. M2M100 is used
directly; Basque pivots eu→en→target / source→en→eu.
- **Hard guard:** `src/lib/translationGuard.ts` permits only model
GET/HEAD (no query string) and same-origin `/ort/` assets. The message text
GET/HEAD (no query string) and same-origin `/ort/` assets. Message text
only travels via `postMessage` to the local worker.
`translation.test.ts` + `translationGuard.test.ts` (9 tests) prove `fetch`
is never called with a sentinel message and every other request is blocked.
- UI: `src/components/chat/TranslateMessage.tsx` in DMs (`ConversationPage`)
and crew chat (`CrewChat`); `TranslationSettings` in Settings → Appearance
- UI: `TranslateMessage` in DMs (`ConversationPage`) and crew chat
(`CrewChat`); `TranslationSettings` in Settings → Appearance
(auto-translate **off** by default). Translations are **memory-only**;
reload/logout clears them. First use downloads ~110 MB per direction with a
progress indicator.
- i18n: 12 new `translateUi` keys in en/es + all 24 `drift.ts` (check 100%).
- **Still to do:** add the remaining language pairs via the **English pivot**
(`xx→en→target`; Opus-MT packs on the same host), add SpotChat, and measure
speed/memory on the APK before a **1.7.2** release.
reload/logout clears them + terminates the worker. First use downloads the
multilingual pack with progress. 12 `translateUi` keys in en/es + all 24
`drift.ts` (check 100%); the old “pilot” note is unused.
- **SpotChat was deliberately skipped** (owner request).
- **Provenance:** M2M100 is MIT open weights (Meta origin) but runs entirely
locally — nothing goes to Meta. Basque Opus is Apache-2.0. No Chrome,
server translation, AI proxy or third-party API.
- **Conversion:** Docker Python 3.11 + torch 2.4.1 + transformers 4.46.3 +
optimum 1.24; dynamic q8; `tokenizer.json` generated from the SentencePiece
models (needs protobuf + `sentencepiece_model_pb2.py`).
- **Still to do:** finish the Basque tokenizer/ONNX runtime fix, verify all 24
pairs in the browser/APK, measure speed/memory, and ship **1.7.2** (the phone
is on 1.7.1). No SpotChat.
- **Keyboard caveat:** a malicious OS keyboard or rooted device is outside the
app’s control. Warn/recommend a trusted offline keyboard, and avoid the OS
keyboard for **secrets** (custom secure input / QR / bunker/Amber,

View file

@ -63,10 +63,8 @@ export function TranslateMessage({ text, messageId, isOwn = false, className }:
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const pilotTarget = language === 'en' || language === 'es';
const handleTranslate = useCallback(async () => {
if (!text.trim() || !pilotTarget) return;
if (!text.trim()) return;
setState('detecting');
setError(null);
setProgress(null);
@ -105,13 +103,13 @@ export function TranslateMessage({ text, messageId, isOwn = false, className }:
setState('error');
setError(t('translateUi.failed'));
}
}, [text, pilotTarget, messageId, language, t]);
}, [text, messageId, language, t]);
useEffect(() => {
if (!autoTranslate || isOwn || !pilotTarget) return;
if (!autoTranslate || isOwn) return;
if (state !== 'idle' || !text.trim()) return;
void handleTranslate();
}, [autoTranslate, isOwn, pilotTarget, state, text, handleTranslate]);
}, [autoTranslate, isOwn, state, text, handleTranslate]);
const body = state === 'done' && !showOriginal ? translated ?? text : text;
@ -120,22 +118,20 @@ export function TranslateMessage({ text, messageId, isOwn = false, className }:
<p className="whitespace-pre-wrap break-words">{body}</p>
{!isOwn && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] opacity-75">
{!pilotTarget && <span>{t('translateUi.pilotNote')}</span>}
{pilotTarget && state === 'idle' && (
{state === 'idle' && (
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">
{t('translateUi.translate')}
</button>
)}
{pilotTarget && (state === 'detecting' || state === 'loading') && (
{(state === 'detecting' || state === 'loading') && (
<span>
{t('translateUi.translating')}
{progress !== null ? ` ${Math.round(progress)}%` : '…'}
</span>
)}
{pilotTarget && state === 'done' && (
{state === 'done' && (
<>
<button
type="button"
@ -150,7 +146,7 @@ export function TranslateMessage({ text, messageId, isOwn = false, className }:
</>
)}
{pilotTarget && state === 'error' && (
{state === 'error' && (
<>
<span>{error ?? t('translateUi.failed')}</span>
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">

View file

@ -33,7 +33,6 @@ export function TranslationSettings() {
</button>
</div>
<p className="text-xs text-muted-foreground">{t('translateUi.privacy')}</p>
<p className="text-xs text-muted-foreground">{t('translateUi.pilotNote')}</p>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { clearTranslationMemory, translateText } from './translation';
import { clearTranslationMemory, isTranslationPairSupported, translateText } from './translation';
class FakeWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
@ -59,14 +59,10 @@ describe('translateText', () => {
expect(result).toBe('same language');
});
it('rejects pairs outside the pilot', async () => {
await expect(
translateText({
id: 'message-3',
text: 'hola',
source: 'ca',
target: 'en',
}),
).rejects.toThrow('UNSUPPORTED');
it('supports all app language pairs (M2M100 + Basque Opus)', () => {
expect(isTranslationPairSupported('ca', 'en')).toBe(true);
expect(isTranslationPairSupported('eu', 'en')).toBe(true);
expect(isTranslationPairSupported('hi', 'es')).toBe(true);
expect(isTranslationPairSupported('en', 'en')).toBe(false);
});
});

View file

@ -41,19 +41,19 @@ interface PendingRequest {
timer: ReturnType<typeof setTimeout>;
}
const MODEL_TIMEOUT_MS = 300_000; // first use downloads a ~110 MB model
const MODEL_TIMEOUT_MS = 900_000; // first use downloads the multilingual model (~617 MB)
const memoryCache = new Map<string, string>();
const pending = new Map<number, PendingRequest>();
let worker: Worker | null = null;
let sequence = 0;
/** The pilot supports English ↔ Spanish. */
/** All app language pairs are supported (M2M100 + Opus Basque). */
export function isTranslationPairSupported(
source: Language | null | undefined,
target: Language,
): boolean {
if (!source) return false;
return (source === 'en' && target === 'es') || (source === 'es' && target === 'en');
return source !== target;
}
function rejectAll(error: Error): void {

View file

@ -1,10 +1,12 @@
/**
* On-device translation worker.
* On-device translation worker (all app languages).
*
* - One multilingual model (M2M100, MIT) covers every app language except
* Basque. Basque uses a small Opus-MT eu↔en pair and pivots through English.
* - Model files come from the self-hosted relay (public ONNX bytes).
* - Message text is received via postMessage and never put into a URL/body.
* - A hard network guard blocks every request except the model host
* (GET/HEAD) and same-origin ONNX runtime assets.
* - The network guard blocks every request except the model host (GET/HEAD)
* and same-origin ONNX runtime assets.
* - No logging of message text; errors are returned as codes only.
*/
import { env, pipeline } from '@xenova/transformers';
@ -14,13 +16,15 @@ const MODEL_ORIGIN = 'https://relay.skate-spots.org';
const MODEL_PREFIX = '/models/';
const APP_ORIGIN = self.location.origin;
const M2M100 = 'Xenova/m2m100_418M';
const EU_EN = 'Xenova/opus-mt-eu-en';
const EN_EU = 'Xenova/opus-mt-en-eu';
env.allowLocalModels = false;
env.allowRemoteModels = true;
env.remoteHost = `${MODEL_ORIGIN}${MODEL_PREFIX}`;
env.useBrowserCache = true;
// Self-hosted ONNX runtime (public/ort/). Keeping this same-origin avoids the
// default CDN and lets the network guard stay strict.
const onnxWasm = (env.backends.onnx as unknown as {
wasm?: { wasmPaths?: string; numThreads?: number };
} | undefined)?.wasm;
@ -39,11 +43,6 @@ globalThis.fetch = createGuardedFetch(
},
);
const MODELS: Record<string, string> = {
'en-es': 'Xenova/opus-mt-en-es',
'es-en': 'Xenova/opus-mt-es-en',
};
type Incoming = {
type: 'translate';
id: number;
@ -55,7 +54,7 @@ type Incoming = {
type Outgoing =
| { type: 'progress'; id: number; status?: string; progress?: number; file?: string }
| { type: 'result'; id: number; text: string }
| { type: 'error'; id: number; code: 'UNSUPPORTED' | 'FAILED' };
| { type: 'error'; id: number; code: 'FAILED' };
const ctx = self as unknown as {
postMessage: (message: Outgoing) => void;
@ -65,6 +64,14 @@ const ctx = self as unknown as {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const translators = new Map<string, Promise<any>>();
/** Collapse UI variants onto the model's language tokens. */
function baseLanguage(code: string): string {
if (code === 'pt-PT') return 'pt';
if (code === 'ko-KP') return 'ko';
if (code === 'zh-CN' || code === 'zh-TW') return 'zh';
return code;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getTranslator(modelId: string, requestId: number): Promise<any> {
const existing = translators.get(modelId);
@ -92,31 +99,55 @@ function getTranslator(modelId: string, requestId: number): Promise<any> {
return loading;
}
async function run(
modelId: string,
text: string,
requestId: number,
options: Record<string, unknown> = {},
): Promise<string> {
const translator = await getTranslator(modelId, requestId);
const output = await translator(text, { max_new_tokens: 512, ...options });
const translated = Array.isArray(output)
? output[0]?.translation_text
: output?.translation_text;
if (typeof translated !== 'string' || !translated) {
throw new Error('empty translation');
}
return translated;
}
async function translate(
text: string,
source: string,
target: string,
requestId: number,
): Promise<string> {
const s = baseLanguage(source);
const t = baseLanguage(target);
if (s === t) return text;
// Basque is not in M2M100: use Opus eu↔en and pivot through English.
if (s === 'eu' || t === 'eu') {
if (s === 'eu' && t === 'en') return run(EU_EN, text, requestId);
if (s === 'en' && t === 'eu') return run(EN_EU, text, requestId);
if (s === 'eu') {
const english = await run(EU_EN, text, requestId);
return run(M2M100, english, requestId, { src_lang: 'en', tgt_lang: t });
}
const english = await run(M2M100, text, requestId, { src_lang: s, tgt_lang: 'en' });
return run(EN_EU, english, requestId);
}
return run(M2M100, text, requestId, { src_lang: s, tgt_lang: t });
}
ctx.onmessage = (event) => {
const data = event.data;
if (!data || data.type !== 'translate' || typeof data.id !== 'number') return;
const pair = `${data.source}-${data.target}`;
const model = MODELS[pair];
if (!model) {
ctx.postMessage({ type: 'error', id: data.id, code: 'UNSUPPORTED' });
return;
}
if (data.source === data.target) {
ctx.postMessage({ type: 'result', id: data.id, text: data.text });
return;
}
void (async () => {
try {
const translator = await getTranslator(model, data.id);
const output = await translator(data.text, { max_new_tokens: 512 });
const translated = Array.isArray(output)
? output[0]?.translation_text
: output?.translation_text;
if (typeof translated !== 'string' || !translated) {
throw new Error('empty translation');
}
const translated = await translate(data.text, data.source, data.target, data.id);
ctx.postMessage({ type: 'result', id: data.id, text: translated });
} catch {
// Never forward the underlying error: it may contain message text.