feat(chat): on-device translation pilot (English ↔ Spanish)

- @xenova/transformers 2.17.2 in a Web Worker; Opus-MT en↔es ONNX q8 packs
  self-hosted on the relay VPS (/models + CORS); ONNX Runtime 1.14 WASM
  self-hosted in public/ort/ (37MB) loaded from ../ort/.
- Hard network guard: only model GET/HEAD (no query) and /ort/ assets allowed.
  Message text travels only via postMessage; 9 tests prove fetch is never called
  with the text and every other request is blocked.
- TranslateMessage in DMs + crew chat; Settings auto-translate off by default;
  memory-only translations; per-model download progress; 12 UI strings in all
  24 locales.
- Both model directions verified from the hosted files; tsc, 138 tests, build
  and smoke green.
This commit is contained in:
SkateSpots 2026-10-27 10:49:54 +07:00
parent 8c7de4d86d
commit d2a24679f0
45 changed files with 1820 additions and 40 deletions

View file

@ -2432,27 +2432,44 @@ Rules:
`~/.bash_history`; clear any open shell's in-memory history with
`history -c; history -w` if it predates this.
### Message translation — on-device (Phase 1, 2026-10-27)
- **Detector:** `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/Spanish/
Portuguese** rule for the short/close cases tinyld confuses. Callers may pass
a conversation/contact language as `prior`. No network, no logs, no storage.
**28/28 sample tests pass** (`detectLanguage.test.ts`), including a test that
`fetch` is never called.
- **Why not Lingua:** Lingua (Apache-2.0, accurate, ca/eu/hi) compiles to a
**99 MB** WASM with 24 languages — rejected as a detector. tinyld + the rule
is small, MIT, and covers all 24 (Galician comes from the rule; Lingua has no
Galician).
### Message translation — on-device pilot live (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/
Spanish/Portuguese** rule for the short/close cases tinyld confuses. Callers
may pass a conversation/contact language as `prior`. No network/logs/storage.
**28/28 sample tests pass**, including one asserting `fetch` is never called.
- **Why not Lingua:** Lingua (Apache-2.0) compiles to a **99 MB** WASM with 24
languages — rejected as a detector. tinyld + the rule is small, MIT, and
covers all 24 (Galician comes from the rule; Lingua has no Galician).
- **Privacy invariant:** message text must **never** leave the device.
Detection and the planned translation run locally; **no Chrome Translator, no
server, no AI proxy/OpenRouter, no third-party API**. Models are public bytes;
only the model is downloaded, never the message.
- **Phase 2 (next):** Opus-MT (Helsinki, Apache/CC-BY) English-pivot in a
**Web Worker**, models self-hosted on Blossom; translations **memory-only**
for DMs/crews; sentinel/network-guard test; opt-in auto-translate; DMs + crew
chat + spot chat. Per-pack downloads can reveal language choice (not content),
so prefer a single/multilingual pack or bundle where practical.
**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).
- 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`.
- **Hard guard:** `src/lib/translationGuard.ts` permits only model
GET/HEAD (no query string) and same-origin `/ort/` assets. The 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
(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.
- **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,

717
package-lock.json generated
View file

@ -74,6 +74,7 @@
"@tanstack/react-query-persist-client": "5.102.8",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"@xenova/transformers": "^2.17.2",
"bitcoinjs-lib": "^7.0.0",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@ -1679,6 +1680,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@huggingface/jinja": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz",
"integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@ -2524,6 +2534,69 @@
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@ -4924,6 +4997,12 @@
"@types/leaflet": "^1.9"
}
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/ngeohash": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
@ -4935,7 +5014,6 @@
"version": "22.20.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
"integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -5425,6 +5503,20 @@
"node": ">=18.0.0"
}
},
"node_modules/@xenova/transformers": {
"version": "2.17.2",
"resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz",
"integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==",
"license": "Apache-2.0",
"dependencies": {
"@huggingface/jinja": "^0.2.2",
"onnxruntime-web": "1.14.0",
"sharp": "^0.32.0"
},
"optionalDependencies": {
"onnxruntime-node": "1.14.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
@ -6064,6 +6156,20 @@
"postcss": "^8.1.0"
}
},
"node_modules/b4a": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
"integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -6071,6 +6177,86 @@
"dev": true,
"license": "MIT"
},
"node_modules/bare-events": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz",
"integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.28.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-path": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
"license": "Apache-2.0"
},
"node_modules/bare-stream": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.8.1",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz",
"integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
@ -6191,6 +6377,41 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
@ -6786,6 +7007,19 @@
"node": ">=0.8"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6804,6 +7038,16 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -7167,6 +7411,30 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -7233,6 +7501,15 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@ -7458,6 +7735,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
@ -7815,6 +8101,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -7836,6 +8131,15 @@
"node": ">=18.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -7923,6 +8227,12 @@
"node": ">=6.0.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -8116,6 +8426,12 @@
"node": ">=16"
}
},
"node_modules/flatbuffers": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz",
"integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==",
"license": "SEE LICENSE IN LICENSE.txt"
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
@ -8181,6 +8497,12 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz",
@ -8288,6 +8610,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -8388,6 +8716,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -8710,6 +9044,12 @@
"node": ">= 0.10"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -9171,6 +9511,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -9322,6 +9668,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -9356,7 +9714,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -9385,6 +9742,12 @@
"node": ">= 18"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
@ -9440,6 +9803,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/native-run": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz",
@ -9511,6 +9880,24 @@
"node": ">=v0.2.0"
}
},
"node_modules/node-abi": {
"version": "3.101.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz",
"integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.60",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz",
@ -9751,6 +10138,50 @@
"wrappy": "1"
}
},
"node_modules/onnx-proto": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz",
"integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==",
"license": "MIT",
"dependencies": {
"protobufjs": "^6.8.8"
}
},
"node_modules/onnxruntime-common": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz",
"integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz",
"integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==",
"license": "MIT",
"optional": true,
"os": [
"win32",
"darwin",
"linux"
],
"dependencies": {
"onnxruntime-common": "~1.14.0"
}
},
"node_modules/onnxruntime-web": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz",
"integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==",
"license": "MIT",
"dependencies": {
"flatbuffers": "^1.12.0",
"guid-typescript": "^1.0.9",
"long": "^4.0.0",
"onnx-proto": "^4.0.4",
"onnxruntime-common": "~1.14.0",
"platform": "^1.3.6"
}
},
"node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
@ -10064,6 +10495,12 @@
"pathe": "^2.0.1"
}
},
"node_modules/platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT"
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
@ -10265,6 +10702,67 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/prebuild-install/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -10377,6 +10875,32 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "6.11.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz",
"integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/long": "^4.0.1",
"@types/node": ">=13.7.0",
"long": "^4.0.0"
},
"bin": {
"pbjs": "bin/pbjs",
"pbts": "bin/pbts"
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@ -10394,6 +10918,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -10518,6 +11052,36 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -10769,7 +11333,6 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@ -11389,7 +11952,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@ -11525,6 +12087,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.32.6",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.2",
"node-addon-api": "^6.1.0",
"prebuild-install": "^7.1.1",
"semver": "^7.5.4",
"simple-get": "^4.0.1",
"tar-fs": "^3.0.4",
"tunnel-agent": "^0.6.0"
},
"engines": {
"node": ">=14.15.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -11631,6 +12216,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-plist": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
@ -11656,6 +12286,15 @@
"node": ">= 5.10.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@ -11775,11 +12414,21 @@
"node": ">= 0.10.0"
}
},
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
@ -12084,6 +12733,50 @@
"node": ">=18"
}
},
"node_modules/tar-fs": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
"integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz",
"integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==",
"license": "MIT",
"dependencies": {
"b4a": "^1.9.0",
"bare-fs": "^4.8.2",
"fast-fifo": "^1.3.2",
"streamx": "^2.28.1"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@ -12850,6 +13543,18 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -12962,7 +13667,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unhead": {
@ -13153,7 +13857,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {

View file

@ -94,6 +94,7 @@
"@tanstack/react-query-persist-client": "5.102.8",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"@xenova/transformers": "^2.17.2",
"bitcoinjs-lib": "^7.0.0",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,6 @@
/*!
* ONNX Runtime Web v1.14.0
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
"use strict";var e={},t="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node;if(t){var r=require("worker_threads"),a=r.parentPort;a.on("message",(e=>onmessage({data:e})));var o=require("fs");Object.assign(global,{self:global,require:require,Module:e,location:{href:__filename},Worker:r.Worker,importScripts:function(e){(0,eval)(o.readFileSync(e,"utf8"))},postMessage:function(e){a.postMessage(e)},performance:global.performance||{now:function(){return Date.now()}}})}var s=!1,n=[],i=function(){var e=Array.prototype.slice.call(arguments).join(" ");t?o.writeSync(2,e+"\n"):console.error(e)};self.alert=function(){var t=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:t,threadId:e._pthread_self()})},e.instantiateWasm=(t,r)=>{var a=new WebAssembly.Instance(e.wasmModule,t);return r(a),e.wasmModule=null,a.exports},self.onunhandledrejection=e=>{throw e.reason??e},self.onmessage=t=>{try{if("load"===t.data.cmd){if(e.wasmModule=t.data.wasmModule,e.wasmMemory=t.data.wasmMemory,e.buffer=e.wasmMemory.buffer,e.ENVIRONMENT_IS_PTHREAD=!0,"string"==typeof t.data.urlOrBlob)importScripts(t.data.urlOrBlob);else{var r=URL.createObjectURL(t.data.urlOrBlob);importScripts(r),URL.revokeObjectURL(r)}ortWasmThreaded(e).then((function(t){e=t}))}else if("run"===t.data.cmd){e.__performance_now_clock_drift=performance.now()-t.data.time,e.__emscripten_thread_init(t.data.pthread_ptr,0,0,1),e.establishStackSpace(),e.PThread.receiveObjectTransfer(t.data),e.PThread.threadInitTLS(),s||(n.forEach((t=>{e.executeNotifiedProxyingQueue(t)})),n=[],s=!0);try{e.invokeEntryPoint(t.data.start_routine,t.data.arg)}catch(t){if("unwind"!=t){if(!(t instanceof e.ExitStatus))throw t;e.keepRuntimeAlive()||e.__emscripten_thread_exit(t.status)}}}else"cancel"===t.data.cmd?e._pthread_self()&&e.__emscripten_thread_exit(-1):"setimmediate"===t.data.target||("processProxyingQueue"===t.data.cmd?s?e.executeNotifiedProxyingQueue(t.data.queue):n.push(t.data.queue):(i("worker.js received unknown command "+t.data.cmd),i(t.data)))}catch(t){throw i("worker.js onmessage() captured an uncaught exception: "+t),t&&t.stack&&i(t.stack),e.__emscripten_thread_crashed&&e.__emscripten_thread_crashed(),t}};

BIN
public/ort/ort-wasm.wasm Normal file

Binary file not shown.

View file

@ -23,6 +23,7 @@ import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { genUserName } from '@/lib/genUserName';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { cn } from '@/lib/utils';
import { TranslateMessage } from '@/components/chat/TranslateMessage';
interface CrewChatProps {
crewName: string;
@ -98,7 +99,7 @@ function CrewMessageBubble({
isOwn ? 'bg-teal-500 text-black rounded-tr-sm' : 'bg-muted rounded-tl-sm',
)}
>
{message.content}
<TranslateMessage text={message.content} messageId={message.id} isOwn={isOwn} />
</div>
</div>
</div>

View file

@ -0,0 +1,165 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '@/contexts/language';
import type { Language } from '@/contexts/language';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { detectLanguage } from '@/lib/detectLanguage';
import { isTranslationPairSupported, translateText } from '@/lib/translation';
import { cn } from '@/lib/utils';
const SOURCE_NAMES: Record<Language, string> = {
en: 'English',
es: 'Spanish',
ca: 'Catalan',
eu: 'Basque',
gl: 'Galician',
hi: 'Hindi',
th: 'Thai',
ja: 'Japanese',
pt: 'Portuguese',
'pt-PT': 'Portuguese (Portugal)',
fr: 'French',
de: 'German',
it: 'Italian',
nl: 'Dutch',
pl: 'Polish',
ru: 'Russian',
ko: 'Korean',
'ko-KP': 'Korean (North)',
'zh-CN': 'Chinese (Simplified)',
'zh-TW': 'Chinese (Traditional)',
uk: 'Ukrainian',
tr: 'Turkish',
vi: 'Vietnamese',
id: 'Indonesian',
sv: 'Swedish',
cs: 'Czech',
ro: 'Romanian',
el: 'Greek',
};
type State = 'idle' | 'detecting' | 'loading' | 'done' | 'error' | 'same';
interface TranslateMessageProps {
text: string;
messageId: string;
isOwn?: boolean;
className?: string;
}
/**
* Message body with on-device translation.
*
* English ↔ Spanish is the pilot. The model runs in a worker; the message text
* is only sent to that local worker (never to a server). Translations are kept
* in memory and cleared on reload/logout.
*/
export function TranslateMessage({ text, messageId, isOwn = false, className }: TranslateMessageProps) {
const { language, t } = useTranslation();
const [autoTranslate] = useLocalStorage<boolean>('skatespots:auto-translate', false);
const [state, setState] = useState<State>('idle');
const [translated, setTranslated] = useState<string | null>(null);
const [source, setSource] = useState<Language | null>(null);
const [showOriginal, setShowOriginal] = useState(false);
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;
setState('detecting');
setError(null);
setProgress(null);
setShowOriginal(false);
const detected = await detectLanguage(text);
if (!detected.language) {
setState('error');
setError(t('translateUi.failed'));
return;
}
setSource(detected.language);
if (detected.language === language) {
setState('same');
return;
}
if (!isTranslationPairSupported(detected.language, language)) {
setState('error');
setError(t('translateUi.unsupported'));
return;
}
setState('loading');
try {
const result = await translateText({
id: messageId,
text,
source: detected.language,
target: language,
onProgress: (p) => setProgress(typeof p.progress === 'number' ? p.progress : null),
});
setTranslated(result);
setState('done');
} catch {
setState('error');
setError(t('translateUi.failed'));
}
}, [text, pilotTarget, messageId, language, t]);
useEffect(() => {
if (!autoTranslate || isOwn || !pilotTarget) return;
if (state !== 'idle' || !text.trim()) return;
void handleTranslate();
}, [autoTranslate, isOwn, pilotTarget, state, text, handleTranslate]);
const body = state === 'done' && !showOriginal ? translated ?? text : text;
return (
<div className={cn('space-y-1', 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' && (
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">
{t('translateUi.translate')}
</button>
)}
{pilotTarget && (state === 'detecting' || state === 'loading') && (
<span>
{t('translateUi.translating')}
{progress !== null ? ` ${Math.round(progress)}%` : '…'}
</span>
)}
{pilotTarget && state === 'done' && (
<>
<button
type="button"
onClick={() => setShowOriginal((value) => !value)}
className="underline hover:no-underline"
>
{showOriginal ? t('translateUi.showTranslation') : t('translateUi.showOriginal')}
</button>
{source && (
<span>{t('translateUi.translatedFrom', { language: SOURCE_NAMES[source] })}</span>
)}
</>
)}
{pilotTarget && state === 'error' && (
<>
<span>{error ?? t('translateUi.failed')}</span>
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">
{t('translateUi.translate')}
</button>
</>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,39 @@
import { useTranslation } from '@/contexts/language';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { cn } from '@/lib/utils';
/** Settings for on-device message translation (pilot: English ↔ Spanish). */
export function TranslationSettings() {
const { t } = useTranslation();
const [autoTranslate, setAutoTranslate] = useLocalStorage<boolean>('skatespots:auto-translate', false);
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t('translateUi.autoTranslate')}</p>
<p className="text-xs text-muted-foreground">{t('translateUi.autoTranslateDesc')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoTranslate}
onClick={() => setAutoTranslate((value) => !value)}
className={cn(
'relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors',
autoTranslate ? 'bg-primary' : 'bg-muted',
)}
>
<span
className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
autoTranslate ? 'left-[22px]' : 'left-0.5',
)}
/>
</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

@ -4,6 +4,21 @@
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
const drift = {
translateUi: {
title: 'Traducció de missatges',
translate: 'Tradueix',
translating: "S'està traduint…",
showOriginal: "Mostra l'original",
showTranslation: 'Mostra la traducció',
translatedFrom: 'Traduït de {{language}}',
failed: 'La traducció ha fallat. Torna-ho a provar.',
unsupported: "Aquest parell d'idiomes encara no està disponible.",
pilotNote: 'Pilot: només anglès ↔ espanyol.',
autoTranslate: 'Tradueix els missatges automàticament',
autoTranslateDesc: "Tradueix els missatges entrants al teu idioma. S'executa en aquest dispositiu; no s'envia res.",
privacy: "La traducció s'executa al teu dispositiu. El teu missatge no surt mai d'aquí. El primer ús descarrega un model de llengua.",
},
};
export default drift;

View file

@ -323,7 +323,21 @@ const drift = {
"ready": "Připojeno · MLS",
"errorPrefix": "Koordinátora se nepodařilo kontaktovat",
"interactiveHint": "Tvůj signer žádá o schválení u každé akce — podle potřeby použij Obnovit a Poslat."
}
},
translateUi: {
title: "Překlad zpráv",
translate: "Přeložit",
translating: "Překládání…",
showOriginal: "Zobrazit originál",
showTranslation: "Zobrazit překlad",
translatedFrom: "Přeloženo z {{language}}",
failed: "Překlad se nezdařil. Zkuste to znovu.",
unsupported: "Tato jazyková kombinace zatím není dostupná.",
pilotNote: "Pilot: pouze angličtina ↔ španělština.",
autoTranslate: "Automaticky překládat zprávy",
autoTranslateDesc: "Překládat příchozí zprávy do vašeho jazyka. Běží na tomto zařízení; nic se neodesílá.",
privacy: "Překlad běží na vašem zařízení. Vaše zpráva ho nikdy neopustí. Při prvním použití se stáhne jazykový model.",
},
};
export default drift;

View file

@ -326,6 +326,20 @@ const drift = {
errorPrefix: "Koordinator konnte nicht erreicht werden",
interactiveHint: "Dein Signer fragt bei jeder Aktion nach Bestätigung – nutze Aktualisieren und Senden nach Bedarf.",
},
translateUi: {
title: "Nachrichtenübersetzung",
translate: "Übersetzen",
translating: "Wird übersetzt…",
showOriginal: "Original anzeigen",
showTranslation: "Übersetzung anzeigen",
translatedFrom: "Übersetzt aus {{language}}",
failed: "Übersetzung fehlgeschlagen. Versuch es erneut.",
unsupported: "Dieses Sprachpaar ist noch nicht verfügbar.",
pilotNote: "Pilot: nur Englisch ↔ Spanisch.",
autoTranslate: "Nachrichten automatisch übersetzen",
autoTranslateDesc: "Eingehende Nachrichten in deine Sprache übersetzen. Läuft auf diesem Gerät; nichts wird gesendet.",
privacy: "Die Übersetzung läuft auf deinem Gerät. Deine Nachricht verlässt es nie. Bei der ersten Nutzung wird ein Sprachmodell heruntergeladen.",
},
};
export default drift;

View file

@ -328,6 +328,20 @@ const drift = {
errorPrefix: "Δεν ήταν δυνατή η πρόσβαση στον συντονιστή",
interactiveHint: "Ο signer σου ζητά έγκριση για κάθε ενέργεια — χρησιμοποίησε Ανανέωση και Αποστολή όπως χρειάζεται.",
},
translateUi: {
title: "Μετάφραση μηνυμάτων",
translate: "Μετάφραση",
translating: "Γίνεται μετάφραση…",
showOriginal: "Εμφάνιση πρωτοτύπου",
showTranslation: "Εμφάνιση μετάφρασης",
translatedFrom: "Μεταφρασμένο από {{language}}",
failed: "Η μετάφραση απέτυχε. Δοκιμάστε ξανά.",
unsupported: "Αυτό το ζεύγος γλωσσών δεν είναι ακόμη διαθέσιμο.",
pilotNote: "Πιλοτικό: μόνο αγγλικά ↔ ισπανικά.",
autoTranslate: "Αυτόματη μετάφραση μηνυμάτων",
autoTranslateDesc: "Μεταφράστε τα εισερχόμενα μηνύματα στη γλώσσα σας. Εκτελείται σε αυτή τη συσκευή· δεν αποστέλλεται τίποτα.",
privacy: "Η μετάφραση εκτελείται στη συσκευή σας. Το μήνυμά σας δεν την εγκαταλείπει ποτέ. Η πρώτη χρήση κατεβάζει ένα γλωσσικό μοντέλο.",
},
};
export default drift;

View file

@ -7519,4 +7519,21 @@ walletUi: {
},
};
export default { ...en, ...batch3En, ...batch4En, ...legacyEn };
const translateUiEn = {
translateUi: {
title: 'Message translation',
translate: 'Translate',
translating: 'Translating…',
showOriginal: 'Show original',
showTranslation: 'Show translation',
translatedFrom: 'Translated from {{language}}',
failed: 'Translation failed. Try again.',
unsupported: 'This language pair is not available yet.',
pilotNote: 'Pilot: English ↔ Spanish only.',
autoTranslate: 'Auto-translate messages',
autoTranslateDesc: 'Translate incoming messages into your language. Runs on this device; nothing is sent.',
privacy: 'Translation runs on your device. Your message never leaves it. The first use downloads a language model.',
},
};
export default { ...en, ...batch3En, ...batch4En, ...legacyEn, ...translateUiEn };

View file

@ -7519,4 +7519,21 @@ walletUi: {
},
};
export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs };
const translateUiEs = {
translateUi: {
title: 'Traducción de mensajes',
translate: 'Traducir',
translating: 'Traduciendo…',
showOriginal: 'Ver original',
showTranslation: 'Ver traducción',
translatedFrom: 'Traducido de {{language}}',
failed: 'La traducción falló. Inténtalo de nuevo.',
unsupported: 'Este par de idiomas aún no está disponible.',
pilotNote: 'Piloto: solo inglés ↔ español.',
autoTranslate: 'Traducir mensajes automáticamente',
autoTranslateDesc: 'Traduce los mensajes entrantes a tu idioma. Se ejecuta en este dispositivo; no se envía nada.',
privacy: 'La traducción se ejecuta en tu dispositivo. Tu mensaje nunca sale de él. El primer uso descarga un modelo de idioma.',
},
};
export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs, ...translateUiEs };

View file

@ -4,6 +4,21 @@
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
const drift = {
translateUi: {
title: 'Mezuen itzulpena',
translate: 'Itzuli',
translating: 'Itzultzen…',
showOriginal: 'Erakutsi originala',
showTranslation: 'Erakutsi itzulpena',
translatedFrom: '{{language}} hizkuntzatik itzulita',
failed: 'Itzulpenak huts egin du. Saiatu berriro.',
unsupported: 'Hizkuntza-bikote hau ez dago erabilgarri oraindik.',
pilotNote: 'Pilotua: ingelesa ↔ gaztelania soilik.',
autoTranslate: 'Itzuli mezuak automatikoki',
autoTranslateDesc: 'Itzuli sarrerako mezuak zure hizkuntzara. Gailu honetan exekutatzen da; ez da ezer bidaltzen.',
privacy: 'Itzulpena zure gailuan exekutatzen da. Zure mezua ez da inoiz hemendik ateratzen. Lehen erabileran hizkuntza-eredu bat deskargatzen da.',
},
};
export default drift;

View file

@ -326,6 +326,20 @@ const drift = {
errorPrefix: "Impossible de joindre le coordinateur",
interactiveHint: "Votre signataire demande une approbation à chaque action — utilisez Actualiser et Envoyer au besoin.",
},
translateUi: {
title: "Traduction des messages",
translate: "Traduire",
translating: "Traduction…",
showOriginal: "Afficher l'original",
showTranslation: "Afficher la traduction",
translatedFrom: "Traduit du {{language}}",
failed: "Échec de la traduction. Réessaie.",
unsupported: "Cette paire de langues n'est pas encore disponible.",
pilotNote: "Pilote : anglais ↔ espagnol uniquement.",
autoTranslate: "Traduire automatiquement les messages",
autoTranslateDesc: "Traduis les messages reçus dans ta langue. Fonctionne sur cet appareil ; rien n'est envoyé.",
privacy: "La traduction s'exécute sur ton appareil. Ton message ne le quitte jamais. La première utilisation télécharge un modèle de langue.",
},
};
export default drift;

View file

@ -4,6 +4,21 @@
* Add keys here when a batch translation needs fixing without editing the
* generated batch files. Values must also exist in `en`.
*/
const drift = {};
const drift = {
translateUi: {
title: 'Tradución de mensaxes',
translate: 'Traducir',
translating: 'Traducindo…',
showOriginal: 'Amosar o orixinal',
showTranslation: 'Amosar a tradución',
translatedFrom: 'Traducido de {{language}}',
failed: 'A tradución fallou. Téntao de novo.',
unsupported: 'Este par de idiomas aínda non está dispoñible.',
pilotNote: 'Piloto: só inglés ↔ español.',
autoTranslate: 'Traducir as mensaxes automaticamente',
autoTranslateDesc: 'Traduce as mensaxes entrantes ao teu idioma. Execútase neste dispositivo; non se envía nada.',
privacy: 'A tradución execútase no teu dispositivo. A túa mensaxe nunca sae del. O primeiro uso descarga un modelo de lingua.',
},
};
export default drift;

View file

@ -1,4 +1,19 @@
/** Post-translation corrections for hi. */
const drift = {};
const drift = {
translateUi: {
title: 'संदेश अनुवाद',
translate: 'अनुवाद करें',
translating: 'अनुवाद हो रहा है…',
showOriginal: 'मूल दिखाएँ',
showTranslation: 'अनुवाद दिखाएँ',
translatedFrom: '{{language}} से अनुवादित',
failed: 'अनुवाद विफल रहा। फिर से कोशिश करें।',
unsupported: 'यह भाषा जोड़ी अभी उपलब्ध नहीं है।',
pilotNote: 'पायलट: केवल अंग्रेज़ी ↔ स्पेनिश।',
autoTranslate: 'संदेशों का स्वतः अनुवाद करें',
autoTranslateDesc: 'आने वाले संदेशों का अनुवाद आपकी भाषा में करें। यह इस डिवाइस पर चलता है; कुछ भी नहीं भेजा जाता।',
privacy: 'अनुवाद आपके डिवाइस पर चलता है। आपका संदेश इसे कभी नहीं छोड़ता। पहली बार उपयोग करने पर भाषा मॉडल डाउनलोड होता है।',
},
};
export default drift;

View file

@ -320,6 +320,20 @@ const driftStrings = {
errorPrefix: 'Tidak dapat menjangkau koordinator',
interactiveHint: 'Penandatanganmu meminta persetujuan untuk setiap tindakan — gunakan Muat ulang dan Kirim sesuai kebutuhan.',
},
translateUi: {
title: 'Terjemahan pesan',
translate: 'Terjemahkan',
translating: 'Menerjemahkan…',
showOriginal: 'Tampilkan asli',
showTranslation: 'Tampilkan terjemahan',
translatedFrom: 'Diterjemahkan dari {{language}}',
failed: 'Terjemahan gagal. Coba lagi.',
unsupported: 'Pasangan bahasa ini belum tersedia.',
pilotNote: 'Percontohan: hanya Inggris ↔ Spanyol.',
autoTranslate: 'Terjemahkan pesan otomatis',
autoTranslateDesc: 'Terjemahkan pesan masuk ke bahasa Anda. Berjalan di perangkat ini; tidak ada yang dikirim.',
privacy: 'Terjemahan berjalan di perangkat Anda. Pesan Anda tidak pernah meninggalkannya. Penggunaan pertama mengunduh model bahasa.',
},
};
export default driftStrings;

View file

@ -326,6 +326,20 @@ const drift = {
errorPrefix: "Impossibile raggiungere il coordinatore",
interactiveHint: "Il tuo signer chiede l'approvazione a ogni azione — usa Aggiorna e Invia secondo necessità.",
},
translateUi: {
title: "Traduzione dei messaggi",
translate: "Traduci",
translating: "Traduzione in corso…",
showOriginal: "Mostra originale",
showTranslation: "Mostra traduzione",
translatedFrom: "Tradotto da {{language}}",
failed: "Traduzione non riuscita. Riprova.",
unsupported: "Questa coppia di lingue non è ancora disponibile.",
pilotNote: "Pilota: solo inglese ↔ spagnolo.",
autoTranslate: "Traduci automaticamente i messaggi",
autoTranslateDesc: "Traduci i messaggi in arrivo nella tua lingua. Funziona su questo dispositivo; nulla viene inviato.",
privacy: "La traduzione viene eseguita sul tuo dispositivo. Il tuo messaggio non lo lascia mai. Al primo utilizzo viene scaricato un modello linguistico.",
},
};
export default drift;

View file

@ -336,6 +336,20 @@ const drift: Dict = {
errorPrefix: 'コーディネーターに接続できませんでした',
interactiveHint: '署名者は各操作で承認を求めます — 必要に応じて更新と送信を使ってください。',
},
translateUi: {
title: 'メッセージの翻訳',
translate: '翻訳',
translating: '翻訳中…',
showOriginal: '原文を表示',
showTranslation: '翻訳を表示',
translatedFrom: '{{language}} から翻訳',
failed: '翻訳に失敗しました。もう一度お試しください。',
unsupported: 'この言語ペアはまだ利用できません。',
pilotNote: 'パイロット:英語 ↔ スペイン語のみ。',
autoTranslate: 'メッセージを自動翻訳',
autoTranslateDesc: '受信メッセージをあなたの言語に翻訳します。このデバイス上で動作し、何も送信されません。',
privacy: '翻訳はお使いのデバイス上で実行されます。メッセージが外部に送られることはありません。初回利用時に言語モデルをダウンロードします。',
},
};
export default drift;

View file

@ -336,6 +336,20 @@ const drift: Dict = {
errorPrefix: '코디네이터에 연결할 수 없습니다',
interactiveHint: '서명자가 각 작업마다 승인을 요청합니다 — 필요에 따라 새로고침과 보내기를 사용하세요.',
},
translateUi: {
title: '메시지 번역',
translate: '번역',
translating: '번역 중…',
showOriginal: '원문 보기',
showTranslation: '번역 보기',
translatedFrom: '{{language}}에서 번역됨',
failed: '번역에 실패했습니다. 다시 시도하세요.',
unsupported: '이 언어 쌍은 아직 사용할 수 없습니다.',
pilotNote: '파일럿: 영어 ↔ 스페인어만.',
autoTranslate: '메시지 자동 번역',
autoTranslateDesc: '받은 메시지를 내 언어로 번역합니다. 이 기기에서 실행되며 아무것도 전송되지 않습니다.',
privacy: '번역은 사용자의 기기에서 실행됩니다. 메시지는 기기를 떠나지 않습니다. 처음 사용할 때 언어 모델을 다운로드합니다.',
},
};
export default drift;

View file

@ -323,7 +323,21 @@ const drift = {
"ready": "Verbonden · MLS",
"errorPrefix": "Kon de coördinator niet bereiken",
"interactiveHint": "Je signer vraagt goedkeuring bij elke actie — gebruik Vernieuwen en Verzenden zoals nodig."
}
},
translateUi: {
title: "Berichtvertaling",
translate: "Vertalen",
translating: "Vertalen…",
showOriginal: "Origineel tonen",
showTranslation: "Vertaling tonen",
translatedFrom: "Vertaald uit {{language}}",
failed: "Vertaling mislukt. Probeer het opnieuw.",
unsupported: "Dit talenpaar is nog niet beschikbaar.",
pilotNote: "Pilot: alleen Engels ↔ Spaans.",
autoTranslate: "Berichten automatisch vertalen",
autoTranslateDesc: "Vertaal inkomende berichten naar jouw taal. Draait op dit apparaat; er wordt niets verzonden.",
privacy: "De vertaling draait op je apparaat. Je bericht verlaat het nooit. Bij het eerste gebruik wordt een taalmodel gedownload.",
},
};
export default drift;

View file

@ -323,7 +323,21 @@ const drift = {
"ready": "Połączono · MLS",
"errorPrefix": "Nie udało się połączyć z koordynatorem",
"interactiveHint": "Twój signer prosi o zatwierdzenie każdej akcji — używaj Odśwież i Wyślij w razie potrzeby."
}
},
translateUi: {
title: "Tłumaczenie wiadomości",
translate: "Przetłumacz",
translating: "Tłumaczenie…",
showOriginal: "Pokaż oryginał",
showTranslation: "Pokaż tłumaczenie",
translatedFrom: "Przetłumaczono z {{language}}",
failed: "Tłumaczenie nie powiodło się. Spróbuj ponownie.",
unsupported: "Ta para języków nie jest jeszcze dostępna.",
pilotNote: "Pilotaż: tylko angielski ↔ hiszpański.",
autoTranslate: "Automatycznie tłumacz wiadomości",
autoTranslateDesc: "Tłumacz przychodzące wiadomości na swój język. Działa na tym urządzeniu; nic nie jest wysyłane.",
privacy: "Tłumaczenie działa na Twoim urządzeniu. Twoja wiadomość nigdy go nie opuszcza. Przy pierwszym użyciu pobierany jest model językowy.",
},
};
export default drift;

View file

@ -326,6 +326,20 @@ const drift = {
errorPrefix: "Não foi possível alcançar o coordenador",
interactiveHint: "Seu signer pede aprovação a cada ação — use Atualizar e Enviar conforme necessário.",
},
translateUi: {
title: "Tradução de mensagens",
translate: "Traduzir",
translating: "Traduzindo…",
showOriginal: "Mostrar original",
showTranslation: "Mostrar tradução",
translatedFrom: "Traduzido de {{language}}",
failed: "A tradução falhou. Tente novamente.",
unsupported: "Este par de idiomas ainda não está disponível.",
pilotNote: "Piloto: apenas inglês ↔ espanhol.",
autoTranslate: "Traduzir mensagens automaticamente",
autoTranslateDesc: "Traduza as mensagens recebidas para o seu idioma. Roda neste dispositivo; nada é enviado.",
privacy: "A tradução roda no seu dispositivo. Sua mensagem nunca sai dele. No primeiro uso, um modelo de idioma é baixado.",
},
};
export default drift;

View file

@ -323,7 +323,21 @@ const drift = {
"ready": "Conectat · MLS",
"errorPrefix": "Coordonatorul nu a putut fi contactat",
"interactiveHint": "Signerul tău cere aprobare la fiecare acțiune — folosește Reîmprospătează și Trimite după nevoie."
}
},
translateUi: {
title: "Traducerea mesajelor",
translate: "Tradu",
translating: "Se traduce…",
showOriginal: "Afișează originalul",
showTranslation: "Afișează traducerea",
translatedFrom: "Tradus din {{language}}",
failed: "Traducerea a eșuat. Încearcă din nou.",
unsupported: "Această pereche de limbi nu este încă disponibilă.",
pilotNote: "Pilot: doar engleză ↔ spaniolă.",
autoTranslate: "Tradu automat mesajele",
autoTranslateDesc: "Tradu mesajele primite în limba ta. Rulează pe acest dispozitiv; nu se trimite nimic.",
privacy: "Traducerea rulează pe dispozitivul tău. Mesajul tău nu îl părăsește niciodată. La prima utilizare se descarcă un model lingvistic.",
},
};
export default drift;

View file

@ -328,6 +328,20 @@ const drift = {
errorPrefix: "Не удалось связаться с координатором",
interactiveHint: "Ваш подписант запрашивает подтверждение для каждого действия — используйте «Обновить» и «Отправить» по необходимости.",
},
translateUi: {
title: "Перевод сообщений",
translate: "Перевести",
translating: "Перевод…",
showOriginal: "Показать оригинал",
showTranslation: "Показать перевод",
translatedFrom: "Перевод с {{language}}",
failed: "Не удалось перевести. Попробуйте ещё раз.",
unsupported: "Эта языковая пара пока недоступна.",
pilotNote: "Пилот: только английский ↔ испанский.",
autoTranslate: "Автоматически переводить сообщения",
autoTranslateDesc: "Переводить входящие сообщения на ваш язык. Работает на этом устройстве; ничего не отправляется.",
privacy: "Перевод выполняется на вашем устройстве. Ваше сообщение его никогда не покидает. При первом использовании загружается языковая модель.",
},
};
export default drift;

View file

@ -328,6 +328,20 @@ const drift = {
errorPrefix: "Kunde inte nå koordinatorn",
interactiveHint: "Din signerare begär godkännande för varje åtgärd — använd Uppdatera och Skicka vid behov.",
},
translateUi: {
title: "Meddelandeöversättning",
translate: "Översätt",
translating: "Översätter…",
showOriginal: "Visa original",
showTranslation: "Visa översättning",
translatedFrom: "Översatt från {{language}}",
failed: "Översättningen misslyckades. Försök igen.",
unsupported: "Det här språkparet är inte tillgängligt än.",
pilotNote: "Pilot: endast engelska ↔ spanska.",
autoTranslate: "Översätt meddelanden automatiskt",
autoTranslateDesc: "Översätt inkommande meddelanden till ditt språk. Körs på den här enheten; inget skickas.",
privacy: "Översättningen körs på din enhet. Ditt meddelande lämnar den aldrig. Vid första användningen laddas en språkmodell ned.",
},
};
export default drift;

View file

@ -320,6 +320,20 @@ const driftStrings = {
errorPrefix: 'เชื่อมต่อ coordinator ไม่ได้',
interactiveHint: 'ตัวลงนามของคุณขออนุมัติทุกครั้งที่ทำรายการ — ใช้รีเฟรชและส่งตามต้องการ',
},
translateUi: {
title: 'การแปลข้อความ',
translate: 'แปล',
translating: 'กำลังแปล…',
showOriginal: 'แสดงต้นฉบับ',
showTranslation: 'แสดงคำแปล',
translatedFrom: 'แปลจาก {{language}}',
failed: 'การแปลล้มเหลว โปรดลองอีกครั้ง',
unsupported: 'คู่ภาษานี้ยังไม่พร้อมใช้งาน',
pilotNote: 'นำร่อง: อังกฤษ ↔ สเปนเท่านั้น',
autoTranslate: 'แปลข้อความอัตโนมัติ',
autoTranslateDesc: 'แปลข้อความที่เข้ามาเป็นภาษาของคุณ ทำงานบนอุปกรณ์นี้ ไม่มีการส่งข้อมูลใด ๆ',
privacy: 'การแปลทำงานบนอุปกรณ์ของคุณ ข้อความของคุณจะไม่ออกจากเครื่อง การใช้งานครั้งแรกจะดาวน์โหลดโมเดลภาษา',
},
};
export default driftStrings;

View file

@ -320,6 +320,20 @@ const driftStrings = {
errorPrefix: 'Koordinatöre ulaşılamadı',
interactiveHint: "İmzalayıcın her işlem için onay ister — gerektiğinde Yenile ve Gönder'i kullan.",
},
translateUi: {
title: 'Mesaj çevirisi',
translate: 'Çevir',
translating: 'Çevriliyor…',
showOriginal: 'Orijinali göster',
showTranslation: 'Çeviriyi göster',
translatedFrom: '{{language}} dilinden çevrildi',
failed: 'Çeviri başarısız oldu. Tekrar deneyin.',
unsupported: 'Bu dil çifti henüz kullanılamıyor.',
pilotNote: 'Pilot: yalnızca İngilizce ↔ İspanyolca.',
autoTranslate: 'Mesajları otomatik çevir',
autoTranslateDesc: 'Gelen mesajları kendi dilinize çevirin. Bu cihazda çalışır; hiçbir şey gönderilmez.',
privacy: 'Çeviri cihazınızda çalışır. Mesajınız cihazınızdan asla ayrılmaz. İlk kullanımda bir dil modeli indirilir.',
},
};
export default driftStrings;

View file

@ -328,6 +328,20 @@ const drift = {
errorPrefix: "Не вдалося зв'язатися з координатором",
interactiveHint: "Ваш підписувач запитує підтвердження для кожної дії — використовуйте «Оновити» та «Надіслати» за потреби.",
},
translateUi: {
title: "Переклад повідомлень",
translate: "Перекласти",
translating: "Переклад…",
showOriginal: "Показати оригінал",
showTranslation: "Показати переклад",
translatedFrom: "Перекладено з {{language}}",
failed: "Не вдалося перекласти. Спробуйте ще раз.",
unsupported: "Ця мовна пара поки недоступна.",
pilotNote: "Пілот: лише англійська ↔ іспанська.",
autoTranslate: "Автоматично перекладати повідомлення",
autoTranslateDesc: "Перекладати вхідні повідомлення вашою мовою. Працює на цьому пристрої; нічого не надсилається.",
privacy: "Переклад виконується на вашому пристрої. Ваше повідомлення ніколи його не залишає. Під час першого використання завантажується мовна модель.",
},
};
export default drift;

View file

@ -320,6 +320,20 @@ const driftStrings = {
errorPrefix: 'Không thể kết nối tới điều phối viên',
interactiveHint: 'Trình ký của bạn yêu cầu phê duyệt mỗi hành động — dùng Làm mới và Gửi khi cần.',
},
translateUi: {
title: 'Dịch tin nhắn',
translate: 'Dịch',
translating: 'Đang dịch…',
showOriginal: 'Xem bản gốc',
showTranslation: 'Xem bản dịch',
translatedFrom: 'Dịch từ {{language}}',
failed: 'Dịch thất bại. Hãy thử lại.',
unsupported: 'Cặp ngôn ngữ này chưa khả dụng.',
pilotNote: 'Thử nghiệm: chỉ tiếng Anh ↔ tiếng Tây Ban Nha.',
autoTranslate: 'Tự động dịch tin nhắn',
autoTranslateDesc: 'Dịch tin nhắn đến sang ngôn ngữ của bạn. Chạy trên thiết bị này; không gửi dữ liệu đi đâu.',
privacy: 'Tính năng dịch chạy trên thiết bị của bạn. Tin nhắn của bạn không bao giờ rời khỏi đó. Lần sử dụng đầu tiên sẽ tải xuống một mô hình ngôn ngữ.',
},
};
export default driftStrings;

View file

@ -336,6 +336,20 @@ const drift: Dict = {
errorPrefix: '无法连接协调器',
interactiveHint: '你的签名器会为每个操作请求批准——按需使用刷新和发送。',
},
translateUi: {
title: '消息翻译',
translate: '翻译',
translating: '正在翻译…',
showOriginal: '显示原文',
showTranslation: '显示译文',
translatedFrom: '译自 {{language}}',
failed: '翻译失败,请重试。',
unsupported: '该语言对暂不可用。',
pilotNote: '试点:仅限英语 ↔ 西班牙语。',
autoTranslate: '自动翻译消息',
autoTranslateDesc: '将收到的消息翻译成你的语言。在此设备上运行,不会发送任何内容。',
privacy: '翻译在此设备上运行。你的消息永远不会离开设备。首次使用时会下载语言模型。',
},
};
export default drift;

View file

@ -336,6 +336,20 @@ const drift: Dict = {
errorPrefix: '無法連線協調器',
interactiveHint: '你的簽章器會為每個操作要求核准——視需要使用重新整理和傳送。',
},
translateUi: {
title: '訊息翻譯',
translate: '翻譯',
translating: '正在翻譯…',
showOriginal: '顯示原文',
showTranslation: '顯示譯文',
translatedFrom: '譯自 {{language}}',
failed: '翻譯失敗,請重試。',
unsupported: '此語言對尚無法使用。',
pilotNote: '試辦:僅限英文 ↔ 西班牙文。',
autoTranslate: '自動翻譯訊息',
autoTranslateDesc: '將收到的訊息翻譯成你的語言。在此裝置上執行,不會傳送任何內容。',
privacy: '翻譯在此裝置上執行。你的訊息永遠不會離開裝置。首次使用時會下載語言模型。',
},
};
export default drift;

View file

@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { clearTranslationMemory, translateText } from './translation';
class FakeWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(request: { id: number; text: string }) {
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'result', id: request.id, text: `translated:${request.text}` },
} as MessageEvent);
});
}
terminate() {}
}
describe('translateText', () => {
afterEach(() => {
vi.unstubAllGlobals();
clearTranslationMemory();
});
it('keeps the message local and never calls fetch', async () => {
const fetchSpy = vi.fn();
const received: string[] = [];
class RecordingWorker extends FakeWorker {
postMessage(request: { id: number; text: string }) {
received.push(request.text);
super.postMessage(request);
}
}
vi.stubGlobal('fetch', fetchSpy);
vi.stubGlobal('Worker', RecordingWorker);
clearTranslationMemory();
const result = await translateText({
id: 'message-1',
text: 'SENTINEL-MESSAGE-TEXT',
source: 'en',
target: 'es',
});
expect(result).toBe('translated:SENTINEL-MESSAGE-TEXT');
expect(received).toEqual(['SENTINEL-MESSAGE-TEXT']);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('returns the input when source and target match', async () => {
const result = await translateText({
id: 'message-2',
text: 'same language',
source: 'en',
target: 'en',
});
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');
});
});

148
src/lib/translation.ts Normal file
View file

@ -0,0 +1,148 @@
/**
* On-device translation client.
*
* The actual model runs in `workers/translate.worker.ts`. This module only
* exchanges `postMessage` with it; it never fetches message text and keeps
* translations in memory only (no IndexedDB/localStorage).
*/
import type { Language } from '@/contexts/language';
export interface TranslationProgress {
progress?: number;
status?: string;
}
export interface TranslateParams {
id: string;
text: string;
source: Language;
target: Language;
onProgress?: (progress: TranslationProgress) => void;
}
type WorkerRequest = {
type: 'translate';
id: number;
text: string;
source: Language;
target: Language;
};
type WorkerResponse =
| { type: 'progress'; id: number; status?: string; progress?: number }
| { type: 'result'; id: number; text: string }
| { type: 'error'; id: number; code: string };
interface PendingRequest {
key: string;
resolve: (text: string) => void;
reject: (error: Error) => void;
onProgress?: (progress: TranslationProgress) => void;
timer: ReturnType<typeof setTimeout>;
}
const MODEL_TIMEOUT_MS = 300_000; // first use downloads a ~110 MB model
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. */
export function isTranslationPairSupported(
source: Language | null | undefined,
target: Language,
): boolean {
if (!source) return false;
return (source === 'en' && target === 'es') || (source === 'es' && target === 'en');
}
function rejectAll(error: Error): void {
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(error);
}
pending.clear();
}
function getWorker(): Worker {
if (worker) return worker;
const created = new Worker(
new URL('../workers/translate.worker.ts', import.meta.url),
{ type: 'module' },
);
created.onmessage = (event: MessageEvent<WorkerResponse>) => {
const data = event.data;
if (!data || typeof data.id !== 'number') return;
const request = pending.get(data.id);
if (!request) return;
if (data.type === 'progress') {
request.onProgress?.({ progress: data.progress, status: data.status });
return;
}
pending.delete(data.id);
clearTimeout(request.timer);
if (data.type === 'result') {
memoryCache.set(request.key, data.text);
request.resolve(data.text);
} else {
request.reject(new Error(data.code || 'FAILED'));
}
};
created.onerror = () => {
rejectAll(new Error('WORKER'));
worker?.terminate();
worker = null;
};
worker = created;
return created;
}
/** Translate a message on-device. The text is never sent over the network. */
export async function translateText(params: TranslateParams): Promise<string> {
if (params.source === params.target) return params.text;
if (!isTranslationPairSupported(params.source, params.target)) {
throw new Error('UNSUPPORTED');
}
const key = `${params.id}:${params.target}`;
const cached = memoryCache.get(key);
if (cached) return cached;
const activeWorker = getWorker();
const id = ++sequence;
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('TIMEOUT'));
}, MODEL_TIMEOUT_MS);
pending.set(id, {
key,
resolve,
reject,
onProgress: params.onProgress,
timer,
});
const request: WorkerRequest = {
type: 'translate',
id,
text: params.text,
source: params.source,
target: params.target,
};
activeWorker.postMessage(request);
});
}
/** Clear the in-memory translation cache (e.g. on logout). */
export function clearTranslationMemory(): void {
memoryCache.clear();
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { createGuardedFetch } from './translationGuard';
const options = {
modelOrigin: 'https://relay.skate-spots.org',
modelPathPrefix: '/models/',
appOrigin: 'https://app.test',
appAssetPathPrefix: '/ort/',
};
describe('createGuardedFetch', () => {
it('allows model GETs only', async () => {
const real = vi.fn().mockResolvedValue({} as Response);
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await guarded('https://relay.skate-spots.org/models/Xenova/opus-mt-en-es/resolve/main/config.json');
expect(real).toHaveBeenCalledTimes(1);
});
it('allows the self-hosted ONNX runtime', async () => {
const real = vi.fn().mockResolvedValue({} as Response);
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await guarded('https://app.test/ort/ort-wasm-simd.wasm');
expect(real).toHaveBeenCalledTimes(1);
});
it('blocks any other origin (even with a sentinel)', async () => {
const real = vi.fn();
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await expect(guarded('https://evil.test/?q=SENTINEL-MESSAGE')).rejects.toThrow('blocked');
expect(real).not.toHaveBeenCalled();
});
it('blocks POSTs to the model host', async () => {
const real = vi.fn();
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await expect(
guarded('https://relay.skate-spots.org/models/Xenova/x', {
method: 'POST',
body: 'SENTINEL-MESSAGE',
}),
).rejects.toThrow('blocked');
expect(real).not.toHaveBeenCalled();
});
it('blocks same-origin JS outside the ONNX runtime directory', async () => {
const real = vi.fn();
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await expect(guarded('https://app.test/assets/evil.js?x=SENTINEL-MESSAGE')).rejects.toThrow('blocked');
expect(real).not.toHaveBeenCalled();
});
it('blocks model URLs with a query string', async () => {
const real = vi.fn();
const guarded = createGuardedFetch(real as unknown as typeof fetch, options);
await expect(guarded('https://relay.skate-spots.org/models/x?q=SENTINEL-MESSAGE')).rejects.toThrow('blocked');
expect(real).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,50 @@
/**
* Network guard for the translation worker.
*
* The worker is allowed to fetch exactly two kinds of URLs:
* 1. the self-hosted model files (GET/HEAD only), and
* 2. same-origin ONNX runtime assets (.wasm/.mjs/.js).
*
* Everything else throws before the request leaves the worker. Message text is
* never put into a URL or a request body; it only travels through
* `postMessage` between the UI and the worker.
*/
export interface TranslationGuardOptions {
modelOrigin: string;
modelPathPrefix: string;
appOrigin: string;
/** Only the self-hosted ONNX runtime (e.g. `/ort/`) may be fetched app-side. */
appAssetPathPrefix: string;
}
const APP_ASSET = /\.(?:wasm|mjs|js)$/i;
export function createGuardedFetch(
realFetch: typeof fetch,
options: TranslationGuardOptions,
): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : null;
const raw = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(raw, request?.url ?? options.appOrigin);
const method = (init?.method ?? request?.method ?? 'GET').toUpperCase();
const read = method === 'GET' || method === 'HEAD';
const isModel = url.origin === options.modelOrigin
&& url.pathname.startsWith(options.modelPathPrefix)
&& !url.search
&& read;
const isAppAsset = url.origin === options.appOrigin
&& url.pathname.startsWith(options.appAssetPathPrefix)
&& APP_ASSET.test(url.pathname)
&& read;
if (!isModel && !isAppAsset) {
throw new Error('blocked network request');
}
return realFetch(input as RequestInfo, init);
}) as typeof fetch;
}

View file

@ -17,6 +17,7 @@ import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hoo
import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { EscrowPanel } from '@/components/EscrowPanel';
import { TranslateMessage } from '@/components/chat/TranslateMessage';
import { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@ -50,7 +51,7 @@ function MessageBubble({ message, isFromMe }: { message: DirectMessage; isFromMe
? "bg-[#ff0040] text-white rounded-br-sm"
: "bg-gray-800 text-white rounded-bl-sm"
)}>
<p className="whitespace-pre-wrap break-words text-sm sm:text-base">{message.content}</p>
<TranslateMessage text={message.content} messageId={message.id} isOwn={isFromMe} className="text-sm sm:text-base" />
<p className={cn(
"text-[10px] sm:text-xs mt-1",
isFromMe ? "text-white/60" : "text-gray-500"

View file

@ -21,6 +21,7 @@ import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { NotificationSettingsCard } from '@/components/NotificationSettingsCard';
import { CoordinatorSettingsCard } from '@/components/CoordinatorSettingsCard';
import { LanguageSelector } from '@/components/LanguageToggle';
import { TranslationSettings } from '@/components/chat/TranslationSettings';
import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab';
import { useQueryClient } from '@tanstack/react-query';
@ -629,6 +630,13 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
</div>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('translateUi.title')}</p>
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<TranslationSettings />
</div>
</div>
{/* Sasquatch assistant — the floating map bubble can be distracting;
let people turn it off and back on. Device-local. */}
<div>

View file

@ -0,0 +1,126 @@
/**
* On-device translation worker.
*
* - 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.
* - No logging of message text; errors are returned as codes only.
*/
import { env, pipeline } from '@xenova/transformers';
import { createGuardedFetch } from '@/lib/translationGuard';
const MODEL_ORIGIN = 'https://relay.skate-spots.org';
const MODEL_PREFIX = '/models/';
const APP_ORIGIN = self.location.origin;
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;
if (onnxWasm) {
onnxWasm.wasmPaths = new URL('../ort/', self.location.href).href;
onnxWasm.numThreads = 1;
}
globalThis.fetch = createGuardedFetch(
globalThis.fetch.bind(globalThis),
{
modelOrigin: MODEL_ORIGIN,
modelPathPrefix: MODEL_PREFIX,
appOrigin: APP_ORIGIN,
appAssetPathPrefix: '/ort/',
},
);
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;
text: string;
source: string;
target: string;
};
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' };
const ctx = self as unknown as {
postMessage: (message: Outgoing) => void;
onmessage: ((event: MessageEvent<Incoming>) => void) | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const translators = new Map<string, Promise<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getTranslator(modelId: string, requestId: number): Promise<any> {
const existing = translators.get(modelId);
if (existing) return existing;
const loading = pipeline('translation', modelId, {
quantized: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
progress_callback: (progress: any) => {
ctx.postMessage({
type: 'progress',
id: requestId,
status: typeof progress?.status === 'string' ? progress.status : undefined,
progress: typeof progress?.progress === 'number' ? progress.progress : undefined,
file: typeof progress?.file === 'string' ? progress.file : undefined,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}).catch((error: any) => {
translators.delete(modelId);
throw error;
});
translators.set(modelId, loading);
return loading;
}
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');
}
ctx.postMessage({ type: 'result', id: data.id, text: translated });
} catch {
// Never forward the underlying error: it may contain message text.
ctx.postMessage({ type: 'error', id: data.id, code: 'FAILED' });
}
})();
};

View file

@ -71,7 +71,12 @@ export default defineConfig(() => ({
},
},
optimizeDeps: {
exclude: ['@lightninglabs/lnc-web'],
exclude: ['@lightninglabs/lnc-web', '@xenova/transformers'],
},
// The translation worker imports the ESM transformers.js build and loads the
// ONNX runtime from /ort/ (self-hosted). Keep it as an ES module worker.
worker: {
format: 'es',
},
build: {