feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+252 -46
View File
@@ -5,6 +5,7 @@ import os
import select
import signal
import sys
import time
from collections.abc import Callable, Iterable
from contextlib import nullcontext, suppress
from pathlib import Path
@@ -75,6 +76,7 @@ from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
from nanobot.config.schema import Config # noqa: E402
from nanobot.security.network import is_loopback_host # noqa: E402
from nanobot.utils.evaluator import evaluate_response # noqa: E402
from nanobot.utils.helpers import sync_workspace_templates # noqa: E402
from nanobot.utils.restart import ( # noqa: E402
@@ -82,6 +84,11 @@ from nanobot.utils.restart import ( # noqa: E402
format_restart_completed_message,
should_show_cli_restart_notice,
)
from nanobot.webui.build import ( # noqa: E402
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
)
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
@@ -724,25 +731,12 @@ def onboard(
)
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively fill in missing values from defaults without overwriting user config."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = _merge_missing_defaults(merged[key], value)
return merged
def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins)."""
import json
from nanobot.channels.registry import discover_all
from nanobot.config.loader import merge_missing_defaults
all_channels = discover_all()
if not all_channels:
@@ -756,7 +750,7 @@ def _onboard_plugins(config_path: Path) -> None:
if name not in channels:
channels[name] = cls.default_config()
else:
channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -883,11 +877,7 @@ 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
try:
interactive = sys.stdin.isatty()
except Exception:
interactive = False
if not interactive:
if not _cli_can_prompt():
console.print(
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
"`nanobot onboard --wizard`.[/red]"
@@ -898,6 +888,19 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
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
@@ -942,6 +945,43 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
return model.model_dump(by_alias=True, exclude_none=True)
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
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", ""}:
@@ -1041,7 +1081,6 @@ def _warn_webui_bind_scope(config: Config) -> None:
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
"""Best-effort wait for the WebUI listener before opening a browser."""
import socket
import time
from urllib.parse import urlparse
@@ -1050,11 +1089,75 @@ def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
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: [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:
@@ -1063,11 +1166,41 @@ def _open_webui_browser(url: str, *, wait: bool = True) -> None:
if wait:
_wait_for_webui(url)
display_url = _webui_display_url(url)
try:
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]")
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 {url}[/yellow]")
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: Any) -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
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(
@@ -1191,9 +1324,9 @@ def serve(
port = port if port is not None else api_cfg.port
timeout = timeout if timeout is not None else api_cfg.timeout
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
if host in {"0.0.0.0", "::"} and not api_key:
if not is_loopback_host(host) and not api_key:
console.print(
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
f"[red]Error: host {host} is available beyond this device but api_key is not set. "
"Set api.api_key in config to prevent unauthenticated access.[/red]"
)
raise typer.Exit(1)
@@ -1217,9 +1350,9 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
if host in {"0.0.0.0", "::"}:
if not is_loopback_host(host):
console.print(
"[yellow]API is bound to all interfaces "
"[yellow]API is available beyond this device "
"(authentication required).[/yellow]"
)
console.print()
@@ -1256,7 +1389,11 @@ def webui(
),
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="Start gateway in the background"),
background: bool = typer.Option(
False,
"--background",
help="Keep the gateway running after this command exits",
),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
@@ -1323,21 +1460,25 @@ def webui(
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
)
if background:
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,
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
@@ -1365,14 +1506,53 @@ def webui(
"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]")
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
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)
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
)
@@ -1387,6 +1567,7 @@ def _run_gateway(
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_bundle_mode: BuildMode = "warn",
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
@@ -1409,8 +1590,29 @@ def _run_gateway(
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
webui_url = _webui_browser_url(config)
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
_prepare_webui_bundle_for_gateway(
config,
mode=webui_bundle_mode,
webui_static_dist=webui_static_dist,
)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
runtime_events = RuntimeEventBus()
@@ -1895,6 +2097,10 @@ app.add_typer(
log_handler_id=_log_handler_id,
load_runtime_config=_load_runtime_config,
run_gateway=_run_gateway,
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
config,
mode=mode,
),
),
name="gateway",
)
@@ -2274,7 +2480,7 @@ def plugins_list(
@plugins_app.command("enable")
def plugins_enable(
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, bedrock)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
):
+19 -2
View File
@@ -25,11 +25,13 @@ from nanobot.gateway.service import (
GatewayServiceResult,
ServiceManagerKind,
)
from nanobot.webui.build import BuildMode
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
GatewayRunner = Callable[..., None]
GatewayRuntimeFactory = Callable[..., Any]
GatewayServiceFactory = Callable[[], Any]
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
def create_gateway_app(
@@ -40,6 +42,7 @@ def create_gateway_app(
run_gateway: GatewayRunner,
runtime_factory: GatewayRuntimeFactory | None = None,
service_factory: GatewayServiceFactory | None = None,
prepare_webui_bundle: WebUIBundlePreparer | None = None,
) -> typer.Typer:
gateway_app = typer.Typer(
help="Start and manage the nanobot gateway.",
@@ -81,14 +84,20 @@ def create_gateway_app(
def service_installer():
return service_factory() if service_factory is not None else GatewayServiceInstaller()
def interactive_build_mode() -> BuildMode:
# `nanobot gateway` is often launched by tests, supervisors, or service managers.
# The higher-level `nanobot webui` command owns interactive first-run guidance.
return "warn"
def start_options(
*,
port: int | None,
verbose: bool,
workspace: str | None,
config: str | None,
loaded_config: Config | None = None,
) -> GatewayStartOptions:
cfg = load_runtime_config(config, workspace)
cfg = loaded_config or load_runtime_config(config, workspace)
resolved_config = str(Path(config).expanduser().resolve()) if config else None
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
return GatewayStartOptions(
@@ -139,6 +148,9 @@ def create_gateway_app(
console.print("[red]Error: --foreground and --background cannot be used together.[/red]")
raise typer.Exit(1)
if background:
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.start_background(
start_options(
@@ -146,6 +158,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
)
)
if result.ok:
@@ -158,7 +171,7 @@ def create_gateway_app(
configure_logging(verbose)
cfg = load_runtime_config(config, workspace)
run_gateway(cfg, port=port)
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
@gateway_app.command("status")
def gateway_status(
@@ -211,6 +224,9 @@ def create_gateway_app(
timeout: int = typer.Option(20, "--timeout", help="Restart timeout in seconds"),
) -> None:
"""Restart the background gateway."""
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.restart(
start_options(
@@ -218,6 +234,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
),
timeout_s=timeout,
)