"""WebUI CLI command.""" from pathlib import Path import typer from pydantic import ValidationError from rich.console import Console from nanobot.cli import terminal as cli_terminal from nanobot.cli.gateway_runtime import _run_gateway from nanobot.cli.runtime_config import ( _load_runtime_config, _print_config_error, _print_runtime_config_validation_error, _provider_setup_error, ) from nanobot.cli.webui_support import ( _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, _warn_webui_bind_scope, _webui_browser_url, _webui_build_mode_for_interactive, _webui_display_url, _webui_endpoint_reachable, ) from nanobot.config.paths import get_workspace_path from nanobot.utils.helpers import sync_workspace_templates from nanobot.webui.dev import ( WebUIDevError, WebUIDevServer, run_webui_dev_server, webui_dev_browser_url, webui_dev_proxy_target, ) console = Console() def _wait_with_existing_foreground_gateway( gateway_host: str, gateway_port: int, dev_server: WebUIDevServer, ) -> None: """Keep a Vite sidecar alive without taking ownership of an external gateway.""" import time console.print( "[dim]Vite is attached to the existing foreground gateway. " "Press Ctrl+C to stop Vite; the gateway will keep running.[/dim]" ) try: while True: dev_server.ensure_running() if not _gateway_health_ready(gateway_host, gateway_port): break time.sleep(0.5) except KeyboardInterrupt: console.print("\n[yellow]Stopping the WebUI dev server.[/yellow]") def webui( port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"), gateway_port: int | None = typer.Option( None, "--gateway-port", help="Gateway health port", ), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), background: bool = typer.Option( False, "--background", help="Keep the gateway running after this command exits", ), dev: bool = typer.Option( False, "--dev", help="Run the Vite development server with live frontend updates", ), no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"), yes: bool = typer.Option( False, "--yes", "-y", help="Apply safe local WebUI defaults without prompting", ), ) -> None: """Prepare the local WebUI, start the gateway, and open the browser workbench.""" from nanobot.config.loader import resolve_config_env_vars, save_config from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions cli_terminal._ensure_interactive_tty_mode() if dev and background: console.print("[red]Error: --dev cannot be combined with --background.[/red]") raise typer.Exit(1) config_path = _resolve_webui_config_path(config) created_config = not config_path.exists() if created_config: console.print(f"[yellow]No config found at {config_path}.[/yellow]") _confirm_webui_action("Create a nanobot config and workspace now?", yes=yes) setup_config = _load_webui_setup_config(config_path) if workspace: setup_config.agents.defaults.workspace = workspace try: resolved_setup_config = resolve_config_env_vars( setup_config.model_copy(deep=True), config_path=config_path, ) except ValueError as exc: _print_config_error(exc) raise typer.Exit(1) from exc provider_error = _provider_setup_error(resolved_setup_config) settings_setup_error = provider_error if provider_error and created_config else None if settings_setup_error: console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]") console.print("Configure a provider and model in WebUI Settings → Models.") if background: console.print( "[red]First-time WebUI setup must run in the foreground. " "Run `nanobot webui` without --background.[/red]" ) raise typer.Exit(1) elif provider_error: console.print(f"[dim]Provider check: {provider_error}[/dim]") setup_config = _run_quick_start_for_webui( setup_config, yes=yes, config_path=config_path, ) if workspace: setup_config.agents.defaults.workspace = workspace try: changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel( setup_config, port=port, yes=yes, ) _warn_webui_bind_scope(setup_config) webui_url = _webui_browser_url(setup_config) except ValidationError as exc: retry_command = f'nanobot webui --config "{config_path}"' _print_runtime_config_validation_error( exc, config_path=config_path, summary="WebUI configuration is invalid.", path_prefix=("channels", "websocket"), retry_command=retry_command, ) raise typer.Exit(1) from exc except ValueError as exc: console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]") raise typer.Exit(1) from exc if created_config or provider_error or changed_webui or workspace: save_config(setup_config, config_path) console.print(f"[green]✓[/green] Saved config: {config_path}") workspace_path = get_workspace_path(setup_config.workspace_path) workspace_path.mkdir(parents=True, exist_ok=True) sync_workspace_templates(workspace_path) runtime_config = _load_runtime_config(str(config_path), workspace) effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port dev_browser_url = webui_dev_browser_url(webui_url) if dev else None console.print() if dev_browser_url: console.print(f"WebUI dev: [cyan]{_webui_display_url(dev_browser_url)}[/cyan]") console.print(f"WebUI gateway: [cyan]{_webui_display_url(webui_url)}[/cyan]") else: console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]") gateway_health_url = _gateway_health_url( runtime_config.gateway.host, effective_gateway_port, ) console.print( f"Gateway health: [cyan]{gateway_health_url}[/cyan]" f"{_gateway_health_bind_note(runtime_config.gateway.host)}" ) if no_open: console.print("[dim]Browser opening disabled by --no-open.[/dim]") if generated_bootstrap_secret: console.print( "[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]" ) console.print( "[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from " f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]" ) webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes) config_arg = str(config_path) workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None runtime = GatewayRuntime( paths=GatewayRuntimePaths.for_instance( data_dir=config_path.parent, workspace=workspace_arg, config_path=config_arg, ) ) start_options = GatewayStartOptions( port=effective_gateway_port, workspace=workspace_arg, config_path=config_arg, ) if background: _prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode) result = runtime.start_background(start_options) restarted = False restart_attempted = False if not result.ok and result.message == "gateway_already_running" and changed_webui: restart_attempted = True console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]") result = runtime.restart(start_options, timeout_s=20) restarted = result.ok if not result.ok and (restart_attempted or result.message != "gateway_already_running"): action = "restarted" if restart_attempted else "started" console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]") console.print(f"Logs: {result.status.log_path}") raise typer.Exit(1) if restarted: console.print("[green]Gateway restarted in the background.[/green]") elif result.ok: console.print("[green]Gateway started in the background.[/green]") else: console.print("[yellow]Gateway is already running in the background.[/yellow]") console.print( "Manage this instance: " f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]" ) console.print( "View logs: " f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]" ) console.print("[dim]Closing the browser does not stop channels or automations.[/dim]") console.print( "Stop nanobot: " f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]" ) if not no_open: _open_webui_browser(webui_url) return gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port) webui_ready = _webui_endpoint_reachable(webui_url) if gateway_ready and webui_ready: console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]") if not dev: console.print( "Restart the gateway if you need it to pick up local source changes: " f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]" ) if not no_open: _open_webui_browser(webui_url, wait=False) if runtime.status().running: _attach_to_background_gateway(runtime) else: console.print( "[yellow]This gateway is controlled by another foreground command. " "Stop it from that terminal.[/yellow]" ) return try: assert dev_browser_url is not None with run_webui_dev_server( target_url=webui_dev_proxy_target(webui_url), browser_url=dev_browser_url, output=lambda message: console.print(f"[green]✓[/green] {message}"), ) as dev_server: if not no_open: _open_webui_browser(dev_browser_url, wait=False) if runtime.status().running: _attach_to_background_gateway( runtime, poll_hook=dev_server.ensure_running, ) else: _wait_with_existing_foreground_gateway( runtime_config.gateway.host, effective_gateway_port, dev_server, ) except WebUIDevError as exc: console.print(f"[red]Error: {exc}[/red]") raise typer.Exit(1) from exc return gateway_port_taken = gateway_ready or _tcp_endpoint_reachable( _host_for_local_browser(runtime_config.gateway.host), effective_gateway_port, ) webui_port_taken = webui_ready if gateway_port_taken or webui_port_taken: _print_foreground_port_conflict( webui_url=webui_url, gateway_host=runtime_config.gateway.host, gateway_port=effective_gateway_port, ) raise typer.Exit(1) _print_webui_foreground_lifecycle(attached=False) if dev_browser_url: dev_proxy_target = webui_dev_proxy_target(webui_url) try: with run_webui_dev_server( target_url=dev_proxy_target, browser_url=dev_browser_url, output=lambda message: console.print(f"[green]✓[/green] {message}"), ) as dev_server: _run_gateway( runtime_config, port=effective_gateway_port, open_browser_url=None if no_open else dev_browser_url, open_browser_ready_url=f"{dev_proxy_target}/webui/bootstrap", webui_static_dist=False, webui_bundle_mode="skip", unconfigured_provider_error=settings_setup_error, webui_dev_server=dev_server, ) except WebUIDevError as exc: console.print(f"[red]Error: {exc}[/red]") raise typer.Exit(1) from exc return _run_gateway( runtime_config, port=effective_gateway_port, open_browser_url=None if no_open else webui_url, webui_bundle_mode=webui_bundle_mode, unconfigured_provider_error=settings_setup_error, )