diff --git a/config/core_config.json b/config/core_config.json index 5f78610..1d92c4f 100644 --- a/config/core_config.json +++ b/config/core_config.json @@ -1,6 +1,5 @@ { "_description": "Tunables for core/* modules. Loaded via core.config_loader.load('core').", - "brain": { "allowed_callback_prefixes": [ "Project.Sanad.voice.", @@ -8,7 +7,6 @@ ], "gestural_speaking_default": false }, - "logger": { "log_level": "INFO", "format": "%(asctime)s [%(name)s] %(levelname)-7s %(message)s", @@ -16,11 +14,9 @@ "file_max_bytes": 10485760, "file_backup_count": 7 }, - "event_bus": { "emit_timeout_sec": 0.5 }, - "paths": { "_comment": "Path roots — resolved against BASE_DIR in core/config.py", "data": "data", @@ -31,7 +27,6 @@ "motion_recordings": "data/recordings/motion", "motions": "data/motions" }, - "gemini_defaults": { "_comment": "Baseline Gemini API config — SINGLE SOURCE OF TRUTH. All voice modules read from here.", "api_key": "", @@ -39,28 +34,28 @@ "model_ws_uri": "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent", "voice_name": "Charon", "ws_timeout_sec": 30, - "default_system_prompt": "You are Sanad (Bousandah), a wise and friendly Emirati assistant. Speak strictly in the UAE dialect (Khaleeji). Be helpful, concise, and use local greetings like 'Marhaba' and 'Ya Khoy'." + "default_system_prompt": "You are Sanad (Bousandah), a wise and friendly Emirati assistant. Speak strictly in the UAE dialect (Khaleeji). Be helpful, concise, and use local greetings like 'Marhaba' and 'Ya Khoy'.", + "_comment_voice_prompts": "Per-voice system prompts. Charon (G1) and Puck (R1) are DELIBERATELY absent: they must keep the robots' verbatim TTS prompt so the site matches Sanadv3/SanadR1. Only Kore (Agibot x2), which has no robot to match, is steered here.", + "voice_system_prompts": { + "Kore": "أنتِ \"موزة\" (Muza) — روبوت إماراتي احترافي تابع لشركة YS Lootah Robotics، تتكلمين باللهجة الإماراتية (الخليجية) بنبرة احترافية راقية، هادئة وواثقة، بدون مبالغة ولا عبارات عاطفية زائدة.\nفي وضع الإعادة هذا مهمتكِ الوحيدة: انطقي النص الذي يعطيكِ إياه المستخدم كما هو تماماً، حرفياً، كلمة بكلمة، من أول كلمة إلى آخر كلمة، بنطقكِ الإماراتي الطبيعي.\nلا تجاوبي على النص، ولا تترجميه، ولا تلخصيه، ولا تعيدي صياغته، ولا تضيفي أي تحية أو تعليق أو كلمة زائدة، ولا تفكري بصوت مسموع.\nإذا كان النص بلغة أخرى فانطقيه بنفس لغته بدون تغيير.\nأخرجي الصوت المنطوق فقط، ثم اسكتي." + } }, - "g1_hardware": { "_comment": "G1 humanoid hardware constants — shared by every motion/voice module that talks to the arm.", "num_motor": 29, "enable_arm_sdk_index": 29, "replay_hz": 60.0 }, - "script_files": { "_comment": "Filenames (under scripts/) used across voice + dashboard", "persona": "sanad_script.txt", "rules": "sanad_rule.txt" }, - "dashboard_defaults": { "host": null, "port": 8000, "interface": "wlan0" }, - "audio_defaults": { "_comment": "Host PulseAudio fallback only — the G1 deployment uses UDP multicast mic + AudioClient.PlayStream speaker (see SANAD_USE_G1_MIC in config.py LIVE_TUNE). Default here is the Jetson/G1 built-in platform-sound chip.", "send_sample_rate": 16000, @@ -70,11 +65,9 @@ "sink": "alsa_output.platform-sound.analog-stereo", "source": "alsa_input.platform-sound.analog-stereo" }, - "dds": { "network_interface_default": "eth0" }, - "auth": { "_comment": "Dashboard login credentials. CHANGE before any non-LAN deployment.", "username": "lkasjda213h", diff --git a/config/gemini_config.json b/config/gemini_config.json index cfe548d..4b3b914 100644 --- a/config/gemini_config.json +++ b/config/gemini_config.json @@ -1,11 +1,13 @@ -{ - "_description": "Tunables for gemini/client.py. All keys are optional — defaults live in code. Gemini credentials (api_key, model_live, voice_name) come from core_config.json's gemini_defaults — single source of truth.", - - "client": { - "_comment": "gemini/client.py — Gemini Live WebSocket client used by the typed-replay engine for one-shot TTS calls.", - "recv_timeout_sec": 30, - "reconnect_max_attempts": 3, - "reconnect_initial_delay_sec": 1.0, - "reconnect_max_delay_sec": 10.0 - } +{ + "_description": "Tunables for gemini/client.py. All keys are optional — defaults live in code. Gemini credentials (api_key, model_live, voice_name) come from core_config.json\u0027s gemini_defaults — single source of truth.", + "client": { + "_comment": "gemini/client.py — Gemini Live WebSocket client used by the typed-replay engine for one-shot TTS calls.", + "recv_timeout_sec": 30, + "reconnect_max_attempts": 3, + "reconnect_initial_delay_sec": 1.0, + "reconnect_max_delay_sec": 10.0, + "post_generation_grace_sec": 3, + "output_transcription": true, + "_comment_grace": "Seconds to keep reading after generationComplete, reset on every frame. 1.2 was too short: reads got cut and the leftover tail bled into the NEXT generation." + } } diff --git a/config/voice_config.json b/config/voice_config.json index 7cc8dca..2f4d4d5 100644 --- a/config/voice_config.json +++ b/config/voice_config.json @@ -1,46 +1,46 @@ { - "_description": "Tunables for voice/* modules. Loaded via core.config_loader.load('voice').", - - "speaker": { - "_comment": "G1 built-in speaker — AudioClient.PlayStream wrapper", - "app_name": "sanad", - "begin_stream_pause_sec": 0.15, - "wait_finish_margin_sec": 0.3 - }, - - "vad": { - "_comment": "Gemini Live server-side voice-activity-detection config", - "start_sensitivity": "START_SENSITIVITY_HIGH", - "end_sensitivity": "END_SENSITIVITY_LOW", - "prefix_padding_ms": 20, - "silence_duration_ms": 200 - }, - - "barge_in": { - "threshold": 500, - "loud_chunks_needed": 3, - "cooldown_sec": 0.3, - "echo_suppress_below": 500, - "ai_speak_grace_sec": 0.15 - }, - - "recording": { - "enabled": true, - "dir_relative": "data/recordings" - }, - - "typed_replay": { - "_comment": "voice/typed_replay.py — max_text_len comes from dashboard.api_input", - "monitor_chunk_size": 512, - "monitor_tail_sec": 0.2 - }, - - "local_tts": { - "_comment": "voice/local_tts.py — offline Coqui TTS", - "model_subdir": "speecht5_tts_clartts_ar", - "vocoder_subdir": "speecht5_hifigan", - "xvector_filename": "arabic_xvector_embedding.pt", - "sample_rate": 16000, - "channels": 1 - } -} + "_description": "Tunables for voice/* modules. Loaded via core.config_loader.load('voice').", + "speaker": { + "_comment": "G1 built-in speaker — AudioClient.PlayStream wrapper", + "app_name": "sanad", + "begin_stream_pause_sec": 0.15, + "wait_finish_margin_sec": 0.3 + }, + "vad": { + "_comment": "Gemini Live server-side voice-activity-detection config", + "start_sensitivity": "START_SENSITIVITY_HIGH", + "end_sensitivity": "END_SENSITIVITY_LOW", + "prefix_padding_ms": 20, + "silence_duration_ms": 200 + }, + "barge_in": { + "threshold": 500, + "loud_chunks_needed": 3, + "cooldown_sec": 0.3, + "echo_suppress_below": 500, + "ai_speak_grace_sec": 0.15 + }, + "recording": { + "enabled": true, + "dir_relative": "data/recordings" + }, + "typed_replay": { + "_comment": "voice/typed_replay.py — max_text_len comes from dashboard.api_input", + "monitor_chunk_size": 512, + "monitor_tail_sec": 0.2, + "fresh_session_per_replay": true, + "warm_session_voices": [] + }, + "local_tts": { + "_comment": "voice/local_tts.py — offline Coqui TTS", + "model_subdir": "speecht5_tts_clartts_ar", + "vocoder_subdir": "speecht5_hifigan", + "xvector_filename": "arabic_xvector_embedding.pt", + "sample_rate": 16000, + "channels": 1 + }, + "live": { + "allow_direct_key": true, + "_comment": "User-authorised: hands the Gemini API key to the browser for live conversation. Ephemeral tokens are refused for AI-Studio AQ. express keys (verified: the key opens a Live session directly, a token minted from it is refused in all four documented forms). While this is on, anyone who can open the dashboard can read the key." + } +} \ No newline at end of file diff --git a/dashboard/app.py b/dashboard/app.py index bf498fd..7586743 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -87,6 +87,7 @@ _REST_ROUTES: list[tuple[str, str, str]] = [ ("records", "/api/records", "records"), ("prompt", "/api/prompt", "prompt"), ("typed_replay", "/api/typed-replay", "typed-replay"), + ("live", "/api/live", "live"), ] _WS_ROUTES: list[str] = ["log_stream"] @@ -146,7 +147,15 @@ async def root(): if index.exists(): from fastapi.responses import HTMLResponse try: - return HTMLResponse(index.read_text(encoding="utf-8")) + # Never cache the page itself. Static assets are served with a + # 7-day max-age, so the ONLY thing that tells a browser about a new + # live.js is the ?v= stamp inside this HTML — if the HTML is stale, + # the browser keeps loading the old (possibly broken) asset and no + # amount of reloading helps. + return HTMLResponse( + index.read_text(encoding="utf-8"), + headers={"Cache-Control": "no-store, must-revalidate", + "Pragma": "no-cache"}) except OSError as exc: return {"error": f"Could not read index.html: {exc}"} return { diff --git a/dashboard/routes/auth.py b/dashboard/routes/auth.py index 9d955bd..16fa3df 100644 --- a/dashboard/routes/auth.py +++ b/dashboard/routes/auth.py @@ -7,7 +7,7 @@ The session is signed by Starlette's SessionMiddleware (stateless cookie). from __future__ import annotations from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, Response from pydantic import BaseModel from Project.Sanad.config import BASE_DIR @@ -28,6 +28,98 @@ def is_authed(request: Request) -> bool: return bool(request.session.get("user")) +# ── login history ──────────────────────────────────────────────────── +# Kept in a small JSON file rather than only in the log, so the dashboard can +# show "last login, from which device" without parsing log text. +LOGIN_HISTORY = BASE_DIR / "data" / "logins.json" +_MAX_HISTORY = 200 + + +def _client_ip(request: Request) -> str: + """Real client IP. The site sits behind Cloudflare and then an Apache + reverse proxy, so request.client is always 127.0.0.1 — the forwarded + headers are the only source of the actual address.""" + for header in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"): + value = request.headers.get(header, "") + if value: + return value.split(",")[0].strip() + return getattr(request.client, "host", "") or "?" + + +def _describe_device(request: Request) -> str: + """Short human label for the User-Agent — "Chrome on Windows", not 200 + characters of tokens.""" + ua = request.headers.get("user-agent", "") + if not ua: + return "unknown device" + low = ua.lower() + if "android" in low: + platform = "Android" + elif "iphone" in low: + platform = "iPhone" + elif "ipad" in low: + platform = "iPad" + elif "windows" in low: + platform = "Windows" + elif "mac os" in low or "macintosh" in low: + platform = "Mac" + elif "linux" in low: + platform = "Linux" + else: + platform = "unknown OS" + # order matters: Edge/Opera also contain "chrome", Chrome contains "safari" + if "edg/" in low: + browser = "Edge" + elif "opr/" in low or "opera" in low: + browser = "Opera" + elif "firefox" in low: + browser = "Firefox" + elif "chrome" in low or "crios" in low: + browser = "Chrome" + elif "safari" in low: + browser = "Safari" + elif "curl" in low: + browser = "curl" + elif "powershell" in low or "winhttp" in low: + browser = "PowerShell" + else: + browser = "unknown browser" + return f"{browser} on {platform}" + + +def _load_logins() -> list: + try: + import json + return json.loads(LOGIN_HISTORY.read_text(encoding="utf-8")) or [] + except Exception: + return [] + + +def _record_login(request: Request, username: str, ok: bool) -> None: + """Append one sign-in attempt. Never raises: a logging problem must not + stop someone from signing in.""" + try: + import json + from datetime import datetime + entries = _load_logins() + entries.append({ + "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "user": username, + "ok": bool(ok), + "device": _describe_device(request), + "ip": _client_ip(request), + "user_agent": request.headers.get("user-agent", "")[:300], + }) + del entries[:-_MAX_HISTORY] + LOGIN_HISTORY.parent.mkdir(parents=True, exist_ok=True) + tmp = LOGIN_HISTORY.with_suffix(".json.tmp") + tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=1), + encoding="utf-8") + tmp.replace(LOGIN_HISTORY) + except Exception: + log.exception("could not record login history") + + class LoginPayload(BaseModel): username: str password: str @@ -46,12 +138,68 @@ async def login_page(request: Request): async def login(request: Request, payload: LoginPayload): if payload.username == USERNAME and payload.password == PASSWORD: request.session["user"] = payload.username - log.info("login OK: %s", payload.username) + log.info("login OK: %s (%s)", payload.username, _describe_device(request)) + _record_login(request, payload.username, True) return {"ok": True, "user": payload.username} - log.warning("login FAILED: %s", payload.username) + log.warning("login FAILED: %s (%s)", payload.username, _describe_device(request)) + _record_login(request, payload.username, False) raise HTTPException(401, "Invalid username or password") +@router.get("/api/auth/logins/export") +async def export_logins(request: Request, format: str = "csv"): + """Download the full sign-in history (not just the rows on screen). + + CSV is written with a UTF-8 BOM so Excel opens it with the columns split + and any non-ASCII intact; without it Excel mangles both. + """ + if not is_authed(request): + raise HTTPException(401, "Not authenticated") + import csv + import io as _io + import json + from datetime import datetime + + entries = list(reversed(_load_logins())) # newest first + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + + if format.lower() == "json": + return Response( + content=json.dumps(entries, ensure_ascii=False, indent=1), + media_type="application/json; charset=utf-8", + headers={"Content-Disposition": + f'attachment; filename="sanadlite-signins-{stamp}.json"'}) + + buf = _io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerow(["when", "user", "result", "device", "ip", "user_agent"]) + for e in entries: + writer.writerow([e.get("at", ""), e.get("user", ""), + "ok" if e.get("ok") else "failed", + e.get("device", ""), e.get("ip", ""), + e.get("user_agent", "")]) + return Response( + content="" + buf.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": + f'attachment; filename="sanadlite-signins-{stamp}.csv"'}) + + +@router.get("/api/auth/logins") +async def login_history(request: Request, limit: int = 20): + """Recent sign-ins: when, from which device, and from which IP. + + Failed attempts are included too — an unexplained failure from an unknown + device is the thing worth noticing on a dashboard that is open to the + internet. + """ + if not is_authed(request): + raise HTTPException(401, "Not authenticated") + entries = _load_logins()[-max(1, min(int(limit), 200)):] + entries.reverse() + return {"logins": entries, "total": len(_load_logins())} + + @router.post("/api/auth/logout") async def logout(request: Request): user = request.session.pop("user", None) diff --git a/dashboard/routes/live.py b/dashboard/routes/live.py new file mode 100644 index 0000000..aacce2f --- /dev/null +++ b/dashboard/routes/live.py @@ -0,0 +1,262 @@ +"""Live Gemini — browser-side realtime conversation with a chosen robot voice. + +Why the browser talks to Gemini directly instead of through this server: +this deployment sits behind an Apache `[P]` rewrite that cannot upgrade a +WebSocket (measured — the same handshake answers 101 straight to uvicorn and +404 through the proxy), so a server-side relay of live audio is impossible +here. Instead the server mints a short-lived **ephemeral auth token** and the +browser opens its own socket to Gemini with that. The real API key never +reaches the page. + +Endpoints: + GET /api/live/config voices, model, and the persona for each + GET /api/live/persona one voice's persona text + POST /api/live/persona edit it (persisted, survives restarts) + POST /api/live/token mint an ephemeral token for a session +""" +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from Project.Sanad.config import BASE_DIR +from Project.Sanad.core.logger import get_logger + +router = APIRouter() +log = get_logger("live_route") + +PERSONA_FILE = BASE_DIR / "data" / "live_personas.json" + +try: + from Project.Sanad.core.config_loader import section as _cfg_section + _LIVE_CFG = _cfg_section("voice", "live") or {} +except Exception: + _LIVE_CFG = {} +# OFF by default: enabling it puts the real API key in the browser. See +# mint_token() for why the safer ephemeral path is unavailable on this key. +_ALLOW_DIRECT_KEY = bool(_LIVE_CFG.get("allow_direct_key", False)) + +# Ephemeral tokens are minted per session and expire quickly; `uses` covers the +# single connect. Keep the window tight — the token is handed to a browser. +_TOKEN_TTL_MIN = 30 +_SESSION_START_WINDOW_MIN = 2 +_REST_BASE = "https://generativelanguage.googleapis.com" +# Ephemeral tokens work ONLY on v1beta (documented). Minting on v1alpha +# succeeds but the resulting token is rejected at connect time with +# "API key not valid" / "unregistered callers", which is what the first +# attempt at this feature hit. +_API_VERSION = "v1beta" + +def _voice_options() -> list[dict]: + """Reuse the voice picker's table so both tabs name the robots identically.""" + try: + from Project.Sanad.dashboard.routes.voice import VOICE_OPTIONS + return [dict(o) for o in VOICE_OPTIONS] + except Exception: + return [{"voice": "Charon", "label": "Unitree G1"}, + {"voice": "Puck", "label": "Unitree R1"}, + {"voice": "Kore", "label": "Agibot x2"}] + + +def _store(): + from Project.Sanad.dashboard.routes.live_personas import load_store + return load_store() + + +@router.get("/config") +async def live_config(): + """Voices, model, and which persona each robot is currently using.""" + from Project.Sanad.config import GEMINI_MODEL + from Project.Sanad.dashboard.routes import live_personas as lp + store = _store() + voices = [] + for opt in _voice_options(): + pid = lp.active_for(store, opt["voice"]) + voices.append(dict(opt, + persona_id=pid, + persona_name=lp.persona_name(store, pid), + persona=lp.persona_text(store, pid))) + return {"model": GEMINI_MODEL, "api_version": _API_VERSION, "voices": voices} + + +@router.get("/personas") +async def list_personas(): + """The whole library, plus the active selection per robot.""" + from Project.Sanad.dashboard.routes import live_personas as lp + store = _store() + return {"personas": lp.all_personas(store), + "active": {o["voice"]: lp.active_for(store, o["voice"]) + for o in _voice_options()}} + + +class PersonaSave(BaseModel): + id: str = "" # empty -> create a new one + name: str + text: str + + +@router.post("/personas") +async def save_persona(payload: PersonaSave): + """Create a persona, or update a saved one. Built-ins are never modified — + editing one saves a copy instead, so the original stays available.""" + from Project.Sanad.dashboard.routes import live_personas as lp + from datetime import datetime + name = payload.name.strip() or "Untitled persona" + text = payload.text.strip() + if not text: + raise HTTPException(400, "Persona text cannot be empty") + if len(text.encode("utf-8")) > 20000: + raise HTTPException(413, "Persona too large (max 20000 bytes)") + + store = _store() + personas = store.setdefault("personas", {}) + pid = payload.id.strip() + if not pid or pid in lp.BUILTIN: + pid = lp.new_id(name, personas) + personas[pid] = {"name": name, "text": text, + "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} + lp.save_store(store) + log.info("persona saved: %s (%s, %d chars)", name, pid, len(text)) + return {"ok": True, "id": pid, "name": name} + + +class PersonaSelect(BaseModel): + voice: str + id: str + + +@router.post("/personas/select") +async def select_persona(payload: PersonaSelect): + """Choose which persona a robot uses. Applies on the next connection.""" + from Project.Sanad.dashboard.routes import live_personas as lp + store = _store() + known = {p["id"] for p in lp.all_personas(store)} + if payload.id not in known: + raise HTTPException(404, f"No persona {payload.id!r}") + store.setdefault("active", {})[payload.voice] = payload.id + lp.save_store(store) + log.info("persona for %s set to %s", payload.voice, payload.id) + return {"ok": True, "voice": payload.voice, "id": payload.id, + "name": lp.persona_name(store, payload.id)} + + +class PersonaDelete(BaseModel): + id: str + + +@router.post("/personas/delete") +async def delete_persona(payload: PersonaDelete): + """Remove a saved persona. Any robot using it falls back to its built-in.""" + from Project.Sanad.dashboard.routes import live_personas as lp + store = _store() + if payload.id in lp.BUILTIN: + raise HTTPException(400, "Built-in personas cannot be deleted") + if payload.id not in (store.get("personas") or {}): + raise HTTPException(404, f"No persona {payload.id!r}") + del store["personas"][payload.id] + reassigned = [] + for voice, pid in list((store.get("active") or {}).items()): + if pid == payload.id: + store["active"][voice] = lp._DEFAULT_FOR_VOICE.get(voice, "") + reassigned.append(voice) + lp.save_store(store) + return {"ok": True, "deleted": payload.id, "reassigned": reassigned} + + +def _mint_token_sync(api_key: str) -> dict: + """Blocking POST to auth_tokens. Returns the raw API response.""" + now = datetime.utcnow() + body = json.dumps({ + "uses": 1, + "expireTime": (now + timedelta(minutes=_TOKEN_TTL_MIN)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "newSessionExpireTime": + (now + timedelta(minutes=_SESSION_START_WINDOW_MIN)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }).encode("utf-8") + url = f"{_REST_BASE}/{_API_VERSION}/auth_tokens?key={api_key}" + req = urllib.request.Request(url, data=body, + headers={"Content-Type": "application/json"}, + method="POST") + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = json.loads(exc.read().decode("utf-8")).get("error", {}).get("message", "") + except Exception: + pass + raise RuntimeError(f"HTTP {exc.code}: {detail or exc.reason}") + except urllib.error.URLError as exc: + raise RuntimeError(f"request failed: {exc.reason}") + + +class TokenPayload(BaseModel): + voice: str = "Charon" + + +@router.post("/token") +async def mint_token(payload: TokenPayload): + """Credential for the browser's own Gemini socket. + + Ephemeral tokens are the safe path — the key stays on the server — but they + are NOT accepted for keys of the "AQ." AI-Studio express kind that this + deployment uses. Measured: the express key opens a Live session directly on + both v1alpha and v1beta, while a token minted from it is refused in all four + documented presentations (access_token encoded/raw, Authorization: Token, + key=). So live conversation here needs the real key in the browser, which is + a deliberate decision and stays OFF until switched on in config: + + voice_config.json > live.allow_direct_key = true + + Turning it on means anyone who can open the dashboard (or its dev tools) can + read the key and spend against it. Getting a standard `AIza…` key instead + would restore the ephemeral path and make this unnecessary. + """ + import asyncio + + from Project.Sanad.config import GEMINI_MODEL + import Project.Sanad.gemini.client as _gc + + api_key = getattr(_gc, "GEMINI_API_KEY", "") or "" + if not api_key: + raise HTTPException(503, "No Gemini API key configured.") + + from Project.Sanad.dashboard.routes import live_personas as lp + store = _store() + voice = payload.voice or "Charon" + persona_id = lp.active_for(store, voice) + common = { + "model": GEMINI_MODEL, + "api_version": _API_VERSION, + "voice": voice, + "persona": lp.persona_text(store, persona_id), + "persona_name": lp.persona_name(store, persona_id), + } + + if _ALLOW_DIRECT_KEY: + log.warning("live session: handing the API key to the browser " + "(live.allow_direct_key is enabled)") + return dict(common, token=api_key, auth_param="key", + api_version="v1alpha", direct_key=True, expires_in_sec=0) + + try: + data = await asyncio.to_thread(_mint_token_sync, api_key) + except RuntimeError as exc: + msg = str(exc) + log.error("could not mint ephemeral token: %s", msg) + if "credits are depleted" in msg or "billing" in msg.lower(): + raise HTTPException(503, "Gemini API credits are depleted — top up billing.") + raise HTTPException(502, f"Could not start a live session: {msg}") + + name = data.get("name", "") + if not name: + raise HTTPException(502, "Gemini returned no token") + + log.info("live session token minted for %s", voice) + return dict(common, token=name, auth_param="access_token", direct_key=False, + expires_in_sec=_TOKEN_TTL_MIN * 60) diff --git a/dashboard/routes/live_personas.py b/dashboard/routes/live_personas.py new file mode 100644 index 0000000..a83896b --- /dev/null +++ b/dashboard/routes/live_personas.py @@ -0,0 +1,165 @@ +"""Persona library for Live Gemini. + +Personas used to be one blob per robot voice. This stores a *library* of named +personas plus which one is active for each robot, so several can be kept side +by side (e.g. a formal receptionist and a playful tour guide for the same +robot) and switched without retyping. + +Shape on disk (data/live_personas.json): + + { + "personas": {"": {"name": str, "text": str, "updated": str}}, + "active": {"": ""} + } + +The previous format — a flat {voice: text} mapping — is migrated on first read, +so nothing the user already wrote is lost. +""" +from __future__ import annotations + +import json +import re +from datetime import datetime + +from Project.Sanad.config import BASE_DIR +from Project.Sanad.core.logger import get_logger + +log = get_logger("live_personas") + +STORE = BASE_DIR / "data" / "live_personas.json" + +# Built-in personas, seeded from each robot's own character. They are always +# present, cannot be deleted, and act as the fallback when nothing is selected. +BUILTIN: dict[str, dict] = { + "builtin:charon": { + "name": "Sanad — Unitree G1 (default)", + "voice": "Charon", + "text": ( + "أنت \"سند\" — روبوت إماراتي من شركة YS Lootah Robotics تعمل على منصة Unitree G1.\n" + "تكلم باللهجة الإماراتية (الخليجية) الأصيلة بشكل طبيعي وواضح، بدون مبالغة.\n" + "ردودك قصيرة ومسموعة: من جملة إلى ثلاث جمل، لأن كلامك يُنطق بصوت وليس مقروءاً.\n" + "لا تستخدم رموزاً ولا قوائم مرقّمة ولا علامات تنسيق — فقط جُمل يسهل نطقها.\n" + "إذا تحدث المستخدم بلغة أخرى، افهمه وردّ عليه بنفس لغته.\n" + "كن مهذباً وواثقاً ومباشراً، وإذا ما تعرف الجواب قل ذلك بصراحة." + ), + }, + "builtin:puck": { + "name": "سوبر دبي — Unitree R1 (default)", + "voice": "Puck", + "text": ( + "أنت \"سوبر دبي\" (super-dubai) — روبوت إماراتي ذكي تابع لشركة لوتاه تيك، " + "تعمل على منصة Unitree R1.\n" + "تكلم باللهجة الإماراتية بشكل طبيعي وراقٍ ومفهوم، ونوّع بداياتك " + "(مرحبابك، أبشر بعزك، حياك الله، زين، تم).\n" + "إذا استخدم المستخدم لغة ثانية، بدّل فوراً وردّ بنفس اللغة.\n" + "ردودك قصيرة ومركزة على الزبدة والحل العملي، بدون رموز أو تنسيق.\n" + "كن ودوداً ومحترماً ومباشراً." + ), + }, + "builtin:kore": { + "name": "موزة — Agibot x2 (default)", + "voice": "Kore", + "text": ( + "أنتِ \"موزة\" (Muza) — روبوت إماراتي احترافي تابع لشركة YS Lootah Robotics " + "تعمل على منصة Agibot X2.\n" + "تتحدثين حصراً باللهجة الإماراتية (الخليجية) في كل رد، بأسلوب احترافي راقٍ " + "وهادئ وواثق، بدون مبالغة في الودّ.\n" + "ردودك قصيرة: من جملة إلى ثلاث جمل، بدون رموز ولا قوائم.\n" + "ابدئي الجلسة بتحية واحدة قصيرة: \"حياك الله\".\n" + "إذا سُئلتِ عن الشركة، عرّفي بها باختصار وبدقة، ولا تختلقي أرقاماً أو التزامات." + ), + }, +} + +_DEFAULT_FOR_VOICE = {v["voice"]: pid for pid, v in BUILTIN.items()} + + +def _blank_store() -> dict: + return {"personas": {}, "active": dict(_DEFAULT_FOR_VOICE)} + + +def _migrate_flat(old: dict) -> dict: + """Convert the old {voice: text} mapping into the library format.""" + store = _blank_store() + for voice, text in old.items(): + if not isinstance(text, str) or not text.strip(): + continue + builtin_id = _DEFAULT_FOR_VOICE.get(voice) + if builtin_id and text.strip() == BUILTIN[builtin_id]["text"].strip(): + continue # unchanged default: nothing to keep + pid = f"saved:{voice.lower()}" + store["personas"][pid] = { + "name": f"{voice} (saved)", + "text": text, + "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + store["active"][voice] = pid + log.info("migrated %d persona(s) from the old format", len(store["personas"])) + return store + + +def load_store() -> dict: + try: + raw = json.loads(STORE.read_text(encoding="utf-8")) + except FileNotFoundError: + return _blank_store() + except Exception: + log.exception("could not read persona store — starting fresh") + return _blank_store() + + if isinstance(raw, dict) and "personas" in raw and "active" in raw: + raw.setdefault("personas", {}) + active = raw.setdefault("active", {}) + for voice, pid in _DEFAULT_FOR_VOICE.items(): + active.setdefault(voice, pid) + return raw + if isinstance(raw, dict): + store = _migrate_flat(raw) + save_store(store) + return store + return _blank_store() + + +def save_store(store: dict) -> None: + STORE.parent.mkdir(parents=True, exist_ok=True) + tmp = STORE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(store, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(STORE) + + +def all_personas(store: dict) -> list[dict]: + """Built-ins first, then saved ones, newest last.""" + out = [{"id": pid, "name": v["name"], "text": v["text"], "builtin": True, + "voice": v["voice"]} + for pid, v in BUILTIN.items()] + for pid, v in (store.get("personas") or {}).items(): + out.append({"id": pid, "name": v.get("name", pid), "text": v.get("text", ""), + "builtin": False, "updated": v.get("updated", "")}) + return out + + +def persona_text(store: dict, persona_id: str) -> str: + if persona_id in BUILTIN: + return BUILTIN[persona_id]["text"] + entry = (store.get("personas") or {}).get(persona_id) + return entry.get("text", "") if entry else "" + + +def persona_name(store: dict, persona_id: str) -> str: + if persona_id in BUILTIN: + return BUILTIN[persona_id]["name"] + entry = (store.get("personas") or {}).get(persona_id) + return entry.get("name", persona_id) if entry else persona_id + + +def active_for(store: dict, voice: str) -> str: + return (store.get("active") or {}).get(voice) or _DEFAULT_FOR_VOICE.get(voice, "") + + +def new_id(name: str, existing: dict) -> str: + base = re.sub(r"[^a-z0-9]+", "-", (name or "persona").lower()).strip("-") or "persona" + pid, n = base, 2 + while pid in existing or pid in BUILTIN: + pid = f"{base}-{n}" + n += 1 + return pid diff --git a/dashboard/routes/logs.py b/dashboard/routes/logs.py index 7eccd04..1ab5f6b 100644 --- a/dashboard/routes/logs.py +++ b/dashboard/routes/logs.py @@ -20,6 +20,20 @@ from Project.Sanad.dashboard.routes._safe_io import safe_path_under router = APIRouter() +@router.get("/live") +async def live_tail(cursor: int = -1, limit: int = 300): + """Polling fallback for the live log panel. + + The dashboard's /ws/logs WebSocket cannot reach this deployment: the site + is served through an Apache `[P]` rewrite which forwards the upgrade as a + plain GET, so the app answers 404 and the panel stays empty forever. The + same ring buffer is exposed here so the browser can poll it instead. + """ + from Project.Sanad.dashboard.websockets.log_stream import recent_since + lines, new_cursor = recent_since(int(cursor), max(1, min(int(limit), 1000))) + return {"lines": lines, "cursor": new_cursor} + + def _list_logs_sync(): LOGS_DIR.mkdir(parents=True, exist_ok=True) files = [] diff --git a/dashboard/routes/prompt.py b/dashboard/routes/prompt.py index f6f1e10..05caaf2 100644 --- a/dashboard/routes/prompt.py +++ b/dashboard/routes/prompt.py @@ -7,11 +7,13 @@ from pydantic import BaseModel from Project.Sanad.config import SCRIPTS_DIR from Project.Sanad.core.config_loader import section as _cfg_section +from Project.Sanad.core.logger import get_logger from Project.Sanad.dashboard.routes._safe_io import ( atomic_write_text, MAX_UPLOAD_BYTES, ) router = APIRouter() +log = get_logger("prompt_route") # Filenames — SINGLE SOURCE in core.script_files _SCRIPTS = _cfg_section("core", "script_files") @@ -96,3 +98,47 @@ async def reload_prompts(): "script_path": str(SCRIPT_PROMPT_PATH), "rule_path": str(RULE_PROMPT_PATH), } + + +# ── Voice replay rule — the [REPLAY_SYSTEM_PROMPT] that shapes the spoken voice ── + +class RulePayload(BaseModel): + content: str + + +@router.get("/rule") +async def get_rule(): + """The current voice replay rule (how typed text is spoken: accent, verbatim, gender).""" + return {"content": _load_rule_prompts().get("replay_prompt", ""), + "rule_path": str(RULE_PROMPT_PATH)} + + +@router.post("/rule") +async def update_rule(payload: RulePayload): + """Save the replay rule to sanad_rule.txt AND apply it live (hot-swap + reconnect).""" + content = payload.content.strip() + if not content: + raise HTTPException(400, "Rule cannot be empty.") + if len(content.encode("utf-8")) > MAX_PROMPT_BYTES: + raise HTTPException(413, f"Rule too large (max {MAX_PROMPT_BYTES} bytes).") + try: + SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) + atomic_write_text( + RULE_PROMPT_PATH, "[REPLAY_SYSTEM_PROMPT]\n" + content.rstrip() + "\n") + except OSError as exc: + raise HTTPException(500, f"Could not write rule: {exc}") + # Apply immediately: hot-swap the live voice_client's system prompt and drop the + # session so the next Generate & Play reconnects with the new rule. + applied = False + try: + from Project.Sanad.main import voice_client + if voice_client is not None: + voice_client.system_prompt = content + if getattr(voice_client, "connected", False): + await voice_client.disconnect() + applied = True + except Exception: + log.exception("could not hot-swap the voice rule") + return {"ok": True, "applied": applied, + "message": ("Rule saved and applied — takes effect on your next Generate & Play." + if applied else "Rule saved (restart to apply).")} diff --git a/dashboard/routes/voice.py b/dashboard/routes/voice.py index cdb9f00..858d24b 100644 --- a/dashboard/routes/voice.py +++ b/dashboard/routes/voice.py @@ -240,3 +240,150 @@ async def _legacy_update_api_key(payload: ApiKeyPayload): "separately (Stop → Start) to pick up the new key." ), } + + +# ─────────────────────── Gemini voice (robot) selection ─────────────────────── +# Friendly robot labels mapped to Gemini prebuilt voice names. Source of truth +# for the dashboard voice picker — each robot maps to one Gemini voice. +# Optionally overridable from core_config.json → gemini_defaults.voice_options +# (a list of {"voice","label"} dicts) without touching code. +_DEFAULT_VOICE_OPTIONS = [ + {"voice": "Charon", "label": "Unitree G1"}, + {"voice": "Puck", "label": "Unitree R1"}, + {"voice": "Kore", "label": "Agibot x2"}, +] + + +def _load_voice_options() -> list[dict]: + raw = _cfg_section("core", "gemini_defaults").get("voice_options") + if isinstance(raw, list) and raw and all( + isinstance(o, dict) and o.get("voice") and o.get("label") for o in raw + ): + return [{"voice": str(o["voice"]), "label": str(o["label"])} for o in raw] + return list(_DEFAULT_VOICE_OPTIONS) + + +VOICE_OPTIONS = _load_voice_options() +_VOICE_BY_NAME = {o["voice"]: o for o in VOICE_OPTIONS} + + +def _current_voice() -> str: + """The voice the Gemini client will actually use on its next connect().""" + try: + import Project.Sanad.gemini.client as _gc + return getattr(_gc, "GEMINI_VOICE", "") or "" + except Exception: + return "" + + +class VoicePayload(BaseModel): + voice: str + + +@router.get("/voices") +async def list_voices(): + """List selectable Gemini voices with robot labels + which one is active.""" + cur = _current_voice() + cur_opt = _VOICE_BY_NAME.get(cur) + return { + "current": cur, + "current_label": cur_opt["label"] if cur_opt else cur, + "options": [ + {"voice": o["voice"], "label": o["label"], "active": o["voice"] == cur} + for o in VOICE_OPTIONS + ], + } + + +@router.post("/voice") +async def set_voice(payload: VoicePayload): + """Switch the Gemini voice, persist it, and apply on the next connect. + + Mirrors the api-key hot-swap: persist to data/motions/config.json + (gemini.voice) so it survives a restart, patch the in-memory GEMINI_VOICE + in BOTH config and the gemini client module (each holds its own binding + from `from ... import GEMINI_VOICE`), then disconnect any idle Gemini + session so the next connect() (e.g. the next Typed Replay) opens with it. + """ + voice = (payload.voice or "").strip() + if voice not in _VOICE_BY_NAME: + raise HTTPException( + 400, + "Unknown voice %r. Allowed: %s" + % (voice, ", ".join(o["voice"] for o in VOICE_OPTIONS)), + ) + + # 1. Persist (atomic temp-then-replace) to data/motions/config.json. + # Run the blocking file I/O off the event-loop thread. + try: + from Project.Sanad.config import load_config, save_config + cfg = (await asyncio.to_thread(load_config)) or {} + gemini_cfg = cfg.get("gemini") if isinstance(cfg.get("gemini"), dict) else {} + gemini_cfg["voice"] = voice + cfg["gemini"] = gemini_cfg + await asyncio.to_thread(save_config, cfg) + except Exception as exc: + log.exception("Failed to persist voice to config.json") + raise HTTPException(500, f"Could not save voice: {exc}") + + # 2. Hot-swap the in-memory globals in both modules that hold GEMINI_VOICE. + try: + import Project.Sanad.config as _cfg_mod + _cfg_mod.GEMINI_VOICE = voice + except Exception: + log.exception("could not patch config.GEMINI_VOICE") + try: + import Project.Sanad.gemini.client as _gc + _gc.GEMINI_VOICE = voice + except Exception: + log.exception("could not patch gemini.client.GEMINI_VOICE") + + # 3. Reopen the Gemini session so the new voice actually takes effect. The + # voice is baked into connect()'s setup handshake and the persistent + # session is only rebuilt when connected is False, so we must drop it — + # otherwise the next Generate & Play reuses the still-open OLD-voice + # session (voice never changes). Acquire the session lock first so we + # WAIT for any in-flight generation to finish (it keeps the old voice, + # which is fine) instead of cutting it off mid-stream; then disconnect. + # Bound the wait so a stuck turn can't hang this request — the persisted + # + patched voice still applies on the next reconnect either way. + from Project.Sanad.main import voice_client + was_connected = False + if voice_client is not None: + was_connected = bool(getattr(voice_client, "connected", False)) + if was_connected: + async def _reset_session(): + async with voice_client.acquire_session("voice_switch"): + await voice_client.disconnect() + try: + await asyncio.wait_for(_reset_session(), timeout=45) + except asyncio.TimeoutError: + log.warning("voice swap: session busy too long; new voice applies on next reconnect") + except Exception: + # The polite path can fail for reasons that have nothing to do + # with the socket — notably the session lock being bound to a + # different event loop than the request runs on (py3.8 binds + # asyncio.Lock at construction, and voice_client is built at + # import time, before uvicorn's loop exists), which raises + # "got Future attached to a different loop" the moment the lock + # is contended. Swallowing that left the OLD-voice socket open, + # so the next Generate & Play spoke in the PREVIOUS robot's + # voice. Drop the socket unconditionally instead — worst case a + # concurrent generation reconnects. + log.warning("voice swap: graceful session reset failed — " + "forcing disconnect so the new voice applies", + exc_info=True) + try: + await voice_client.disconnect() + except Exception: + log.exception("voice swap: forced disconnect failed too") + + label = _VOICE_BY_NAME[voice]["label"] + log.info("Gemini voice switched to %s (%s)", voice, label) + return { + "ok": True, + "voice": voice, + "label": label, + "was_connected": was_connected, + "message": f"Voice set to {label} ({voice}) — applies on the next Generate & Play.", + } diff --git a/dashboard/static/index.html b/dashboard/static/index.html index d18f1c5..0e2508f 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -102,6 +102,30 @@ + + +
@@ -121,6 +145,7 @@
Voice & Audio
+
Live Gemini
Recordings
Settings & Logs
@@ -147,6 +172,19 @@
+ +
+

Robot Voice

+
+ Pick which robot's voice Gemini speaks with. Applies on the next + Generate & Play and is saved for next time. +
+
+ Loading… +
+
+
+

Typed Replay Engine

@@ -166,7 +204,8 @@
-
+ +
@@ -174,6 +213,64 @@ + +
+
+ +
+

Live Conversation idle

+

+ Talk to the robot in real time. Audio goes straight from this browser to Gemini — + the server only issues a short-lived session token, so the API key is never exposed. + Needs microphone permission. +

+ + +
+ +
+ + + +
+
+ + model: -- +
+ +
+ + +
+
+ +
+

Persona

+

+ How the robot selected above behaves in conversation — its character, dialect and + answering style. Pick any persona from the library and press + Use for this robot; it applies on the next connection. +

+
+ + + +
+
+ + +
+ + + +
+
+ +
+
+
@@ -181,6 +278,22 @@

Saved Records

+
+ + + + +
No records saved
@@ -196,33 +309,34 @@
- -
-

Scripts Manager

-
- + +
+

Voice Rule

+
+ The instruction that shapes how the robot speaks your typed text (accent, verbatim, gender). + Save & Apply takes effect on the next Generate & Play. +
+
- - - - + +
- -
-

Prompt Management

-
- -
- - -
-
-

Live Logs

+

Sign-in History

+
Loading…
+
+ + + +
+
+ +
+

Live Logs connecting…

@@ -242,7 +356,7 @@ function toast(m,t='info'){const b=document.getElementById('toast-box'),e=docume function esc(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} function btnLoad(b){if(b&&b.classList)b.classList.add('loading');} function btnDone(b){if(b&&b.classList)b.classList.remove('loading');} -async function api(m,p,b){const o={method:m,headers:{'Content-Type':'application/json'},credentials:'same-origin'};if(b)o.body=JSON.stringify(b);const r=await fetch(API+p,o);if(r.status===401){location.href='/login?next='+encodeURIComponent(location.pathname);throw new Error('Not authenticated');}const j=await r.json();if(!r.ok){toast(j.detail||j.error||'Error '+r.status,'err');throw new Error(j.detail||j.error);}return j;} +async function api(m,p,b){const o={method:m,headers:{'Content-Type':'application/json'},credentials:'same-origin'};if(b)o.body=JSON.stringify(b);const r=await fetch(API+p,o);if(r.status===401){location.href='/login?next='+encodeURIComponent(location.pathname);throw new Error('Not authenticated');}let j={};const txt=await r.text();if(txt){try{j=JSON.parse(txt);}catch(e){const msg=r.ok?'Unexpected non-JSON response from server':('Server error '+r.status+' (timeout or gateway)');toast(msg,'err');throw new Error(msg);}}if(!r.ok){toast(j.detail||j.error||('Error '+r.status),'err');throw new Error(j.detail||j.error||('Error '+r.status));}return j;} // Tabs function switchTab(name){document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.textContent.toLowerCase().includes(name.slice(0,4))));document.querySelectorAll('.tab-content').forEach(c=>c.classList.toggle('active',c.id==='tab-'+name));} @@ -307,40 +421,41 @@ async function refreshAudio(){ async function toggleMic(){try{await api('POST','/api/audio/mic/mute');}catch(e){}refreshAudio();} async function toggleSpeaker(){try{await api('POST','/api/audio/speaker/mute');}catch(e){}refreshAudio();} -// Scripts -async function refreshScripts(){try{const r=await api('GET','/api/scripts/');const sel=document.getElementById('script-select');sel.innerHTML=''+(r.files||[]).map(f=>``).join('');}catch(e){}} -async function loadScript(name){if(!name)return;try{const r=await api('POST','/api/scripts/load',{name});document.getElementById('script-content').value=r.content||'';}catch(e){}} -async function saveScript(){const name=document.getElementById('script-select').value,content=document.getElementById('script-content').value;if(!name)return toast('Select file','err');try{await api('POST','/api/scripts/save',{name,content});toast('Saved','ok');refreshScripts();}catch(e){}} -async function createScript(){const name=document.getElementById('script-new-name').value,content=document.getElementById('script-content').value;if(!name)return toast('Enter filename','err');try{await api('POST','/api/scripts/create',{name,content});toast('Created: '+name,'ok');refreshScripts();}catch(e){}} -async function deleteScript(){const name=document.getElementById('script-select').value;if(!name)return;if(confirm('Delete '+name+'?'))try{await api('POST','/api/scripts/delete',{name});toast('Deleted','ok');document.getElementById('script-content').value='';refreshScripts();}catch(e){}} - -// Prompt -async function refreshPrompt(){try{const r=await api('GET','/api/prompt/');document.getElementById('prompt-content').value=r.system_prompt||'';document.getElementById('prompt-info').textContent=`Script: ${r.script_path} | Rule: ${r.rule_path}`;}catch(e){}} -async function updatePrompt(){try{await api('POST','/api/prompt/update',{content:document.getElementById('prompt-content').value});toast('Saved','ok');}catch(e){}} -async function reloadPrompt(){try{const r=await api('POST','/api/prompt/reload');document.getElementById('prompt-content').value=r.system_prompt||'';toast('Reloaded','ok');}catch(e){}} +// Voice Rule — edit the replay instruction (sanad_rule.txt [REPLAY_SYSTEM_PROMPT]). +async function refreshRule(){try{const r=await api('GET','/api/prompt/rule');document.getElementById('rule-content').value=r.content||'';}catch(e){}} +async function saveRule(b){btnLoad(b);try{const r=await api('POST','/api/prompt/rule',{content:document.getElementById('rule-content').value});toast(r.message||'Rule saved','ok');}catch(e){}btnDone(b);} // Typed Replay — audio plays in YOUR browser (not the server's speaker). -let _trAudio=null; +let _trAudio=null,_trGenerating=false; function _playTRInBrowser(){ try{if(_trAudio){_trAudio.pause();_trAudio.src='';}}catch(e){} _trAudio=new Audio(API+'/api/typed-replay/audio/last?t='+Date.now()); return _trAudio.play(); } async function trGenerate(b){ + if(_trGenerating)return toast('Still generating — please wait…','info'); const t=document.getElementById('tr-text').value; if(!t)return toast('Enter text','err'); - btnLoad(b); + _trGenerating=true;btnLoad(b); try{ - await api('POST','/api/typed-replay/say',{ + const res=await api('POST','/api/typed-replay/say',{ text:t, record:document.getElementById('tr-capture').checked, record_name:document.getElementById('tr-name').value, }); + // Surface an off-voice / partial take instead of leaving it in the log. + const warnEl=document.getElementById('tr-warning'); + if(warnEl){ + if(res&&res.voice_warning){warnEl.textContent='\u26a0 '+res.voice_warning;warnEl.style.display='';} + else{warnEl.style.display='none';warnEl.textContent='';} + } await _playTRInBrowser(); - toast('Generated — playing in your browser','ok'); + const hz=(res&&res.pitch_hz)?` — ${Math.round(res.pitch_hz)} Hz`:''; + toast((res&&res.voice_warning)?('Generated, but check the warning'+hz):('Generated — playing in your browser'+hz), + (res&&res.voice_warning)?'err':'ok'); refreshTR(); }catch(e){toast('Play failed: '+(e&&e.message||e),'err');} - btnDone(b); + finally{_trGenerating=false;btnDone(b);} } async function trReplayLast(b){ btnLoad(b); @@ -356,7 +471,63 @@ async function trSaveLast(b){btnLoad(b);try{await api('POST','/api/typed-replay/ async function refreshTR(){try{const r=await api('GET','/api/typed-replay/status');const s=r.session||{};document.getElementById('tr-session').innerHTML=`Text: ${esc(s.text||'--')}
Audio: ${s.has_audio?'Yes':'No'} | Capture: ${s.has_capture?'Yes':'No'}
Replays: ${s.replay_count||0}
Generated: ${s.generated_at||'--'}
Saved: ${esc(s.saved_as||'--')}`;}catch(e){}} // Records -async function refreshRecords(){try{const r=await api('GET','/api/records/');const el=document.getElementById('records-list');if(!(r.records||[]).length){el.innerHTML='
No records saved
';return;}el.innerHTML=`
Total: ${r.total_records} | Updated: ${r.last_updated||'--'}
`+(r.records||[]).map(rec=>{const n=esc(rec.record_name);return``;}).join('')+'
NameTextReplays
${n}${esc(rec.text||'')}${rec.replay_count||0}
';}catch(e){}} +let _records=[]; +// Fold Arabic so a search for "سلام" also matches "السلام"/"سَلام": strip +// diacritics and tatweel, then normalise the alef/ya/ta-marbuta variants. +function _norm(s){return (s==null?'':String(s)).toLowerCase() + .replace(/[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED\u0640]/g,'') + .replace(/[\u0623\u0625\u0622\u0671]/g,'\u0627') + .replace(/\u0629/g,'\u0647').replace(/\u0649/g,'\u064A') + .replace(/\u0624/g,'\u0648').replace(/\u0626/g,'\u064A');} +function _dur(rec){const f=(rec.files||{});const a=f.speaker_recording||f.gemini_raw_output||{};return a.duration_seconds||0;} +function _saved(rec){return ((rec.timeline||{}).saved_at)||((rec.timeline||{}).audio_generated_at)||'';} +function clearRecordSearch(){const b=document.getElementById('records-search');if(b)b.value='';const v=document.getElementById('records-voice');if(v)v.value='';renderRecords();} +function renderRecords(){ + const el=document.getElementById('records-list');if(!el)return; + if(!_records.length){el.innerHTML='
No records saved
';return;} + const q=_norm((document.getElementById('records-search')||{}).value||'').trim(); + const terms=q?q.split(/\s+/):[]; + // every term must appear somewhere in the name or the text + const voiceSel=document.getElementById('records-voice'); + const want=(voiceSel||{}).value||''; + let list=_records.filter(r=>{ + if(want && (r.voice||'')!==want) return false; + const hay=_norm(r.record_name)+' '+_norm(r.text)+' '+_norm(r.voice_label)+' '+_norm(r.voice); + return terms.every(t=>hay.indexOf(t)>=0);}); + const sort=((document.getElementById('records-sort')||{}).value)||'new'; + const cmp={new:(a,b)=>_saved(b).localeCompare(_saved(a)), + old:(a,b)=>_saved(a).localeCompare(_saved(b)), + name:(a,b)=>String(a.record_name).localeCompare(String(b.record_name)), + long:(a,b)=>_dur(b)-_dur(a), + plays:(a,b)=>(b.replay_count||0)-(a.replay_count||0)}[sort]; + list=list.slice().sort(cmp); + const head=`
Showing ${list.length} of ${_records.length}${terms.length?' — filtered':''}
`; + if(!list.length){el.innerHTML=head+'
Nothing matches that search
';return;} + el.innerHTML=head+''+ + list.map(rec=>{const n=esc(rec.record_name);const d=_dur(rec); + const vl=rec.voice_label||rec.voice||'--'; + return ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``;}).join('')+'
NameVoiceTextSavedDurPitchPlays
${n}${esc(vl)}${esc(rec.text||'')}${esc((_saved(rec)||'--').slice(0,16))}${d?d.toFixed(1)+'s':'--'}${rec.pitch_hz?Math.round(rec.pitch_hz)+' Hz':'--'}${rec.voice_warning?' \u26a0':''}${rec.replay_count||0} `+ + ` `+ + ` `+ + `
'; +} +function _syncVoiceFilter(){ + const sel=document.getElementById('records-voice');if(!sel)return; + const seen=new Map(); + _records.forEach(r=>{if(r.voice)seen.set(r.voice,r.voice_label||r.voice);}); + const keep=sel.value; + sel.innerHTML=''+ + [...seen.entries()].map(([v,l])=>``).join(''); + if([...seen.keys()].includes(keep))sel.value=keep; +} +async function refreshRecords(){try{const r=await api('GET','/api/records/');_records=r.records||[];_syncVoiceFilter();renderRecords();}catch(e){}} // Browser-side playback — streams the WAV from /api/records/audio/{name} // and plays it through the user's speakers (not the robot's). let _recordAudio=null; @@ -473,11 +644,91 @@ function downloadLogBundle(){ async function refreshStatus(){try{const s=await api('GET','/api/status');document.getElementById('status-dot').className='dot dot-ok';document.getElementById('status-text').textContent='Online';const gb=document.getElementById('gemini-badge');if(s.voice?.connected){gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-ok';gb.textContent='GEMINI';}else{gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-err';gb.textContent='GEMINI OFF';}}catch(e){document.getElementById('status-dot').className='dot dot-err';document.getElementById('status-text').textContent='Offline';}} // WebSocket logs -let logWs;function connectLogs(){const p=location.protocol==='https:'?'wss':'ws';logWs=new WebSocket(`${p}://${location.host}/ws/logs`);const box=document.getElementById('log-box');logWs.onmessage=e=>{box.textContent+=e.data+'\n';if(box.childNodes.length>1000)box.textContent=box.textContent.split('\n').slice(-500).join('\n');box.scrollTop=box.scrollHeight;};logWs.onclose=()=>setTimeout(connectLogs,3000);} +let logWs, _logPoll=null, _logCursor=-1, _logMode=''; +function _logAppend(lines){ + const box=document.getElementById('log-box');if(!box||!lines||!lines.length)return; + box.textContent+=lines.join('\n')+'\n'; + const all=box.textContent.split('\n'); + if(all.length>1000)box.textContent=all.slice(-500).join('\n'); + box.scrollTop=box.scrollHeight; +} +function _logStatus(mode){ + if(_logMode===mode)return; _logMode=mode; + const el=document.getElementById('log-mode'); + if(el)el.textContent=mode==='ws'?'live (websocket)':(mode==='poll'?'live (polling)':'disconnected'); +} +// Poll the ring buffer. Used whenever the WebSocket cannot be established — +// which is always on the public URL, because the reverse proxy in front of +// this app forwards the upgrade as a plain GET. +async function _pollLogs(){ + try{ + const r=await api('GET','/api/logs/live?cursor='+_logCursor+'&limit=300'); + if(r&&r.lines){_logAppend(r.lines);_logCursor=r.cursor;_logStatus('poll');} + }catch(e){_logStatus('');} +} +function _startPolling(){ + if(_logPoll)return; + _pollLogs(); + _logPoll=setInterval(_pollLogs,2000); +} +function connectLogs(){ + const p=location.protocol==='https:'?'wss':'ws'; + let opened=false; + try{ logWs=new WebSocket(`${p}://${location.host}/ws/logs`); } + catch(e){ _startPolling(); return; } + logWs.onopen=()=>{opened=true;if(_logPoll){clearInterval(_logPoll);_logPoll=null;}_logStatus('ws');}; + logWs.onmessage=e=>_logAppend([e.data]); + logWs.onerror=()=>{try{logWs.close();}catch(_){}}; + logWs.onclose=()=>{ + // No WebSocket here: fall back rather than retrying forever into a proxy + // that will never upgrade. + _startPolling(); + if(opened)setTimeout(connectLogs,5000); + }; + // Belt and braces: if it never opens, start polling anyway. + setTimeout(()=>{if(!opened)_startPolling();},3000); +} +// ── sign-in history ──────────────────────────────────────────────── +// Exports the FULL history, not just the rows on screen. Navigating rather +// than fetching lets the browser save the file and send the session cookie. +function exportLogins(fmt){ + window.location='/api/auth/logins/export?format='+encodeURIComponent(fmt||'csv'); +} +async function refreshLogins(){ + try{ + const r=await api('GET','/api/auth/logins?limit=20'); + const el=document.getElementById('login-list');if(!el)return; + const rows=r.logins||[]; + if(!rows.length){el.innerHTML='
No sign-ins recorded yet
';return;} + el.innerHTML=''+ + rows.map(x=>``+ + ``+ + ``+ + ``+ + ``+ + ``).join('')+'
WhenDeviceIP
${esc(x.at||'')}${esc(x.device||'')}${esc(x.ip||'')}${x.ok?'ok':'failed'}
'; + }catch(e){} +} // Init — every audio feature in lite plays client-side via