Files
hermes-agent/gateway/web_console/sse.py
Alexander Whitestone 8e0f24db3f
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui.
React + TypeScript frontend with Vite, Python aiohttp backend API.

Components:
- web_console/ — React frontend (chat, sessions, memory, settings, skills,
  gateway config, cron, workspace, tools, browser, insights pages)
- gateway/web_console/ — Python backend API (23 endpoints, SSE event bus,
  11 service modules)
- gateway/platforms/api_server_ui.py — embedded browser UI for API server
- gateway/platforms/api_server.py — route registration refactored into
  _register_routes(), web console mounted via maybe_register_web_console()
- run-gui.sh / setup-gui.sh — one-command launch and setup scripts
- tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing)
- tests/web_console/ — 13 backend test files (51 passing)
- docs/plans/ — implementation plan, API schema, frontend architecture

Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to
cron/scheduler.py (pre-existing import bug).

Closes #325
2026-04-13 18:01:51 -04:00

108 lines
3.0 KiB
Python

"""Helpers for building server-sent event streams for the web console."""
from __future__ import annotations
import asyncio
import contextlib
import json
from dataclasses import dataclass
from typing import Any, AsyncIterable
from aiohttp import web
@dataclass(slots=True)
class SseMessage:
"""An SSE message payload."""
data: Any
event: str | None = None
id: str | None = None
retry: int | None = None
SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
def json_dumps(data: Any) -> str:
"""Serialize data for SSE payload lines."""
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def format_sse_message(message: SseMessage) -> bytes:
"""Encode a single SSE message frame."""
lines: list[str] = []
if message.event:
lines.append(f"event: {message.event}")
if message.id:
lines.append(f"id: {message.id}")
if message.retry is not None:
lines.append(f"retry: {message.retry}")
payload = json_dumps(message.data)
for line in payload.splitlines() or [payload]:
lines.append(f"data: {line}")
return ("\n".join(lines) + "\n\n").encode("utf-8")
def format_sse_ping(comment: str = "ping") -> bytes:
"""Encode an SSE keepalive comment frame."""
return f": {comment}\n\n".encode("utf-8")
async def _safe_sse_write(response: web.StreamResponse, chunk: bytes) -> bool:
"""Write an SSE chunk, returning False if the client disconnected."""
try:
await response.write(chunk)
except (ConnectionResetError, RuntimeError):
return False
return True
async def stream_sse(
request: web.Request,
events: AsyncIterable[SseMessage],
*,
keepalive_interval: float = 15.0,
ping_comment: str = "ping",
) -> web.StreamResponse:
"""Write an async iterable of SSE messages to the client with keepalives."""
response = web.StreamResponse(status=200, headers=SSE_HEADERS)
await response.prepare(request)
iterator = aiter(events)
next_message_task: asyncio.Task[SseMessage] | None = None
while True:
if next_message_task is None:
next_message_task = asyncio.create_task(anext(iterator))
done, _pending = await asyncio.wait({next_message_task}, timeout=keepalive_interval)
if not done:
if not await _safe_sse_write(response, format_sse_ping(ping_comment)):
next_message_task.cancel()
with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration):
await next_message_task
break
continue
try:
message = next_message_task.result()
except StopAsyncIteration:
break
finally:
next_message_task = None
if not await _safe_sse_write(response, format_sse_message(message)):
break
with contextlib.suppress(ConnectionResetError, RuntimeError):
await response.write_eof()
return response