aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 10:36:39 -0700
committerAdam Soltys <[email protected]>2026-10-09 10:36:39 -0700
commit41d7b9d4e5d0e10cdac708d9c9cd4838e47240ec (patch)
tree767d6dffc8dc14b90c368606d8fc39c68b050fb2 /names
parentad028dd1c61e41913e4628046b96bf162bc4ba4a (diff)
downloadcoinosv3-41d7b9d4e5d0e10cdac708d9c9cd4838e47240ec.tar.gz
coinosv3-41d7b9d4e5d0e10cdac708d9c9cd4838e47240ec.zip
Translation: posts in another language are shown in yours — "Translated from Spanish · Show original" under the words, automatic by default for posts the language guess calls foreign (Settings → Nostr → Translation switches it off and leaves a Translate link instead), and ⋯ → Translate for any post. The browser's on-device Translator does it when the language pair is already installed (desktop Chrome); otherwise the registrar's new POST /translate (names/translate.js): a cached, 4-at-a-time front for a LibreTranslate container on cs (names/compose-libretranslate.yml, v1.9.6, 32 languages; none for sw/mr/ta/te, so those readers get no offer). Links, nostr: mentions, :emoji:, #tags and long codes are never sent to the model — only the prose between them, batched in one call (src/translate-split.js, shared). Translations are remembered (coinos-translations, 300). tools/translate-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'names')
-rw-r--r--names/compose-libretranslate.yml26
-rw-r--r--names/server.js17
-rw-r--r--names/translate.js82
3 files changed, 125 insertions, 0 deletions
diff --git a/names/compose-libretranslate.yml b/names/compose-libretranslate.yml
new file mode 100644
index 0000000..80ed9c6
--- /dev/null
+++ b/names/compose-libretranslate.yml
@@ -0,0 +1,26 @@
+# LibreTranslate for post translation (coinos v3). Reached only by the
+# names registrar over the docker network (POST /translate there); no port
+# on the host, no web UI. Models live in the lt-data volume.
+services:
+ libretranslate:
+ image: libretranslate/libretranslate:v1.9.6
+ container_name: libretranslate
+ environment:
+ LT_LOAD_ONLY: en,es,fr,de,pt,id,tr,vi,ru,ar,ur,hi,bn,ja,ko,zh,it,nl,pl,uk,fa,he,th,sv,cs,el,ro,hu,fi,da,nb,ca
+ LT_DISABLE_WEB_UI: "true"
+ LT_CHAR_LIMIT: "5000"
+ LT_THREADS: "4"
+ LT_UPDATE_MODELS: "true"
+ volumes:
+ - lt-data:/home/libretranslate/.local
+ networks: [net]
+ deploy:
+ resources:
+ limits:
+ memory: 8g
+ restart: unless-stopped
+volumes:
+ lt-data:
+networks:
+ net:
+ external: true
diff --git a/names/server.js b/names/server.js
index 6cd82a1..463ab8e 100644
--- a/names/server.js
+++ b/names/server.js
@@ -28,6 +28,7 @@ import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
import { ArkManager } from '../src/ark/manager.js';
import { fetchPreview } from './preview.js';
+import { translate, languages } from './translate.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
@@ -1036,6 +1037,22 @@ Bun.serve({
});
}
+ // A post in another language, in the reader's (LibreTranslate behind a
+ // cache — see translate.js). POST { text, target, source? } →
+ // { text, source }; GET /translate/languages lists what it can do.
+ if (url.pathname === '/translate/languages' && req.method === 'GET') {
+ try { return new Response(JSON.stringify(await languages()), { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=3600' } }); }
+ catch { return json({ error: 'translation unavailable' }, 503); }
+ }
+ if (url.pathname === '/translate' && req.method === 'POST') {
+ if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
+ let body = null;
+ try { body = await req.json(); } catch {}
+ if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400);
+ try { return json(await translate(body.text, body.target, body.source)); }
+ catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); }
+ }
+
if (url.pathname === '/health') {
return json({
ok: true, domain: DOMAIN, names: Object.keys(state.names).length,
diff --git a/names/translate.js b/names/translate.js
new file mode 100644
index 0000000..7d6181d
--- /dev/null
+++ b/names/translate.js
@@ -0,0 +1,82 @@
+// Post translation for coinos v3: a thin, cached front for a LibreTranslate
+// container on the same docker network. Notes are public, so nothing private
+// passes through here; the cache keys on the text itself, so a popular post
+// is translated once whoever asks.
+import { createHash } from 'node:crypto';
+import { splitProse, hasWords, translatePieces } from '../src/translate-split.js';
+
+const LT = process.env.LT_URL || 'http://libretranslate:5000';
+const MAX_CHARS = 5000;
+const CACHE_MAX = 20000;
+const BUSY_MAX = 4; // LibreTranslate is CPU-bound: a few at once, the rest wait their turn
+const cache = new Map(); // sha256(target|text) -> { text, source }
+let busy = 0;
+const waiting = [];
+let langs = null, langsAt = 0;
+
+async function slot() {
+ if (busy < BUSY_MAX) { busy++; return; }
+ await new Promise((r) => waiting.push(r));
+ busy++;
+}
+function release() { busy--; const next = waiting.shift(); if (next) next(); }
+
+// The languages the container has models for: { code: [targets] }
+export async function languages() {
+ if (langs && Date.now() - langsAt < 3600_000) return langs;
+ const r = await fetch(LT + '/languages', { signal: AbortSignal.timeout(5000) });
+ if (!r.ok) throw new Error('languages ' + r.status);
+ const list = await r.json();
+ langs = Object.fromEntries(list.map((l) => [l.code, l.targets || []]));
+ if (langs['zh-Hans']) langs.zh = langs['zh-Hans']; // the app says 'zh'
+ langsAt = Date.now();
+ return langs;
+}
+
+// The app's locale codes ↔ LibreTranslate's ('zh' is Simplified there).
+const norm = (c) => String(c || '').toLowerCase().split(/[-_]/)[0];
+const toLt = (c) => (c === 'zh' ? 'zh-Hans' : c);
+
+export async function translate(text, target, source = 'auto') {
+ text = String(text || '');
+ target = norm(target);
+ source = source && source !== 'auto' ? norm(source) : 'auto';
+ if (!text.trim()) return { text: '', source };
+ if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
+ const have = await languages();
+ if (!have[toLt(target)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
+ const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex');
+ const hit = cache.get(key);
+ if (hit) { cache.delete(key); cache.set(key, hit); return hit; }
+ const prose = splitProse(text).filter((p) => p.prose && hasWords(p.prose));
+ if (!prose.length) return { text, source: source === 'auto' ? null : source };
+ await slot();
+ try {
+ const call = async (path, body) => {
+ const r = await fetch(LT + path, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
+ });
+ const out = await r.json().catch(() => ({}));
+ if (!r.ok) throw Object.assign(new Error(out.error || path + ' ' + r.status), { status: r.status === 400 ? 422 : 502 });
+ return out;
+ };
+ let from = source, confidence = null;
+ if (from === 'auto') {
+ const d = await call('/detect', { q: prose.map((p) => p.prose).join('\n') });
+ from = norm(d[0]?.language) || null;
+ confidence = d[0]?.confidence ?? null;
+ if (!from) throw Object.assign(new Error('unknown language'), { status: 422 });
+ }
+ if (from === target) return { text, source: from, confidence };
+ if (!have[toLt(from)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
+ const translated = await translatePieces(text, async (qs) => {
+ const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' });
+ return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText];
+ });
+ const out = { text: translated, source: from, confidence };
+ cache.set(key, out);
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
+ return out;
+ } finally { release(); }
+}