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) ))} -