nanobot-voice-interface/supertonic_gateway.py
2026-03-05 15:10:14 -05:00

322 lines
12 KiB
Python

"""SuperTonic Gateway — nanobot integration for the web UI.
Connects to the already-running nanobot process via a Unix domain socket.
nanobot must be started separately (e.g. ``nanobot gateway``) with the API
channel enabled in its config.
Wire protocol (newline-delimited JSON)
---------------------------------------
Client → nanobot::
{"type": "message", "content": "hello", "chat_id": "web"}
{"type": "ping"}
{"type": "ui-response", "request_id": "<uuid>", "value": "Option A", "chat_id": "web"}
{"type": "command", "command": "reset", "chat_id": "web"}
nanobot → client::
{"type": "message", "content": "Hi!", "chat_id": "web", "is_progress": false}
{"type": "agent_state", "state": "thinking", "chat_id": "web"}
{"type": "toast", "kind": "text"|"image", "content": "...", "title": "...", "duration_ms": 5000}
{"type": "choice", "request_id": "<uuid>", "question": "...", "choices": ["A", "B"],
"title": "...", "chat_id": "web"}
{"type": "pong"}
{"type": "error", "error": "..."}
The public ``SuperTonicGateway`` interface (``spawn_tui``, ``send_user_message``,
``stop_tui``, ``shutdown``) is unchanged so ``app.py`` and ``voice_rtc.py``
require no modification.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from wisper import WisperBus, WisperEvent
# Default path — must match nanobot's channels.api.socket_path config value.
DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock"
# ---------------------------------------------------------------------------
# NanobotApiProcess — connects to the running nanobot via its Unix socket
# ---------------------------------------------------------------------------
class NanobotApiProcess:
"""Connects to the running nanobot process via its Unix domain socket.
Lifecycle
---------
``start()`` — opens a connection to nanobot's API socket.
``send()`` — writes a user message over the socket.
``stop()`` — closes the connection.
"""
def __init__(self, bus: WisperBus, socket_path: Path) -> None:
self._bus = bus
self._socket_path = socket_path
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._read_task: asyncio.Task | None = None
@property
def running(self) -> bool:
return (
self._writer is not None
and not self._writer.is_closing()
and self._read_task is not None
and not self._read_task.done()
)
async def start(self) -> None:
if self.running:
await self._bus.publish(
WisperEvent(role="system", text="Already connected to nanobot.")
)
return
if not self._socket_path.exists():
await self._bus.publish(
WisperEvent(
role="system",
text=(
f"Nanobot API socket not found at {self._socket_path}. "
"Make sure nanobot is running with the API channel enabled "
"(set channels.api.enabled = true in ~/.nanobot/config.json, "
"then run: nanobot gateway)."
),
)
)
return
try:
self._reader, self._writer = await asyncio.open_unix_connection(
path=str(self._socket_path)
)
except OSError as exc:
await self._bus.publish(
WisperEvent(
role="system",
text=f"Could not connect to nanobot API socket: {exc}",
)
)
return
self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader")
await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot."))
async def send(self, text: str) -> None:
if not self.running or self._writer is None:
await self._bus.publish(
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return
payload = json.dumps({"type": "message", "content": text, "chat_id": "web"}) + "\n"
try:
self._writer.write(payload.encode())
await self._writer.drain()
except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
async def send_ui_response(self, request_id: str, value: str) -> None:
"""Forward a ui-response (choice selection) back to nanobot."""
if not self.running or self._writer is None:
return
payload = (
json.dumps(
{"type": "ui-response", "request_id": request_id, "value": value, "chat_id": "web"}
)
+ "\n"
)
try:
self._writer.write(payload.encode())
await self._writer.drain()
except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
async def send_command(self, command: str) -> None:
"""Send a command (e.g. 'reset') to nanobot."""
if not self.running or self._writer is None:
await self._bus.publish(
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return
payload = json.dumps({"type": "command", "command": command, "chat_id": "web"}) + "\n"
try:
self._writer.write(payload.encode())
await self._writer.drain()
except OSError as exc:
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
await self._cleanup()
async def stop(self) -> None:
await self._cleanup()
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
async def _cleanup(self) -> None:
if self._read_task and not self._read_task.done():
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
if self._writer:
try:
self._writer.close()
await self._writer.wait_closed()
except OSError:
pass
self._writer = None
self._reader = None
async def _read_loop(self) -> None:
"""Read newline-delimited JSON from nanobot and publish WisperEvents."""
assert self._reader is not None
try:
while True:
try:
line = await self._reader.readline()
except OSError:
break
if not line:
break # EOF — nanobot closed the connection
await self._handle_line(line)
finally:
await self._bus.publish(
WisperEvent(role="system", text="Nanobot closed the connection.")
)
# Clear writer so running → False
self._writer = None
self._reader = None
async def _handle_line(self, line: bytes) -> None:
raw = line.decode(errors="replace").strip()
if not raw:
return
try:
obj = json.loads(raw)
except json.JSONDecodeError:
await self._bus.publish(
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
)
return
msg_type = str(obj.get("type", ""))
if msg_type == "message":
content = str(obj.get("content", ""))
is_progress = bool(obj.get("is_progress", False))
if is_progress:
# Intermediate tool-call hint — show in UI, skip TTS
await self._bus.publish(WisperEvent(role="nanobot-progress", text=content))
else:
# Final answer — display + TTS
await self._bus.publish(WisperEvent(role="nanobot", text=content))
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
elif msg_type == "agent_state":
state = str(obj.get("state", ""))
await self._bus.publish(WisperEvent(role="agent-state", text=state))
elif msg_type == "toast":
# Forward the full toast payload as JSON so the frontend can render it.
await self._bus.publish(WisperEvent(role="toast", text=json.dumps(obj)))
elif msg_type == "choice":
# Forward the full choice payload as JSON so the frontend can render it.
await self._bus.publish(WisperEvent(role="choice", text=json.dumps(obj)))
elif msg_type == "pong":
pass # keepalive, ignore
elif msg_type == "error":
await self._bus.publish(
WisperEvent(role="system", text=f"Nanobot error: {obj.get('error', '')}")
)
# ---------------------------------------------------------------------------
# SuperTonicGateway — public interface (unchanged from original)
# ---------------------------------------------------------------------------
class SuperTonicGateway:
def __init__(self) -> None:
self.bus = WisperBus()
self._lock = asyncio.Lock()
self._process: NanobotApiProcess | None = None
socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser()
self._socket_path = socket_path
async def subscribe(self) -> asyncio.Queue[WisperEvent]:
return await self.bus.subscribe()
async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None:
await self.bus.unsubscribe(queue)
async def spawn_tui(self) -> None:
"""Connect to nanobot (name kept for API compatibility with app.py)."""
async with self._lock:
if self._process and self._process.running:
await self.bus.publish(
WisperEvent(role="system", text="Already connected to nanobot.")
)
return
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
await self._process.start()
async def send_user_message(self, text: str) -> None:
message = text.strip()
if not message:
return
await self.bus.publish(WisperEvent(role="user", text=message))
async with self._lock:
if not self._process:
await self.bus.publish(
WisperEvent(
role="system",
text="Not connected to nanobot. Click spawn first.",
)
)
return
await self._process.send(message)
async def send_ui_response(self, request_id: str, value: str) -> None:
"""Forward a choice selection back to nanobot."""
async with self._lock:
if self._process:
await self._process.send_ui_response(request_id, value)
async def send_command(self, command: str) -> None:
"""Send a command (e.g. 'reset') to nanobot."""
async with self._lock:
if self._process:
await self._process.send_command(command)
async def stop_tui(self) -> None:
"""Disconnect from nanobot (name kept for API compatibility with app.py)."""
async with self._lock:
if self._process:
await self._process.stop()
self._process = None
async def shutdown(self) -> None:
await self.stop_tui()