Theseus: translator peers list — fall back when a mirror is down
The chip ran against a single endpoint, which is the fastest way to go dark: libretranslate.com's public tier moved behind an API key in late 2026, and most of the historical public mirrors (libretranslate.de, argosopentech, lt.vern.cc, translate.terraprint.co) either 502 at any given time, serve a parked page, or started requiring a key of their own. One URL in settings meant one of those going down meant the chip stopped working. Settings.translateEndpoints is now an ordered list. The translator tries each peer in order and returns the first non-error answer; a dead peer is logged and skipped. Order is preserved — the first entry is the primary. Shipped defaults put Silent Mode's own instances (translate.silentmode.st, the BNS name translate.x) at the top and keep libretranslate.com as the last-resort entry; neither Silent Mode URL answers today, but a user's chip starts working as soon as either goes live without a browser release. Settings › General › Translate pages grew a list editor (same shape as the quick-links one): PRIMARY tag on row 0, add a peer, remove any row; bns:// URLs are accepted so a BNS translator doesn't have to be fronted by an https host. The single-URL `translateEndpoint` setting carried over from the earlier draft is migrated on load — a custom URL goes to the front of the list, the historical default is dropped.
This commit is contained in:
parent
f3c8998ecc
commit
0b36532922
2 changed files with 110 additions and 10 deletions
58
main.js
58
main.js
|
|
@ -524,7 +524,20 @@ const SETTINGS_DEFAULTS = {
|
||||||
// (same contract: a function that takes an array of strings and a target
|
// (same contract: a function that takes an array of strings and a target
|
||||||
// tag, returns an array of translations).
|
// tag, returns an array of translations).
|
||||||
translateBackend: "libretranslate", // libretranslate | (future) bergamot
|
translateBackend: "libretranslate", // libretranslate | (future) bergamot
|
||||||
translateEndpoint: "https://libretranslate.com/translate",
|
// Ordered peer list — tried in sequence, the first one that answers
|
||||||
|
// wins. The free public LibreTranslate tiers go dark every few months
|
||||||
|
// (the libretranslate.com free tier moved behind an API key in late
|
||||||
|
// 2026, several mirrors 502 at any given time), so a list beats one
|
||||||
|
// endpoint: a dead mirror doesn't kill the feature, it just loses the
|
||||||
|
// round. Silent Mode's own instances (silentmode.st + the BNS name)
|
||||||
|
// come first so they take the load whenever they're up; the public
|
||||||
|
// mirrors are a safety net for when they're not. Users can edit the
|
||||||
|
// list in Settings › General › Translate pages.
|
||||||
|
translateEndpoints: [
|
||||||
|
"https://translate.silentmode.st/translate",
|
||||||
|
"https://translate.x/translate",
|
||||||
|
"https://libretranslate.com/translate",
|
||||||
|
],
|
||||||
translateApiKey: "",
|
translateApiKey: "",
|
||||||
// Light up the URL-bar translate chip when the loaded page's language
|
// Light up the URL-bar translate chip when the loaded page's language
|
||||||
// differs from the user's preferred one. Clicking the chip translates
|
// differs from the user's preferred one. Clicking the chip translates
|
||||||
|
|
@ -607,6 +620,19 @@ function loadSettings() {
|
||||||
settings.quickLinks = SETTINGS_DEFAULTS.quickLinks.map((L) => ({ ...L }));
|
settings.quickLinks = SETTINGS_DEFAULTS.quickLinks.map((L) => ({ ...L }));
|
||||||
}
|
}
|
||||||
} catch (e) { console.warn("quickLinks migrate:", e?.message); }
|
} catch (e) { console.warn("quickLinks migrate:", e?.message); }
|
||||||
|
// Translator moved from a single `translateEndpoint` to a peer list in
|
||||||
|
// the same release that shipped the chip. A custom endpoint comes along
|
||||||
|
// as the primary peer; the historical LibreTranslate.com default is
|
||||||
|
// dropped so the user picks up the fresh default list (silentmode.st +
|
||||||
|
// the BNS name + the public mirror).
|
||||||
|
try {
|
||||||
|
const legacy = typeof settings.translateEndpoint === "string" ? settings.translateEndpoint.trim() : "";
|
||||||
|
if (legacy && legacy !== "https://libretranslate.com/translate") {
|
||||||
|
const have = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : SETTINGS_DEFAULTS.translateEndpoints.slice();
|
||||||
|
if (!have.includes(legacy)) settings.translateEndpoints = [legacy, ...have];
|
||||||
|
}
|
||||||
|
if ("translateEndpoint" in settings) delete settings.translateEndpoint;
|
||||||
|
} catch (e) { console.warn("translateEndpoints migrate:", e?.message); }
|
||||||
}
|
}
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
|
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
|
||||||
|
|
@ -4543,8 +4569,7 @@ function translationTargetBase() {
|
||||||
// LibreTranslate POST /translate. `q` may be a single string or an array; the
|
// LibreTranslate POST /translate. `q` may be a single string or an array; the
|
||||||
// response's `translatedText` is a string or array to match. One POST per
|
// response's `translatedText` is a string or array to match. One POST per
|
||||||
// call — the caller chunks when the batch would exceed the request budget.
|
// call — the caller chunks when the batch would exceed the request budget.
|
||||||
async function translatorLibreTranslate(texts, from, to) {
|
async function translatorPostOnce(url, texts, from, to) {
|
||||||
const url = settings.translateEndpoint || SETTINGS_DEFAULTS.translateEndpoint;
|
|
||||||
const body = {
|
const body = {
|
||||||
q: texts,
|
q: texts,
|
||||||
source: from || "auto",
|
source: from || "auto",
|
||||||
|
|
@ -4564,14 +4589,37 @@ async function translatorLibreTranslate(texts, from, to) {
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {}
|
let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {}
|
||||||
throw new Error(`translator HTTP ${r.status}${detail ? ": " + detail : ""}`);
|
throw new Error(`HTTP ${r.status}${detail ? ": " + detail : ""}`);
|
||||||
}
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (Array.isArray(data.translatedText)) return data.translatedText;
|
if (Array.isArray(data.translatedText)) return data.translatedText;
|
||||||
if (typeof data.translatedText === "string") return [data.translatedText];
|
if (typeof data.translatedText === "string") return [data.translatedText];
|
||||||
// Some LibreTranslate deployments return a bare array of {translatedText}.
|
// Some LibreTranslate deployments return a bare array of {translatedText}.
|
||||||
if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? "");
|
if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? "");
|
||||||
throw new Error("translator: unexpected response shape");
|
throw new Error("unexpected response shape");
|
||||||
|
}
|
||||||
|
// The configured peer list, sanitised: a legacy single `translateEndpoint`
|
||||||
|
// migrates to list[0], an empty list falls back to the shipped defaults.
|
||||||
|
function translatorPeers() {
|
||||||
|
const list = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : [];
|
||||||
|
const peers = list.map((s) => String(s || "").trim()).filter(Boolean);
|
||||||
|
if (peers.length) return peers;
|
||||||
|
const legacy = String(settings.translateEndpoint || "").trim();
|
||||||
|
if (legacy) return [legacy];
|
||||||
|
return SETTINGS_DEFAULTS.translateEndpoints.slice();
|
||||||
|
}
|
||||||
|
// Try each peer in order; return the first good answer, or throw the last
|
||||||
|
// error. A peer that answers with a non-2xx (needs-API-key, 502, rate limit)
|
||||||
|
// is skipped. The ordering is preserved — a user who put their own instance
|
||||||
|
// first keeps it as the primary; the list is a fallback chain, not a pool.
|
||||||
|
async function translatorLibreTranslate(texts, from, to) {
|
||||||
|
const peers = translatorPeers();
|
||||||
|
const errors = [];
|
||||||
|
for (const url of peers) {
|
||||||
|
try { return await translatorPostOnce(url, texts, from, to); }
|
||||||
|
catch (e) { errors.push(`${url}: ${e?.message || e}`); console.warn(`[translate] peer failed ${url}:`, e?.message || e); }
|
||||||
|
}
|
||||||
|
throw new Error(`all ${peers.length} translator peer(s) failed — ${errors[errors.length - 1] || "no detail"}`);
|
||||||
}
|
}
|
||||||
// Chunk a texts array so each POST stays under a reasonable size — LibreTranslate
|
// Chunk a texts array so each POST stays under a reasonable size — LibreTranslate
|
||||||
// instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST
|
// instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST
|
||||||
|
|
|
||||||
|
|
@ -284,12 +284,17 @@
|
||||||
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.</div></div>
|
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.</div></div>
|
||||||
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
|
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||||
<div class="txt"><div class="t">Translation engine</div><div class="d">LibreTranslate-compatible endpoint. The public free tier is rate-limited; point this at a self-hosted LibreTranslate for unlimited use and privacy. On-device Bergamot (WASM) is coming in a later release.</div></div>
|
<div class="txt"><div class="t">Translation peers</div>
|
||||||
<input id="translateEndpoint" type="text" placeholder="https://libretranslate.com/translate" style="min-width:280px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
<div class="d">Ordered list of LibreTranslate-compatible endpoints. The first one that answers wins; a dead or rate-limited peer is skipped and the next is tried. Keep Silent Mode's own instances at the top; add your own self-hosted LibreTranslate for privacy or unlimited use. On-device Bergamot (WASM) is coming in a later release.</div></div>
|
||||||
|
<div id="translateEndpointList" style="display:flex;flex-direction:column;gap:6px"></div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
<input id="translatePeerUrl" type="text" placeholder="https://your-libretranslate/translate" style="flex:1;min-width:220px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||||||
|
<button id="translatePeerAdd" class="btn">Add peer</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="txt"><div class="t">API key (optional)</div><div class="d">Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.</div></div>
|
<div class="txt"><div class="t">API key (optional)</div><div class="d">Shared across all peers in the list. Some LibreTranslate instances need a key for the paid tier or to raise the rate limit; leave blank for peers that don't.</div></div>
|
||||||
<input id="translateApiKey" type="text" placeholder="(none)" style="min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
<input id="translateApiKey" type="text" placeholder="(none)" style="min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||||||
</div>
|
</div>
|
||||||
<h2 class="sub">Startup</h2>
|
<h2 class="sub">Startup</h2>
|
||||||
|
|
@ -1042,7 +1047,7 @@
|
||||||
// Text inputs that round-trip through settings-set on change. Trimmed; a
|
// Text inputs that round-trip through settings-set on change. Trimmed; a
|
||||||
// cleared field writes an empty string, which main re-defaults from
|
// cleared field writes an empty string, which main re-defaults from
|
||||||
// SETTINGS_DEFAULTS on next launch.
|
// SETTINGS_DEFAULTS on next launch.
|
||||||
const TEXT_FIELDS = ["translateEndpoint", "translateApiKey"];
|
const TEXT_FIELDS = ["translateApiKey"];
|
||||||
C.get().then((s) => {
|
C.get().then((s) => {
|
||||||
for (const k of TOGGLES) {
|
for (const k of TOGGLES) {
|
||||||
const el = document.getElementById(k); if (!el) continue;
|
const el = document.getElementById(k); if (!el) continue;
|
||||||
|
|
@ -1101,6 +1106,53 @@
|
||||||
// Live-sync from main (edits from other surfaces, or the strip's own add).
|
// Live-sync from main (edits from other surfaces, or the strip's own add).
|
||||||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
|
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
|
||||||
})();
|
})();
|
||||||
|
// ---- Translation-peer list editor (General) ---------------------------
|
||||||
|
// Same shape as the Quick-links editor above: add a row, trash a row,
|
||||||
|
// each change writes the whole array. The chip + translator service read
|
||||||
|
// the list in order and skip dead peers; the top entry is the primary.
|
||||||
|
(function () {
|
||||||
|
const list = document.getElementById("translateEndpointList");
|
||||||
|
const urlIn = document.getElementById("translatePeerUrl");
|
||||||
|
const addBtn = document.getElementById("translatePeerAdd");
|
||||||
|
if (!list || !addBtn) return;
|
||||||
|
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]);
|
||||||
|
function hostOf(u) { try { return new URL(u).host; } catch { return u; } }
|
||||||
|
function render(peers) {
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (!peers.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "pmuted"; empty.style.fontSize = "12.5px";
|
||||||
|
empty.textContent = "No peers configured — add one below, or restart to restore the defaults.";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < peers.length; i++) {
|
||||||
|
const url = peers[i];
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
|
||||||
|
const primary = i === 0 ? '<span style="font-size:11px;color:var(--acid-text);background:rgba(214,255,61,.08);border:1px solid rgba(214,255,61,.3);border-radius:6px;padding:2px 6px;margin-right:6px">PRIMARY</span>' : '';
|
||||||
|
row.innerHTML = `<div style="flex:1;min-width:0"><div style="font-weight:600">${primary}${esc(hostOf(url))}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(url)}</div></div>
|
||||||
|
<button class="btn ghost" data-del="${i}">Remove</button>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
|
||||||
|
const i = Number(b.dataset.del);
|
||||||
|
const cur = (await C.get()).translateEndpoints || [];
|
||||||
|
C.set("translateEndpoints", cur.filter((_, idx) => idx !== i));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
render(s.translateEndpoints || []);
|
||||||
|
addBtn.onclick = async () => {
|
||||||
|
const u = String(urlIn.value || "").trim();
|
||||||
|
if (!u) return;
|
||||||
|
const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u;
|
||||||
|
const cur = (await C.get()).translateEndpoints || [];
|
||||||
|
if (cur.includes(url)) { urlIn.value = ""; return; }
|
||||||
|
C.set("translateEndpoints", [...cur, url]);
|
||||||
|
urlIn.value = "";
|
||||||
|
};
|
||||||
|
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || []));
|
||||||
|
})();
|
||||||
// ---- website language (General page) — friendly wrapper over the
|
// ---- website language (General page) — friendly wrapper over the
|
||||||
// same languageMode/languageValue setting the Anti-fingerprinting Language
|
// same languageMode/languageValue setting the Anti-fingerprinting Language
|
||||||
// row edits. "Auto" = languageMode="show" (follow OS); anything else
|
// row edits. "Auto" = languageMode="show" (follow OS); anything else
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue