stable
This commit is contained in:
parent
b7614eb3f8
commit
db4ce8b14f
22 changed files with 3557 additions and 823 deletions
|
|
@ -1,31 +1,28 @@
|
|||
"""SuperTonic Gateway — nanobot integration for the web UI.
|
||||
"""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::
|
||||
Wire protocol (newline-delimited JSON-RPC 2.0)
|
||||
-----------------------------------------------
|
||||
Client -> nanobot notifications::
|
||||
|
||||
{"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"}
|
||||
{"jsonrpc": "2.0", "method": "message.send",
|
||||
"params": {"content": "hello", "chat_id": "web", "metadata": {}}}
|
||||
{"jsonrpc": "2.0", "method": "card.respond",
|
||||
"params": {"card_id": "card_123", "value": "Option A"}}
|
||||
{"jsonrpc": "2.0", "method": "command.execute",
|
||||
"params": {"command": "reset", "chat_id": "web"}}
|
||||
|
||||
nanobot → client::
|
||||
nanobot -> client notifications::
|
||||
|
||||
{"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.
|
||||
{"jsonrpc": "2.0", "method": "message",
|
||||
"params": {"content": "Hi!", "chat_id": "web", "is_progress": false}}
|
||||
{"jsonrpc": "2.0", "method": "agent_state",
|
||||
"params": {"state": "thinking", "chat_id": "web"}}
|
||||
{"jsonrpc": "2.0", "method": "card",
|
||||
"params": {"id": "card_123", "kind": "text", "title": "Weather", "lane": "context"}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -34,27 +31,30 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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"
|
||||
_JSONRPC_VERSION = "2.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NanobotApiProcess — connects to the running nanobot via its Unix socket
|
||||
# ---------------------------------------------------------------------------
|
||||
def _encode(obj: dict[str, Any]) -> bytes:
|
||||
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": _JSONRPC_VERSION,
|
||||
"method": method,
|
||||
}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return payload
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Connects to the running nanobot process via its Unix domain socket."""
|
||||
|
||||
def __init__(self, bus: WisperBus, socket_path: Path) -> None:
|
||||
self._bus = bus
|
||||
|
|
@ -74,9 +74,7 @@ class NanobotApiProcess:
|
|||
|
||||
async def start(self) -> None:
|
||||
if self.running:
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text="Already connected to nanobot.")
|
||||
)
|
||||
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
|
||||
return
|
||||
|
||||
if not self._socket_path.exists():
|
||||
|
|
@ -99,64 +97,57 @@ class NanobotApiProcess:
|
|||
)
|
||||
except OSError as exc:
|
||||
await self._bus.publish(
|
||||
WisperEvent(
|
||||
role="system",
|
||||
text=f"Could not connect to nanobot API socket: {exc}",
|
||||
)
|
||||
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:
|
||||
async def send(self, text: str, metadata: dict[str, Any] | None = None) -> 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.",
|
||||
)
|
||||
)
|
||||
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
|
||||
return
|
||||
payload = json.dumps({"type": "message", "content": text, "chat_id": "web"}) + "\n"
|
||||
try:
|
||||
self._writer.write(payload.encode())
|
||||
await self._writer.drain()
|
||||
await self._send_notification(
|
||||
"message.send",
|
||||
{
|
||||
"content": text,
|
||||
"chat_id": "web",
|
||||
"metadata": dict(metadata or {}),
|
||||
},
|
||||
)
|
||||
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."""
|
||||
async def send_card_response(self, card_id: str, value: str) -> None:
|
||||
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()
|
||||
await self._send_notification(
|
||||
"card.respond",
|
||||
{
|
||||
"card_id": card_id,
|
||||
"value": value,
|
||||
},
|
||||
)
|
||||
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.",
|
||||
)
|
||||
)
|
||||
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
|
||||
return
|
||||
payload = json.dumps({"type": "command", "command": command, "chat_id": "web"}) + "\n"
|
||||
try:
|
||||
self._writer.write(payload.encode())
|
||||
await self._writer.drain()
|
||||
await self._send_notification(
|
||||
"command.execute",
|
||||
{
|
||||
"command": command,
|
||||
"chat_id": "web",
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
|
||||
await self._cleanup()
|
||||
|
|
@ -165,10 +156,6 @@ class NanobotApiProcess:
|
|||
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()
|
||||
|
|
@ -187,8 +174,12 @@ class NanobotApiProcess:
|
|||
self._writer = None
|
||||
self._reader = None
|
||||
|
||||
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
|
||||
assert self._writer is not None
|
||||
self._writer.write(_encode(_jsonrpc_notification(method, params)))
|
||||
await self._writer.drain()
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""Read newline-delimited JSON from nanobot and publish WisperEvents."""
|
||||
assert self._reader is not None
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -197,13 +188,10 @@ class NanobotApiProcess:
|
|||
except OSError:
|
||||
break
|
||||
if not line:
|
||||
break # EOF — nanobot closed the connection
|
||||
break
|
||||
await self._handle_line(line)
|
||||
finally:
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text="Nanobot closed the connection.")
|
||||
)
|
||||
# Clear writer so running → False
|
||||
await self._bus.publish(WisperEvent(role="system", text="Nanobot closed the connection."))
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
|
||||
|
|
@ -219,43 +207,39 @@ class NanobotApiProcess:
|
|||
)
|
||||
return
|
||||
|
||||
msg_type = str(obj.get("type", ""))
|
||||
if not isinstance(obj, dict) or obj.get("jsonrpc") != _JSONRPC_VERSION:
|
||||
await self._bus.publish(
|
||||
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
|
||||
)
|
||||
return
|
||||
|
||||
if msg_type == "message":
|
||||
content = str(obj.get("content", ""))
|
||||
is_progress = bool(obj.get("is_progress", False))
|
||||
if "method" not in obj:
|
||||
error = obj.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = str(error.get("message", "Unknown JSON-RPC error"))
|
||||
await self._bus.publish(WisperEvent(role="system", text=f"Nanobot error: {message}"))
|
||||
return
|
||||
|
||||
method = str(obj.get("method", "")).strip()
|
||||
params = obj.get("params", {})
|
||||
if params is None or not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
if method == "message":
|
||||
content = str(params.get("content", ""))
|
||||
is_progress = bool(params.get("is_progress", False))
|
||||
is_tool_hint = bool(params.get("is_tool_hint", False))
|
||||
if is_progress:
|
||||
# Intermediate tool-call hint — show in UI, skip TTS
|
||||
await self._bus.publish(WisperEvent(role="nanobot-progress", text=content))
|
||||
role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
|
||||
await self._bus.publish(WisperEvent(role=role, 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", ""))
|
||||
elif method == "agent_state":
|
||||
state = str(params.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)
|
||||
# ---------------------------------------------------------------------------
|
||||
elif method == "card":
|
||||
await self._bus.publish(WisperEvent(role="card", text=json.dumps(params)))
|
||||
|
||||
|
||||
class SuperTonicGateway:
|
||||
|
|
@ -272,51 +256,40 @@ class SuperTonicGateway:
|
|||
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 def connect_nanobot(self) -> None:
|
||||
async with self._lock:
|
||||
if self._process and self._process.running:
|
||||
await self.bus.publish(
|
||||
WisperEvent(role="system", text="Already connected to nanobot.")
|
||||
)
|
||||
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:
|
||||
async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> 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.",
|
||||
)
|
||||
)
|
||||
await self.bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
|
||||
return
|
||||
await self._process.send(message)
|
||||
await self._process.send(message, metadata=metadata)
|
||||
|
||||
async def send_ui_response(self, request_id: str, value: str) -> None:
|
||||
"""Forward a choice selection back to nanobot."""
|
||||
async def send_card_response(self, card_id: str, value: str) -> None:
|
||||
async with self._lock:
|
||||
if self._process:
|
||||
await self._process.send_ui_response(request_id, value)
|
||||
await self._process.send_card_response(card_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 def disconnect_nanobot(self) -> None:
|
||||
async with self._lock:
|
||||
if self._process:
|
||||
await self._process.stop()
|
||||
self._process = None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
await self.stop_tui()
|
||||
await self.disconnect_nanobot()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue