mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
feat(channels): WebSocket server channel, debug UI, and logging
Add WebSocket server channel for outbound push, a Vite/React debugging interface under webui/websocket-debug, and refine log wording. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,418 @@
|
|||||||
|
"""WebSocket server channel: nanobot acts as a WebSocket server and serves connected clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import email.utils
|
||||||
|
import hmac
|
||||||
|
import http
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Self
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import Field, field_validator, model_validator
|
||||||
|
from websockets.asyncio.server import ServerConnection, serve
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
from websockets.http11 import Request as WsRequest, Response
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_trailing_slash(path: str) -> str:
|
||||||
|
if len(path) > 1 and path.endswith("/"):
|
||||||
|
return path.rstrip("/")
|
||||||
|
return path or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config_path(path: str) -> str:
|
||||||
|
return _strip_trailing_slash(path)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketConfig(Base):
|
||||||
|
"""WebSocket server channel configuration.
|
||||||
|
|
||||||
|
Clients connect with URLs like ``ws://{host}:{port}{path}?client_id=...&token=...``.
|
||||||
|
- ``client_id``: Used for ``allow_from`` authorization; if omitted, a value is generated and logged.
|
||||||
|
- ``token``: If non-empty, the ``token`` query param may match this static secret; short-lived tokens
|
||||||
|
from ``token_issue_path`` are also accepted.
|
||||||
|
- ``token_issue_path``: If non-empty, **GET** (HTTP/1.1) to this path returns JSON
|
||||||
|
``{"token": "...", "expires_in": <seconds>}``; use ``?token=...`` when opening the WebSocket.
|
||||||
|
Must differ from ``path`` (the WS upgrade path). If the client runs in the **same process** as
|
||||||
|
nanobot and shares the asyncio loop, use a thread or async HTTP client for GET—do not call
|
||||||
|
blocking ``urllib`` or synchronous ``httpx`` from inside a coroutine.
|
||||||
|
- ``token_issue_secret``: If non-empty, token requests must send ``Authorization: Bearer <secret>`` or
|
||||||
|
``X-Nanobot-Auth: <secret>``.
|
||||||
|
- ``websocket_requires_token``: If True, the handshake must include a valid token (static or issued and not expired).
|
||||||
|
- Each connection has its own session: a unique ``chat_id`` maps to the agent session internally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8765
|
||||||
|
path: str = "/"
|
||||||
|
token: str = ""
|
||||||
|
token_issue_path: str = ""
|
||||||
|
token_issue_secret: str = ""
|
||||||
|
token_ttl_s: int = Field(default=300, ge=30, le=86_400)
|
||||||
|
websocket_requires_token: bool = False
|
||||||
|
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
streaming: bool = True
|
||||||
|
max_message_bytes: int = Field(default=1_048_576, ge=1024, le=16_777_216)
|
||||||
|
ping_interval_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||||
|
ping_timeout_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||||
|
ssl_certfile: str = ""
|
||||||
|
ssl_keyfile: str = ""
|
||||||
|
|
||||||
|
@field_validator("path")
|
||||||
|
@classmethod
|
||||||
|
def path_must_start_with_slash(cls, value: str) -> str:
|
||||||
|
if not value.startswith("/"):
|
||||||
|
raise ValueError('path must start with "/"')
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("token_issue_path")
|
||||||
|
@classmethod
|
||||||
|
def token_issue_path_format(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if not value.startswith("/"):
|
||||||
|
raise ValueError('token_issue_path must start with "/"')
|
||||||
|
return _normalize_config_path(value)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def token_issue_path_differs_from_ws_path(self) -> Self:
|
||||||
|
if not self.token_issue_path:
|
||||||
|
return self
|
||||||
|
if _normalize_config_path(self.token_issue_path) == _normalize_config_path(self.path):
|
||||||
|
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
||||||
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = Headers(
|
||||||
|
[
|
||||||
|
("Date", email.utils.formatdate(usegmt=True)),
|
||||||
|
("Connection", "close"),
|
||||||
|
("Content-Length", str(len(body))),
|
||||||
|
("Content-Type", "application/json; charset=utf-8"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reason = http.HTTPStatus(status).phrase
|
||||||
|
return Response(status, reason, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
|
||||||
|
"""Parse normalized path and query parameters in one pass."""
|
||||||
|
parsed = urlparse("ws://x" + path_with_query)
|
||||||
|
path = _strip_trailing_slash(parsed.path or "/")
|
||||||
|
return path, parse_qs(parsed.query)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_http_path(path_with_query: str) -> str:
|
||||||
|
"""Return the path component (no query string), with trailing slash normalized (root stays ``/``)."""
|
||||||
|
return _parse_request_path(path_with_query)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
||||||
|
return _parse_request_path(path_with_query)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inbound_payload(raw: str) -> str | None:
|
||||||
|
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.startswith("{"):
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("content", "text", "message"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||||
|
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||||
|
if not configured_secret:
|
||||||
|
return True
|
||||||
|
authorization = headers.get("Authorization") or headers.get("authorization")
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
supplied = authorization[7:].strip()
|
||||||
|
return hmac.compare_digest(supplied, configured_secret)
|
||||||
|
header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
|
||||||
|
if not header_token:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(header_token.strip(), configured_secret)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketChannel(BaseChannel):
|
||||||
|
"""Run a local WebSocket server; forward text/JSON messages to the message bus."""
|
||||||
|
|
||||||
|
name = "websocket"
|
||||||
|
display_name = "WebSocket"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebSocketConfig.model_validate(config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self.config: WebSocketConfig = config
|
||||||
|
self._connections: dict[str, Any] = {}
|
||||||
|
self._issued_tokens: dict[str, float] = {}
|
||||||
|
self._stop_event: asyncio.Event | None = None
|
||||||
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return WebSocketConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def _expected_path(self) -> str:
|
||||||
|
return _normalize_config_path(self.config.path)
|
||||||
|
|
||||||
|
def _build_ssl_context(self) -> ssl.SSLContext | None:
|
||||||
|
cert = self.config.ssl_certfile.strip()
|
||||||
|
key = self.config.ssl_keyfile.strip()
|
||||||
|
if not cert and not key:
|
||||||
|
return None
|
||||||
|
if not cert or not key:
|
||||||
|
raise ValueError(
|
||||||
|
"websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
|
||||||
|
)
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def _purge_expired_issued_tokens(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
for token_key, expiry in list(self._issued_tokens.items()):
|
||||||
|
if now > expiry:
|
||||||
|
self._issued_tokens.pop(token_key, None)
|
||||||
|
|
||||||
|
def _take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
||||||
|
"""Validate and consume one issued token (single use per connection attempt)."""
|
||||||
|
if not token_value:
|
||||||
|
return False
|
||||||
|
self._purge_expired_issued_tokens()
|
||||||
|
expiry = self._issued_tokens.get(token_value)
|
||||||
|
if expiry is None:
|
||||||
|
return False
|
||||||
|
if time.monotonic() > expiry:
|
||||||
|
self._issued_tokens.pop(token_value, None)
|
||||||
|
return False
|
||||||
|
self._issued_tokens.pop(token_value, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _handle_token_issue_http(self, connection: Any, request: Any) -> Any:
|
||||||
|
secret = self.config.token_issue_secret.strip()
|
||||||
|
if secret:
|
||||||
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
|
return connection.respond(401, "Unauthorized")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"websocket: token_issue_path is set but token_issue_secret is empty; "
|
||||||
|
"any client can obtain connection tokens — set token_issue_secret for production."
|
||||||
|
)
|
||||||
|
self._purge_expired_issued_tokens()
|
||||||
|
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
|
||||||
|
self._issued_tokens[token_value] = time.monotonic() + float(self.config.token_ttl_s)
|
||||||
|
|
||||||
|
return _http_json_response(
|
||||||
|
{"token": token_value, "expires_in": self.config.token_ttl_s}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _authorize_websocket_handshake(self, connection: Any, request_path: str) -> Any:
|
||||||
|
query = _parse_query(request_path)
|
||||||
|
supplied = (query.get("token") or [None])[0]
|
||||||
|
static_token = self.config.token.strip()
|
||||||
|
|
||||||
|
if static_token:
|
||||||
|
if supplied == static_token:
|
||||||
|
return None
|
||||||
|
if supplied and self._take_issued_token_if_valid(supplied):
|
||||||
|
return None
|
||||||
|
return connection.respond(401, "Unauthorized")
|
||||||
|
|
||||||
|
if self.config.websocket_requires_token:
|
||||||
|
if supplied and self._take_issued_token_if_valid(supplied):
|
||||||
|
return None
|
||||||
|
return connection.respond(401, "Unauthorized")
|
||||||
|
|
||||||
|
if supplied:
|
||||||
|
self._take_issued_token_if_valid(supplied)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
ssl_context = self._build_ssl_context()
|
||||||
|
scheme = "wss" if ssl_context else "ws"
|
||||||
|
|
||||||
|
async def process_request(
|
||||||
|
connection: ServerConnection,
|
||||||
|
request: WsRequest,
|
||||||
|
) -> Any:
|
||||||
|
got, _ = _parse_request_path(request.path)
|
||||||
|
if self.config.token_issue_path:
|
||||||
|
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||||
|
if got == issue_expected:
|
||||||
|
return self._handle_token_issue_http(connection, request)
|
||||||
|
|
||||||
|
expected_ws = self._expected_path()
|
||||||
|
if got != expected_ws:
|
||||||
|
return connection.respond(404, "Not Found")
|
||||||
|
return self._authorize_websocket_handshake(connection, request.path)
|
||||||
|
|
||||||
|
async def handler(connection: ServerConnection) -> None:
|
||||||
|
await self._connection_loop(connection)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"WebSocket server listening on {}://{}:{}{}",
|
||||||
|
scheme,
|
||||||
|
self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
self.config.path,
|
||||||
|
)
|
||||||
|
if self.config.token_issue_path:
|
||||||
|
logger.info(
|
||||||
|
"WebSocket token issue route: {}://{}:{}{}",
|
||||||
|
scheme,
|
||||||
|
self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
_normalize_config_path(self.config.token_issue_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def runner() -> None:
|
||||||
|
async with serve(
|
||||||
|
handler,
|
||||||
|
self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
process_request=process_request,
|
||||||
|
max_size=self.config.max_message_bytes,
|
||||||
|
ping_interval=self.config.ping_interval_s,
|
||||||
|
ping_timeout=self.config.ping_timeout_s,
|
||||||
|
ssl=ssl_context,
|
||||||
|
):
|
||||||
|
assert self._stop_event is not None
|
||||||
|
await self._stop_event.wait()
|
||||||
|
|
||||||
|
self._server_task = asyncio.create_task(runner())
|
||||||
|
await self._server_task
|
||||||
|
|
||||||
|
async def _connection_loop(self, connection: Any) -> None:
|
||||||
|
request = connection.request
|
||||||
|
path_part = request.path if request else "/"
|
||||||
|
_, query = _parse_request_path(path_part)
|
||||||
|
client_id_raw = (query.get("client_id") or [None])[0]
|
||||||
|
client_id = client_id_raw.strip() if client_id_raw else ""
|
||||||
|
if not client_id:
|
||||||
|
client_id = f"anon-{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
chat_id = str(uuid.uuid4())
|
||||||
|
self._connections[chat_id] = connection
|
||||||
|
|
||||||
|
await connection.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"event": "ready",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw in connection:
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
try:
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
logger.warning("websocket: ignoring non-utf8 binary frame")
|
||||||
|
continue
|
||||||
|
content = _parse_inbound_payload(raw)
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=client_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=content,
|
||||||
|
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("websocket connection ended: {}", e)
|
||||||
|
finally:
|
||||||
|
self._connections.pop(chat_id, None)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._stop_event:
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._server_task:
|
||||||
|
await self._server_task
|
||||||
|
self._server_task = None
|
||||||
|
self._connections.clear()
|
||||||
|
self._issued_tokens.clear()
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
connection = self._connections.get(msg.chat_id)
|
||||||
|
if connection is None:
|
||||||
|
logger.warning("websocket: no active connection for chat_id={}", msg.chat_id)
|
||||||
|
return
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"event": "message",
|
||||||
|
"text": msg.content,
|
||||||
|
}
|
||||||
|
if msg.media:
|
||||||
|
payload["media"] = msg.media
|
||||||
|
if msg.reply_to:
|
||||||
|
payload["reply_to"] = msg.reply_to
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
await connection.send(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("websocket send failed: {}", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def send_delta(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
delta: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
connection = self._connections.get(chat_id)
|
||||||
|
if connection is None:
|
||||||
|
return
|
||||||
|
meta = metadata or {}
|
||||||
|
if meta.get("_stream_end"):
|
||||||
|
body: dict[str, Any] = {"event": "stream_end"}
|
||||||
|
if meta.get("_stream_id") is not None:
|
||||||
|
body["stream_id"] = meta["_stream_id"]
|
||||||
|
else:
|
||||||
|
body = {
|
||||||
|
"event": "delta",
|
||||||
|
"text": delta,
|
||||||
|
}
|
||||||
|
if meta.get("_stream_id") is not None:
|
||||||
|
body["stream_id"] = meta["_stream_id"]
|
||||||
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
await connection.send(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("websocket stream send failed: {}", e)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Unit and lightweight integration tests for the WebSocket channel."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.websocket import (
|
||||||
|
WebSocketChannel,
|
||||||
|
WebSocketConfig,
|
||||||
|
_issue_route_secret_matches,
|
||||||
|
_normalize_config_path,
|
||||||
|
_normalize_http_path,
|
||||||
|
_parse_inbound_payload,
|
||||||
|
_parse_query,
|
||||||
|
_parse_request_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
|
||||||
|
assert _normalize_http_path("/chat/") == "/chat"
|
||||||
|
assert _normalize_http_path("/chat?x=1") == "/chat"
|
||||||
|
assert _normalize_http_path("/") == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_request_path_matches_normalize_and_query() -> None:
|
||||||
|
path, query = _parse_request_path("/ws/?token=secret&client_id=u1")
|
||||||
|
assert path == _normalize_http_path("/ws/?token=secret&client_id=u1")
|
||||||
|
assert query == _parse_query("/ws/?token=secret&client_id=u1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_config_path_matches_request() -> None:
|
||||||
|
assert _normalize_config_path("/ws/") == "/ws"
|
||||||
|
assert _normalize_config_path("/") == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_query_extracts_token_and_client_id() -> None:
|
||||||
|
query = _parse_query("/?token=secret&client_id=u1")
|
||||||
|
assert query.get("token") == ["secret"]
|
||||||
|
assert query.get("client_id") == ["u1"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("plain", "plain"),
|
||||||
|
('{"content": "hi"}', "hi"),
|
||||||
|
('{"text": "there"}', "there"),
|
||||||
|
('{"message": "x"}', "x"),
|
||||||
|
(" ", None),
|
||||||
|
("{}", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_inbound_payload(raw: str, expected: str | None) -> None:
|
||||||
|
assert _parse_inbound_payload(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inbound_invalid_json_falls_back_to_raw_string() -> None:
|
||||||
|
assert _parse_inbound_payload("{not json") == "{not json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_socket_config_path_must_start_with_slash() -> None:
|
||||||
|
with pytest.raises(ValueError, match='path must start with "/"'):
|
||||||
|
WebSocketConfig(path="bad")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssl_context_requires_both_cert_and_key_files() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "sslCertfile": "/tmp/c.pem", "sslKeyfile": ""},
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="ssl_certfile and ssl_keyfile"):
|
||||||
|
channel._build_ssl_context()
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_config_includes_safe_bind_and_streaming() -> None:
|
||||||
|
defaults = WebSocketChannel.default_config()
|
||||||
|
assert defaults["enabled"] is False
|
||||||
|
assert defaults["host"] == "127.0.0.1"
|
||||||
|
assert defaults["streaming"] is True
|
||||||
|
assert defaults["allowFrom"] == ["*"]
|
||||||
|
assert defaults.get("tokenIssuePath", "") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_issue_path_must_differ_from_websocket_path() -> None:
|
||||||
|
with pytest.raises(ValueError, match="token_issue_path must differ"):
|
||||||
|
WebSocketConfig(path="/ws", token_issue_path="/ws")
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_route_secret_matches_bearer_and_header() -> None:
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
|
||||||
|
secret = "my-secret"
|
||||||
|
bearer_headers = Headers([("Authorization", "Bearer my-secret")])
|
||||||
|
assert _issue_route_secret_matches(bearer_headers, secret) is True
|
||||||
|
x_headers = Headers([("X-Nanobot-Auth", "my-secret")])
|
||||||
|
assert _issue_route_secret_matches(x_headers, secret) is True
|
||||||
|
wrong = Headers([("Authorization", "Bearer other")])
|
||||||
|
assert _issue_route_secret_matches(wrong, secret) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._connections["chat-1"] = mock_ws
|
||||||
|
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
content="hello",
|
||||||
|
reply_to="m1",
|
||||||
|
media=["/tmp/a.png"],
|
||||||
|
)
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
mock_ws.send.assert_awaited_once()
|
||||||
|
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||||
|
assert payload["event"] == "message"
|
||||||
|
assert payload["text"] == "hello"
|
||||||
|
assert payload["reply_to"] == "m1"
|
||||||
|
assert payload["media"] == ["/tmp/a.png"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_missing_connection_is_noop_without_error() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||||
|
msg = OutboundMessage(channel="websocket", chat_id="missing", content="x")
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_emits_delta_and_stream_end() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._connections["chat-1"] = mock_ws
|
||||||
|
|
||||||
|
await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"})
|
||||||
|
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
|
||||||
|
|
||||||
|
assert mock_ws.send.await_count == 2
|
||||||
|
first = json.loads(mock_ws.send.call_args_list[0][0][0])
|
||||||
|
second = json.loads(mock_ws.send.call_args_list[1][0][0])
|
||||||
|
assert first["event"] == "delta"
|
||||||
|
assert first["text"] == "part"
|
||||||
|
assert first["stream_id"] == "sid"
|
||||||
|
assert second["event"] == "stream_end"
|
||||||
|
assert second["stream_id"] == "sid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_end_to_end_client_receives_ready_and_agent_sees_inbound() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
port = 29876
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": port,
|
||||||
|
"path": "/ws",
|
||||||
|
},
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=tester") as client:
|
||||||
|
ready_raw = await client.recv()
|
||||||
|
ready = json.loads(ready_raw)
|
||||||
|
assert ready["event"] == "ready"
|
||||||
|
assert ready["client_id"] == "tester"
|
||||||
|
chat_id = ready["chat_id"]
|
||||||
|
|
||||||
|
await client.send(json.dumps({"content": "ping from client"}))
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
|
||||||
|
bus.publish_inbound.assert_awaited()
|
||||||
|
inbound = bus.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound.channel == "websocket"
|
||||||
|
assert inbound.sender_id == "tester"
|
||||||
|
assert inbound.chat_id == chat_id
|
||||||
|
assert inbound.content == "ping from client"
|
||||||
|
|
||||||
|
await client.send("plain text frame")
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
assert bus.publish_inbound.await_count >= 2
|
||||||
|
second = [c[0][0] for c in bus.publish_inbound.call_args_list][-1]
|
||||||
|
assert second.content == "plain text frame"
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_rejects_handshake_when_mismatch() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
port = 29877
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": port,
|
||||||
|
"path": "/",
|
||||||
|
"token": "secret",
|
||||||
|
},
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(websockets.exceptions.InvalidStatus) as excinfo:
|
||||||
|
async with websockets.connect(f"ws://127.0.0.1:{port}/?token=wrong"):
|
||||||
|
pass
|
||||||
|
assert excinfo.value.response.status_code == 401
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_path_returns_404() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
port = 29878
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": port,
|
||||||
|
"path": "/ws",
|
||||||
|
},
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(websockets.exceptions.InvalidStatus) as excinfo:
|
||||||
|
async with websockets.connect(f"ws://127.0.0.1:{port}/other"):
|
||||||
|
pass
|
||||||
|
assert excinfo.value.response.status_code == 404
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_discovers_websocket_channel() -> None:
|
||||||
|
from nanobot.channels.registry import load_channel_class
|
||||||
|
|
||||||
|
cls = load_channel_class("websocket")
|
||||||
|
assert cls.name == "websocket"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_route_issues_token_then_websocket_requires_it() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
port = 29879
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": port,
|
||||||
|
"path": "/ws",
|
||||||
|
"tokenIssuePath": "/auth/token",
|
||||||
|
"tokenIssueSecret": "route-secret",
|
||||||
|
"websocketRequiresToken": True,
|
||||||
|
},
|
||||||
|
bus,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deny = await _http_get(f"http://127.0.0.1:{port}/auth/token")
|
||||||
|
assert deny.status_code == 401
|
||||||
|
|
||||||
|
issue = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/auth/token",
|
||||||
|
headers={"Authorization": "Bearer route-secret"},
|
||||||
|
)
|
||||||
|
assert issue.status_code == 200
|
||||||
|
token = issue.json()["token"]
|
||||||
|
assert token.startswith("nbwt_")
|
||||||
|
|
||||||
|
with pytest.raises(websockets.exceptions.InvalidStatus) as missing_token:
|
||||||
|
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=x"):
|
||||||
|
pass
|
||||||
|
assert missing_token.value.response.status_code == 401
|
||||||
|
|
||||||
|
uri = f"ws://127.0.0.1:{port}/ws?token={token}&client_id=caller"
|
||||||
|
async with websockets.connect(uri) as client:
|
||||||
|
ready = json.loads(await client.recv())
|
||||||
|
assert ready["event"] == "ready"
|
||||||
|
assert ready["client_id"] == "caller"
|
||||||
|
|
||||||
|
with pytest.raises(websockets.exceptions.InvalidStatus) as reuse:
|
||||||
|
async with websockets.connect(uri):
|
||||||
|
pass
|
||||||
|
assert reuse.value.response.status_code == 401
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nanobot WebSocket Debug</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1820
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "nanobot-websocket-debug",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { JsonLogBody } from "./JsonLogBody";
|
||||||
|
import { summarizeServerPayload, tryParseServerPayload } from "./protocol";
|
||||||
|
|
||||||
|
type ConnectionStatus = "idle" | "connecting" | "open" | "closed" | "error";
|
||||||
|
|
||||||
|
type LogDirection = "in" | "out" | "system";
|
||||||
|
|
||||||
|
type LogFilter = "all" | LogDirection;
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
id: string;
|
||||||
|
at: number;
|
||||||
|
direction: LogDirection;
|
||||||
|
raw: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createLogId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestampMs: number): string {
|
||||||
|
return new Date(timestampMs).toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
fractionalSecondDigits: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, string>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value.trim()) {
|
||||||
|
search.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queryString = search.toString();
|
||||||
|
return queryString ? `?${queryString}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [useDevProxy, setUseDevProxy] = useState(true);
|
||||||
|
const [directWsUrl, setDirectWsUrl] = useState("ws://127.0.0.1:8765/");
|
||||||
|
const [clientId, setClientId] = useState("webui-debug");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [sendPayload, setSendPayload] = useState("Hello, nanobot");
|
||||||
|
const [sendAsJson, setSendAsJson] = useState(false);
|
||||||
|
|
||||||
|
const [tokenIssuePath, setTokenIssuePath] = useState("/auth/token");
|
||||||
|
const [tokenIssueSecret, setTokenIssueSecret] = useState("");
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [logFilter, setLogFilter] = useState<LogFilter>("all");
|
||||||
|
const [logSearch, setLogSearch] = useState("");
|
||||||
|
const [readyInfo, setReadyInfo] = useState<{ chatId: string; clientId: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [streamChunks, setStreamChunks] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const logScrollEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const resolvedWsUrl = useMemo(() => {
|
||||||
|
if (!useDevProxy) {
|
||||||
|
return directWsUrl.trim();
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const query = buildQuery({
|
||||||
|
client_id: clientId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
return `${protocol}//${window.location.host}/nanobot-dev${query}`;
|
||||||
|
}, [useDevProxy, directWsUrl, clientId, token]);
|
||||||
|
|
||||||
|
const appendLog = useCallback((direction: LogDirection, raw: string, summary?: string) => {
|
||||||
|
setLogs((previous) => [
|
||||||
|
...previous,
|
||||||
|
{
|
||||||
|
id: createLogId(),
|
||||||
|
at: Date.now(),
|
||||||
|
direction,
|
||||||
|
raw,
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
const needle = logSearch.trim().toLowerCase();
|
||||||
|
return logs.filter((entry) => {
|
||||||
|
if (logFilter !== "all" && entry.direction !== logFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const inRaw = entry.raw.toLowerCase().includes(needle);
|
||||||
|
const inSummary = entry.summary?.toLowerCase().includes(needle) ?? false;
|
||||||
|
return inRaw || inSummary;
|
||||||
|
});
|
||||||
|
}, [logs, logFilter, logSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logScrollEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
}, [logs.length]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socketRef.current = null;
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
setStatus("closed");
|
||||||
|
appendLog("system", "[local] Disconnected");
|
||||||
|
}, [appendLog]);
|
||||||
|
|
||||||
|
const handleInboundFrame = useCallback(
|
||||||
|
(rawText: string) => {
|
||||||
|
const parsed = tryParseServerPayload(rawText);
|
||||||
|
const summary = parsed ? summarizeServerPayload(parsed) : undefined;
|
||||||
|
appendLog("in", rawText, summary);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "ready") {
|
||||||
|
setReadyInfo({ chatId: parsed.chat_id, clientId: parsed.client_id });
|
||||||
|
setStreamChunks({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "delta") {
|
||||||
|
const streamKey = String(parsed.stream_id ?? "__default__");
|
||||||
|
setStreamChunks((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[streamKey]: (previous[streamKey] ?? "") + parsed.text,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "stream_end") {
|
||||||
|
const streamKey = String(parsed.stream_id ?? "__default__");
|
||||||
|
setStreamChunks((previous) => {
|
||||||
|
const finishedText = previous[streamKey];
|
||||||
|
if (finishedText !== undefined) {
|
||||||
|
const streamLabel = streamKey === "__default__" ? "(default)" : streamKey;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
appendLog(
|
||||||
|
"system",
|
||||||
|
`[stream_end] stream_id=${streamLabel} accumulated_len=${finishedText.length}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const next = { ...previous };
|
||||||
|
delete next[streamKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "message") {
|
||||||
|
setStreamChunks({});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendLog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
disconnect();
|
||||||
|
setLastError(null);
|
||||||
|
setReadyInfo(null);
|
||||||
|
setStreamChunks({});
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
let url = resolvedWsUrl;
|
||||||
|
if (!useDevProxy) {
|
||||||
|
const query = buildQuery({ client_id: clientId, token });
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(directWsUrl);
|
||||||
|
parsedUrl.search = query.slice(1);
|
||||||
|
url = parsedUrl.toString();
|
||||||
|
} catch {
|
||||||
|
setLastError("Invalid WebSocket URL");
|
||||||
|
setStatus("error");
|
||||||
|
appendLog("system", "[error] Failed to parse WebSocket URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog("system", `[connect] ${url}`);
|
||||||
|
|
||||||
|
let socket: WebSocket;
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLastError(message);
|
||||||
|
setStatus("error");
|
||||||
|
appendLog("system", `[error] ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setStatus("open");
|
||||||
|
appendLog("system", "[open] WebSocket connection established");
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
handleInboundFrame(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendLog(
|
||||||
|
"system",
|
||||||
|
`[recv] Non-text frame (${String(event.data?.constructor?.name ?? "unknown")})`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
setLastError("WebSocket error (see browser console for details)");
|
||||||
|
appendLog("system", "[error] WebSocket error event");
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
socketRef.current = null;
|
||||||
|
setStatus((previous) => (previous === "connecting" ? "error" : "closed"));
|
||||||
|
appendLog(
|
||||||
|
"system",
|
||||||
|
`[close] code=${event.code} reason=${event.reason || "(empty)"} wasClean=${event.wasClean}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
appendLog,
|
||||||
|
clientId,
|
||||||
|
directWsUrl,
|
||||||
|
disconnect,
|
||||||
|
handleInboundFrame,
|
||||||
|
resolvedWsUrl,
|
||||||
|
token,
|
||||||
|
useDevProxy,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendOutbound = useCallback(() => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
appendLog("system", "[send failed] Not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
if (sendAsJson) {
|
||||||
|
body = sendPayload.trim();
|
||||||
|
try {
|
||||||
|
JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
appendLog("system", "[send failed] Invalid JSON in send payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = sendPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(body);
|
||||||
|
appendLog("out", body);
|
||||||
|
}, [appendLog, sendAsJson, sendPayload]);
|
||||||
|
|
||||||
|
const clearLogs = useCallback(() => {
|
||||||
|
setLogs([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchIssuedToken = useCallback(async () => {
|
||||||
|
const path = tokenIssuePath.trim() || "/";
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
|
let httpUrl: string;
|
||||||
|
if (useDevProxy) {
|
||||||
|
httpUrl = `${window.location.origin}/nanobot-dev${normalizedPath}`;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const wsBase = new URL(directWsUrl);
|
||||||
|
const origin = `${wsBase.protocol === "wss:" ? "https:" : "http:"}//${wsBase.host}`;
|
||||||
|
httpUrl = `${origin}${normalizedPath}`;
|
||||||
|
} catch {
|
||||||
|
appendLog("system", "[token fetch failed] Enter a valid direct WebSocket URL first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog("system", `[HTTP GET] ${httpUrl}`);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
const secret = tokenIssueSecret.trim();
|
||||||
|
if (secret) {
|
||||||
|
headers["X-Nanobot-Auth"] = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(httpUrl, { headers });
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
appendLog("system", `[token fetch failed] HTTP ${response.status}: ${responseText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(responseText) as unknown;
|
||||||
|
} catch {
|
||||||
|
appendLog("system", `[token fetch failed] Response is not JSON: ${responseText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || !("token" in parsed)) {
|
||||||
|
appendLog("system", `[token fetch failed] Missing token field: ${responseText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const issued = (parsed as { token: unknown }).token;
|
||||||
|
if (typeof issued !== "string" || !issued) {
|
||||||
|
appendLog("system", "[token fetch failed] token must be a non-empty string");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToken(issued);
|
||||||
|
appendLog("system", "[token fetch ok] Token field updated");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
appendLog("system", `[token fetch failed] ${message}`);
|
||||||
|
}
|
||||||
|
}, [appendLog, directWsUrl, tokenIssuePath, tokenIssueSecret, useDevProxy]);
|
||||||
|
|
||||||
|
const statusLabel = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "idle":
|
||||||
|
return "Not connected";
|
||||||
|
case "connecting":
|
||||||
|
return "Connecting";
|
||||||
|
case "open":
|
||||||
|
return "Connected";
|
||||||
|
case "closed":
|
||||||
|
return "Closed";
|
||||||
|
case "error":
|
||||||
|
return "Error";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>Nanobot WebSocket debug</h1>
|
||||||
|
<p className="app-header-desc">
|
||||||
|
Same protocol as <code style={{ color: "var(--accent)" }}>nanobot/channels/websocket.py</code>: first frame{" "}
|
||||||
|
<code>ready</code>; downstream <code>message</code> / <code>delta</code> / <code>stream_end</code>; upstream
|
||||||
|
plain text or JSON with <code>content</code> / <code>text</code> / <code>message</code>.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="layout-main">
|
||||||
|
<div className="layout-left">
|
||||||
|
<section className={`panel panel--connection panel--status-${status}`}>
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<div className="connection-form">
|
||||||
|
<div className="connection-block connection-block--proxy">
|
||||||
|
<label className="connection-proxy">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useDevProxy}
|
||||||
|
onChange={(event) => setUseDevProxy(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="connection-proxy__body">
|
||||||
|
<span className="connection-proxy__title">Use Vite dev proxy</span>
|
||||||
|
<span className="connection-proxy__detail">
|
||||||
|
Path <code className="connection-inline-code">/nanobot-dev</code>
|
||||||
|
<span className="connection-proxy__env"> · target from </span>
|
||||||
|
<code className="connection-inline-code">VITE_NANOBOT_PROXY_TARGET</code>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!useDevProxy ? (
|
||||||
|
<div className="connection-block">
|
||||||
|
<label className="connection-field__label" htmlFor="connection-ws-url">
|
||||||
|
WebSocket URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="connection-ws-url"
|
||||||
|
className="connection-input"
|
||||||
|
value={directWsUrl}
|
||||||
|
onChange={(event) => setDirectWsUrl(event.target.value)}
|
||||||
|
placeholder="ws://127.0.0.1:8765/"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="connection-block">
|
||||||
|
<div className="connection-field__label" id="connection-resolved-label">
|
||||||
|
Resolved WebSocket URL
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="connection-url-box"
|
||||||
|
role="status"
|
||||||
|
aria-labelledby="connection-resolved-label"
|
||||||
|
>
|
||||||
|
<code className="connection-url-box__code">{resolvedWsUrl}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="connection-block connection-block--stack"
|
||||||
|
role="group"
|
||||||
|
aria-label="WebSocket query parameters"
|
||||||
|
>
|
||||||
|
<div className="connection-field">
|
||||||
|
<label className="connection-field__label" htmlFor="connection-client-id">
|
||||||
|
client_id
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="connection-client-id"
|
||||||
|
className="connection-input"
|
||||||
|
value={clientId}
|
||||||
|
onChange={(event) => setClientId(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="connection-field">
|
||||||
|
<label className="connection-field__label" htmlFor="connection-token">
|
||||||
|
token<span className="connection-field__optional"> (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="connection-token"
|
||||||
|
className="connection-input"
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => setToken(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="connection-block connection-block--stack connection-block--issue"
|
||||||
|
role="group"
|
||||||
|
aria-label="HTTP token issuance"
|
||||||
|
>
|
||||||
|
<p className="connection-block__caption">Fetch token (HTTP GET)</p>
|
||||||
|
<div className="connection-field">
|
||||||
|
<label className="connection-field__label" htmlFor="connection-issue-path">
|
||||||
|
Path
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="connection-issue-path"
|
||||||
|
className="connection-input connection-input--mono"
|
||||||
|
value={tokenIssuePath}
|
||||||
|
onChange={(event) => setTokenIssuePath(event.target.value)}
|
||||||
|
placeholder="/auth/token"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="connection-field">
|
||||||
|
<label className="connection-field__label" htmlFor="connection-secret">
|
||||||
|
X-Nanobot-Auth
|
||||||
|
<span className="connection-field__optional"> (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="connection-secret"
|
||||||
|
className="connection-input"
|
||||||
|
value={tokenIssueSecret}
|
||||||
|
onChange={(event) => setTokenIssueSecret(event.target.value)}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="connection-block connection-block--actions">
|
||||||
|
<div className="connection-actions" role="group" aria-labelledby="connection-actions-heading">
|
||||||
|
<p className="connection-actions__title" id="connection-actions-heading">
|
||||||
|
Actions
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="connection-actions__ws" role="group" aria-label="WebSocket">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary connection-actions__btn-ws"
|
||||||
|
onClick={connect}
|
||||||
|
disabled={status === "connecting"}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger connection-actions__btn-ws" onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="connection-actions__http" role="group" aria-label="HTTP fetch token">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="connection-btn-secondary connection-actions__btn-http"
|
||||||
|
onClick={fetchIssuedToken}
|
||||||
|
>
|
||||||
|
Fetch token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="connection-actions__status" aria-live="polite" aria-relevant="text">
|
||||||
|
<div className="connection-actions__status-line">
|
||||||
|
<span className="connection-actions__status-label">Status</span>
|
||||||
|
<span className={`status-pill ${status}`}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
{lastError ? (
|
||||||
|
<p className="connection-error" role="alert">
|
||||||
|
{lastError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{readyInfo && (
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Session</h2>
|
||||||
|
<p className="session-inline">
|
||||||
|
<span>
|
||||||
|
<strong>chat_id</strong> <code>{readyInfo.chatId}</code>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>client_id</strong> <code>{readyInfo.clientId}</code>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Send</h2>
|
||||||
|
<div className="form-horizontal">
|
||||||
|
<div className="field-row">
|
||||||
|
<span className="field-row__label field-row__label--narrow">Mode</span>
|
||||||
|
<div className="field-row__control">
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sendAsJson}
|
||||||
|
onChange={(event) => setSendAsJson(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Send as JSON (valid JSON; server reads content / text / message)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row field-row--top">
|
||||||
|
<span className="field-row__label">{sendAsJson ? "JSON" : "Text"}</span>
|
||||||
|
<div className="field-row__control field-row__control--fill">
|
||||||
|
<textarea
|
||||||
|
value={sendPayload}
|
||||||
|
onChange={(event) => setSendPayload(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-row__send">
|
||||||
|
<button type="button" className="primary" onClick={sendOutbound} disabled={status !== "open"}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layout-right">
|
||||||
|
<section className="panel panel--stream">
|
||||||
|
<h2>Streaming delta</h2>
|
||||||
|
<div className="stream-grid">
|
||||||
|
{Object.keys(streamChunks).length === 0 ? (
|
||||||
|
<p className="stream-placeholder" role="status">
|
||||||
|
After you connect and receive downstream <code>delta</code> events, streamed text appears here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
Object.entries(streamChunks).map(([streamKey, text]) => (
|
||||||
|
<div key={streamKey} className="stream-panel">
|
||||||
|
<h3>{streamKey === "__default__" ? "Default stream" : `stream_id=${streamKey}`}</h3>
|
||||||
|
<div className="stream-text">{text}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="panel panel--stretch">
|
||||||
|
<div className="panel-toolbar">
|
||||||
|
<h2>Message log</h2>
|
||||||
|
<span className="log-toolbar-meta">
|
||||||
|
{logs.length}
|
||||||
|
{filteredLogs.length !== logs.length ? ` / showing ${filteredLogs.length}` : ""} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="log-toolbar-row">
|
||||||
|
<div className="log-filter" role="group" aria-label="Log direction filter">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "all" as const, label: "All" },
|
||||||
|
{ key: "in" as const, label: "In" },
|
||||||
|
{ key: "out" as const, label: "Out" },
|
||||||
|
{ key: "system" as const, label: "System" },
|
||||||
|
] as const
|
||||||
|
).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`chip ${logFilter === key ? "chip--active" : ""}`}
|
||||||
|
onClick={() => setLogFilter(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="log-search"
|
||||||
|
placeholder="Search raw text or summary…"
|
||||||
|
value={logSearch}
|
||||||
|
onChange={(event) => setLogSearch(event.target.value)}
|
||||||
|
aria-label="Search logs"
|
||||||
|
/>
|
||||||
|
<button type="button" className="toolbar-btn" onClick={clearLogs}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="log-list-wrap">
|
||||||
|
<ul className="log-list" aria-live="polite" aria-relevant="additions">
|
||||||
|
{filteredLogs.length === 0 && (
|
||||||
|
<li className="log-empty">
|
||||||
|
{logs.length === 0
|
||||||
|
? "Messages will appear here after you connect."
|
||||||
|
: "No logs match the current filters."}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{filteredLogs.map((entry) => (
|
||||||
|
<li key={entry.id} className="log-item">
|
||||||
|
<div className="log-meta">
|
||||||
|
<span className="log-time">{formatTime(entry.at)}</span>
|
||||||
|
<span className={`badge ${entry.direction}`}>
|
||||||
|
{entry.direction === "in"
|
||||||
|
? "← in"
|
||||||
|
: entry.direction === "out"
|
||||||
|
? "→ out"
|
||||||
|
: "sys"}
|
||||||
|
</span>
|
||||||
|
{entry.summary && <span className="log-meta__summary">{entry.summary}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="log-body">
|
||||||
|
<JsonLogBody raw={entry.raw} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div ref={logScrollEndRef} className="log-scroll-anchor" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function JsonString({ value }: { value: string }) {
|
||||||
|
return <span className="json-str">"{value}"</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonValue({ value, indent }: { value: unknown; indent: number }): ReactNode {
|
||||||
|
const pad = indent * 0.5;
|
||||||
|
if (value === null) {
|
||||||
|
return <span className="json-null">null</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return <span className="json-bool">{value ? "true" : "false"}</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return <span className="json-num">{String(value)}</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return <JsonString value={value} />;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return <span>[]</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{"["}
|
||||||
|
<br />
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<span key={index} style={{ display: "block", paddingLeft: `${pad + 0.75}rem` }}>
|
||||||
|
<JsonValue value={item} indent={indent + 1} />
|
||||||
|
{index < value.length - 1 ? "," : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ display: "block", paddingLeft: `${pad}rem` }}>{"]"}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <span>{"{}"}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{"{"}
|
||||||
|
<br />
|
||||||
|
{entries.map(([key, child], index) => (
|
||||||
|
<span key={key} style={{ display: "block", paddingLeft: `${pad + 0.75}rem` }}>
|
||||||
|
<span className="json-key">"{key}"</span>
|
||||||
|
<span className="json-punct">: </span>
|
||||||
|
<JsonValue value={child} indent={indent + 1} />
|
||||||
|
{index < entries.length - 1 ? "," : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ display: "block", paddingLeft: `${pad}rem` }}>{"}"}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="json-unknown">{String(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseJsonObject(raw: string): unknown | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonLogBody({ raw }: { raw: string }) {
|
||||||
|
const parsed = tryParseJsonObject(raw);
|
||||||
|
if (parsed === null) {
|
||||||
|
return <span className="log-body-plain">{raw}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="log-body-json">
|
||||||
|
<JsonValue value={parsed} indent={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("root element missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/** Mirrors nanobot/channels/websocket.py outbound payloads. */
|
||||||
|
|
||||||
|
export type ReadyEvent = {
|
||||||
|
event: "ready";
|
||||||
|
chat_id: string;
|
||||||
|
client_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageEvent = {
|
||||||
|
event: "message";
|
||||||
|
text: string;
|
||||||
|
media?: unknown;
|
||||||
|
reply_to?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeltaEvent = {
|
||||||
|
event: "delta";
|
||||||
|
text: string;
|
||||||
|
stream_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamEndEvent = {
|
||||||
|
event: "stream_end";
|
||||||
|
stream_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedServerPayload =
|
||||||
|
| ReadyEvent
|
||||||
|
| MessageEvent
|
||||||
|
| DeltaEvent
|
||||||
|
| StreamEndEvent;
|
||||||
|
|
||||||
|
export function tryParseServerPayload(raw: string): ParsedServerPayload | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed.startsWith("{")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed) as unknown;
|
||||||
|
if (data && typeof data === "object" && !Array.isArray(data) && "event" in data) {
|
||||||
|
return data as ParsedServerPayload;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeServerPayload(parsed: ParsedServerPayload): string {
|
||||||
|
switch (parsed.event) {
|
||||||
|
case "ready":
|
||||||
|
return `ready · chat_id=${parsed.chat_id} · client_id=${parsed.client_id}`;
|
||||||
|
case "message": {
|
||||||
|
const extras: string[] = [];
|
||||||
|
if (parsed.media !== undefined) {
|
||||||
|
extras.push("media");
|
||||||
|
}
|
||||||
|
if (parsed.reply_to !== undefined) {
|
||||||
|
extras.push("reply_to");
|
||||||
|
}
|
||||||
|
const suffix = extras.length ? ` · [${extras.join(", ")}]` : "";
|
||||||
|
const preview =
|
||||||
|
parsed.text.length > 120 ? `${parsed.text.slice(0, 120)}…` : parsed.text;
|
||||||
|
return `message · ${preview}${suffix}`;
|
||||||
|
}
|
||||||
|
case "delta": {
|
||||||
|
const sid =
|
||||||
|
parsed.stream_id !== undefined ? ` stream_id=${String(parsed.stream_id)}` : "";
|
||||||
|
const preview =
|
||||||
|
parsed.text.length > 80 ? `${parsed.text.slice(0, 80)}…` : parsed.text;
|
||||||
|
return `delta${sid} · ${preview}`;
|
||||||
|
}
|
||||||
|
case "stream_end": {
|
||||||
|
const sid =
|
||||||
|
parsed.stream_id !== undefined ? ` stream_id=${String(parsed.stream_id)}` : "";
|
||||||
|
return `stream_end${sid}`;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const unknownEvent: string = (parsed as { event: string }).event;
|
||||||
|
return unknownEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_NANOBOT_PROXY_TARGET?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev proxy: avoid browser CORS when calling token_issue HTTP route or WebSocket
|
||||||
|
* on another origin. Set VITE_NANOBOT_PROXY_TARGET (e.g. http://127.0.0.1:8765).
|
||||||
|
* Connect WebSocket to: ws://localhost:5173/nanobot-dev/?client_id=...
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
const proxyTarget = env.VITE_NANOBOT_PROXY_TARGET ?? "http://127.0.0.1:8765";
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/nanobot-dev": {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: (path) => {
|
||||||
|
const stripped = path.replace(/^\/nanobot-dev/, "");
|
||||||
|
if (!stripped || stripped === "") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return stripped.startsWith("/") ? stripped : `/${stripped}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user