From 63975aa75b8193b59771bd3b909dbdf2a175a238 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 24 Apr 2026 12:07:46 -0400 Subject: [PATCH] fix: mobile chat in new layout --- hermes_cli/main.py | 22 +- hermes_cli/pty_bridge.py | 16 +- hermes_cli/web_server.py | 29 +- pyproject.toml | 6 + tests/hermes_cli/test_web_server.py | 10 + uv.lock | 10 +- web/src/App.tsx | 60 +++- web/src/components/ChatSidebar.tsx | 11 +- web/src/contexts/PageHeaderProvider.tsx | 19 +- web/src/i18n/en.ts | 3 + web/src/i18n/types.ts | 3 + web/src/i18n/zh.ts | 3 + web/src/lib/dashboard-flags.ts | 15 + web/src/pages/ChatPage.tsx | 369 ++++++++++++++++++++---- web/src/pages/DocsPage.tsx | 2 +- web/src/pages/SessionsPage.tsx | 33 ++- web/vite.config.ts | 15 +- 17 files changed, 526 insertions(+), 100 deletions(-) create mode 100644 web/src/lib/dashboard-flags.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 897a6c3cd..7de68d2cb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6715,9 +6715,15 @@ def cmd_dashboard(args): try: import fastapi # noqa: F401 import uvicorn # noqa: F401 - except ImportError: - print("Web UI dependencies not installed.") - print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'") + except ImportError as e: + print("Web UI dependencies not installed (need fastapi + uvicorn).") + print( + f"Re-install the package into this interpreter so metadata updates apply:\n" + f" cd {PROJECT_ROOT}\n" + f" {sys.executable} -m pip install -e .\n" + "If `pip` is missing in this venv, use: uv pip install -e ." + ) + print(f"Import error: {e}") sys.exit(1) if "HERMES_WEB_DIST" not in os.environ: @@ -6726,11 +6732,13 @@ def cmd_dashboard(args): from hermes_cli.web_server import start_server + embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1" start_server( host=args.host, port=args.port, open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), + embedded_chat=embedded_chat, ) @@ -8916,6 +8924,14 @@ Examples: action="store_true", help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", ) + dashboard_parser.add_argument( + "--tui", + action="store_true", + help=( + "Expose the in-browser Chat tab (embedded `hermes --tui` via PTY/WebSocket). " + "Alternatively set HERMES_DASHBOARD_TUI=1." + ), + ) dashboard_parser.set_defaults(func=cmd_dashboard) # ========================================================================= diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index b32013f7f..9a8a73bad 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -96,10 +96,18 @@ class PtyBridge: ordinary exec failures (missing binary, bad cwd, etc.). """ if not _PTY_AVAILABLE: - raise PtyUnavailableError( - "Pseudo-terminals are unavailable on this platform. " - "Hermes Agent supports Windows only via WSL." - ) + if sys.platform.startswith("win"): + raise PtyUnavailableError( + "Pseudo-terminals are unavailable on this platform. " + "Hermes Agent supports Windows only via WSL." + ) + if ptyprocess is None: + raise PtyUnavailableError( + "The `ptyprocess` package is missing. " + "Install with: pip install ptyprocess " + "(or pip install -e '.[pty]')." + ) + raise PtyUnavailableError("Pseudo-terminals are unavailable.") # Let caller-supplied env fully override inheritance; if they pass # None we inherit the server's env (same semantics as subprocess). spawn_env = os.environ.copy() if env is None else env diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e20bd6289..8847de814 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -73,6 +73,10 @@ app = FastAPI(title="Hermes Agent", version=__version__) _SESSION_TOKEN = secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" +# In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui`` +# or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`. +_DASHBOARD_EMBEDDED_CHAT_ENABLED = False + # Simple rate limiter for the reveal endpoint _reveal_timestamps: List[float] = [] _REVEAL_MAX_PER_WINDOW = 5 @@ -2370,6 +2374,10 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]: @app.websocket("/api/pty") async def pty_ws(ws: WebSocket) -> None: + if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: + await ws.close(code=4403) + return + # --- auth + loopback check (before accept so we can close cleanly) --- token = ws.query_params.get("token", "") expected = _SESSION_TOKEN @@ -2476,6 +2484,10 @@ async def pty_ws(ws: WebSocket) -> None: @app.websocket("/api/ws") async def gateway_ws(ws: WebSocket) -> None: + if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: + await ws.close(code=4403) + return + token = ws.query_params.get("token", "") if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): await ws.close(code=4401) @@ -2505,6 +2517,10 @@ async def gateway_ws(ws: WebSocket) -> None: @app.websocket("/api/pub") async def pub_ws(ws: WebSocket) -> None: + if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: + await ws.close(code=4403) + return + token = ws.query_params.get("token", "") if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): await ws.close(code=4401) @@ -2531,6 +2547,10 @@ async def pub_ws(ws: WebSocket) -> None: @app.websocket("/api/events") async def events_ws(ws: WebSocket) -> None: + if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: + await ws.close(code=4403) + return + token = ws.query_params.get("token", "") if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): await ws.close(code=4401) @@ -2591,8 +2611,10 @@ def mount_spa(application: FastAPI): def _serve_index(): """Return index.html with the session token injected.""" html = _index_path.read_text() + chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" token_script = ( - f'' + f'" ) html = html.replace("", f"{token_script}", 1) return HTMLResponse( @@ -3105,10 +3127,15 @@ def start_server( port: int = 9119, open_browser: bool = True, allow_public: bool = False, + *, + embedded_chat: bool = False, ): """Start the web UI server.""" import uvicorn + global _DASHBOARD_EMBEDDED_CHAT_ENABLED + _DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat + _LOCALHOST = ("127.0.0.1", "localhost", "::1") if host not in _LOCALHOST and not allow_public: raise SystemExit( diff --git a/pyproject.toml b/pyproject.toml index 2b76537fc..221121524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,11 @@ dependencies = [ "edge-tts>=7.2.7,<8", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + # Web dashboard (`hermes dashboard` — localhost SPA + API) + "fastapi>=0.104.0,<1", + "uvicorn[standard]>=0.24.0,<1", + # Embedded Chat tab (/api/pty) on POSIX — required for PTY, not optional + "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", ] [project.optional-dependencies] @@ -78,6 +83,7 @@ termux = [ ] dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"] feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"] +# Same pins as core; kept so `hermes-agent[web]` stays a no-op alias for older docs/scripts. web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8ff9285e4..e83f5bdeb 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1707,6 +1707,7 @@ class TestPtyWebSocket: # Avoid exec'ing the actual TUI in tests: every test below installs # its own fake argv via ``ws._resolve_chat_argv``. self.ws_module = ws + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) self.token = ws._SESSION_TOKEN self.client = TestClient(ws.app) @@ -1719,6 +1720,15 @@ class TestPtyWebSocket: q = {"token": tok, **params} return f"/api/pty?{urlencode(q)}" + def test_rejects_when_embedded_chat_disabled(self, monkeypatch): + monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False) + from starlette.websockets import WebSocketDisconnect + + with pytest.raises(WebSocketDisconnect) as exc: + with self.client.websocket_connect(self._url()): + pass + assert exc.value.code == 4403 + def test_rejects_missing_token(self, monkeypatch): monkeypatch.setattr( self.ws_module, diff --git a/uv.lock b/uv.lock index 080aefeb1..ab3d6a0c5 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-16T11:49:00.318115Z" +exclude-newer = "2026-04-17T15:09:44.835508886Z" exclude-newer-span = "P7D" [[package]] @@ -1870,13 +1870,14 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.10.0" +version = "0.11.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "edge-tts" }, { name = "exa-py" }, { name = "fal-client" }, + { name = "fastapi" }, { name = "fire" }, { name = "firecrawl-py" }, { name = "httpx", extra = ["socks"] }, @@ -1884,6 +1885,7 @@ dependencies = [ { name = "openai" }, { name = "parallel-web" }, { name = "prompt-toolkit" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, @@ -1891,6 +1893,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "tenacity" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] @@ -2059,6 +2062,7 @@ requires-dist = [ { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, { name = "exa-py", specifier = ">=2.9.0,<3" }, { name = "fal-client", specifier = ">=0.13.1,<1" }, + { name = "fastapi", specifier = ">=0.104.0,<1" }, { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" }, { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, @@ -2105,6 +2109,7 @@ requires-dist = [ { name = "openai", specifier = ">=2.21.0,<3" }, { name = "parallel-web", specifier = ">=0.4.2,<1" }, { name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" }, { name = "pydantic", specifier = ">=2.12.5,<3" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, @@ -2131,6 +2136,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.1.4,<10" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 5434a0197..f4285a21b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -65,15 +65,22 @@ import { useI18n } from "@/i18n"; import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; +import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; function RootRedirect() { return ; } -/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */ -const BUILTIN_ROUTES: Record = { +const CHAT_NAV_ITEM: NavItem = { + path: "/chat", + labelKey: "chat", + label: "Chat", + icon: Terminal, +}; + +/** Built-in routes except /chat (only with `hermes dashboard --tui`). */ +const BUILTIN_ROUTES_CORE: Record = { "/": RootRedirect, - "/chat": ChatPage, "/sessions": SessionsPage, "/analytics": AnalyticsPage, "/logs": LogsPage, @@ -84,8 +91,7 @@ const BUILTIN_ROUTES: Record = { "/docs": DocsPage, }; -const BUILTIN_NAV: NavItem[] = [ - { path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal }, +const BUILTIN_NAV_REST: NavItem[] = [ { path: "/sessions", labelKey: "sessions", @@ -170,7 +176,10 @@ function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem return items; } -function buildRoutes(manifests: PluginManifest[]): Array<{ +function buildRoutes( + builtinRoutes: Record, + manifests: PluginManifest[], +): Array<{ key: string; path: string; element: ReactNode; @@ -192,7 +201,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{ element: ReactNode; }> = []; - for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) { + for (const [path, Component] of Object.entries(builtinRoutes)) { const om = byOverride.get(path); if (om) { routes.push({ @@ -207,7 +216,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{ for (const m of addons) { if (m.tab.hidden) continue; - if (BUILTIN_ROUTES[m.tab.path]) continue; + if (builtinRoutes[m.tab.path]) continue; routes.push({ key: `plugin:${m.name}`, path: m.tab.path, @@ -217,7 +226,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{ for (const m of manifests) { if (!m.tab.hidden) continue; - if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue; + if (builtinRoutes[m.tab.path] || m.tab.override) continue; routes.push({ key: `plugin:hidden:${m.name}`, path: m.tab.path, @@ -236,12 +245,32 @@ export default function App() { const [mobileOpen, setMobileOpen] = useState(false); const closeMobile = useCallback(() => setMobileOpen(false), []); const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; + const normalizedPath = pathname.replace(/\/$/, "") || "/"; + const isChatRoute = normalizedPath === "/chat"; + const embeddedChat = isDashboardEmbeddedChatEnabled(); + + const builtinRoutes = useMemo( + () => ({ + ...BUILTIN_ROUTES_CORE, + ...(embeddedChat ? { "/chat": ChatPage } : {}), + }), + [embeddedChat], + ); + + const builtinNav = useMemo( + () => + embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST, + [embeddedChat], + ); const navItems = useMemo( - () => buildNavItems(BUILTIN_NAV, manifests), - [manifests], + () => buildNavItems(builtinNav, manifests), + [builtinNav, manifests], + ); + const routes = useMemo( + () => buildRoutes(builtinRoutes, manifests), + [builtinRoutes, manifests], ); - const routes = useMemo(() => buildRoutes(manifests), [manifests]); const pluginTabMeta = useMemo( () => manifests @@ -468,8 +497,9 @@ export default function App() { className={cn( "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "px-3 sm:px-6", - "pt-2 sm:pt-4 lg:pt-6", - "pb-4 sm:pb-8", + isChatRoute + ? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4" + : "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8", isDocsRoute && "min-h-0 flex-1", )} > @@ -477,7 +507,7 @@ export default function App() {
diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 924edc0e2..2ee32f97d 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -31,6 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; +import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -66,9 +67,10 @@ const STATE_TONE: Record = { interface ChatSidebarProps { channel: string; + className?: string; } -export function ChatSidebar({ channel }: ChatSidebarProps) { +export function ChatSidebar({ channel, className }: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState // for it inside an effect (React 19's set-state-in-effect rule). The // counter is the dependency on purpose — it's not read in the memo body, @@ -284,7 +286,12 @@ export function ChatSidebar({ channel }: ChatSidebarProps) { const banner = error ?? info.credential_warning ?? null; return ( -