This commit is contained in:
kacper 2026-03-06 22:51:19 -05:00
parent 6acf267d48
commit b7614eb3f8
4794 changed files with 1280808 additions and 1546 deletions

152
app.py
View file

@ -4,8 +4,9 @@ import json
from pathlib import Path
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from supertonic_gateway import SuperTonicGateway
@ -13,13 +14,24 @@ from voice_rtc import WebRTCVoiceSession
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
INDEX_PATH = STATIC_DIR / "index.html"
DIST_DIR = BASE_DIR / "frontend" / "dist"
app = FastAPI(title="Nanobot SuperTonic Wisper Web")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
gateway = SuperTonicGateway()
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Session store: one voice session per connection (keyed by a simple counter).
# For this single-user app we keep at most one active session at a time.
_active_session: WebRTCVoiceSession | None = None
_active_queue: asyncio.Queue | None = None
_sender_task: asyncio.Task | None = None
@app.get("/health")
@ -27,90 +39,86 @@ async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})
@app.get("/")
async def index() -> HTMLResponse:
html = INDEX_PATH.read_text(encoding="utf-8")
return HTMLResponse(content=html)
@app.post("/rtc/offer")
async def rtc_offer(request: Request) -> JSONResponse:
global _active_session, _active_queue, _sender_task
payload = await request.json()
@app.websocket("/ws/chat")
async def websocket_chat(websocket: WebSocket) -> None:
await websocket.accept()
send_lock = asyncio.Lock()
async def safe_send_json(payload: dict[str, Any]) -> None:
async with send_lock:
await websocket.send_json(payload)
# Tear down any previous session cleanly.
if _active_session is not None:
await _active_session.close()
_active_session = None
if _active_queue is not None:
await gateway.unsubscribe(_active_queue)
_active_queue = None
if _sender_task is not None:
_sender_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _sender_task
_sender_task = None
queue = await gateway.subscribe()
voice_session = WebRTCVoiceSession(gateway=gateway, send_json=safe_send_json)
sender = asyncio.create_task(_sender_loop(safe_send_json, queue, voice_session))
try:
while True:
raw_message = await websocket.receive_text()
try:
message = json.loads(raw_message)
except json.JSONDecodeError:
await safe_send_json(
{"role": "system", "text": "Invalid JSON message.", "timestamp": ""}
)
continue
_active_queue = queue
msg_type = str(message.get("type", "")).strip()
if msg_type == "spawn":
await gateway.spawn_tui()
elif msg_type == "stop":
await gateway.stop_tui()
elif msg_type == "rtc-offer":
await voice_session.handle_offer(message)
elif msg_type == "rtc-ice-candidate":
await voice_session.handle_ice_candidate(message)
elif msg_type == "voice-ptt":
voice_session.set_push_to_talk_pressed(bool(message.get("pressed", False)))
elif msg_type == "user-message":
await gateway.send_user_message(str(message.get("text", "")))
elif msg_type == "ui-response":
await gateway.send_ui_response(
str(message.get("request_id", "")),
str(message.get("value", "")),
)
elif msg_type == "command":
await gateway.send_command(str(message.get("command", "")))
else:
await safe_send_json(
{
"role": "system",
"text": (
"Unknown message type. Use spawn, stop, rtc-offer, "
"rtc-ice-candidate, voice-ptt, user-message, "
"ui-response, or command."
),
"timestamp": "",
}
)
except WebSocketDisconnect:
pass
finally:
sender.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sender
await voice_session.close()
await gateway.unsubscribe(queue)
voice_session = WebRTCVoiceSession(gateway=gateway)
_active_session = voice_session
_sender_task = asyncio.create_task(
_sender_loop(queue, voice_session),
name="rtc-sender",
)
answer = await voice_session.handle_offer(payload)
if answer is None:
return JSONResponse(
{"error": "WebRTC backend unavailable on host (aiortc is not installed)."},
status_code=503,
)
# Connect to nanobot if not already connected.
await gateway.spawn_tui()
return JSONResponse(answer)
@app.on_event("shutdown")
async def on_shutdown() -> None:
global _active_session, _active_queue, _sender_task
if _sender_task is not None:
_sender_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _sender_task
if _active_session is not None:
await _active_session.close()
if _active_queue is not None:
await gateway.unsubscribe(_active_queue)
await gateway.shutdown()
async def _sender_loop(
send_json: Callable[[dict[str, Any]], Awaitable[None]],
queue: asyncio.Queue,
voice_session: WebRTCVoiceSession,
voice_session: "WebRTCVoiceSession",
) -> None:
while True:
event = await queue.get()
if event.role == "nanobot-tts":
await voice_session.queue_output_text(event.text)
continue
await send_json(event.to_dict())
voice_session.send_to_datachannel(event.to_dict())
# Serve the Vite-built frontend as static files.
# This must come AFTER all API routes so the API endpoints are not shadowed.
if DIST_DIR.exists():
# Mount assets sub-directory (hashed JS/CSS)
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse:
candidate = DIST_DIR / full_path
if candidate.is_file():
return FileResponse(str(candidate))
return FileResponse(str(DIST_DIR / "index.html"))