mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
506 lines
16 KiB
Python
506 lines
16 KiB
Python
"""Shared WebUI setup, URL, health, and browser helpers."""
|
|
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import typer
|
|
from pydantic import ValidationError
|
|
from rich.console import Console
|
|
from rich.markup import escape
|
|
from rich.text import Text
|
|
|
|
from nanobot.cli.runtime_config import (
|
|
_load_config_for_cli,
|
|
_print_model_setup_steps,
|
|
_print_runtime_config_validation_error,
|
|
_provider_setup_error,
|
|
)
|
|
from nanobot.config.schema import Config
|
|
from nanobot.security.network import is_loopback_host
|
|
from nanobot.webui.build import (
|
|
BuildMode,
|
|
WebUIBuildError,
|
|
ensure_webui_bundle,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from nanobot.gateway.runtime import GatewayRuntime
|
|
|
|
__all__ = [
|
|
"_attach_to_background_gateway",
|
|
"_confirm_webui_action",
|
|
"_ensure_local_webui_channel",
|
|
"_gateway_health_bind_note",
|
|
"_gateway_health_ready",
|
|
"_gateway_health_url",
|
|
"_gateway_instance_command",
|
|
"_host_for_local_browser",
|
|
"_load_webui_setup_config",
|
|
"_open_webui_browser",
|
|
"_prepare_webui_bundle_for_gateway",
|
|
"_print_foreground_port_conflict",
|
|
"_print_webui_foreground_lifecycle",
|
|
"_resolve_webui_config_path",
|
|
"_run_quick_start_for_webui",
|
|
"_tcp_endpoint_reachable",
|
|
"_validate_gateway_startup",
|
|
"_warn_webui_bind_scope",
|
|
"_webui_browser_url",
|
|
"_webui_build_mode_for_interactive",
|
|
"_webui_channel_enabled",
|
|
"_webui_display_url",
|
|
"_webui_endpoint_reachable",
|
|
]
|
|
|
|
console = Console()
|
|
|
|
|
|
def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
|
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
|
if yes:
|
|
return
|
|
if not _cli_can_prompt():
|
|
console.print(
|
|
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
|
|
"`nanobot onboard --wizard`.[/red]"
|
|
)
|
|
raise typer.Exit(1)
|
|
if not typer.confirm(message, default=True):
|
|
console.print("[yellow]WebUI setup cancelled.[/yellow]")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _cli_can_prompt() -> bool:
|
|
try:
|
|
return sys.stdin.isatty()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
|
|
if yes:
|
|
return "auto"
|
|
return "prompt" if _cli_can_prompt() else "warn"
|
|
|
|
|
|
def _resolve_webui_config_path(config: str | None) -> Path:
|
|
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
|
|
from nanobot.config.loader import get_config_path, set_config_path
|
|
|
|
if not config:
|
|
return get_config_path()
|
|
config_path = Path(config).expanduser().resolve(strict=False)
|
|
set_config_path(config_path)
|
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
|
return config_path
|
|
|
|
|
|
def _load_webui_setup_config(config_path: Path) -> Config:
|
|
"""Load config for first-run mutation without resolving env-var placeholders."""
|
|
return _load_config_for_cli(config_path)
|
|
|
|
|
|
def _webui_config_dict(config: Config) -> dict[str, Any]:
|
|
"""Return the current WebSocket config as a mutable alias-key dictionary."""
|
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
|
|
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
|
model = WebSocketConfig.model_validate(current)
|
|
return model.model_dump(by_alias=True, exclude_none=True)
|
|
|
|
|
|
def _webui_channel_enabled(config: Config) -> bool:
|
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
|
|
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
|
return bool(WebSocketConfig.model_validate(current).enabled)
|
|
|
|
|
|
def _validate_gateway_startup(config: Config) -> str | None:
|
|
"""Validate gateway startup and return a provider error recoverable through WebUI."""
|
|
from nanobot.config.loader import get_config_path
|
|
|
|
config_path = get_config_path()
|
|
try:
|
|
webui_config = _webui_config_dict(config)
|
|
except ValidationError as exc:
|
|
retry_command = f'nanobot gateway --config "{config_path}"'
|
|
_print_runtime_config_validation_error(
|
|
exc,
|
|
config_path=config_path,
|
|
summary="Gateway configuration is invalid.",
|
|
path_prefix=("channels", "websocket"),
|
|
retry_command=retry_command,
|
|
)
|
|
raise typer.Exit(1) from exc
|
|
|
|
provider_error = _provider_setup_error(config)
|
|
if not provider_error:
|
|
return None
|
|
|
|
if bool(webui_config["enabled"]):
|
|
console.print(
|
|
Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
|
|
)
|
|
console.print(
|
|
"Gateway will start so you can configure a provider and model "
|
|
"in WebUI Settings → Models."
|
|
)
|
|
browser_url = _webui_browser_url(config)
|
|
webui_url = browser_url.split("/#/", 1)[0]
|
|
console.print(Text(f"WebUI: {webui_url}", style="cyan"))
|
|
if browser_url != webui_url:
|
|
secret_key = (
|
|
"tokenIssueSecret"
|
|
if str(webui_config.get("tokenIssueSecret") or "").strip()
|
|
else "token"
|
|
)
|
|
console.print(
|
|
Text(
|
|
f"If prompted, enter the configured channels.websocket.{secret_key} "
|
|
f"value (see {config_path}).",
|
|
style="dim",
|
|
)
|
|
)
|
|
return provider_error
|
|
|
|
console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
|
|
console.print("Complete provider/model setup:")
|
|
_print_model_setup_steps(config_path)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _prepare_webui_bundle_for_gateway(
|
|
config: Config,
|
|
*,
|
|
mode: BuildMode,
|
|
webui_static_dist: bool = True,
|
|
) -> None:
|
|
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
|
|
if not webui_static_dist or not _webui_channel_enabled(config):
|
|
return
|
|
|
|
def _print(message: str) -> None:
|
|
console.print(f"[yellow]{escape(message)}[/yellow]")
|
|
|
|
def _confirm(message: str) -> bool:
|
|
return typer.confirm(message, default=True)
|
|
|
|
try:
|
|
ensure_webui_bundle(
|
|
mode=mode,
|
|
confirm=_confirm if mode == "prompt" else None,
|
|
output=_print,
|
|
)
|
|
except WebUIBuildError as exc:
|
|
if mode == "warn":
|
|
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
|
|
return
|
|
console.print(f"[red]Error: {escape(str(exc))}[/red]")
|
|
raise typer.Exit(1) from exc
|
|
|
|
|
|
def _host_for_local_browser(host: str) -> str:
|
|
"""Map bind hosts to a browser-openable local host."""
|
|
if host in {"0.0.0.0", ""}:
|
|
return "127.0.0.1"
|
|
if host == "::":
|
|
return "[::1]"
|
|
if ":" in host and not host.startswith("["):
|
|
return f"[{host}]"
|
|
return host
|
|
|
|
|
|
def _gateway_health_url(host: str, port: int) -> str:
|
|
"""Return a health URL that can be opened from this device."""
|
|
return f"http://{_host_for_local_browser(host)}:{port}/health"
|
|
|
|
|
|
def _gateway_health_bind_note(host: str) -> str:
|
|
"""Describe a non-local bind without presenting it as a usable URL."""
|
|
return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
|
|
|
|
|
|
def _webui_bootstrap_secret(config: Config) -> str:
|
|
ws_cfg = _webui_config_dict(config)
|
|
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
|
|
|
|
|
def _webui_browser_url(config: Config) -> str:
|
|
from urllib.parse import quote
|
|
|
|
ws_cfg = _webui_config_dict(config)
|
|
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
|
port = int(ws_cfg.get("port") or 8765)
|
|
base_url = f"http://{host}:{port}"
|
|
secret = _webui_bootstrap_secret(config)
|
|
if not secret:
|
|
return base_url
|
|
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
|
|
|
|
|
|
def _webui_display_url(url: str) -> str:
|
|
marker = "bootstrapSecret="
|
|
if marker not in url:
|
|
return url
|
|
prefix, _ = url.split(marker, 1)
|
|
return f"{prefix}{marker}<redacted>"
|
|
|
|
|
|
def _ensure_local_webui_channel(
|
|
config: Config,
|
|
*,
|
|
port: int | None,
|
|
yes: bool,
|
|
) -> tuple[bool, bool]:
|
|
"""Enable the local WebUI channel with safe localhost defaults."""
|
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
|
|
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
|
model = WebSocketConfig.model_validate(current)
|
|
changed = False
|
|
generated_secret = False
|
|
|
|
needs_enable = not model.enabled
|
|
needs_port = port is not None and model.port != port
|
|
needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
|
|
if not needs_enable and not needs_port and not needs_secret:
|
|
return False, False
|
|
|
|
target_port = port if port is not None else model.port
|
|
console.print()
|
|
console.print("[bold]Local WebUI setup[/bold]")
|
|
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
|
|
console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
|
|
console.print(" Auth: generated WebUI bootstrap secret stored in config")
|
|
console.print(
|
|
" LAN access requires an explicit host change plus a WebUI password in config."
|
|
)
|
|
_confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
|
|
|
|
if not model.enabled:
|
|
model.enabled = True
|
|
changed = True
|
|
if model.host != "127.0.0.1":
|
|
model.host = "127.0.0.1"
|
|
changed = True
|
|
if port is not None and model.port != port:
|
|
model.port = port
|
|
changed = True
|
|
if not model.websocket_requires_token:
|
|
model.websocket_requires_token = True
|
|
changed = True
|
|
if needs_secret:
|
|
import secrets
|
|
|
|
model.token_issue_secret = secrets.token_urlsafe(32)
|
|
changed = True
|
|
generated_secret = True
|
|
|
|
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
|
|
return changed, generated_secret
|
|
|
|
|
|
def _warn_webui_bind_scope(config: Config) -> None:
|
|
ws_cfg = _webui_config_dict(config)
|
|
host = str(ws_cfg.get("host") or "127.0.0.1")
|
|
if host in {"127.0.0.1", "localhost", "::1"}:
|
|
return
|
|
console.print(
|
|
"[yellow]Warning: WebUI is configured to bind outside localhost. "
|
|
"Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
|
|
)
|
|
|
|
|
|
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
|
|
"""Best-effort wait for the WebUI listener before opening a browser."""
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
host = parsed.hostname or "127.0.0.1"
|
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
|
|
return
|
|
time.sleep(0.1)
|
|
|
|
|
|
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
|
|
"""Return whether a local TCP endpoint accepts connections."""
|
|
import socket
|
|
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout_s):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
|
|
"""Return whether the nanobot gateway health endpoint responds OK."""
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
browser_host = _host_for_local_browser(host)
|
|
try:
|
|
with urllib.request.urlopen(
|
|
f"http://{browser_host}:{port}/health",
|
|
timeout=timeout_s,
|
|
) as response:
|
|
if response.status != 200:
|
|
return False
|
|
body = response.read(1024)
|
|
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
|
return False
|
|
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return False
|
|
return payload.get("status") == "ok"
|
|
|
|
|
|
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
|
|
"""Return whether the WebUI URL's TCP endpoint is already listening."""
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
host = parsed.hostname or "127.0.0.1"
|
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
|
|
|
|
|
|
def _print_foreground_port_conflict(
|
|
*,
|
|
webui_url: str,
|
|
gateway_host: str,
|
|
gateway_port: int,
|
|
) -> None:
|
|
console.print(
|
|
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
|
|
)
|
|
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
|
|
console.print(
|
|
f" Gateway health: "
|
|
f"[cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
|
|
)
|
|
console.print()
|
|
console.print("If this is an existing nanobot instance, use it or stop it first:")
|
|
console.print(" [cyan]nanobot gateway status[/cyan]")
|
|
console.print(" [cyan]nanobot gateway stop[/cyan]")
|
|
console.print(
|
|
"Or choose different ports with [cyan]--port[/cyan] "
|
|
"and [cyan]--gateway-port[/cyan]."
|
|
)
|
|
|
|
|
|
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
|
"""Open the WebUI in the user's default browser, with a copyable fallback."""
|
|
import webbrowser
|
|
|
|
if wait:
|
|
_wait_for_webui(url)
|
|
display_url = _webui_display_url(url)
|
|
try:
|
|
webbrowser.open(url)
|
|
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
|
except Exception as exc:
|
|
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
|
|
|
|
|
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
|
"""Explain how the browser and gateway lifecycles differ."""
|
|
console.print()
|
|
if attached:
|
|
console.print("[green]nanobot is attached to the existing gateway.[/green]")
|
|
else:
|
|
console.print("[green]nanobot is running in this terminal.[/green]")
|
|
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
|
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
|
|
|
|
|
def _attach_to_background_gateway(
|
|
runtime: "GatewayRuntime",
|
|
*,
|
|
poll_hook: Callable[[], None] | None = None,
|
|
) -> None:
|
|
"""Keep a foreground WebUI command attached to a managed gateway."""
|
|
_print_webui_foreground_lifecycle(attached=True)
|
|
try:
|
|
while runtime.status().running:
|
|
if poll_hook is not None:
|
|
poll_hook()
|
|
time.sleep(0.5)
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
|
result = runtime.stop()
|
|
if result.ok or result.message == "gateway_not_running":
|
|
console.print("[green]Gateway stopped.[/green]")
|
|
return
|
|
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
|
|
raise typer.Exit(1)
|
|
|
|
console.print("[yellow]Gateway stopped.[/yellow]")
|
|
|
|
|
|
def _gateway_instance_command(
|
|
subcommand: str,
|
|
*,
|
|
config_path: Path,
|
|
workspace: str | None,
|
|
) -> str:
|
|
"""Return a copyable gateway command for the same config/workspace instance."""
|
|
import shlex
|
|
|
|
parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
|
|
if workspace:
|
|
workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
|
|
parts.extend(["--workspace", workspace_path])
|
|
return " ".join(shlex.quote(part) for part in parts)
|
|
|
|
|
|
def _run_quick_start_for_webui(
|
|
config: Config,
|
|
*,
|
|
yes: bool,
|
|
config_path: Path,
|
|
) -> Config:
|
|
"""Offer the existing Quick Start flow when provider setup is missing."""
|
|
if yes:
|
|
console.print(
|
|
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
|
|
"provider credentials.[/red]"
|
|
)
|
|
console.print("Complete provider/model setup:")
|
|
_print_model_setup_steps(config_path)
|
|
raise typer.Exit(1)
|
|
|
|
console.print()
|
|
console.print("[yellow]Model provider setup is not ready.[/yellow]")
|
|
console.print(
|
|
"Quick Start will ask for provider, API key/base URL, model, and WebUI password."
|
|
)
|
|
_confirm_webui_action("Run Quick Start now?", yes=False)
|
|
|
|
from nanobot.cli.onboard import run_quick_start_onboard
|
|
|
|
try:
|
|
result = run_quick_start_onboard(config)
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]Error: {exc}[/red]")
|
|
console.print(
|
|
"[yellow]Run `nanobot onboard --wizard` "
|
|
"after installing wizard dependencies.[/yellow]"
|
|
)
|
|
raise typer.Exit(1) from exc
|
|
if not result.should_save:
|
|
console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
|
|
raise typer.Exit(1)
|
|
return result.config
|