feat(webui): add integrated Vite dev mode (#5239)
This commit is contained in:
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import channel_field_value
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.config.paths import get_config_path
|
||||
|
||||
|
||||
def local_state_present(section: Any) -> bool:
|
||||
|
||||
@@ -25,6 +25,7 @@ from nanobot.cli.webui_support import (
|
||||
_tcp_endpoint_reachable,
|
||||
_webui_browser_url,
|
||||
_webui_channel_enabled,
|
||||
_webui_display_url,
|
||||
_webui_endpoint_reachable,
|
||||
)
|
||||
from nanobot.config.paths import is_default_workspace
|
||||
@@ -34,6 +35,7 @@ from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
|
||||
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
from nanobot.webui.build import BuildMode
|
||||
from nanobot.webui.dev import WebUIDevError, WebUIDevServer
|
||||
from nanobot.webui.sidebar_state import read_webui_sidebar_state
|
||||
|
||||
__all__ = ["_run_gateway"]
|
||||
@@ -41,6 +43,34 @@ __all__ = ["_run_gateway"]
|
||||
console = Console()
|
||||
|
||||
|
||||
def _http_endpoint_responding(url: str, *, timeout_s: float = 0.25) -> bool:
|
||||
"""Return whether an HTTP endpoint responds, including with an auth error."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout_s):
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return True
|
||||
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _watch_webui_dev_server(
|
||||
server: WebUIDevServer,
|
||||
shutdown_event: asyncio.Event,
|
||||
*,
|
||||
poll_interval_s: float = 0.2,
|
||||
) -> None:
|
||||
"""Fail the foreground gateway when its owned Vite sidecar exits."""
|
||||
while not shutdown_event.is_set():
|
||||
await asyncio.sleep(poll_interval_s)
|
||||
if shutdown_event.is_set():
|
||||
return
|
||||
server.ensure_running()
|
||||
|
||||
|
||||
def _signal_name(signum: int) -> str:
|
||||
with suppress(ValueError):
|
||||
return signal.Signals(signum).name
|
||||
@@ -258,12 +288,14 @@ def _run_gateway(
|
||||
*,
|
||||
port: int | None = None,
|
||||
open_browser_url: str | None = None,
|
||||
open_browser_ready_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,
|
||||
unconfigured_provider_error: str | None = None,
|
||||
webui_dev_server: WebUIDevServer | None = None,
|
||||
) -> None:
|
||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||
@@ -760,10 +792,21 @@ def _run_gateway(
|
||||
import webbrowser
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Channels start asynchronously. When the caller supplies a backend
|
||||
# readiness route, wait for an actual HTTP response rather than probing
|
||||
# the WebSocket listener with an incomplete TCP connection.
|
||||
if open_browser_ready_url:
|
||||
for _ in range(40): # ~4s max per listener
|
||||
if await asyncio.to_thread(
|
||||
_http_endpoint_responding,
|
||||
open_browser_ready_url,
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
parsed = urlparse(open_browser_url)
|
||||
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
|
||||
target_port = parsed.port or port
|
||||
# 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(
|
||||
@@ -776,11 +819,12 @@ def _run_gateway(
|
||||
break
|
||||
except OSError:
|
||||
await asyncio.sleep(0.1)
|
||||
display_url = _webui_display_url(open_browser_url)
|
||||
try:
|
||||
webbrowser.open(open_browser_url)
|
||||
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
|
||||
console.print(f"[green]✓[/green] Opened browser at {display_url}")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
||||
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
|
||||
|
||||
async def run() -> None:
|
||||
tasks: list[asyncio.Task[Any]] = []
|
||||
@@ -827,6 +871,11 @@ def _run_gateway(
|
||||
_open_browser_when_ready(),
|
||||
name="nanobot-open-browser",
|
||||
))
|
||||
if webui_dev_server is not None:
|
||||
tasks.append(asyncio.create_task(
|
||||
_watch_webui_dev_server(webui_dev_server, shutdown_event),
|
||||
name="nanobot-webui-dev-server",
|
||||
))
|
||||
runtime_tasks = asyncio.gather(*tasks)
|
||||
shutdown_task = asyncio.create_task(
|
||||
shutdown_event.wait(),
|
||||
@@ -842,6 +891,8 @@ def _run_gateway(
|
||||
runtime_tasks.cancel()
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
except WebUIDevError:
|
||||
raise
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
|
||||
+103
-12
@@ -39,10 +39,39 @@ from nanobot.cli.webui_support import (
|
||||
)
|
||||
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(
|
||||
@@ -57,6 +86,11 @@ def webui(
|
||||
"--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,
|
||||
@@ -70,6 +104,9 @@ def webui(
|
||||
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:
|
||||
@@ -143,8 +180,13 @@ def webui(
|
||||
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()
|
||||
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
|
||||
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,
|
||||
@@ -223,19 +265,45 @@ def webui(
|
||||
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:
|
||||
if not dev:
|
||||
console.print(
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
"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(
|
||||
@@ -252,6 +320,29 @@ def webui(
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -424,11 +425,17 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||
|
||||
|
||||
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
|
||||
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]")
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Vite development-server lifecycle for the WebUI source checkout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from nanobot.webui.build import default_webui_source_dir, pick_webui_build_runner
|
||||
|
||||
WEBUI_DEV_HOST = "127.0.0.1"
|
||||
WEBUI_DEV_PORT = 5173
|
||||
|
||||
|
||||
class WebUIDevError(RuntimeError):
|
||||
"""Raised when the local Vite development server cannot be started."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebUIDevServer:
|
||||
"""A running Vite development server owned by the foreground CLI."""
|
||||
|
||||
process: subprocess.Popen[Any]
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
"""Raise when Vite exits while the foreground command still owns it."""
|
||||
if (returncode := self.process.poll()) is not None:
|
||||
raise WebUIDevError(
|
||||
f"WebUI development server exited unexpectedly (code {returncode})"
|
||||
)
|
||||
|
||||
def stop(self, *, timeout_s: float = 5.0) -> None:
|
||||
"""Stop and reap the direct Vite process."""
|
||||
if self.process.poll() is not None:
|
||||
return
|
||||
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=timeout_s)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
self.process.kill()
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
self.process.wait(timeout=2)
|
||||
|
||||
|
||||
def webui_dev_browser_url(webui_url: str) -> str:
|
||||
"""Move a configured WebUI URL to Vite while preserving its auth fragment."""
|
||||
parsed = urlsplit(webui_url)
|
||||
return urlunsplit(("http", f"{WEBUI_DEV_HOST}:{WEBUI_DEV_PORT}", parsed.path, "", parsed.fragment))
|
||||
|
||||
|
||||
def webui_dev_proxy_target(webui_url: str) -> str:
|
||||
"""Return the backend origin Vite should use for HTTP proxy requests."""
|
||||
parsed = urlsplit(webui_url)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
||||
|
||||
|
||||
def _endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.2) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout_s):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _runner_name(runner: str) -> str:
|
||||
return Path(runner).stem.casefold()
|
||||
|
||||
|
||||
def _ensure_vite_cli(
|
||||
source_dir: Path,
|
||||
*,
|
||||
runner: str,
|
||||
subprocess_run: Callable[..., subprocess.CompletedProcess[Any]],
|
||||
output: Callable[[str], None] | None,
|
||||
) -> Path:
|
||||
vite_cli = source_dir / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
if vite_cli.is_file():
|
||||
return vite_cli
|
||||
|
||||
if output is not None:
|
||||
output(f"Installing WebUI development dependencies with `{runner}`...")
|
||||
if _runner_name(runner) == "bun" and (source_dir / "bun.lock").is_file():
|
||||
command = [runner, "install", "--frozen-lockfile"]
|
||||
elif _runner_name(runner) == "npm" and (source_dir / "package-lock.json").is_file():
|
||||
command = [runner, "ci"]
|
||||
else:
|
||||
command = [runner, "install"]
|
||||
try:
|
||||
subprocess_run(command, cwd=source_dir, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise WebUIDevError(
|
||||
f"frontend dependency install failed ({exc.returncode}): {' '.join(command)}"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise WebUIDevError(f"frontend dependency install failed: {exc}") from exc
|
||||
|
||||
if not vite_cli.is_file():
|
||||
raise WebUIDevError(
|
||||
f"Vite was not installed under {source_dir}; run `cd webui && {runner} install`"
|
||||
)
|
||||
return vite_cli
|
||||
|
||||
|
||||
def _vite_command(runner: str, vite_cli: Path) -> list[str]:
|
||||
if node := shutil.which("node"):
|
||||
return [node, str(vite_cli)]
|
||||
if _runner_name(runner) == "bun":
|
||||
return [runner, str(vite_cli)]
|
||||
raise WebUIDevError("Node.js is required to run the WebUI development server")
|
||||
|
||||
|
||||
def start_webui_dev_server(
|
||||
*,
|
||||
target_url: str,
|
||||
browser_url: str,
|
||||
source_dir: Path | None = None,
|
||||
runner: str | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
output: Callable[[str], None] | None = None,
|
||||
timeout_s: float = 15.0,
|
||||
popen: Callable[..., subprocess.Popen[Any]] = subprocess.Popen,
|
||||
subprocess_run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
|
||||
endpoint_reachable: Callable[..., bool] = _endpoint_reachable,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> WebUIDevServer:
|
||||
"""Start Vite from a source checkout and wait until its listener is ready."""
|
||||
resolved_source = source_dir or default_webui_source_dir()
|
||||
if not (resolved_source / "package.json").is_file():
|
||||
raise WebUIDevError(
|
||||
"`nanobot webui --dev` requires a source checkout containing webui/package.json"
|
||||
)
|
||||
if endpoint_reachable(WEBUI_DEV_HOST, WEBUI_DEV_PORT):
|
||||
raise WebUIDevError(
|
||||
f"WebUI development port {WEBUI_DEV_PORT} is already in use; stop that process first"
|
||||
)
|
||||
|
||||
command_runner = runner or pick_webui_build_runner()
|
||||
if command_runner is None:
|
||||
raise WebUIDevError(
|
||||
"neither `bun` nor `npm` is available on PATH; install one to use WebUI dev mode"
|
||||
)
|
||||
vite_cli = _ensure_vite_cli(
|
||||
resolved_source,
|
||||
runner=command_runner,
|
||||
subprocess_run=subprocess_run,
|
||||
output=output,
|
||||
)
|
||||
command = _vite_command(command_runner, vite_cli)
|
||||
child_env = dict(environ or os.environ)
|
||||
child_env["NANOBOT_API_URL"] = target_url
|
||||
|
||||
try:
|
||||
# Keep Vite in the foreground console group so Ctrl+C reaches both it
|
||||
# and the gateway. Directly invoking Vite avoids a package-manager child.
|
||||
process = popen(command, cwd=resolved_source, env=child_env)
|
||||
except OSError as exc:
|
||||
raise WebUIDevError(f"could not start the WebUI development server: {exc}") from exc
|
||||
server = WebUIDevServer(process=process)
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise WebUIDevError(
|
||||
f"WebUI development server exited before it was ready (code {process.returncode})"
|
||||
)
|
||||
if endpoint_reachable(WEBUI_DEV_HOST, WEBUI_DEV_PORT):
|
||||
if output is not None:
|
||||
parsed_url = urlsplit(browser_url)
|
||||
display_url = urlunsplit(
|
||||
(parsed_url.scheme, parsed_url.netloc, parsed_url.path, "", "")
|
||||
)
|
||||
output(f"WebUI dev server: {display_url}")
|
||||
return server
|
||||
sleep(0.1)
|
||||
|
||||
server.stop()
|
||||
raise WebUIDevError(
|
||||
f"WebUI development server did not listen on {WEBUI_DEV_HOST}:{WEBUI_DEV_PORT} "
|
||||
f"within {timeout_s:g}s"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def run_webui_dev_server(
|
||||
*,
|
||||
target_url: str,
|
||||
browser_url: str,
|
||||
output: Callable[[str], None] | None = None,
|
||||
) -> Generator[WebUIDevServer, None, None]:
|
||||
"""Run a Vite sidecar for the duration of a foreground WebUI command."""
|
||||
server = start_webui_dev_server(
|
||||
target_url=target_url,
|
||||
browser_url=browser_url,
|
||||
output=output,
|
||||
)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.stop()
|
||||
Reference in New Issue
Block a user