322 lines
12 KiB
Python
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()
|