feat(webui): add initial webui with websocket chat flow

This commit is contained in:
Xubin Ren
2026-04-18 18:51:53 +00:00
parent 6bfb75ed03
commit 9ed3031a42
76 changed files with 7088 additions and 38 deletions
+110 -10
View File
@@ -636,6 +636,21 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
cfg = _load_runtime_config(config, workspace)
_run_gateway(cfg, port=port)
def _run_gateway(
config: Config,
*,
port: int | None = None,
open_browser_url: str | None = None,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
@@ -644,12 +659,6 @@ def gateway(
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
@@ -749,8 +758,9 @@ def gateway(
cron.on_job = on_cron_job
# Create channel manager
channels = ChannelManager(config, bus)
# Create channel manager (forwards SessionManager so the WebSocket channel
# can serve the embedded webui's REST surface).
channels = ChannelManager(config, bus, session_manager=session_manager)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
@@ -881,15 +891,43 @@ def gateway(
))
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
async def _open_browser_when_ready() -> None:
"""Wait for the gateway to bind, then point the user's browser at the webui."""
if not open_browser_url:
return
import webbrowser
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
reader, writer = await asyncio.open_connection(
config.gateway.host or "127.0.0.1", port
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
break
except OSError:
await asyncio.sleep(0.1)
try:
webbrowser.open(open_browser_url)
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
async def run():
try:
await cron.start()
await heartbeat.start()
await asyncio.gather(
tasks = [
agent.run(),
channels.start_all(),
_health_server(config.gateway.host, port),
)
]
if open_browser_url:
tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks)
except KeyboardInterrupt:
console.print("\nShutting down...")
except Exception:
@@ -907,6 +945,68 @@ def gateway(
asyncio.run(run())
@app.command()
def web(
port: int | None = typer.Option(None, "--port", "-p", help="WebSocket port for the webui"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser when ready"),
):
"""Start the gateway with the embedded webui and (by default) open a browser."""
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
cfg = _load_runtime_config(config, workspace)
# Force the websocket channel on with token-gated auth so the webui is functional.
# ``--port`` applies to the webui's websocket/HTTP port, not the gateway's
# management port, since that's the only surface users visit.
ws_section = cfg.channels.websocket
if isinstance(ws_section, dict):
ws_section.setdefault("host", "127.0.0.1")
ws_section["enabled"] = True
ws_section["websocketRequiresToken"] = True
if port is not None:
ws_section["port"] = port
ws_host = ws_section.get("host", "127.0.0.1")
ws_port = ws_section.get("port", 8765)
ws_path = ws_section.get("path", "/")
else:
ws_section.enabled = True
if hasattr(ws_section, "websocket_requires_token"):
ws_section.websocket_requires_token = True
if port is not None:
ws_section.port = port
ws_host = getattr(ws_section, "host", "127.0.0.1") or "127.0.0.1"
ws_port = getattr(ws_section, "port", 8765)
ws_path = getattr(ws_section, "path", "/") or "/"
# Confirm the bundled SPA exists before promising the user a browser launch.
from nanobot.channels.manager import _default_webui_dist
dist = _default_webui_dist()
if dist is None:
console.print(
"[yellow]Warning: webui assets not found at nanobot/web/dist/. "
"Run `cd webui && bun install && bun run build` from a source checkout.[/yellow]"
)
scheme = "http"
# Browsers refuse cookies/JS on 0.0.0.0 — collapse to loopback for the visit URL.
visit_host = "127.0.0.1" if ws_host in {"0.0.0.0", "::"} else ws_host
open_url = f"{scheme}://{visit_host}:{ws_port}{ws_path if ws_path != '/' else ''}/"
# The gateway's management port is separate from the webui port; leave it
# on its configured default so --port only moves the visible surface.
_run_gateway(
cfg,
open_browser_url=open_url if open_browser else None,
)
# ============================================================================
# Agent Commands
# ============================================================================