Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>, translate/locales: the community UI catalogues (public MIT files), so the login screen can be translated
# share/<token>: 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_-]+/.+")),
Expand Down
4 changes: 3 additions & 1 deletion server/guests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 32 additions & 2 deletions server/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
222 changes: 220 additions & 2 deletions server/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()


17 changes: 15 additions & 2 deletions tests/language_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand All @@ -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")
Expand Down Expand Up @@ -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()
20 changes: 11 additions & 9 deletions ui/src/components/GuestsTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<LimitKey, number> & { agents: string[]; tools: string[]; cell: boolean; language?: '' | 'en' | 'fr'; adult?: boolean };
type Config = Record<LimitKey, number> & { 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;
Expand Down Expand Up @@ -375,14 +376,15 @@ function Limits({ guest, onSave }: { guest: Guest; onSave: (c: Partial<Config>)
</label>
))}
</div>
<label className="block text-[11px] text-[var(--text-secondary)] max-w-xs">
{t('Language')}
<select className={btn.input + ' mt-1'} value={cfg.language ?? ''} onChange={e => setCfg(c => ({ ...c, language: e.target.value as Config['language'] }))}>
<option value="">{t('Their browser\'s')}</option>
{LOCALES.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
<div className="block text-[11px] text-[var(--text-secondary)] max-w-xs space-y-1">
<span>{t('Language')}</span>
<LanguageSelect
value={(cfg.language ?? '') as LocalePref}
onChange={lang => setCfg(c => ({ ...c, language: lang }))}
autoLabel={t("Their browser's")}
/>
<span className="block mt-1 text-[var(--text-muted)]">{t('Their interface and their agents\' replies. In their own Shell:B it is the starting choice; they can change it in Settings.')}</span>
</label>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--text-muted)]">{t('0 = no limit. {memories} memories, {v0} tokens today.', { memories: guest.memories, v0: guest.today.tokens.toLocaleString() })}</span>
<button className={btn.subtle} onClick={() => onSave(cfg)}>{t('Save limits')}</button>
Expand Down
Loading
Loading