Files
hermes-agent/gateway/web_console/api/media.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

132 lines
5.2 KiB
Python

"""Media API routes for the Hermes Web Console backend."""
from __future__ import annotations
import importlib.util
import json
import uuid
from pathlib import Path
from types import ModuleType
from typing import Any
from aiohttp import web
from hermes_cli.config import ensure_hermes_home, get_hermes_home
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _load_tool_module(module_name: str, file_name: str) -> ModuleType:
module_path = Path(__file__).resolve().parents[3] / "tools" / file_name
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load tool module from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _media_upload_dir() -> Path:
ensure_hermes_home()
target = get_hermes_home() / "uploads" / "web_console"
target.mkdir(parents=True, exist_ok=True)
return target
async def handle_media_upload(request: web.Request) -> web.Response:
if not request.content_type.startswith("multipart/"):
return _json_error(status=400, code="invalid_upload", message="Upload requests must use multipart/form-data.")
reader = await request.multipart()
part = await reader.next()
if part is None or part.name != "file":
return _json_error(status=400, code="missing_file", message="The upload must include a 'file' field.")
filename = part.filename or f"upload-{uuid.uuid4().hex}"
safe_name = Path(filename).name or f"upload-{uuid.uuid4().hex}"
destination = _media_upload_dir() / safe_name
if destination.exists():
destination = _media_upload_dir() / f"{destination.stem}-{uuid.uuid4().hex[:8]}{destination.suffix}"
size = 0
with destination.open("wb") as handle:
while True:
chunk = await part.read_chunk()
if not chunk:
break
size += len(chunk)
handle.write(chunk)
return web.json_response(
{
"ok": True,
"media": {
"file_path": str(destination),
"filename": destination.name,
"content_type": part.headers.get("Content-Type"),
"size": size,
},
}
)
async def handle_media_transcribe(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
file_path = data.get("file_path")
if not isinstance(file_path, str) or not file_path.strip():
return _json_error(status=400, code="invalid_file_path", message="The 'file_path' field must be a non-empty string.")
transcription_tools = _load_tool_module("hermes_web_console_transcription_tools", "transcription_tools.py")
result = transcription_tools.transcribe_audio(file_path.strip(), model=data.get("model"))
status = 200 if result.get("success") else 400
return web.json_response({"ok": bool(result.get("success")), "transcription": result}, status=status)
async def handle_media_tts(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
text = data.get("text")
if not isinstance(text, str) or not text.strip():
return _json_error(status=400, code="invalid_text", message="The 'text' field must be a non-empty string.")
output_path = data.get("output_path")
if output_path is not None and not isinstance(output_path, str):
return _json_error(status=400, code="invalid_output_path", message="The 'output_path' field must be a string when provided.")
tts_tool = _load_tool_module("hermes_web_console_tts_tool", "tts_tool.py")
raw_result = tts_tool.text_to_speech_tool(text=text, output_path=output_path)
try:
result = json.loads(raw_result)
except (TypeError, ValueError):
result = {"success": False, "error": "TTS returned an invalid payload.", "raw_result": raw_result}
status = 200 if result.get("success") else 400
return web.json_response({"ok": bool(result.get("success")), "tts": result}, status=status)
async def handle_media_transcribe_upload(request: web.Request) -> web.Response:
return _json_error(status=501, code="not_implemented", message="Use /api/gui/media/upload followed by /api/gui/media/transcribe.")
def register_media_api_routes(app: web.Application) -> None:
app.router.add_post("/api/gui/media/upload", handle_media_upload)
app.router.add_post("/api/gui/media/transcribe", handle_media_transcribe)
app.router.add_post("/api/gui/media/tts", handle_media_tts)