mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
feat(webui): add integrated Vite dev mode (#5239)
This commit is contained in:
parent
4e8702a47b
commit
2fe135db3e
@ -104,6 +104,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
||||||
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
|
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
|
||||||
|
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
|
||||||
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||||
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
|
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
|
||||||
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
||||||
@ -111,6 +112,10 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|||||||
|
|
||||||
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
|
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
|
||||||
|
|
||||||
|
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
|
||||||
|
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
|
||||||
|
WebSocket channel port, and stops Vite together with the foreground gateway.
|
||||||
|
|
||||||
## Gateway
|
## Gateway
|
||||||
|
|
||||||
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.contracts import channel_field_value
|
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:
|
def local_state_present(section: Any) -> bool:
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from nanobot.cli.webui_support import (
|
|||||||
_tcp_endpoint_reachable,
|
_tcp_endpoint_reachable,
|
||||||
_webui_browser_url,
|
_webui_browser_url,
|
||||||
_webui_channel_enabled,
|
_webui_channel_enabled,
|
||||||
|
_webui_display_url,
|
||||||
_webui_endpoint_reachable,
|
_webui_endpoint_reachable,
|
||||||
)
|
)
|
||||||
from nanobot.config.paths import is_default_workspace
|
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.evaluator import evaluate_response, resolve_evaluator_prompt
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
from nanobot.webui.build import BuildMode
|
from nanobot.webui.build import BuildMode
|
||||||
|
from nanobot.webui.dev import WebUIDevError, WebUIDevServer
|
||||||
from nanobot.webui.sidebar_state import read_webui_sidebar_state
|
from nanobot.webui.sidebar_state import read_webui_sidebar_state
|
||||||
|
|
||||||
__all__ = ["_run_gateway"]
|
__all__ = ["_run_gateway"]
|
||||||
@ -41,6 +43,34 @@ __all__ = ["_run_gateway"]
|
|||||||
console = Console()
|
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:
|
def _signal_name(signum: int) -> str:
|
||||||
with suppress(ValueError):
|
with suppress(ValueError):
|
||||||
return signal.Signals(signum).name
|
return signal.Signals(signum).name
|
||||||
@ -258,12 +288,14 @@ def _run_gateway(
|
|||||||
*,
|
*,
|
||||||
port: int | None = None,
|
port: int | None = None,
|
||||||
open_browser_url: str | None = None,
|
open_browser_url: str | None = None,
|
||||||
|
open_browser_ready_url: str | None = None,
|
||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_bundle_mode: BuildMode = "warn",
|
webui_bundle_mode: BuildMode = "warn",
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
health_server_enabled: bool = True,
|
health_server_enabled: bool = True,
|
||||||
unconfigured_provider_error: str | None = None,
|
unconfigured_provider_error: str | None = None,
|
||||||
|
webui_dev_server: WebUIDevServer | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
from nanobot.agent.model_presets import load_model_preset_catalog
|
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||||
@ -760,10 +792,21 @@ def _run_gateway(
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from urllib.parse import urlparse
|
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)
|
parsed = urlparse(open_browser_url)
|
||||||
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
|
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
|
||||||
target_port = parsed.port or port
|
target_port = parsed.port or port
|
||||||
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
|
||||||
for _ in range(40): # ~4s max
|
for _ in range(40): # ~4s max
|
||||||
try:
|
try:
|
||||||
_reader, writer = await asyncio.open_connection(
|
_reader, writer = await asyncio.open_connection(
|
||||||
@ -776,11 +819,12 @@ def _run_gateway(
|
|||||||
break
|
break
|
||||||
except OSError:
|
except OSError:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
display_url = _webui_display_url(open_browser_url)
|
||||||
try:
|
try:
|
||||||
webbrowser.open(open_browser_url)
|
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:
|
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:
|
async def run() -> None:
|
||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
@ -827,6 +871,11 @@ def _run_gateway(
|
|||||||
_open_browser_when_ready(),
|
_open_browser_when_ready(),
|
||||||
name="nanobot-open-browser",
|
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)
|
runtime_tasks = asyncio.gather(*tasks)
|
||||||
shutdown_task = asyncio.create_task(
|
shutdown_task = asyncio.create_task(
|
||||||
shutdown_event.wait(),
|
shutdown_event.wait(),
|
||||||
@ -842,6 +891,8 @@ def _run_gateway(
|
|||||||
runtime_tasks.cancel()
|
runtime_tasks.cancel()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nShutting down...")
|
console.print("\nShutting down...")
|
||||||
|
except WebUIDevError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|||||||
@ -39,10 +39,39 @@ from nanobot.cli.webui_support import (
|
|||||||
)
|
)
|
||||||
from nanobot.config.paths import get_workspace_path
|
from nanobot.config.paths import get_workspace_path
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
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()
|
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(
|
def webui(
|
||||||
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
|
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
|
||||||
gateway_port: int | None = typer.Option(
|
gateway_port: int | None = typer.Option(
|
||||||
@ -57,6 +86,11 @@ def webui(
|
|||||||
"--background",
|
"--background",
|
||||||
help="Keep the gateway running after this command exits",
|
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"),
|
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
|
||||||
yes: bool = typer.Option(
|
yes: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
@ -70,6 +104,9 @@ def webui(
|
|||||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||||
|
|
||||||
cli_terminal._ensure_interactive_tty_mode()
|
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)
|
config_path = _resolve_webui_config_path(config)
|
||||||
created_config = not config_path.exists()
|
created_config = not config_path.exists()
|
||||||
if created_config:
|
if created_config:
|
||||||
@ -143,7 +180,12 @@ def webui(
|
|||||||
runtime_config = _load_runtime_config(str(config_path), workspace)
|
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
|
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()
|
||||||
|
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]")
|
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
|
||||||
gateway_health_url = _gateway_health_url(
|
gateway_health_url = _gateway_health_url(
|
||||||
runtime_config.gateway.host,
|
runtime_config.gateway.host,
|
||||||
@ -223,6 +265,7 @@ def webui(
|
|||||||
webui_ready = _webui_endpoint_reachable(webui_url)
|
webui_ready = _webui_endpoint_reachable(webui_url)
|
||||||
if gateway_ready and webui_ready:
|
if gateway_ready and webui_ready:
|
||||||
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
|
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
|
||||||
|
if not dev:
|
||||||
console.print(
|
console.print(
|
||||||
"Restart the gateway if you need it to pick up local source changes: "
|
"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]"
|
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||||
@ -238,6 +281,31 @@ def webui(
|
|||||||
)
|
)
|
||||||
return
|
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(
|
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
|
||||||
_host_for_local_browser(runtime_config.gateway.host),
|
_host_for_local_browser(runtime_config.gateway.host),
|
||||||
effective_gateway_port,
|
effective_gateway_port,
|
||||||
@ -252,6 +320,29 @@ def webui(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
_print_webui_foreground_lifecycle(attached=False)
|
_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(
|
_run_gateway(
|
||||||
runtime_config,
|
runtime_config,
|
||||||
port=effective_gateway_port,
|
port=effective_gateway_port,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
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]")
|
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."""
|
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||||
_print_webui_foreground_lifecycle(attached=True)
|
_print_webui_foreground_lifecycle(attached=True)
|
||||||
try:
|
try:
|
||||||
while runtime.status().running:
|
while runtime.status().running:
|
||||||
|
if poll_hook is not None:
|
||||||
|
poll_hook()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
||||||
|
|||||||
211
nanobot/webui/dev.py
Normal file
211
nanobot/webui/dev.py
Normal file
@ -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()
|
||||||
@ -3,7 +3,8 @@ import json
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
from contextlib import suppress
|
import urllib.error
|
||||||
|
from contextlib import contextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
@ -33,6 +34,7 @@ from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
|||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.providers.unconfigured_provider import UnconfiguredProvider
|
from nanobot.providers.unconfigured_provider import UnconfiguredProvider
|
||||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
||||||
|
from nanobot.webui.dev import WebUIDevError
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
@ -2176,6 +2178,171 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
|||||||
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_dev_rejects_background_before_creating_config(tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["webui", "--dev", "--background", "--yes", "--config", str(config_file)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "--dev cannot be combined with --background" in result.stdout
|
||||||
|
assert not config_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text("{}", encoding="utf-8")
|
||||||
|
seen: dict[str, object] = {}
|
||||||
|
_patch_webui_provider_ready(monkeypatch)
|
||||||
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_dev_server(**kwargs):
|
||||||
|
seen["dev_kwargs"] = kwargs
|
||||||
|
seen["dev_running"] = True
|
||||||
|
dev_server = SimpleNamespace(
|
||||||
|
url=kwargs["browser_url"],
|
||||||
|
ensure_running=lambda: None,
|
||||||
|
)
|
||||||
|
seen["dev_server"] = dev_server
|
||||||
|
try:
|
||||||
|
yield dev_server
|
||||||
|
finally:
|
||||||
|
seen["dev_running"] = False
|
||||||
|
|
||||||
|
def fake_run_gateway(_config: Config, **kwargs) -> None:
|
||||||
|
assert seen["dev_running"] is True
|
||||||
|
seen["gateway_kwargs"] = kwargs
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
|
||||||
|
monkeypatch.setattr("nanobot.cli.webui._run_gateway", fake_run_gateway)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"webui",
|
||||||
|
"--dev",
|
||||||
|
"--config",
|
||||||
|
str(config_file),
|
||||||
|
"--port",
|
||||||
|
"8899",
|
||||||
|
"--gateway-port",
|
||||||
|
"18888",
|
||||||
|
"--yes",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
dev_kwargs = seen["dev_kwargs"]
|
||||||
|
assert isinstance(dev_kwargs, dict)
|
||||||
|
assert dev_kwargs["target_url"] == "http://127.0.0.1:8899"
|
||||||
|
browser_url = dev_kwargs["browser_url"]
|
||||||
|
assert isinstance(browser_url, str)
|
||||||
|
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
|
||||||
|
gateway_kwargs = seen["gateway_kwargs"]
|
||||||
|
assert isinstance(gateway_kwargs, dict)
|
||||||
|
assert gateway_kwargs == {
|
||||||
|
"port": 18888,
|
||||||
|
"open_browser_url": browser_url,
|
||||||
|
"open_browser_ready_url": "http://127.0.0.1:8899/webui/bootstrap",
|
||||||
|
"webui_static_dist": False,
|
||||||
|
"webui_bundle_mode": "skip",
|
||||||
|
"unconfigured_provider_error": None,
|
||||||
|
"webui_dev_server": seen["dev_server"],
|
||||||
|
}
|
||||||
|
assert seen["dev_running"] is False
|
||||||
|
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
|
||||||
|
r"\s+", " ", _strip_ansi(result.stdout)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_dev_waits_for_external_gateway_via_health_endpoint(monkeypatch) -> None:
|
||||||
|
health_results = iter((True, False))
|
||||||
|
health_calls: list[tuple[str, int]] = []
|
||||||
|
sidecar_checks = 0
|
||||||
|
|
||||||
|
def fake_health(host: str, port: int) -> bool:
|
||||||
|
health_calls.append((host, port))
|
||||||
|
return next(health_results)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", fake_health)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.webui._webui_endpoint_reachable",
|
||||||
|
lambda _url: pytest.fail("must not probe the WebSocket endpoint while waiting"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("time.sleep", lambda _seconds: None)
|
||||||
|
|
||||||
|
def ensure_sidecar_running() -> None:
|
||||||
|
nonlocal sidecar_checks
|
||||||
|
sidecar_checks += 1
|
||||||
|
|
||||||
|
dev_server = MagicMock()
|
||||||
|
dev_server.ensure_running.side_effect = ensure_sidecar_running
|
||||||
|
cli_webui._wait_with_existing_foreground_gateway("127.0.0.1", 18888, dev_server)
|
||||||
|
|
||||||
|
assert health_calls == [("127.0.0.1", 18888), ("127.0.0.1", 18888)]
|
||||||
|
assert sidecar_checks == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webui_dev_monitor_fails_when_sidecar_exits() -> None:
|
||||||
|
dev_server = MagicMock()
|
||||||
|
dev_server.ensure_running.side_effect = WebUIDevError(
|
||||||
|
"WebUI development server exited unexpectedly (code 23)"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||||
|
await cli_gateway_runtime._watch_webui_dev_server(
|
||||||
|
dev_server,
|
||||||
|
asyncio.Event(),
|
||||||
|
poll_interval_s=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webui_dev_monitor_ignores_an_expected_gateway_shutdown() -> None:
|
||||||
|
dev_server = MagicMock()
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
await cli_gateway_runtime._watch_webui_dev_server(
|
||||||
|
dev_server,
|
||||||
|
shutdown_event,
|
||||||
|
poll_interval_s=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dev_server.ensure_running.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_readiness_accepts_http_auth_response(monkeypatch) -> None:
|
||||||
|
def auth_required(*_args, **_kwargs):
|
||||||
|
raise urllib.error.HTTPError(
|
||||||
|
"http://127.0.0.1:8765/webui/bootstrap",
|
||||||
|
401,
|
||||||
|
"authentication required",
|
||||||
|
hdrs=None,
|
||||||
|
fp=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", auth_required)
|
||||||
|
|
||||||
|
assert cli_gateway_runtime._http_endpoint_responding(
|
||||||
|
"http://127.0.0.1:8765/webui/bootstrap"
|
||||||
|
) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_readiness_rejects_connection_error(monkeypatch) -> None:
|
||||||
|
def unavailable(*_args, **_kwargs):
|
||||||
|
raise urllib.error.URLError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", unavailable)
|
||||||
|
|
||||||
|
assert cli_gateway_runtime._http_endpoint_responding(
|
||||||
|
"http://127.0.0.1:8765/webui/bootstrap"
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
@ -2506,6 +2673,21 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
|
|||||||
assert "Gateway stopped" in output
|
assert "Gateway stopped" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
||||||
|
class _FakeRuntime:
|
||||||
|
def status(self):
|
||||||
|
return SimpleNamespace(running=True)
|
||||||
|
|
||||||
|
def sidecar_exited() -> None:
|
||||||
|
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
||||||
|
|
||||||
|
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||||
|
cli_webui_support._attach_to_background_gateway(
|
||||||
|
_FakeRuntime(),
|
||||||
|
poll_hook=sidecar_exited,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
|
|||||||
175
tests/webui/test_dev.py
Normal file
175
tests/webui/test_dev.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.webui.dev import (
|
||||||
|
WebUIDevError,
|
||||||
|
WebUIDevServer,
|
||||||
|
run_webui_dev_server,
|
||||||
|
start_webui_dev_server,
|
||||||
|
webui_dev_browser_url,
|
||||||
|
webui_dev_proxy_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProcess:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pid = 123
|
||||||
|
self.returncode: int | None = None
|
||||||
|
self.terminated = False
|
||||||
|
self.killed = False
|
||||||
|
|
||||||
|
def poll(self) -> int | None:
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
self.terminated = True
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
self.killed = True
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
def wait(self, *, timeout: float) -> int:
|
||||||
|
if self.returncode is None:
|
||||||
|
raise subprocess.TimeoutExpired("vite", timeout)
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def _write_webui_source(source: Path, *, with_vite: bool = True) -> Path:
|
||||||
|
source.mkdir(parents=True)
|
||||||
|
(source / "package.json").write_text("{}", encoding="utf-8")
|
||||||
|
(source / "bun.lock").write_text("", encoding="utf-8")
|
||||||
|
vite_cli = source / "node_modules" / "vite" / "bin" / "vite.js"
|
||||||
|
if with_vite:
|
||||||
|
vite_cli.parent.mkdir(parents=True)
|
||||||
|
vite_cli.write_text("", encoding="utf-8")
|
||||||
|
return vite_cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_urls_preserve_secret_and_target_only_the_backend_origin() -> None:
|
||||||
|
webui_url = "http://127.0.0.1:8899/#/?bootstrapSecret=secret"
|
||||||
|
|
||||||
|
assert webui_dev_browser_url(webui_url) == (
|
||||||
|
"http://127.0.0.1:5173/#/?bootstrapSecret=secret"
|
||||||
|
)
|
||||||
|
assert webui_dev_proxy_target(webui_url) == "http://127.0.0.1:8899"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_webui_dev_server_uses_vite_directly_and_sets_proxy_target(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
source = tmp_path / "webui"
|
||||||
|
vite_cli = _write_webui_source(source)
|
||||||
|
process = _FakeProcess()
|
||||||
|
popen_calls: list[tuple[list[str], dict[str, object]]] = []
|
||||||
|
reachability = iter((False, True))
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
def fake_popen(command: list[str], **kwargs):
|
||||||
|
popen_calls.append((command, kwargs))
|
||||||
|
return process
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.dev.shutil.which",
|
||||||
|
lambda name: "node" if name == "node" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
server = start_webui_dev_server(
|
||||||
|
target_url="http://127.0.0.1:8899",
|
||||||
|
browser_url="http://127.0.0.1:5173/#/?bootstrapSecret=secret",
|
||||||
|
source_dir=source,
|
||||||
|
runner="bun",
|
||||||
|
environ={"EXISTING": "value"},
|
||||||
|
output=output.append,
|
||||||
|
popen=fake_popen,
|
||||||
|
endpoint_reachable=lambda *_args, **_kwargs: next(reachability),
|
||||||
|
sleep=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert server.process is process
|
||||||
|
command, kwargs = popen_calls[0]
|
||||||
|
assert command == ["node", str(vite_cli)]
|
||||||
|
assert kwargs["cwd"] == source
|
||||||
|
assert kwargs["env"] == {
|
||||||
|
"EXISTING": "value",
|
||||||
|
"NANOBOT_API_URL": "http://127.0.0.1:8899",
|
||||||
|
}
|
||||||
|
assert output == ["WebUI dev server: http://127.0.0.1:5173/"]
|
||||||
|
assert "secret" not in output[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_server_installs_locked_dependencies_when_vite_is_missing(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "webui"
|
||||||
|
vite_cli = _write_webui_source(source, with_vite=False)
|
||||||
|
commands: list[list[str]] = []
|
||||||
|
process = _FakeProcess()
|
||||||
|
reachability = iter((False, True))
|
||||||
|
|
||||||
|
def fake_run(command: list[str], *, cwd: Path, check: bool):
|
||||||
|
commands.append(command)
|
||||||
|
assert cwd == source
|
||||||
|
assert check is True
|
||||||
|
vite_cli.parent.mkdir(parents=True)
|
||||||
|
vite_cli.write_text("", encoding="utf-8")
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
|
||||||
|
start_webui_dev_server(
|
||||||
|
target_url="http://127.0.0.1:8765",
|
||||||
|
browser_url="http://127.0.0.1:5173",
|
||||||
|
source_dir=source,
|
||||||
|
runner="bun",
|
||||||
|
popen=lambda *_args, **_kwargs: process,
|
||||||
|
subprocess_run=fake_run,
|
||||||
|
endpoint_reachable=lambda *_args, **_kwargs: next(reachability),
|
||||||
|
sleep=lambda _seconds: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert commands == [["bun", "install", "--frozen-lockfile"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_server_requires_a_source_checkout(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(WebUIDevError, match="source checkout"):
|
||||||
|
start_webui_dev_server(
|
||||||
|
target_url="http://127.0.0.1:8765",
|
||||||
|
browser_url="http://127.0.0.1:5173",
|
||||||
|
source_dir=tmp_path / "missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_server_stop_terminates_and_reaps_the_direct_process() -> None:
|
||||||
|
process = _FakeProcess()
|
||||||
|
server = WebUIDevServer(process=process)
|
||||||
|
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
assert process.terminated is True
|
||||||
|
assert process.killed is False
|
||||||
|
assert process.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_server_reports_an_unexpected_exit() -> None:
|
||||||
|
process = _FakeProcess()
|
||||||
|
process.returncode = 23
|
||||||
|
server = WebUIDevServer(process=process)
|
||||||
|
|
||||||
|
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||||
|
server.ensure_running()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_server_context_stops_the_child(monkeypatch) -> None:
|
||||||
|
process = _FakeProcess()
|
||||||
|
process.returncode = 0
|
||||||
|
server = type("Server", (), {"process": process})()
|
||||||
|
stopped: list[bool] = []
|
||||||
|
server.stop = lambda: stopped.append(True)
|
||||||
|
monkeypatch.setattr("nanobot.webui.dev.start_webui_dev_server", lambda **_kwargs: server)
|
||||||
|
|
||||||
|
with run_webui_dev_server(target_url="unused", browser_url="unused") as running:
|
||||||
|
assert running is server
|
||||||
|
|
||||||
|
assert stopped == [True]
|
||||||
@ -40,7 +40,26 @@ python -m pip install -e .
|
|||||||
|
|
||||||
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
|
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
|
||||||
|
|
||||||
### 2. Enable the WebSocket channel
|
### 2. Start the gateway and Vite
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot webui --dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The command safely prepares the local WebSocket channel, starts both the gateway and Vite,
|
||||||
|
and opens `http://127.0.0.1:5173`. Vite proxies to the configured WebSocket channel and applies
|
||||||
|
frontend changes with HMR. Press Ctrl+C in that terminal to stop both processes.
|
||||||
|
|
||||||
|
Use `--no-open` to skip opening a browser. `--dev` is foreground-only and cannot be combined
|
||||||
|
with `--background`.
|
||||||
|
|
||||||
|
## Manual development setup
|
||||||
|
|
||||||
|
The two-terminal workflow remains available when you want to manage each process separately.
|
||||||
|
|
||||||
|
### 1. Enable the WebSocket channel
|
||||||
|
|
||||||
In `~/.nanobot/config.json`, merge:
|
In `~/.nanobot/config.json`, merge:
|
||||||
|
|
||||||
@ -48,7 +67,7 @@ In `~/.nanobot/config.json`, merge:
|
|||||||
{ "channels": { "websocket": { "enabled": true } } }
|
{ "channels": { "websocket": { "enabled": true } } }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Start the gateway
|
### 2. Start the gateway
|
||||||
|
|
||||||
In one terminal:
|
In one terminal:
|
||||||
|
|
||||||
@ -56,7 +75,7 @@ In one terminal:
|
|||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Start the WebUI dev server
|
### 3. Start the WebUI dev server
|
||||||
|
|
||||||
In another terminal:
|
In another terminal:
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user