2026-04-08 21:39:35 +08:00
|
|
|
|
"""WebSocket server channel: nanobot acts as a WebSocket server and serves connected clients."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-08-31 16:23:07 +08:00
|
|
|
|
import errno
|
2026-08-02 00:22:19 +09:00
|
|
|
|
import ipaddress
|
2026-04-08 21:39:35 +08:00
|
|
|
|
import json
|
2026-08-27 10:23:35 +08:00
|
|
|
|
import socket
|
2026-04-08 21:39:35 +08:00
|
|
|
|
import ssl
|
|
|
|
|
|
import uuid
|
2026-05-29 03:42:53 +08:00
|
|
|
|
from contextlib import suppress
|
2026-04-18 18:51:53 +00:00
|
|
|
|
from pathlib import Path
|
2026-08-26 18:04:38 +08:00
|
|
|
|
from typing import TYPE_CHECKING, Any, Self, TypeGuard, cast
|
2026-08-02 06:06:08 +09:00
|
|
|
|
from urllib.parse import urlsplit, urlunsplit
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-08-02 00:22:19 +09:00
|
|
|
|
from pydantic import Field, PrivateAttr, field_validator, model_validator
|
2026-08-27 10:23:35 +08:00
|
|
|
|
from websockets.asyncio.server import Server, ServerConnection, serve, unix_serve
|
2026-04-09 13:47:53 +08:00
|
|
|
|
from websockets.exceptions import ConnectionClosed
|
2026-04-18 08:44:12 +00:00
|
|
|
|
from websockets.http11 import Request as WsRequest
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-08-05 19:24:41 +08:00
|
|
|
|
from nanobot.bus.events import (
|
|
|
|
|
|
OUTBOUND_META_AGENT_UI,
|
|
|
|
|
|
OutboundMessage,
|
|
|
|
|
|
)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
|
|
from nanobot.channels.base import BaseChannel
|
|
|
|
|
|
from nanobot.config.schema import Base
|
2026-07-28 11:40:01 +08:00
|
|
|
|
from nanobot.session.webui_turns import (
|
|
|
|
|
|
clear_websocket_turn_if_current,
|
|
|
|
|
|
mark_websocket_turn_transcript_persistence_failed,
|
|
|
|
|
|
websocket_turn_transcript_persistence_failed,
|
|
|
|
|
|
)
|
2026-06-02 14:49:06 +08:00
|
|
|
|
from nanobot.webui.gateway_services import GatewayServices
|
|
|
|
|
|
from nanobot.webui.http_utils import (
|
|
|
|
|
|
normalize_config_path as _normalize_config_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
from nanobot.webui.http_utils import (
|
|
|
|
|
|
parse_request_path as _parse_request_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
from nanobot.webui.http_utils import (
|
|
|
|
|
|
query_first as _query_first,
|
|
|
|
|
|
)
|
2026-08-26 18:04:38 +08:00
|
|
|
|
from nanobot.webui.inbound_commands import WebUICommandRouter
|
2026-07-28 11:40:01 +08:00
|
|
|
|
from nanobot.webui.metadata import (
|
|
|
|
|
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
|
|
|
|
|
WEBUI_TURN_METADATA_KEY,
|
|
|
|
|
|
)
|
2026-08-26 18:04:38 +08:00
|
|
|
|
from nanobot.webui.outbound_projection import WebUIOutboundProjector
|
|
|
|
|
|
from nanobot.webui.session_identity import is_valid_webui_chat_id
|
2026-07-28 11:40:01 +08:00
|
|
|
|
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
2026-06-02 14:49:06 +08:00
|
|
|
|
from nanobot.webui.websocket_logging import websockets_server_logger
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-08-26 18:04:38 +08:00
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from nanobot.bus.outbound_events import ProgressEvent, RecoveryStateEvent
|
|
|
|
|
|
from nanobot.providers.base import LLMUsage
|
|
|
|
|
|
|
2026-07-03 18:17:52 +08:00
|
|
|
|
# Plain HTTP WebUI routes also run through websockets.process_request.
|
|
|
|
|
|
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
2026-08-27 10:23:35 +08:00
|
|
|
|
_LISTENER_CHECK_INTERVAL_S = 0.5
|
|
|
|
|
|
_LISTENER_STABLE_AFTER_S = 30.0
|
|
|
|
|
|
_LISTENER_RESTART_BACKOFF_S = (1.0, 2.0, 4.0, 8.0, 16.0, 30.0)
|
|
|
|
|
|
|
|
|
|
|
|
# A bind conflict or invalid address needs operator action and must not be
|
|
|
|
|
|
# retried forever. These errors can be caused by a transient local network
|
|
|
|
|
|
# interruption and are safe to retry at the channel boundary.
|
|
|
|
|
|
_RECOVERABLE_LISTENER_ERRNOS = {
|
|
|
|
|
|
getattr(socket, name)
|
|
|
|
|
|
for name in (
|
|
|
|
|
|
"ECONNABORTED",
|
|
|
|
|
|
"ECONNRESET",
|
|
|
|
|
|
"EHOSTDOWN",
|
|
|
|
|
|
"EHOSTUNREACH",
|
|
|
|
|
|
"ENETDOWN",
|
|
|
|
|
|
"ENETRESET",
|
|
|
|
|
|
"ENETUNREACH",
|
|
|
|
|
|
"ETIMEDOUT",
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(socket, name)
|
|
|
|
|
|
}
|
|
|
|
|
|
_RECOVERABLE_LISTENER_WINERRORS = {
|
|
|
|
|
|
64, # ERROR_NETNAME_DELETED / "The specified network name is no longer available."
|
|
|
|
|
|
995, # ERROR_OPERATION_ABORTED
|
|
|
|
|
|
10050, # WSAENETDOWN
|
|
|
|
|
|
10052, # WSAENETRESET
|
|
|
|
|
|
10053, # WSAECONNABORTED
|
|
|
|
|
|
10054, # WSAECONNRESET
|
|
|
|
|
|
10060, # WSAETIMEDOUT
|
|
|
|
|
|
10065, # WSAEHOSTUNREACH
|
|
|
|
|
|
}
|
2026-07-03 18:17:52 +08:00
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-08-02 00:59:09 +09:00
|
|
|
|
_ROUTING_ASSERTION_HEADERS = frozenset(
|
|
|
|
|
|
{
|
|
|
|
|
|
"host",
|
|
|
|
|
|
"forwarded",
|
|
|
|
|
|
"x-forwarded-for",
|
|
|
|
|
|
"x-forwarded-host",
|
|
|
|
|
|
"x-forwarded-proto",
|
|
|
|
|
|
"x-real-ip",
|
|
|
|
|
|
"cf-connecting-ip",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_routing_assertion_header(value: str) -> bool:
|
|
|
|
|
|
normalized = value.casefold()
|
|
|
|
|
|
return normalized in _ROUTING_ASSERTION_HEADERS or normalized.startswith("x-forwarded-")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:22:19 +09:00
|
|
|
|
class TrustedProxyAuthConfig(Base):
|
|
|
|
|
|
"""Authentication assertions accepted from explicitly trusted proxy peers."""
|
|
|
|
|
|
|
|
|
|
|
|
trusted_peer_cidrs: list[str] = Field(min_length=1)
|
|
|
|
|
|
assertion_header: str = Field(min_length=1)
|
|
|
|
|
|
_trusted_peer_networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = PrivateAttr(
|
|
|
|
|
|
default=()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@field_validator("trusted_peer_cidrs")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def validate_trusted_peer_cidrs(cls, values: list[str]) -> list[str]:
|
|
|
|
|
|
normalized: list[str] = []
|
|
|
|
|
|
for value in values:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
network = ipaddress.ip_network(value, strict=False)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError(f"invalid trusted proxy CIDR: {value!r}") from exc
|
|
|
|
|
|
if network.prefixlen == 0:
|
|
|
|
|
|
raise ValueError("universal trusted proxy CIDRs are not allowed")
|
|
|
|
|
|
if isinstance(network, ipaddress.IPv6Network):
|
|
|
|
|
|
mapped_start = ipaddress.IPv6Address("::ffff:0:0")
|
|
|
|
|
|
mapped_end = ipaddress.IPv6Address("::ffff:ffff:ffff")
|
|
|
|
|
|
if mapped_start in network and mapped_end in network:
|
|
|
|
|
|
raise ValueError("trusted proxy CIDRs must not cover all IPv4-mapped addresses")
|
|
|
|
|
|
normalized.append(network.with_prefixlen)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
@field_validator("assertion_header")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def validate_assertion_header(cls, value: str) -> str:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
if not value or any(char.isspace() or ord(char) < 0x21 for char in value):
|
|
|
|
|
|
raise ValueError("assertion_header must be a valid HTTP header name")
|
2026-08-02 00:59:09 +09:00
|
|
|
|
if _is_routing_assertion_header(value):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"assertion_header must identify a proxy-generated authentication assertion, "
|
|
|
|
|
|
"not a routing or client metadata header"
|
|
|
|
|
|
)
|
2026-08-02 00:22:19 +09:00
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
@model_validator(mode="after")
|
|
|
|
|
|
def compile_trusted_peer_networks(self) -> Self:
|
|
|
|
|
|
self._trusted_peer_networks = tuple(
|
|
|
|
|
|
ipaddress.ip_network(value, strict=False) for value in self.trusted_peer_cidrs
|
|
|
|
|
|
)
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
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>``.
|
2026-08-02 06:06:08 +09:00
|
|
|
|
- ``public_ws_url``: Optional public WebSocket endpoint returned by WebUI bootstrap instead of
|
|
|
|
|
|
deriving one from proxy request headers. Its path must match ``path``.
|
2026-04-08 21:39:35 +08:00
|
|
|
|
- ``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.
|
2026-04-09 13:47:53 +08:00
|
|
|
|
- ``media`` field in outbound messages contains local filesystem paths; remote clients need a
|
|
|
|
|
|
shared filesystem or an HTTP file server to access these files.
|
2026-04-08 21:39:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-03 18:17:52 +08:00
|
|
|
|
enabled: bool = True
|
2026-04-08 21:39:35 +08:00
|
|
|
|
host: str = "127.0.0.1"
|
|
|
|
|
|
port: int = 8765
|
2026-05-29 03:42:53 +08:00
|
|
|
|
unix_socket_path: str = ""
|
2026-04-08 21:39:35 +08:00
|
|
|
|
path: str = "/"
|
2026-08-02 06:06:08 +09:00
|
|
|
|
public_ws_url: str = ""
|
2026-04-08 21:39:35 +08:00
|
|
|
|
token: str = ""
|
|
|
|
|
|
token_issue_path: str = ""
|
|
|
|
|
|
token_issue_secret: str = ""
|
2026-08-02 00:22:19 +09:00
|
|
|
|
trusted_proxy_auth: TrustedProxyAuthConfig | None = None
|
2026-04-08 21:39:35 +08:00
|
|
|
|
token_ttl_s: int = Field(default=300, ge=30, le=86_400)
|
2026-04-09 15:18:02 +08:00
|
|
|
|
websocket_requires_token: bool = True
|
2026-04-08 21:39:35 +08:00
|
|
|
|
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
|
|
|
|
|
streaming: bool = True
|
2026-04-21 10:29:47 +00:00
|
|
|
|
# Default 36 MB, upper 40 MB: supports up to 4 images at ~6 MB each after
|
|
|
|
|
|
# client-side Worker normalization (see webui Composer). 4 × 6 MB × 1.37
|
|
|
|
|
|
# (base64 overhead) + envelope framing stays under 36 MB; the 40 MB ceiling
|
|
|
|
|
|
# leaves a small margin for sender slop without opening a DoS avenue.
|
|
|
|
|
|
max_message_bytes: int = Field(default=37_748_736, ge=1024, le=41_943_040)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
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 = ""
|
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
@field_validator("unix_socket_path")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def unix_socket_path_format(cls, value: str) -> str:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if "\x00" in value:
|
|
|
|
|
|
raise ValueError("unix_socket_path must not contain NUL bytes")
|
|
|
|
|
|
path = Path(value).expanduser()
|
|
|
|
|
|
if not path.is_absolute():
|
|
|
|
|
|
raise ValueError("unix_socket_path must be an absolute path")
|
|
|
|
|
|
return str(path)
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
@field_validator("path")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def path_must_start_with_slash(cls, value: str) -> str:
|
|
|
|
|
|
if not value.startswith("/"):
|
|
|
|
|
|
raise ValueError('path must start with "/"')
|
2026-04-09 15:18:02 +08:00
|
|
|
|
return _normalize_config_path(value)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
2026-08-02 06:06:08 +09:00
|
|
|
|
@field_validator("public_ws_url")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def public_ws_url_format(cls, value: str) -> str:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
parsed = urlsplit(value)
|
|
|
|
|
|
if (
|
|
|
|
|
|
parsed.scheme not in {"ws", "wss"}
|
|
|
|
|
|
or not parsed.netloc
|
|
|
|
|
|
or parsed.username is not None
|
|
|
|
|
|
or parsed.password is not None
|
|
|
|
|
|
or parsed.query
|
|
|
|
|
|
or parsed.fragment
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("public_ws_url must be an absolute ws:// or wss:// URL without credentials")
|
|
|
|
|
|
return urlunsplit(
|
|
|
|
|
|
(parsed.scheme, parsed.netloc, _normalize_config_path(parsed.path or "/"), "", "")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@model_validator(mode="after")
|
|
|
|
|
|
def public_ws_url_matches_path(self) -> Self:
|
|
|
|
|
|
if self.public_ws_url and urlsplit(self.public_ws_url).path != _normalize_config_path(self.path):
|
|
|
|
|
|
raise ValueError("public_ws_url path must match path")
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
@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
|
|
|
|
|
|
|
2026-05-06 23:15:18 +08:00
|
|
|
|
@model_validator(mode="after")
|
|
|
|
|
|
def wildcard_host_requires_auth(self) -> Self:
|
|
|
|
|
|
if self.host not in ("0.0.0.0", "::"):
|
|
|
|
|
|
return self
|
2026-08-02 00:38:17 +09:00
|
|
|
|
if self.token.strip() or self.token_issue_secret.strip() or self.trusted_proxy_auth is not None:
|
2026-05-06 23:15:18 +08:00
|
|
|
|
return self
|
|
|
|
|
|
raise ValueError(
|
2026-08-02 00:38:17 +09:00
|
|
|
|
"host is 0.0.0.0 (all interfaces) but neither token, token_issue_secret, "
|
|
|
|
|
|
"nor trusted_proxy_auth is set — set one to prevent unauthenticated access"
|
2026-05-06 23:15:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
data = cast(object, json.loads(text))
|
2026-04-08 21:39:35 +08:00
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
return text
|
|
|
|
|
|
if isinstance(data, dict):
|
2026-07-29 21:37:11 +08:00
|
|
|
|
payload = cast(dict[str, Any], data)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
for key in ("content", "text", "message"):
|
2026-07-29 21:37:11 +08:00
|
|
|
|
value = payload.get(key)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
if isinstance(value, str) and value.strip():
|
|
|
|
|
|
return value
|
|
|
|
|
|
return None
|
|
|
|
|
|
return None
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 18:04:38 +08:00
|
|
|
|
def _is_valid_chat_id(value: Any) -> TypeGuard[str]: # pyright: ignore[reportUnusedFunction]
|
|
|
|
|
|
return is_valid_webui_chat_id(value)
|
2026-04-18 08:44:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
|
|
|
|
|
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
|
|
|
|
|
|
|
|
|
|
|
A frame qualifies when it parses as a JSON object with a string ``type`` field.
|
|
|
|
|
|
Legacy frames (plain text, or ``{"content": ...}`` without ``type``) return None;
|
|
|
|
|
|
callers should fall back to :func:`_parse_inbound_payload` for those.
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = raw.strip()
|
|
|
|
|
|
if not text.startswith("{"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
data = cast(object, json.loads(text))
|
2026-04-18 08:44:12 +00:00
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
return None
|
2026-07-29 21:37:11 +08:00
|
|
|
|
envelope = cast(dict[str, Any], data)
|
|
|
|
|
|
t = envelope.get("type")
|
2026-04-18 08:44:12 +00:00
|
|
|
|
if not isinstance(t, str):
|
|
|
|
|
|
return None
|
2026-07-29 21:37:11 +08:00
|
|
|
|
return envelope
|
2026-04-18 08:44:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 10:23:35 +08:00
|
|
|
|
class _ListenerUnavailableError(OSError):
|
|
|
|
|
|
"""Raised when a previously bound listener loses its serving socket."""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
class WebSocketChannel(BaseChannel):
|
|
|
|
|
|
"""Run a local WebSocket server; forward text/JSON messages to the message bus."""
|
|
|
|
|
|
|
|
|
|
|
|
name = "websocket"
|
|
|
|
|
|
display_name = "WebSocket"
|
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
config: Any,
|
|
|
|
|
|
bus: MessageBus,
|
|
|
|
|
|
*,
|
2026-06-02 14:49:06 +08:00
|
|
|
|
gateway: GatewayServices,
|
2026-04-18 18:51:53 +00:00
|
|
|
|
):
|
2026-04-08 21:39:35 +08:00
|
|
|
|
if isinstance(config, dict):
|
|
|
|
|
|
config = WebSocketConfig.model_validate(config)
|
|
|
|
|
|
super().__init__(config, bus)
|
|
|
|
|
|
self.config: WebSocketConfig = config
|
2026-04-18 08:44:12 +00:00
|
|
|
|
# chat_id -> connections subscribed to it (fan-out target).
|
2026-07-29 21:37:11 +08:00
|
|
|
|
self._subs: dict[str, set[ServerConnection]] = {}
|
2026-04-18 08:44:12 +00:00
|
|
|
|
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
|
2026-07-29 21:37:11 +08:00
|
|
|
|
self._conn_chats: dict[ServerConnection, set[str]] = {}
|
2026-04-18 08:44:12 +00:00
|
|
|
|
# connection -> default chat_id for legacy frames that omit routing.
|
2026-07-29 21:37:11 +08:00
|
|
|
|
self._conn_default: dict[ServerConnection, str] = {}
|
2026-07-22 22:35:39 +08:00
|
|
|
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
2026-08-26 18:04:38 +08:00
|
|
|
|
self._webui_connections = gateway.endpoint.webui_connections
|
2026-04-08 21:39:35 +08:00
|
|
|
|
self._stop_event: asyncio.Event | None = None
|
|
|
|
|
|
self._server_task: asyncio.Task[None] | None = None
|
2026-08-27 10:23:35 +08:00
|
|
|
|
self._server: Server | None = None
|
2026-05-31 12:55:23 +08:00
|
|
|
|
|
2026-06-02 14:49:06 +08:00
|
|
|
|
self.gateway = gateway
|
|
|
|
|
|
self._media = gateway.media
|
2026-06-06 19:49:33 +08:00
|
|
|
|
self._transcripts = gateway.transcripts
|
2026-08-07 11:20:08 +08:00
|
|
|
|
self._temporary_chats = gateway.temporary_chats
|
2026-08-26 18:04:38 +08:00
|
|
|
|
self._session_projection = gateway.session_projection
|
|
|
|
|
|
self._commands = WebUICommandRouter(self, gateway)
|
|
|
|
|
|
self._webui_request_tasks = self._commands.request_tasks
|
|
|
|
|
|
self._webui_request_operations = self._commands.request_operations
|
|
|
|
|
|
self._webui_request_locks = self._commands.request_locks
|
|
|
|
|
|
self._outbound = WebUIOutboundProjector(self, self._session_projection)
|
2026-05-31 12:55:23 +08:00
|
|
|
|
|
2026-05-23 01:39:46 +08:00
|
|
|
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
2026-08-22 19:41:37 +08:00
|
|
|
|
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-04-18 08:44:12 +00:00
|
|
|
|
# -- Subscription bookkeeping -------------------------------------------
|
|
|
|
|
|
|
2026-08-26 18:04:38 +08:00
|
|
|
|
def webui_subscribers(self, chat_id: str) -> tuple[ServerConnection, ...]:
|
|
|
|
|
|
"""Return a stable snapshot of one chat's transport subscribers."""
|
|
|
|
|
|
return tuple(self._subs.get(chat_id, ()))
|
|
|
|
|
|
|
|
|
|
|
|
def webui_connection_chats(self, connection: ServerConnection) -> tuple[str, ...]:
|
|
|
|
|
|
return tuple(self._conn_chats.get(connection, ()))
|
|
|
|
|
|
|
|
|
|
|
|
def webui_attach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
|
|
|
|
self._attach(connection, chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
def webui_detach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
|
|
|
|
self._detach(connection, chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
def webui_clear_connection_default(self, connection: ServerConnection) -> None:
|
|
|
|
|
|
self._conn_default.pop(connection, None)
|
|
|
|
|
|
|
|
|
|
|
|
def webui_clear_stream_buffers(self, chat_id: str) -> None:
|
|
|
|
|
|
self._clear_stream_buffers(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
async def webui_hydrate(self, chat_id: str) -> None:
|
|
|
|
|
|
await self._hydrate_after_subscribe(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
async def webui_send_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
event: str,
|
|
|
|
|
|
**fields: Any,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
await self._send_event(connection, event, **fields)
|
|
|
|
|
|
|
|
|
|
|
|
async def webui_send_raw(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
raw: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
label: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=label)
|
|
|
|
|
|
|
|
|
|
|
|
async def webui_dispatch_message(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
sender_id: str,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
media: list[str] | None,
|
|
|
|
|
|
metadata: dict[str, Any],
|
|
|
|
|
|
is_dm: bool,
|
|
|
|
|
|
session_key: str | None,
|
|
|
|
|
|
require_existing_session: bool,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
await self._handle_message(
|
|
|
|
|
|
sender_id=sender_id,
|
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
media=media,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
is_dm=is_dm,
|
|
|
|
|
|
session_key=session_key,
|
|
|
|
|
|
require_existing_session=require_existing_session,
|
|
|
|
|
|
)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _attach(self, connection: ServerConnection, chat_id: str) -> None:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"""Idempotently subscribe *connection* to *chat_id*."""
|
|
|
|
|
|
self._subs.setdefault(chat_id, set()).add(connection)
|
|
|
|
|
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
|
|
|
|
|
|
2026-08-05 19:24:41 +08:00
|
|
|
|
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
|
|
|
|
chats = self._conn_chats.get(connection)
|
|
|
|
|
|
if chats is not None:
|
|
|
|
|
|
chats.discard(chat_id)
|
|
|
|
|
|
if not chats:
|
|
|
|
|
|
self._conn_chats.pop(connection, None)
|
|
|
|
|
|
subscribers = self._subs.get(chat_id)
|
|
|
|
|
|
if subscribers is not None:
|
|
|
|
|
|
subscribers.discard(connection)
|
|
|
|
|
|
if not subscribers:
|
|
|
|
|
|
self._subs.pop(chat_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_stream_buffers(self, chat_id: str) -> None:
|
|
|
|
|
|
for key in tuple(self._stream_text_buffers):
|
|
|
|
|
|
if key[0] == chat_id:
|
|
|
|
|
|
self._stream_text_buffers.pop(key, None)
|
2026-08-22 19:41:37 +08:00
|
|
|
|
for key in tuple(self._reasoning_text_buffers):
|
|
|
|
|
|
if key[0] == chat_id:
|
|
|
|
|
|
self._reasoning_text_buffers.pop(key, None)
|
2026-08-05 19:24:41 +08:00
|
|
|
|
|
|
|
|
|
|
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
2026-08-26 18:04:38 +08:00
|
|
|
|
await self._commands.cleanup_connection(connection)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
|
|
|
|
|
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
2026-07-23 15:57:13 +08:00
|
|
|
|
"""Replay persisted or actively running per-chat state after subscribe."""
|
2026-08-26 18:04:38 +08:00
|
|
|
|
await self._outbound.hydrate(chat_id)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
async def _send_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
event: str,
|
|
|
|
|
|
**fields: Any,
|
|
|
|
|
|
) -> None:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"""Send a control event (attached, error, ...) to a single connection."""
|
|
|
|
|
|
payload: dict[str, Any] = {"event": event}
|
|
|
|
|
|
payload.update(fields)
|
|
|
|
|
|
raw = json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await connection.send(raw)
|
|
|
|
|
|
except ConnectionClosed:
|
2026-08-05 19:24:41 +08:00
|
|
|
|
await self._cleanup_connection(connection)
|
2026-04-18 08:44:12 +00:00
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("failed to send {} event: {}", event, e)
|
2026-04-18 08:44:12 +00:00
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
@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(
|
2026-05-06 21:11:26 +08:00
|
|
|
|
"ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
|
2026-04-08 21:39:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
2026-04-09 15:18:02 +08:00
|
|
|
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
2026-04-08 21:39:35 +08:00
|
|
|
|
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
|
# -- HTTP dispatch ------------------------------------------------------
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
"""Compatibility proxy for the externally composed gateway endpoint."""
|
|
|
|
|
|
return await self.gateway.endpoint.process_request(
|
|
|
|
|
|
connection,
|
|
|
|
|
|
request,
|
|
|
|
|
|
is_allowed=self.is_allowed,
|
|
|
|
|
|
)
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _authorize_websocket_handshake(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
query: dict[str, list[str]],
|
2026-08-02 00:38:17 +09:00
|
|
|
|
headers: Any = None,
|
2026-07-29 21:37:11 +08:00
|
|
|
|
) -> Any:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
"""Compatibility proxy for handshake tests and integrations."""
|
|
|
|
|
|
return self.gateway.endpoint.authorize_websocket_handshake(
|
|
|
|
|
|
connection,
|
|
|
|
|
|
query,
|
|
|
|
|
|
headers,
|
|
|
|
|
|
)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _consume_issued_token(self, connection: ServerConnection, token: str) -> bool:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
return self.gateway.endpoint.consume_issued_token(connection, token)
|
2026-07-22 22:35:39 +08:00
|
|
|
|
|
2026-05-29 17:17:22 +08:00
|
|
|
|
# -- Server lifecycle and connection ingress ---------------------------
|
|
|
|
|
|
|
2026-08-27 10:23:35 +08:00
|
|
|
|
@staticmethod
|
2026-08-31 16:23:07 +08:00
|
|
|
|
def _socket_is_accepting(sock: socket.socket) -> bool:
|
|
|
|
|
|
"""Return whether a bound socket still advertises a listen capability.
|
|
|
|
|
|
|
|
|
|
|
|
``SO_ACCEPTCONN`` is not portable: macOS/BSD raise ``OSError`` with
|
|
|
|
|
|
``ENOPROTOOPT`` ("Protocol not available") for this option even on a
|
|
|
|
|
|
perfectly healthy listening socket. Treating that as "not serving"
|
|
|
|
|
|
makes the listener look permanently degraded, so the caller retries
|
|
|
|
|
|
forever and the channel never reaches a ready state. When the option
|
|
|
|
|
|
is unavailable we fall back to the file-descriptor liveness check.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if sock.fileno() < 0:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
if exc.errno in (errno.ENOPROTOOPT, errno.EOPNOTSUPP, errno.EINVAL):
|
|
|
|
|
|
return True
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _listener_is_serving(cls, server: Server) -> bool:
|
2026-08-27 10:23:35 +08:00
|
|
|
|
"""Return whether every bound socket still has a live listen capability."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
sockets = server.sockets
|
|
|
|
|
|
return bool(sockets) and server.is_serving() and all(
|
2026-08-31 16:23:07 +08:00
|
|
|
|
cls._socket_is_accepting(sock) for sock in sockets
|
2026-08-27 10:23:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_recoverable_listener_error(error: Exception, *, was_serving: bool) -> bool:
|
|
|
|
|
|
if isinstance(error, _ListenerUnavailableError):
|
|
|
|
|
|
return True
|
|
|
|
|
|
if not isinstance(error, OSError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if was_serving:
|
|
|
|
|
|
return True
|
|
|
|
|
|
winerror = getattr(error, "winerror", None)
|
|
|
|
|
|
return (
|
|
|
|
|
|
error.errno in _RECOVERABLE_LISTENER_ERRNOS
|
|
|
|
|
|
or winerror in _RECOVERABLE_LISTENER_WINERRORS
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _wait_for_listener_loss(self, server: Server) -> None:
|
|
|
|
|
|
"""Wait for shutdown or raise when the serving socket disappears."""
|
|
|
|
|
|
assert self._stop_event is not None
|
|
|
|
|
|
while not self._stop_event.is_set():
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.wait_for(
|
|
|
|
|
|
self._stop_event.wait(),
|
|
|
|
|
|
timeout=_LISTENER_CHECK_INTERVAL_S,
|
|
|
|
|
|
)
|
|
|
|
|
|
except TimeoutError:
|
|
|
|
|
|
if not self._listener_is_serving(server):
|
|
|
|
|
|
raise _ListenerUnavailableError(
|
|
|
|
|
|
"WebSocket listener is no longer accepting connections"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _close_server(self, server: Server, socket_path: str) -> None:
|
|
|
|
|
|
server.close()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await server.wait_closed()
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
self.logger.warning("WebSocket server close failed: {}", exc)
|
|
|
|
|
|
if socket_path:
|
|
|
|
|
|
with suppress(FileNotFoundError):
|
|
|
|
|
|
Path(socket_path).unlink()
|
|
|
|
|
|
|
|
|
|
|
|
def _log_listener_ready(self, scheme: str) -> None:
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
|
"WebSocket server listening on {}",
|
|
|
|
|
|
(
|
|
|
|
|
|
f"unix:{self.config.unix_socket_path}{self.config.path}"
|
|
|
|
|
|
if self.config.unix_socket_path
|
|
|
|
|
|
else f"{scheme}://{self.config.host}:{self.config.port}{self.config.path}"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
if self.config.token_issue_path:
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
|
"WebSocket token issue route: {}",
|
|
|
|
|
|
(
|
|
|
|
|
|
f"unix:{self.config.unix_socket_path}"
|
|
|
|
|
|
f"{_normalize_config_path(self.config.token_issue_path)}"
|
|
|
|
|
|
if self.config.unix_socket_path
|
|
|
|
|
|
else (
|
|
|
|
|
|
f"{scheme}://{self.config.host}:{self.config.port}"
|
|
|
|
|
|
f"{_normalize_config_path(self.config.token_issue_path)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
async def start(self) -> None:
|
2026-05-11 14:03:38 +08:00
|
|
|
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
|
|
|
|
|
|
|
|
|
|
|
redirect_lib_logging("websockets", level="WARNING")
|
2026-06-02 16:13:49 +08:00
|
|
|
|
ws_logger = websockets_server_logger()
|
2026-05-11 14:03:38 +08:00
|
|
|
|
|
2026-08-27 10:23:35 +08:00
|
|
|
|
stop_event = asyncio.Event()
|
|
|
|
|
|
self._stop_event = stop_event
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
ssl_context = self._build_ssl_context()
|
|
|
|
|
|
scheme = "wss" if ssl_context else "ws"
|
|
|
|
|
|
|
|
|
|
|
|
async def process_request(
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
request: WsRequest,
|
|
|
|
|
|
) -> Any:
|
2026-04-18 18:51:53 +00:00
|
|
|
|
return await self._dispatch_http(connection, request)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
async def handler(connection: ServerConnection) -> None:
|
|
|
|
|
|
await self._connection_loop(connection)
|
|
|
|
|
|
|
|
|
|
|
|
async def runner() -> None:
|
2026-05-29 03:42:53 +08:00
|
|
|
|
socket_path = self.config.unix_socket_path
|
2026-08-27 10:23:35 +08:00
|
|
|
|
failures = 0
|
|
|
|
|
|
while not stop_event.is_set():
|
|
|
|
|
|
server: Server | None = None
|
|
|
|
|
|
was_serving = False
|
|
|
|
|
|
started_at = 0.0
|
|
|
|
|
|
try:
|
|
|
|
|
|
if socket_path:
|
|
|
|
|
|
path_obj = Path(socket_path)
|
|
|
|
|
|
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
with suppress(FileNotFoundError):
|
|
|
|
|
|
path_obj.unlink()
|
|
|
|
|
|
server = await unix_serve(
|
|
|
|
|
|
handler,
|
|
|
|
|
|
socket_path,
|
|
|
|
|
|
process_request=process_request,
|
|
|
|
|
|
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
|
|
|
|
|
max_size=self.config.max_message_bytes,
|
|
|
|
|
|
ping_interval=self.config.ping_interval_s,
|
|
|
|
|
|
ping_timeout=self.config.ping_timeout_s,
|
|
|
|
|
|
logger=ws_logger,
|
|
|
|
|
|
)
|
|
|
|
|
|
with suppress(OSError):
|
|
|
|
|
|
path_obj.chmod(0o600)
|
|
|
|
|
|
else:
|
|
|
|
|
|
server = await serve(
|
|
|
|
|
|
handler,
|
|
|
|
|
|
self.config.host,
|
|
|
|
|
|
self.config.port,
|
|
|
|
|
|
process_request=process_request,
|
|
|
|
|
|
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
|
|
|
|
|
max_size=self.config.max_message_bytes,
|
|
|
|
|
|
ping_interval=self.config.ping_interval_s,
|
|
|
|
|
|
ping_timeout=self.config.ping_timeout_s,
|
|
|
|
|
|
ssl=ssl_context,
|
|
|
|
|
|
logger=ws_logger,
|
|
|
|
|
|
)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-08-27 10:23:35 +08:00
|
|
|
|
self._server = server
|
|
|
|
|
|
was_serving = True
|
|
|
|
|
|
if not self._listener_is_serving(server):
|
|
|
|
|
|
raise _ListenerUnavailableError(
|
|
|
|
|
|
"WebSocket listener did not enter a serving state"
|
|
|
|
|
|
)
|
|
|
|
|
|
self._running = True
|
|
|
|
|
|
started_at = asyncio.get_running_loop().time()
|
|
|
|
|
|
self._log_listener_ready(scheme)
|
|
|
|
|
|
await self._wait_for_listener_loss(server)
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
if not self._is_recoverable_listener_error(
|
|
|
|
|
|
exc,
|
|
|
|
|
|
was_serving=was_serving,
|
|
|
|
|
|
):
|
|
|
|
|
|
raise
|
|
|
|
|
|
uptime = (
|
|
|
|
|
|
asyncio.get_running_loop().time() - started_at
|
|
|
|
|
|
if started_at
|
|
|
|
|
|
else 0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
if uptime >= _LISTENER_STABLE_AFTER_S:
|
|
|
|
|
|
failures = 0
|
|
|
|
|
|
delay = _LISTENER_RESTART_BACKOFF_S[
|
|
|
|
|
|
min(failures, len(_LISTENER_RESTART_BACKOFF_S) - 1)
|
|
|
|
|
|
]
|
|
|
|
|
|
failures += 1
|
|
|
|
|
|
self.logger.warning(
|
|
|
|
|
|
"WebSocket listener failed ({}: {}); retrying in {:.1f}s",
|
|
|
|
|
|
type(exc).__name__,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
delay,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.wait_for(stop_event.wait(), timeout=delay)
|
|
|
|
|
|
except TimeoutError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
if server is not None:
|
|
|
|
|
|
await self._close_server(server, socket_path)
|
|
|
|
|
|
if self._server is server:
|
|
|
|
|
|
self._server = None
|
|
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(runner())
|
|
|
|
|
|
self._server_task = task
|
|
|
|
|
|
try:
|
|
|
|
|
|
await task
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._running = False
|
|
|
|
|
|
if self._server_task is task:
|
|
|
|
|
|
self._server_task = None
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
async def _connection_loop(self, connection: ServerConnection) -> None:
|
2026-04-08 21:39:35 +08:00
|
|
|
|
request = connection.request
|
|
|
|
|
|
path_part = request.path if request else "/"
|
|
|
|
|
|
_, query = _parse_request_path(path_part)
|
2026-04-09 15:18:02 +08:00
|
|
|
|
client_id_raw = _query_first(query, "client_id")
|
2026-04-08 21:39:35 +08:00
|
|
|
|
client_id = client_id_raw.strip() if client_id_raw else ""
|
|
|
|
|
|
if not client_id:
|
|
|
|
|
|
client_id = f"anon-{uuid.uuid4().hex[:12]}"
|
2026-04-09 15:18:02 +08:00
|
|
|
|
elif len(client_id) > 128:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("client_id too long ({} chars), truncating", len(client_id))
|
2026-04-09 15:18:02 +08:00
|
|
|
|
client_id = client_id[:128]
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-04-18 08:44:12 +00:00
|
|
|
|
default_chat_id = str(uuid.uuid4())
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-09 15:18:02 +08:00
|
|
|
|
await connection.send(
|
|
|
|
|
|
json.dumps(
|
|
|
|
|
|
{
|
|
|
|
|
|
"event": "ready",
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"chat_id": default_chat_id,
|
2026-04-09 15:18:02 +08:00
|
|
|
|
"client_id": client_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# Register only after ready is successfully sent to avoid out-of-order sends
|
2026-04-18 08:44:12 +00:00
|
|
|
|
self._conn_default[connection] = default_chat_id
|
|
|
|
|
|
self._attach(connection, default_chat_id)
|
2026-05-16 01:14:11 +08:00
|
|
|
|
await self._hydrate_after_subscribe(default_chat_id)
|
2026-04-09 15:18:02 +08:00
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
async for raw in connection:
|
|
|
|
|
|
if isinstance(raw, bytes):
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw = raw.decode("utf-8")
|
|
|
|
|
|
except UnicodeDecodeError:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("ignoring non-utf8 binary frame")
|
2026-04-08 21:39:35 +08:00
|
|
|
|
continue
|
2026-04-18 08:44:12 +00:00
|
|
|
|
|
|
|
|
|
|
envelope = _parse_envelope(raw)
|
|
|
|
|
|
if envelope is not None:
|
|
|
|
|
|
await self._dispatch_envelope(connection, client_id, envelope)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
content = _parse_inbound_payload(raw)
|
|
|
|
|
|
if content is None:
|
|
|
|
|
|
continue
|
2026-05-14 11:32:29 +08:00
|
|
|
|
# WebSocket already authenticates at handshake time (token),
|
|
|
|
|
|
# so pairing is not applicable. Treat as non-DM to avoid
|
|
|
|
|
|
# sending pairing codes to an already-authenticated client.
|
2026-04-08 21:39:35 +08:00
|
|
|
|
await self._handle_message(
|
|
|
|
|
|
sender_id=client_id,
|
2026-04-18 08:44:12 +00:00
|
|
|
|
chat_id=default_chat_id,
|
2026-04-08 21:39:35 +08:00
|
|
|
|
content=content,
|
|
|
|
|
|
metadata={"remote": getattr(connection, "remote_address", None)},
|
2026-05-14 11:32:29 +08:00
|
|
|
|
is_dm=False,
|
2026-04-08 21:39:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("connection ended: {}", e)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
finally:
|
2026-08-05 19:24:41 +08:00
|
|
|
|
await self._cleanup_connection(connection)
|
2026-04-18 08:44:12 +00:00
|
|
|
|
|
2026-05-29 17:17:22 +08:00
|
|
|
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
|
|
|
|
|
|
2026-04-18 08:44:12 +00:00
|
|
|
|
async def _dispatch_envelope(
|
|
|
|
|
|
self,
|
2026-07-29 21:37:11 +08:00
|
|
|
|
connection: ServerConnection,
|
2026-04-18 08:44:12 +00:00
|
|
|
|
client_id: str,
|
|
|
|
|
|
envelope: dict[str, Any],
|
|
|
|
|
|
) -> None:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
"""Delegate one typed envelope to the WebUI application router."""
|
|
|
|
|
|
await self._commands.dispatch(connection, client_id, envelope)
|
2026-08-10 15:43:54 +08:00
|
|
|
|
|
2026-08-16 02:49:00 +08:00
|
|
|
|
def _prune_webui_request_operations(self) -> None:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
"""Compatibility hook for request-cache boundary tests."""
|
|
|
|
|
|
self._commands.prune_request_operations()
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-05-29 17:17:22 +08:00
|
|
|
|
# -- Outbound WebSocket events -----------------------------------------
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
async def stop(self) -> None:
|
2026-08-27 10:23:35 +08:00
|
|
|
|
server_task = self._server_task
|
|
|
|
|
|
if not self._running and server_task is None:
|
2026-04-09 15:18:02 +08:00
|
|
|
|
return
|
2026-04-08 21:39:35 +08:00
|
|
|
|
self._running = False
|
|
|
|
|
|
if self._stop_event:
|
|
|
|
|
|
self._stop_event.set()
|
2026-08-27 10:23:35 +08:00
|
|
|
|
if server_task:
|
2026-04-09 15:18:02 +08:00
|
|
|
|
try:
|
2026-08-27 10:23:35 +08:00
|
|
|
|
await server_task
|
2026-06-22 22:09:17 +08:00
|
|
|
|
except asyncio.CancelledError:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
current_task = asyncio.current_task()
|
|
|
|
|
|
if current_task is not None and current_task.cancelling():
|
2026-06-22 22:09:17 +08:00
|
|
|
|
raise
|
|
|
|
|
|
self.logger.debug("server task was already cancelled during shutdown")
|
2026-04-09 15:18:02 +08:00
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("server task error during shutdown: {}", e)
|
2026-08-27 10:23:35 +08:00
|
|
|
|
if self._server_task is server_task:
|
|
|
|
|
|
self._server_task = None
|
2026-08-26 18:04:38 +08:00
|
|
|
|
await self._commands.close()
|
2026-04-18 08:44:12 +00:00
|
|
|
|
self._subs.clear()
|
|
|
|
|
|
self._conn_chats.clear()
|
|
|
|
|
|
self._conn_default.clear()
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
async def _safe_send_to(
|
|
|
|
|
|
self,
|
|
|
|
|
|
connection: ServerConnection,
|
|
|
|
|
|
raw: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
label: str = "",
|
|
|
|
|
|
) -> None:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
2026-04-09 15:18:02 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await connection.send(raw)
|
|
|
|
|
|
except ConnectionClosed:
|
2026-08-05 19:24:41 +08:00
|
|
|
|
await self._cleanup_connection(connection)
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("connection gone{}", label)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("send failed{}", label)
|
2026-04-09 15:18:02 +08:00
|
|
|
|
raise
|
|
|
|
|
|
|
2026-07-28 11:40:01 +08:00
|
|
|
|
def _persist_turn_transcript_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
event: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
metadata: dict[str, Any] | None,
|
|
|
|
|
|
phase: str,
|
|
|
|
|
|
include_source: bool = False,
|
|
|
|
|
|
transcript_overrides: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
2026-08-07 11:20:08 +08:00
|
|
|
|
if not self._temporary_chats.should_persist_transcript(chat_id):
|
2026-08-05 19:24:41 +08:00
|
|
|
|
return True
|
2026-07-28 11:40:01 +08:00
|
|
|
|
persisted = self._transcripts.prepare_and_append(
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
event,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase=phase,
|
|
|
|
|
|
include_source=include_source,
|
|
|
|
|
|
transcript_overrides=transcript_overrides,
|
|
|
|
|
|
)
|
2026-08-22 19:41:37 +08:00
|
|
|
|
return self._retain_turn_on_transcript_failure(
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
persisted=persisted,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase=phase,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _retain_turn_on_transcript_failure(
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
persisted: bool,
|
|
|
|
|
|
metadata: dict[str, Any] | None,
|
|
|
|
|
|
phase: str,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
|
2026-07-28 11:40:01 +08:00
|
|
|
|
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
|
|
|
|
|
mark_websocket_turn_transcript_persistence_failed(
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
owner if isinstance(owner, str) else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
return persisted
|
|
|
|
|
|
|
2026-08-22 19:41:37 +08:00
|
|
|
|
def _persist_turn_stream_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
event: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
completed_text: str | None,
|
|
|
|
|
|
metadata: dict[str, Any] | None,
|
|
|
|
|
|
phase: str,
|
|
|
|
|
|
include_source: bool = False,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Persist the canonical end of a live stream, never its wire chunks."""
|
|
|
|
|
|
if not self._temporary_chats.should_persist_transcript(chat_id):
|
|
|
|
|
|
return True
|
|
|
|
|
|
persisted = self._transcripts.prepare_and_append_stream_event(
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
event,
|
|
|
|
|
|
completed_text=completed_text,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase=phase,
|
|
|
|
|
|
include_source=include_source,
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._retain_turn_on_transcript_failure(
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
persisted=persisted,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase=phase,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
2026-08-26 18:04:38 +08:00
|
|
|
|
await self._outbound.send(msg)
|
2026-05-12 09:05:24 +00:00
|
|
|
|
|
2026-08-26 18:04:38 +08:00
|
|
|
|
async def send_projected_message(
|
|
|
|
|
|
self,
|
|
|
|
|
|
msg: OutboundMessage,
|
|
|
|
|
|
progress_event: ProgressEvent | None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Serialize one ordinary outbound message selected by the projector."""
|
2026-04-18 08:44:12 +00:00
|
|
|
|
conns = list(self._subs.get(msg.chat_id, ()))
|
2026-04-25 15:46:47 +00:00
|
|
|
|
text = msg.content
|
2026-06-02 14:49:06 +08:00
|
|
|
|
wire_text = self._media.rewrite_local_markdown_images(text)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
payload: dict[str, Any] = {
|
|
|
|
|
|
"event": "message",
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"chat_id": msg.chat_id,
|
2026-05-23 01:39:46 +08:00
|
|
|
|
"text": wire_text,
|
2026-04-08 21:39:35 +08:00
|
|
|
|
}
|
2026-08-17 00:37:16 +08:00
|
|
|
|
turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY)
|
|
|
|
|
|
if isinstance(turn_id, str) and turn_id:
|
|
|
|
|
|
payload["turn_id"] = turn_id
|
2026-04-08 21:39:35 +08:00
|
|
|
|
if msg.media:
|
|
|
|
|
|
payload["media"] = msg.media
|
2026-04-24 19:17:58 +00:00
|
|
|
|
urls: list[dict[str, str]] = []
|
|
|
|
|
|
for entry in msg.media:
|
2026-06-02 14:49:06 +08:00
|
|
|
|
signed = self._media.sign_or_stage_media_path(Path(entry))
|
2026-04-24 19:17:58 +00:00
|
|
|
|
if signed is not None:
|
|
|
|
|
|
urls.append(signed)
|
|
|
|
|
|
if urls:
|
|
|
|
|
|
payload["media_urls"] = urls
|
2026-04-08 21:39:35 +08:00
|
|
|
|
if msg.reply_to:
|
|
|
|
|
|
payload["reply_to"] = msg.reply_to
|
2026-05-16 01:14:11 +08:00
|
|
|
|
lat = msg.metadata.get("latency_ms")
|
|
|
|
|
|
if isinstance(lat, (int, float)):
|
|
|
|
|
|
payload["latency_ms"] = int(lat)
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if progress_event and progress_event.tool_events:
|
|
|
|
|
|
payload["tool_events"] = progress_event.tool_events
|
2026-05-16 01:14:11 +08:00
|
|
|
|
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
|
|
|
|
|
|
if agent_ui is not None:
|
|
|
|
|
|
payload["agent_ui"] = agent_ui
|
2026-04-18 18:51:53 +00:00
|
|
|
|
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
|
|
|
|
|
# progress strings) so WS clients can render them as subordinate
|
|
|
|
|
|
# trace rows rather than conversational replies.
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if progress_event and progress_event.tool_hint:
|
2026-04-18 18:51:53 +00:00
|
|
|
|
payload["kind"] = "tool_hint"
|
2026-06-30 00:03:07 +08:00
|
|
|
|
elif progress_event:
|
2026-04-18 18:51:53 +00:00
|
|
|
|
payload["kind"] = "progress"
|
2026-06-06 19:49:33 +08:00
|
|
|
|
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
2026-07-28 11:40:01 +08:00
|
|
|
|
self._persist_turn_transcript_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
msg.chat_id,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
metadata=msg.metadata,
|
|
|
|
|
|
phase=phase,
|
|
|
|
|
|
include_source=True,
|
|
|
|
|
|
transcript_overrides={"text": text},
|
|
|
|
|
|
)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
raw = json.dumps(payload, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-04-18 08:44:12 +00:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" ")
|
2026-04-08 21:39:35 +08:00
|
|
|
|
|
2026-05-13 07:13:43 +00:00
|
|
|
|
async def send_reasoning_delta(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
delta: str,
|
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
*,
|
|
|
|
|
|
stream_id: str | None = None,
|
2026-05-13 07:13:43 +00:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
|
2026-05-16 01:14:11 +08:00
|
|
|
|
clients receive a stream that opens, updates in place, and closes —
|
2026-05-13 07:13:43 +00:00
|
|
|
|
rendered above the active assistant bubble with a shimmer header
|
|
|
|
|
|
until the matching ``reasoning_end`` arrives.
|
2026-05-13 06:27:53 +00:00
|
|
|
|
"""
|
2026-05-13 07:13:43 +00:00
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not delta:
|
2026-05-13 06:27:53 +00:00
|
|
|
|
return
|
2026-05-13 07:13:43 +00:00
|
|
|
|
meta = metadata or {}
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "reasoning_delta",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"text": delta,
|
2026-05-13 06:27:53 +00:00
|
|
|
|
}
|
2026-05-13 07:13:43 +00:00
|
|
|
|
if stream_id is not None:
|
|
|
|
|
|
body["stream_id"] = stream_id
|
2026-08-22 19:41:37 +08:00
|
|
|
|
stream_key = (chat_id, str(stream_id or ""))
|
|
|
|
|
|
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
|
|
|
|
|
|
self._persist_turn_stream_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
chat_id,
|
|
|
|
|
|
body,
|
2026-08-22 19:41:37 +08:00
|
|
|
|
completed_text=None,
|
2026-06-06 19:49:33 +08:00
|
|
|
|
metadata=meta,
|
|
|
|
|
|
phase="reasoning",
|
|
|
|
|
|
)
|
2026-05-13 07:13:43 +00:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-05-13 06:27:53 +00:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" reasoning ")
|
|
|
|
|
|
|
2026-05-13 07:13:43 +00:00
|
|
|
|
async def send_reasoning_end(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
*,
|
|
|
|
|
|
stream_id: str | None = None,
|
2026-05-13 07:13:43 +00:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Close the current reasoning stream segment for in-place renderers."""
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
meta = metadata or {}
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "reasoning_end",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
if stream_id is not None:
|
|
|
|
|
|
body["stream_id"] = stream_id
|
2026-08-22 19:41:37 +08:00
|
|
|
|
stream_key = (chat_id, str(stream_id or ""))
|
|
|
|
|
|
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
|
|
|
|
|
|
self._persist_turn_stream_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
chat_id,
|
|
|
|
|
|
body,
|
2026-08-22 19:41:37 +08:00
|
|
|
|
completed_text=reasoning_text or None,
|
2026-06-06 19:49:33 +08:00
|
|
|
|
metadata=meta,
|
|
|
|
|
|
phase="reasoning",
|
|
|
|
|
|
)
|
2026-05-13 07:13:43 +00:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-05-13 07:13:43 +00:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" reasoning_end ")
|
|
|
|
|
|
|
2026-06-01 14:42:11 +08:00
|
|
|
|
async def send_file_edit_events(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
edits: list[dict[str, Any]],
|
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
payload: dict[str, Any] = {
|
|
|
|
|
|
"event": "file_edit",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"edits": edits,
|
|
|
|
|
|
}
|
2026-07-28 11:40:01 +08:00
|
|
|
|
self._persist_turn_transcript_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
chat_id,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase="activity",
|
|
|
|
|
|
)
|
2026-06-01 14:42:11 +08:00
|
|
|
|
raw = json.dumps(payload, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-06-01 14:42:11 +08:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" file_edit ")
|
|
|
|
|
|
|
2026-04-08 21:39:35 +08:00
|
|
|
|
async def send_delta(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
delta: str,
|
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
2026-06-30 00:03:07 +08:00
|
|
|
|
*,
|
|
|
|
|
|
stream_id: str | None = None,
|
|
|
|
|
|
stream_end: bool = False,
|
|
|
|
|
|
resuming: bool = False,
|
2026-07-23 18:35:41 +08:00
|
|
|
|
merge_next: bool = False,
|
2026-04-08 21:39:35 +08:00
|
|
|
|
) -> None:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
2026-04-08 21:39:35 +08:00
|
|
|
|
meta = metadata or {}
|
2026-06-30 00:03:07 +08:00
|
|
|
|
stream_key = (chat_id, str(stream_id or ""))
|
2026-08-22 19:41:37 +08:00
|
|
|
|
completed_text: str | None = None
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if stream_end:
|
2026-04-18 08:44:12 +00:00
|
|
|
|
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
2026-07-23 18:35:41 +08:00
|
|
|
|
buffered = (
|
|
|
|
|
|
self._stream_text_buffers.setdefault(stream_key, [])
|
|
|
|
|
|
if merge_next
|
|
|
|
|
|
else self._stream_text_buffers.pop(stream_key, [])
|
|
|
|
|
|
)
|
2026-05-23 01:43:48 +08:00
|
|
|
|
if delta:
|
|
|
|
|
|
buffered.append(delta)
|
|
|
|
|
|
full_text = "".join(buffered)
|
2026-06-02 14:49:06 +08:00
|
|
|
|
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
2026-08-22 19:41:37 +08:00
|
|
|
|
completed_text = rewritten
|
2026-06-10 15:30:29 +08:00
|
|
|
|
if delta or rewritten != full_text:
|
2026-05-23 01:39:46 +08:00
|
|
|
|
body["text"] = rewritten
|
2026-04-08 21:39:35 +08:00
|
|
|
|
else:
|
|
|
|
|
|
body = {
|
|
|
|
|
|
"event": "delta",
|
2026-04-18 08:44:12 +00:00
|
|
|
|
"chat_id": chat_id,
|
2026-04-08 21:39:35 +08:00
|
|
|
|
"text": delta,
|
|
|
|
|
|
}
|
2026-05-23 01:39:46 +08:00
|
|
|
|
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if stream_id is not None:
|
|
|
|
|
|
body["stream_id"] = stream_id
|
2026-07-22 22:35:39 +08:00
|
|
|
|
if stream_end and resuming:
|
|
|
|
|
|
body["resuming"] = True
|
2026-07-23 18:35:41 +08:00
|
|
|
|
if stream_end and merge_next:
|
|
|
|
|
|
body["merge_next"] = True
|
2026-08-22 19:41:37 +08:00
|
|
|
|
self._persist_turn_stream_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
chat_id,
|
|
|
|
|
|
body,
|
2026-08-22 19:41:37 +08:00
|
|
|
|
completed_text=completed_text,
|
2026-06-06 19:49:33 +08:00
|
|
|
|
metadata=meta,
|
|
|
|
|
|
phase="answer",
|
2026-07-07 10:17:23 +08:00
|
|
|
|
include_source=True,
|
2026-06-06 19:49:33 +08:00
|
|
|
|
)
|
2026-04-08 21:39:35 +08:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-04-18 08:44:12 +00:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" stream ")
|
2026-05-01 12:38:22 -03:00
|
|
|
|
|
2026-05-16 01:14:11 +08:00
|
|
|
|
async def send_turn_end(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
latency_ms: int | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
goal_state: dict[str, Any] | None = None,
|
2026-08-22 00:53:16 +08:00
|
|
|
|
usage: LLMUsage | None = None,
|
2026-08-16 16:34:11 +08:00
|
|
|
|
context_window_tokens: int | None = None,
|
2026-06-06 19:49:33 +08:00
|
|
|
|
metadata: dict[str, Any] | None = None,
|
2026-07-28 11:40:01 +08:00
|
|
|
|
turn_owner: str | None = None,
|
2026-05-16 01:14:11 +08:00
|
|
|
|
) -> None:
|
2026-05-01 12:38:22 -03:00
|
|
|
|
"""Signal that the agent has fully finished processing the current turn."""
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
2026-08-16 16:34:11 +08:00
|
|
|
|
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
|
|
|
|
|
if isinstance(turn_id, str) and turn_id:
|
|
|
|
|
|
body["turn_id"] = turn_id
|
2026-05-16 01:14:11 +08:00
|
|
|
|
if latency_ms is not None:
|
|
|
|
|
|
body["latency_ms"] = int(latency_ms)
|
|
|
|
|
|
if goal_state is not None:
|
|
|
|
|
|
body["goal_state"] = goal_state
|
2026-08-22 00:53:16 +08:00
|
|
|
|
if usage is not None:
|
|
|
|
|
|
body["usage"] = usage.to_turn_dict()
|
2026-08-16 16:34:11 +08:00
|
|
|
|
if context_window_tokens is not None:
|
|
|
|
|
|
body["context_window_tokens"] = int(context_window_tokens)
|
2026-07-28 11:40:01 +08:00
|
|
|
|
canonical_webui_turn = (metadata or {}).get("webui") is True
|
|
|
|
|
|
prior_persistence_failure = (
|
|
|
|
|
|
canonical_webui_turn
|
|
|
|
|
|
and websocket_turn_transcript_persistence_failed(chat_id, turn_owner)
|
|
|
|
|
|
)
|
|
|
|
|
|
persisted = self._persist_turn_transcript_event(
|
2026-06-06 19:49:33 +08:00
|
|
|
|
chat_id,
|
|
|
|
|
|
body,
|
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
|
phase="complete",
|
2026-07-28 11:40:01 +08:00
|
|
|
|
transcript_overrides=(
|
|
|
|
|
|
{WEBUI_TRANSCRIPT_INCOMPLETE_KEY: True}
|
|
|
|
|
|
if prior_persistence_failure
|
|
|
|
|
|
else None
|
|
|
|
|
|
),
|
2026-06-06 19:49:33 +08:00
|
|
|
|
)
|
2026-07-28 11:40:01 +08:00
|
|
|
|
if persisted:
|
|
|
|
|
|
# A successful completion either has a complete transcript or now
|
|
|
|
|
|
# carries a durable incomplete marker. The HTTP replay path can
|
|
|
|
|
|
# recover the latter from session history after a gateway restart.
|
|
|
|
|
|
clear_websocket_turn_if_current(chat_id, turn_owner)
|
2026-08-22 19:41:37 +08:00
|
|
|
|
self._clear_stream_buffers(chat_id)
|
2026-05-01 12:38:22 -03:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
2026-05-01 12:38:22 -03:00
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" turn_end ")
|
2026-05-06 14:15:36 +00:00
|
|
|
|
|
2026-08-22 19:33:22 +08:00
|
|
|
|
async def send_recovery_state(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
event: RecoveryStateEvent,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Publish one structured recovery transition without chat pollution."""
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "recovery_state",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"status": event.status,
|
|
|
|
|
|
"recovery_id": event.recovery_id,
|
|
|
|
|
|
"attempts": event.attempts,
|
|
|
|
|
|
}
|
|
|
|
|
|
if event.reason:
|
|
|
|
|
|
body["reason"] = event.reason
|
|
|
|
|
|
if event.can_continue is not None:
|
|
|
|
|
|
body["can_continue"] = event.can_continue
|
|
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in list(self._subs.get(chat_id, ())):
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" recovery_state ")
|
|
|
|
|
|
|
2026-05-16 01:14:11 +08:00
|
|
|
|
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
|
|
|
|
|
|
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
|
|
|
|
|
body = {"event": "goal_state", "chat_id": chat_id, "goal_state": blob}
|
|
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" goal_state ")
|
|
|
|
|
|
|
|
|
|
|
|
async def send_goal_status(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
started_at: float | None = None,
|
2026-07-28 11:40:01 +08:00
|
|
|
|
turn_id: str | None = None,
|
2026-05-16 01:14:11 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Notify subscribed clients that a turn started or finished (wall-clock hint)."""
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "goal_status",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
}
|
|
|
|
|
|
if status == "running" and started_at is not None:
|
|
|
|
|
|
body["started_at"] = started_at
|
2026-07-28 11:40:01 +08:00
|
|
|
|
if turn_id:
|
|
|
|
|
|
body["turn_id"] = turn_id
|
2026-05-16 01:14:11 +08:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" goal_status ")
|
|
|
|
|
|
|
2026-05-17 23:51:52 +08:00
|
|
|
|
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
|
2026-06-15 17:30:06 +08:00
|
|
|
|
"""Notify WebUI clients that a session row should refresh."""
|
2026-06-16 15:10:51 +08:00
|
|
|
|
conns = list(self._conn_chats)
|
2026-05-06 14:15:36 +00:00
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
|
|
|
|
|
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
2026-05-17 23:51:52 +08:00
|
|
|
|
if scope:
|
|
|
|
|
|
body["scope"] = scope
|
2026-05-06 14:15:36 +00:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" session_updated ")
|
2026-05-12 09:05:24 +00:00
|
|
|
|
|
2026-08-18 18:28:19 +08:00
|
|
|
|
async def send_user_input(
|
2026-08-18 09:53:55 +08:00
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
created_at_ms: int,
|
2026-08-18 18:28:19 +08:00
|
|
|
|
provenance: dict[str, Any],
|
2026-08-18 09:53:55 +08:00
|
|
|
|
) -> None:
|
2026-08-18 18:28:19 +08:00
|
|
|
|
"""Project user input produced outside a WebSocket connection."""
|
2026-08-18 09:53:55 +08:00
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
if not conns:
|
|
|
|
|
|
return
|
|
|
|
|
|
body: dict[str, Any] = {
|
2026-08-18 18:28:19 +08:00
|
|
|
|
"event": "user_message",
|
2026-08-18 09:53:55 +08:00
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"text": content,
|
|
|
|
|
|
"created_at_ms": created_at_ms,
|
2026-08-18 18:28:19 +08:00
|
|
|
|
"starts_turn": False,
|
2026-08-18 09:53:55 +08:00
|
|
|
|
}
|
2026-08-18 18:28:19 +08:00
|
|
|
|
if provenance:
|
|
|
|
|
|
body["provenance"] = provenance
|
2026-08-18 09:53:55 +08:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
2026-08-18 18:28:19 +08:00
|
|
|
|
await self._safe_send_to(connection, raw, label=" user_message ")
|
2026-08-18 09:53:55 +08:00
|
|
|
|
|
2026-05-12 09:05:24 +00:00
|
|
|
|
async def send_runtime_model_updated(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
model_name: Any,
|
|
|
|
|
|
model_preset: Any = None,
|
|
|
|
|
|
) -> None:
|
2026-05-16 01:14:11 +08:00
|
|
|
|
"""Broadcast runtime model changes to every open websocket connection."""
|
2026-05-12 09:05:24 +00:00
|
|
|
|
conns = list(self._conn_chats)
|
|
|
|
|
|
if not conns or not isinstance(model_name, str) or not model_name.strip():
|
|
|
|
|
|
return
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "runtime_model_updated",
|
|
|
|
|
|
"model_name": model_name.strip(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if isinstance(model_preset, str) and model_preset.strip():
|
|
|
|
|
|
body["model_preset"] = model_preset.strip()
|
|
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
2026-07-23 15:57:13 +08:00
|
|
|
|
|
|
|
|
|
|
async def send_turn_model_updated(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
model_name: Any,
|
2026-08-16 02:50:30 +08:00
|
|
|
|
model_preset: Any = None,
|
2026-08-16 16:34:11 +08:00
|
|
|
|
context_window_tokens: Any = None,
|
2026-08-22 19:41:37 +08:00
|
|
|
|
fallback: bool = False,
|
2026-07-23 15:57:13 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Notify one chat's subscribers which model is handling its current request."""
|
|
|
|
|
|
conns = list(self._subs.get(chat_id, ()))
|
|
|
|
|
|
if (
|
|
|
|
|
|
not conns
|
|
|
|
|
|
or not isinstance(model_name, str)
|
|
|
|
|
|
or not model_name.strip()
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
body: dict[str, Any] = {
|
|
|
|
|
|
"event": "turn_model_updated",
|
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
|
"model_name": model_name.strip(),
|
|
|
|
|
|
}
|
2026-08-16 02:50:30 +08:00
|
|
|
|
if isinstance(model_preset, str) and model_preset.strip():
|
|
|
|
|
|
body["model_preset"] = model_preset.strip()
|
2026-08-16 16:34:11 +08:00
|
|
|
|
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
|
|
|
|
|
body["context_window_tokens"] = context_window_tokens
|
2026-08-22 19:41:37 +08:00
|
|
|
|
if fallback:
|
|
|
|
|
|
body["fallback"] = True
|
2026-07-23 15:57:13 +08:00
|
|
|
|
raw = json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
for connection in conns:
|
|
|
|
|
|
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|