nanobot/nanobot/cli/webui_support.py

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