From 14b7486ff6412a16dd9685a08e0a5aeca1e134a2 Mon Sep 17 00:00:00 2001 From: EveAeternam Date: Tue, 13 Oct 2026 01:50:09 +0000 Subject: [PATCH] Languages: 25 interface languages loaded at run time from Shell-B-Translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Languages shows coverage per catalogue (living, classical, constructed), the Night Shift translation batch and per-string checks; the language picker in General and in a guest's limits lists every language. French stays bundled; the others load from /api/translate/catalogue/ (public, so the login screen is translated too), falling back to the GitHub repo, and the browser's cached copy refreshes in the background. Agents answer in the chosen language (prompts.LANGUAGES), guests keep any known code. Ported from 31bdb4e on public-release. fr.json: the new strings are appended at the end in the catalogue's existing order (no re-sort), since main's fr.json differs from public-release's at the end of the file. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EHrMRRAgRmbkHVbaoJ6jZN --- server/auth.py | 3 +- server/guests.py | 4 +- server/prompts.py | 34 +- server/translate.py | 222 ++++++++- tests/language_test.py | 17 +- ui/src/components/GuestsTab.tsx | 20 +- ui/src/components/LanguageFlag.tsx | 383 ++++++++++++++ ui/src/components/LanguageSelect.tsx | 191 +++++++ ui/src/components/LanguagesSettings.tsx | 634 ++++++++++++++++++++++++ ui/src/components/Panels.tsx | 22 +- ui/src/i18n/fr.json | 34 +- ui/src/i18n/index.ts | 189 ++++++- ui/src/types.ts | 4 +- 13 files changed, 1713 insertions(+), 44 deletions(-) create mode 100644 ui/src/components/LanguageFlag.tsx create mode 100644 ui/src/components/LanguageSelect.tsx create mode 100644 ui/src/components/LanguagesSettings.tsx diff --git a/server/auth.py b/server/auth.py index c5e5a96..474226d 100644 --- a/server/auth.py +++ b/server/auth.py @@ -48,8 +48,9 @@ MIN_PASSWORD = 8 # screen/*: the Shell:B Screen helper signs in with its own device token (screen.py checks it on every call) # v1/*: the Shell:B API checks its own API key on every call (public_api.py) +# translate/catalogue/, translate/locales: the community UI catalogues (public MIT files), so the login screen can be translated # share/: a read-only snapshot the owner published (shares.py); the 32-hex token is the whole credential -PUBLIC = re.compile(r"^/api/(auth(/|$)|_hold$|files/[^/]+$|share/[0-9a-f]{32}$|screen/(connect|next|(frame|result)/[0-9a-f]+|disconnect)$|fleet/agent$|v1/)") +PUBLIC = re.compile(r"^/api/(auth(/|$)|_hold$|files/[^/]+$|share/[0-9a-f]{32}$|screen/(connect|next|(frame|result)/[0-9a-f]+|disconnect)$|fleet/agent$|translate/(catalogue/[A-Za-z0-9_-]{1,16}|locales)$|v1/)") # what every frame key may reach, whatever its scope: (methods, path under /api/) FRAME_SHARED = [ ({"GET", "HEAD"}, re.compile(r"^assets/file/[A-Za-z0-9_-]+/.+")), diff --git a/server/guests.py b/server/guests.py index a99f0a4..f4d6528 100644 --- a/server/guests.py +++ b/server/guests.py @@ -210,7 +210,9 @@ def clean_config(cfg: dict | None) -> dict: out["tools"] = [t for t in cfg.get("tools") or [] if t in GUEST_TOOLS] # their own full Shell:B in a container (cells.py), the default since 2026-10-10; off = the lighter guest app (guest_app.py) out["cell"] = bool(cfg.get("cell", True)) - out["language"] = cfg.get("language") if cfg.get("language") in ("en", "fr") else "" # "" = the browser's (ui/src/i18n) + from prompts import LANGUAGES + lang = str(cfg.get("language") or "").strip() + out["language"] = lang if lang in LANGUAGES or lang.lower() in LANGUAGES or lang in ("en", "fr") else "" out["adult"] = bool(cfg.get("adult", False)) # Aphrodite in their own Shell:B: the owner's call per guest, off by default return out diff --git a/server/prompts.py b/server/prompts.py index 3cdc6c4..77540fb 100644 --- a/server/prompts.py +++ b/server/prompts.py @@ -170,13 +170,43 @@ # ── prompt builders ───────────────────────────────────────────────────────── -LANGUAGES = {"en": "English", "fr": "French"} +LANGUAGES = { + "en": "English", + "fr": "French", + "gd": "Scottish Gaelic", + "ga": "Irish", + "cy": "Welsh", + "gv": "Manx", + "kw": "Cornish", + "br": "Breton", + "eu": "Basque", + "rm": "Romansh", + "liv": "Livonian", + "smn": "Inari Sami", + "es": "Spanish", + "ja": "Japanese", + "uk": "Ukrainian", + "sr": "Serbian", + "sr-latn": "Serbian (Latin script)", + # Dead and Classical Languages + "la": "Latin (Classical Latin)", + "grc": "Ancient Greek", + "ang": "Old English", + "non": "Old Norse", + # Fictional and Constructed Languages + "eo": "Esperanto", + "tlh": "Klingon (tlhIngan Hol)", + "sjn": "Sindarin (Elvish)", + "val": "High Valyrian", + "en-pirate": "Pirate English (talk like a swaggering pirate: ahoy, aye, avast)", +} def language_note(lang: str) -> str: """The interface language (settings `language`, a guest's config): agents answer in it unless the person writes in another one. English needs no note; the models default to it.""" - name = LANGUAGES.get((lang or "").split("-")[0].lower()) + raw = (lang or "").lower().strip() + name = LANGUAGES.get(raw) or LANGUAGES.get(raw.split("-")[0]) if not name or name == "English": return "" return (f"## Language\nThis person uses Shell:B in {name}. Reply in {name} unless they write to you in another language or " diff --git a/server/translate.py b/server/translate.py index 85cc72e..9d819d3 100644 --- a/server/translate.py +++ b/server/translate.py @@ -3,12 +3,16 @@ instructions, and comes back verbatim in meaning: no summaries, no answers to questions inside it. The chat UI's message menu (Translate to…) calls the route for one reply; agents use the tool when asked to translate, -or to read something in a language they handle badly. UI localisation (menus in other languages) is not part of this. +or to read something in a language they handle badly. The routes below also serve the interface catalogues from +Shell-B-Translations (checked out next to web/) and drive its local Ollama batch and checks (Settings → Languages). """ +import asyncio import json +from pathlib import Path import re +import sys -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Request, Response import db @@ -122,3 +126,217 @@ async def api_translate(req: Request): raise HTTPException(400, str(e)) except Exception as e: # noqa: BLE001 raise HTTPException(502, f"Translation failed: {type(e).__name__}: {e}") + + +TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "Shell-B-Translations" +_batch_lock = asyncio.Lock() +_batch_running = False + + +@router.get("/catalogue/{code}") +def api_catalogue(code: str): + code = code.strip() + if not re.match(r"^[a-zA-Z0-9_-]{1,16}$", code): + raise HTTPException(400, "invalid-locale-code") + loc_file = TRANSLATIONS_DIR / "locales" / f"{code}.json" + if not loc_file.exists(): + ui_file = Path(__file__).resolve().parent.parent / "ui" / "src" / "i18n" / f"{code}.json" + if ui_file.exists(): + loc_file = ui_file + if not loc_file.exists() or not loc_file.is_file(): + raise HTTPException(404, f"Catalogue for '{code}' not found") + try: + content = loc_file.read_bytes() + return Response(content=content, media_type="application/json", headers={"Cache-Control": "public, max-age=60"}) + except Exception as e: + raise HTTPException(500, f"Error reading catalogue: {e}") + + +@router.get("/locales") +def api_locales(): + locales_file = TRANSLATIONS_DIR / "locales.json" + if locales_file.exists(): + try: + return json.loads(locales_file.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + +@router.get("/coverage") +def api_coverage(): + idx_file = TRANSLATIONS_DIR / "index.json" + items, total_strings = [], 0 + if idx_file.exists(): + try: + data = json.loads(idx_file.read_text(encoding="utf-8")) + items = data.get("items", []) + total_strings = data.get("strings", 0) + except Exception: + pass + + ver_dir = TRANSLATIONS_DIR / "verification" + meta_file = TRANSLATIONS_DIR / "locales.json" + meta_data = {} + if meta_file.exists(): + try: + meta_data = json.loads(meta_file.read_text(encoding="utf-8")) + except Exception: + pass + + for it in items: + code = it.get("code") + if code in meta_data: + meta = meta_data[code] + if "sources" in meta: + it["sources"] = meta["sources"] + if "flag" in meta and not it.get("flag"): + it["flag"] = meta["flag"] + if "category" in meta and not it.get("category"): + it["category"] = meta["category"] + ver_file = ver_dir / f"{code}.json" + if ver_file.exists(): + try: + ver = json.loads(ver_file.read_text(encoding="utf-8")) + it["verified"] = ver.get("verified", it.get("covered", 0)) + it["verifiedPct"] = ver.get("verifiedPct", 100.0) + it["needsReviewCount"] = ver.get("needsReviewCount", 0) + except Exception: + pass + else: + it["verified"] = it.get("covered", 0) + it["verifiedPct"] = 100.0 + it["needsReviewCount"] = 0 + # Get nightshift schedule info + sched = None + try: + with db.conn() as c: + r = c.execute("SELECT id, name, cron, tz, enabled, next_run, last_run, last_status " + "FROM schedules WHERE name LIKE '%Night Shift%Language%'").fetchone() + if r: + sched = { + "id": r["id"], "name": r["name"], "cron": r["cron"], "tz": r["tz"], + "enabled": bool(r["enabled"]), + "nextRun": r["next_run"] * 1000 if r["next_run"] else None, + "lastRun": r["last_run"] * 1000 if r["last_run"] else None, + "lastStatus": r["last_status"] + } + except Exception: + pass + return { + "ok": True, + "strings": total_strings, + "items": items, + "batchRunning": _batch_running, + "schedule": sched + } + + +async def _run_batch_worker(limit: int = 20): + global _batch_running + import subprocess + async with _batch_lock: + _batch_running = True + try: + cmd = [sys.executable, str(TRANSLATIONS_DIR / "scripts" / "translate_ollama.py"), + "--lang", "all", "--batch", "10", "--limit", str(limit)] + await asyncio.to_thread(subprocess.run, cmd, cwd=str(TRANSLATIONS_DIR), check=False) + val_cmd = [sys.executable, "-I", str(TRANSLATIONS_DIR / "scripts" / "validate.py")] + await asyncio.to_thread(subprocess.run, val_cmd, cwd=str(TRANSLATIONS_DIR), check=False) + finally: + _batch_running = False + + +@router.post("/run-batch") +async def api_run_batch(req: Request): + import auth + if not (auth.is_owner(req) or auth.is_admin(req) or req.scope.get("shellb_auth") == "local"): + raise HTTPException(401, "login-required") + global _batch_running + if _batch_running: + return {"ok": True, "alreadyRunning": True} + asyncio.create_task(_run_batch_worker()) + return {"ok": True, "started": True} + + +@router.post("/sync-sr-latn") +async def api_sync_sr_latn(req: Request): + import auth + if not (auth.is_owner(req) or auth.is_admin(req) or req.scope.get("shellb_auth") == "local"): + raise HTTPException(401, "login-required") + import subprocess + cmd = [sys.executable, str(TRANSLATIONS_DIR / "scripts" / "translate_ollama.py"), "--sync-sr-latn"] + await asyncio.to_thread(subprocess.run, cmd, cwd=str(TRANSLATIONS_DIR), check=False) + val_cmd = [sys.executable, "-I", str(TRANSLATIONS_DIR / "scripts" / "validate.py")] + await asyncio.to_thread(subprocess.run, val_cmd, cwd=str(TRANSLATIONS_DIR), check=False) + return api_coverage() + + +@router.get("/review/{code}") +def api_review(code: str): + code = code.strip() + if not re.match(r"^[a-zA-Z0-9_-]{1,16}$", code): + raise HTTPException(400, "invalid-locale-code") + ver_file = TRANSLATIONS_DIR / "verification" / f"{code}.json" + if ver_file.exists(): + try: + return json.loads(ver_file.read_text(encoding="utf-8")) + except Exception as e: + raise HTTPException(500, f"Error reading verification file: {e}") + try: + import subprocess + subprocess.run([sys.executable, str(TRANSLATIONS_DIR / "scripts" / "verify.py"), "--lang", code], + cwd=str(TRANSLATIONS_DIR), check=False) + if ver_file.exists(): + return json.loads(ver_file.read_text(encoding="utf-8")) + except Exception: + pass + return {"code": code, "needsReview": [], "needsReviewCount": 0, "verified": 0, "total": 0} + + +@router.post("/confirm-string") +async def api_confirm_string(req: Request): + import auth + if not (auth.is_owner(req) or auth.is_admin(req) or req.scope.get("shellb_auth") == "local"): + raise HTTPException(401, "login-required") + body = await req.json() + code = str(body.get("code") or "").strip() + key = str(body.get("key") or "").strip() + translation = str(body.get("translation") or "").strip() + if not code or not key or not translation: + raise HTTPException(400, "code, key, and translation are required") + if not re.match(r"^[a-zA-Z0-9_-]{1,16}$", code): + raise HTTPException(400, "invalid-locale-code") + + loc_file = TRANSLATIONS_DIR / "locales" / f"{code}.json" + if not loc_file.exists(): + raise HTTPException(404, f"Locale '{code}' not found") + + try: + cat = json.loads(loc_file.read_text(encoding="utf-8")) + cat[key] = translation + loc_file.write_text(json.dumps(cat, indent=1, ensure_ascii=False) + "\n", encoding="utf-8") + + import subprocess + subprocess.run([sys.executable, str(TRANSLATIONS_DIR / "scripts" / "verify.py"), "--lang", code], + cwd=str(TRANSLATIONS_DIR), check=False) + subprocess.run([sys.executable, "-I", str(TRANSLATIONS_DIR / "scripts" / "validate.py")], + cwd=str(TRANSLATIONS_DIR), check=False) + return {"ok": True, "key": key, "translation": translation} + except Exception as e: + raise HTTPException(500, f"Error saving confirmed string: {e}") + + +@router.post("/verify") +async def api_verify(req: Request): + import auth + if not (auth.is_owner(req) or auth.is_admin(req) or req.scope.get("shellb_auth") == "local"): + raise HTTPException(401, "login-required") + import subprocess + cmd = [sys.executable, str(TRANSLATIONS_DIR / "scripts" / "verify.py"), "--all"] + await asyncio.to_thread(subprocess.run, cmd, cwd=str(TRANSLATIONS_DIR), check=False) + val_cmd = [sys.executable, "-I", str(TRANSLATIONS_DIR / "scripts" / "validate.py")] + await asyncio.to_thread(subprocess.run, val_cmd, cwd=str(TRANSLATIONS_DIR), check=False) + return api_coverage() + + diff --git a/tests/language_test.py b/tests/language_test.py index b87ac95..9fbe3ca 100644 --- a/tests/language_test.py +++ b/tests/language_test.py @@ -4,8 +4,10 @@ from smoke_test import client, shellb # noqa: F401 (shellb: the scratch server is imported) +import auth import guests import prompts +import translate ADMIN = {"x-shellb-admin": open(str(shellb.db.DATA / "admin.key")).read().strip()} if (shellb.db.DATA / "admin.key").is_file() else {} @@ -15,8 +17,8 @@ def test_note_only_for_non_english(self): self.assertEqual(prompts.language_note(""), "") self.assertEqual(prompts.language_note("en"), "") self.assertIn("Reply in French", prompts.language_note("fr")) - self.assertIn("Reply in French", prompts.language_note("fr-CA")) - self.assertEqual(prompts.language_note("tlh"), "") # unknown codes add nothing rather than a wrong name + self.assertEqual(prompts.language_note("xx"), "") # unknown codes add nothing rather than a wrong name + self.assertIn("Reply in Klingon", prompts.language_note("tlh")) def test_guest_config_keeps_a_known_language(self): self.assertEqual(guests.clean_config({"language": "fr"})["language"], "fr") @@ -44,6 +46,17 @@ def test_system_prompt_includes_the_note(self): text = prompts.build_system(agent, [], "medium", [], [], [], {"language": ""}) self.assertNotIn("## Language", text) + def test_catalogues_are_public_and_checked(self): + # the login screen loads its language before anyone signs in + self.assertTrue(auth.PUBLIC.match("/api/translate/catalogue/sr-Latn")) + self.assertTrue(auth.PUBLIC.match("/api/translate/locales")) + self.assertFalse(auth.PUBLIC.match("/api/translate/coverage")) + self.assertFalse(auth.PUBLIC.match("/api/translate/catalogue/../keys")) + self.assertEqual(client.get("/api/translate/catalogue/x.y").status_code, 400) + self.assertEqual(client.get("/api/translate/catalogue/zz-none").status_code, 404) + if (translate.TRANSLATIONS_DIR / "locales" / "fr.json").is_file(): + self.assertEqual(client.get("/api/translate/catalogue/fr").json()["Log in"], "Se connecter") + if __name__ == "__main__": unittest.main() diff --git a/ui/src/components/GuestsTab.tsx b/ui/src/components/GuestsTab.tsx index db5cf29..2ab8098 100644 --- a/ui/src/components/GuestsTab.tsx +++ b/ui/src/components/GuestsTab.tsx @@ -1,4 +1,5 @@ -import { LOCALES, t } from '../i18n'; +import { LOCALES, t, type LocalePref } from '../i18n'; +import { LanguageSelect } from './LanguageSelect'; import { useCallback, useEffect, useState } from 'react'; import { Check, ChevronDown, Copy, Flag, Heart, KeyRound, Link2, Loader2, LockOpen, LogOut, Mail, Pause, Play, Plus, ShieldOff, Trash2, Users } from 'lucide-react'; import { fmtBytes, req } from '../api'; @@ -16,7 +17,7 @@ const LIMIT_FIELDS = [ ['audioPerDay', 'Audio clips a day'], ['storageMB', 'Space (MB)'], ] as const; type LimitKey = typeof LIMIT_FIELDS[number][0]; -type Config = Record & { agents: string[]; tools: string[]; cell: boolean; language?: '' | 'en' | 'fr'; adult?: boolean }; +type Config = Record & { agents: string[]; tools: string[]; cell: boolean; language?: string; adult?: boolean }; interface Usage { turns: number; tokens: number; images: number; audio: number; reports: number } interface Guest { id: string; name: string; created: number; disabled: boolean; config: Config; lastSeen: number | null; today: Usage; @@ -375,14 +376,15 @@ function Limits({ guest, onSave }: { guest: Guest; onSave: (c: Partial) ))} -