Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
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
108 lines
3.0 KiB
Python
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
|