preact
This commit is contained in:
parent
6acf267d48
commit
b7614eb3f8
4794 changed files with 1280808 additions and 1546 deletions
152
app.py
152
app.py
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue