feat: add WebUI mode support with related configuration and onboarding (#252)
* feat: add WebUI mode support with related configuration and onboarding steps * feat: enhance WebUI port configuration to prevent conflicts with backend port * feat: add support for fresh interactive session detection in WebUI
This commit is contained in:
@@ -81,7 +81,9 @@ def onboard(
|
||||
"--show-thinking/--no-show-thinking",
|
||||
help="Pre-set thinking-panel visibility",
|
||||
),
|
||||
ui: str | None = typer.Option(None, "--ui", help="Pre-set UI backend (tui | cli)"),
|
||||
ui: str | None = typer.Option(
|
||||
None, "--ui", help="Pre-set UI backend (tui | cli | webui)"
|
||||
),
|
||||
port: int | None = typer.Option(
|
||||
None, "--port", help="Pre-set langgraph dev server port"
|
||||
),
|
||||
@@ -1740,6 +1742,17 @@ def _version_callback(value: bool):
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
def _is_fresh_interactive_session(prompt: str | None, thread_id: str | None) -> bool:
|
||||
"""True for a brand-new interactive session — no one-shot ``-p`` prompt and
|
||||
no ``--resume`` / ``--thread-id`` to continue.
|
||||
|
||||
This is the only case where a WebUI-configured ``EvoSci`` opens the browser
|
||||
app: a one-shot or a resume has a concrete conversation to render in the
|
||||
terminal, so it falls back to the Rich CLI instead.
|
||||
"""
|
||||
return not prompt and not thread_id
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def _main_callback(
|
||||
ctx: typer.Context,
|
||||
@@ -1804,7 +1817,7 @@ def _main_callback(
|
||||
ui: str | None = typer.Option(
|
||||
None,
|
||||
"--ui",
|
||||
help="UI backend: tui (default) or cli.",
|
||||
help="UI backend: tui (default), cli, or webui.",
|
||||
),
|
||||
):
|
||||
"""EvoScientist Agent - AI-powered research & code execution CLI"""
|
||||
@@ -1871,8 +1884,8 @@ def _main_callback(
|
||||
|
||||
if mode and mode not in ("run", "daemon"):
|
||||
raise typer.BadParameter("--mode must be 'run' or 'daemon'")
|
||||
if ui and ui.lower() not in ("cli", "tui"):
|
||||
raise typer.BadParameter("--ui must be 'tui' or 'cli'")
|
||||
if ui and ui.lower() not in ("cli", "tui", "webui"):
|
||||
raise typer.BadParameter("--ui must be 'tui', 'cli', or 'webui'")
|
||||
|
||||
# --name only makes sense in run mode
|
||||
if name and not (
|
||||
@@ -1956,6 +1969,26 @@ def _main_callback(
|
||||
# Ensure memory and skills subdirs exist in workspace
|
||||
ensure_dirs()
|
||||
|
||||
# WebUI mode: instead of the in-terminal CLI/TUI, run a deploy-style
|
||||
# langgraph server (full MCP + async) + the published @evoscientist/webui
|
||||
# front-end (npx) in THIS terminal, then block. Reuses start_langgraph_dev
|
||||
# but leaves `EvoSci deploy` untouched (it stays a clean server for external
|
||||
# UIs / SDK clients).
|
||||
#
|
||||
# The browser app is only launched for a FRESH interactive session. With
|
||||
# `-p` (one-shot) or `--resume`/`--thread-id` (continue a specific
|
||||
# conversation), there is concrete terminal output to render, so fall back
|
||||
# to the Rich CLI instead of opening the browser UI.
|
||||
from .tui_runtime import normalize_ui_backend
|
||||
|
||||
if normalize_ui_backend(config.ui_backend) == "webui":
|
||||
if _is_fresh_interactive_session(prompt, thread_id):
|
||||
from ..deploy.webui import run_webui
|
||||
|
||||
run_webui(config, workspace_dir=workspace_dir)
|
||||
return
|
||||
config.ui_backend = "cli"
|
||||
|
||||
# Auto-start langgraph dev (after workspace resolution, so deployed
|
||||
# async sub-agents inherit the CLI's workspace via EVOSCIENTIST_WORKSPACE_DIR).
|
||||
_ensure_async_subagent_server(config, workspace_dir=workspace_dir)
|
||||
|
||||
@@ -9,7 +9,11 @@ from ..stream.console import console
|
||||
from .tui_backends import RichStreamingBackend, StreamingTUIBackend
|
||||
|
||||
DEFAULT_UI_BACKEND = "cli"
|
||||
SUPPORTED_UI_BACKENDS = ("cli", "tui")
|
||||
# "webui" launches the browser front-end instead of an in-terminal UI; it is
|
||||
# intercepted earlier (cli/commands.py:_main_callback) and never reaches the
|
||||
# streaming backends, but is listed here so normalize/resolve preserve it
|
||||
# rather than falling back to "cli".
|
||||
SUPPORTED_UI_BACKENDS = ("cli", "tui", "webui")
|
||||
_LEGACY_BACKEND_MAP = {"textual": "tui", "rich": "cli"}
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli"})
|
||||
VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli", "webui"})
|
||||
|
||||
VALID_WORKSPACE_MODES: frozenset[str] = frozenset({"daemon", "run"})
|
||||
|
||||
|
||||
@@ -43,23 +43,24 @@ from .validators import validate_tavily_key
|
||||
|
||||
|
||||
def _step_ui_backend(config: EvoScientistConfig) -> str:
|
||||
"""Step 0: Select UI backend (Rich CLI or Textual TUI).
|
||||
"""Step 0: Select UI backend (Textual TUI, Rich CLI, or browser WebUI).
|
||||
|
||||
Args:
|
||||
config: Current configuration.
|
||||
|
||||
Returns:
|
||||
Selected backend name ("tui" or "cli").
|
||||
Selected backend name ("tui", "cli", or "webui").
|
||||
"""
|
||||
choices = [
|
||||
Choice(title="TUI (full-screen interface, recommended)", value="tui"),
|
||||
Choice(title="CLI (classic terminal, lightweight)", value="cli"),
|
||||
Choice(title="WebUI (browser interface, beta)", value="webui"),
|
||||
]
|
||||
|
||||
# Map legacy values to current ones
|
||||
_legacy_map = {"textual": "tui", "rich": "cli"}
|
||||
default_backend = _legacy_map.get(config.ui_backend, config.ui_backend)
|
||||
if default_backend not in ("tui", "cli"):
|
||||
if default_backend not in ("tui", "cli", "webui"):
|
||||
default_backend = "tui"
|
||||
|
||||
backend = questionary.select(
|
||||
@@ -161,6 +162,72 @@ def _step_langgraph_dev_port(config: EvoScientistConfig) -> int:
|
||||
return port
|
||||
|
||||
|
||||
def _step_webui_port(config: EvoScientistConfig) -> int:
|
||||
"""Step 0.6: Choose the local TCP port for the WebUI front-end.
|
||||
|
||||
Only asked when ``ui_backend == "webui"``. This is the Next.js server port
|
||||
the browser opens (``@evoscientist/webui``); the backend keeps its own
|
||||
``langgraph_dev_port``. Mirrors the langgraph-dev port prompt's UX.
|
||||
|
||||
Returns the chosen port; caller assigns it to ``config.webui_port``.
|
||||
"""
|
||||
from ...langgraph_dev.manager import _is_port_occupied
|
||||
|
||||
current_port = getattr(config, "webui_port", 4716)
|
||||
backend_port = getattr(config, "langgraph_dev_port", 6174)
|
||||
occupied = _is_port_occupied(current_port)
|
||||
conflicts_backend = current_port == backend_port
|
||||
|
||||
# Bake live availability into the label (same single-paren style as the
|
||||
# langgraph-dev / ccproxy prompts) so the status shows WITH the question.
|
||||
# The WebUI port must also differ from the backend (langgraph dev) port —
|
||||
# run_webui refuses equal ports at startup, so reject them here too instead
|
||||
# of saving a config that fails to launch later.
|
||||
if occupied or conflicts_backend:
|
||||
reason = "occupied" if occupied else f"= backend port {backend_port}"
|
||||
prompt_label = (
|
||||
f"Enter port for WebUI server "
|
||||
f"(Current: {current_port}, {reason}, pick another):"
|
||||
)
|
||||
else:
|
||||
prompt_label = (
|
||||
f"Enter port for WebUI server "
|
||||
f"(Current: {current_port}, available, Enter to keep):"
|
||||
)
|
||||
|
||||
def valid_port(value: str) -> bool:
|
||||
if not value:
|
||||
# Keep the default only if it's free AND not the backend port.
|
||||
return not occupied and not conflicts_backend
|
||||
try:
|
||||
port = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if not (1024 < port < 65536):
|
||||
return False
|
||||
if port == backend_port:
|
||||
return False
|
||||
return not _is_port_occupied(port)
|
||||
|
||||
raw = questionary.text(
|
||||
prompt_label,
|
||||
validate=valid_port,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=QMARK,
|
||||
).ask()
|
||||
|
||||
if raw is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
port = int(raw) if raw else current_port
|
||||
console.print(f" [green]✓ WebUI will open at http://localhost:{port}[/green]")
|
||||
console.print(
|
||||
" [yellow]⚠️ Beta: the WebUI won't show your CLI/TUI chat history "
|
||||
"yet.[/yellow]"
|
||||
)
|
||||
return port
|
||||
|
||||
|
||||
def _step_provider(config: EvoScientistConfig) -> str:
|
||||
"""Step 1: Select LLM provider.
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from .steps import (
|
||||
_step_thinking,
|
||||
_step_tinytex,
|
||||
_step_ui_backend,
|
||||
_step_webui_port,
|
||||
_step_workspace,
|
||||
)
|
||||
from .style import (
|
||||
@@ -421,6 +422,11 @@ def run_onboard(
|
||||
)
|
||||
else:
|
||||
config.ui_backend = _step_ui_backend(config)
|
||||
# WebUI mode needs a front-end port; ask right after the mode
|
||||
# choice (only when chosen interactively — non-interactive /
|
||||
# preset runs keep the config default).
|
||||
if config.ui_backend == "webui" and not strict and preset_ui is None:
|
||||
config.webui_port = _step_webui_port(config)
|
||||
_autosave(config)
|
||||
|
||||
if "port" in sections_to_run:
|
||||
|
||||
@@ -114,6 +114,12 @@ class EvoScientistConfig:
|
||||
# 2024. Override if it conflicts with another local service.
|
||||
langgraph_dev_port: int = 6174
|
||||
|
||||
# Port for the WebUI front-end (Next.js server from @evoscientist/webui),
|
||||
# used only when ui_backend == "webui". 4716 is 6174 reversed — a memorable
|
||||
# pairing with the langgraph dev port that it connects to. The backend keeps
|
||||
# its own port (langgraph_dev_port); this is just the browser server.
|
||||
webui_port: int = 4716
|
||||
|
||||
# Whether langgraph dev persists its runtime state to .langgraph_api/ next
|
||||
# to the subprocess cwd. True (default) keeps async-task, scheduler, and
|
||||
# Store API state across subprocess restarts — useful for future
|
||||
@@ -149,7 +155,9 @@ class EvoScientistConfig:
|
||||
|
||||
# UI Settings
|
||||
show_thinking: bool = True
|
||||
ui_backend: Literal["cli", "tui"] = "tui"
|
||||
# "webui" launches the browser front-end (@evoscientist/webui via npx) +
|
||||
# a deploy-style langgraph server instead of the in-terminal CLI/TUI.
|
||||
ui_backend: Literal["cli", "tui", "webui"] = "tui"
|
||||
log_level: str = "warning"
|
||||
reasoning_effort: str = "high"
|
||||
|
||||
@@ -503,6 +511,7 @@ _ENV_MAPPINGS = {
|
||||
"checkpoint_keep_per_thread": "EVOSCIENTIST_CHECKPOINT_KEEP_PER_THREAD",
|
||||
"enable_async_subagents": "EVOSCIENTIST_ENABLE_ASYNC_SUBAGENTS",
|
||||
"langgraph_dev_port": "EVOSCIENTIST_LANGGRAPH_DEV_PORT",
|
||||
"webui_port": "EVOSCIENTIST_WEBUI_PORT",
|
||||
"code_interpreter_timeout": "EVOSCIENTIST_CODE_INTERPRETER_TIMEOUT",
|
||||
"code_interpreter_max_result_chars": "EVOSCIENTIST_CODE_INTERPRETER_MAX_RESULT_CHARS",
|
||||
"sandbox_execute_timeout": "EVOSCIENTIST_SANDBOX_EXECUTE_TIMEOUT",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""``EvoSci`` WebUI mode — deploy-style LangGraph server + browser front-end.
|
||||
|
||||
Selected via ``ui_backend = "webui"`` (onboard → "Select UI mode" → WebUI).
|
||||
Running ``EvoSci`` then becomes, in ONE terminal:
|
||||
|
||||
EvoSci deploy + npx @evoscientist/webui
|
||||
|
||||
i.e. start a *full* langgraph dev server (MCP + async sub-agents, exactly like
|
||||
``EvoSci deploy``) AND launch the published ``@evoscientist/webui`` Next.js
|
||||
front-end via ``npx``, so the user never needs two terminals.
|
||||
|
||||
Design boundary: this module deliberately REUSES the low-level
|
||||
``start_langgraph_dev`` primitive but does **not** import, call, or modify the
|
||||
``deploy`` command. ``EvoSci deploy`` stays a clean, opinionated standalone
|
||||
server for *external* consumers (deep-agents-ui, agent-chat-ui, LangSmith
|
||||
Studio, SDK clients); WebUI mode is a separate, parallel launcher.
|
||||
|
||||
``npx @evoscientist/webui@latest`` is used (not a pinned version) so each launch
|
||||
transparently pulls the newest published UI — front-end fixes ship to users
|
||||
without touching the EvoScientist install. The trade-off: the first launch (and
|
||||
the first launch after a new release) downloads the package and needs network;
|
||||
subsequent launches reuse the npm cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer # type: ignore[import-untyped]
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from ..stream.console import console
|
||||
|
||||
# Front-end npm package + spec. ``@latest`` → always the newest published UI.
|
||||
_WEBUI_PACKAGE = "@evoscientist/webui@latest"
|
||||
_DEFAULT_WEBUI_PORT = 4716
|
||||
|
||||
|
||||
def run_webui(config: Any, workspace_dir: str | None = None) -> None:
|
||||
"""Start the deploy-style backend + the WebUI front-end, then block.
|
||||
|
||||
Args:
|
||||
config: Effective ``EvoScientistConfig`` (already env-applied upstream,
|
||||
but re-applied here so this is safe to call standalone).
|
||||
workspace_dir: Resolved workspace path; falls back to
|
||||
``config.default_workdir`` then cwd.
|
||||
|
||||
Blocks until Ctrl+C / SIGTERM, or until the front-end process exits, then
|
||||
tears down both subprocesses. Never returns a value.
|
||||
"""
|
||||
from ..config import apply_config_to_env
|
||||
from ..langgraph_dev.manager import (
|
||||
_DEFAULT_PORT,
|
||||
_LOG_FILE,
|
||||
_is_port_occupied,
|
||||
_read_workspace_sidecar,
|
||||
is_langgraph_dev_running,
|
||||
start_langgraph_dev,
|
||||
stop_langgraph_dev,
|
||||
)
|
||||
|
||||
apply_config_to_env(config)
|
||||
|
||||
# 1. Resolve workspace (CLI-resolved value > config.default_workdir > cwd),
|
||||
# mirroring `EvoSci deploy`. The langgraph dev subprocess inherits this via
|
||||
# EVOSCIENTIST_WORKSPACE_DIR (set inside start_langgraph_dev).
|
||||
if workspace_dir:
|
||||
ws = os.path.abspath(os.path.expanduser(workspace_dir))
|
||||
elif getattr(config, "default_workdir", ""):
|
||||
ws = os.path.abspath(os.path.expanduser(config.default_workdir))
|
||||
else:
|
||||
ws = os.getcwd()
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
|
||||
# 2. Resolve ports: backend = langgraph dev (browser connects here),
|
||||
# webui_port = the local Next.js server the browser actually opens.
|
||||
backend_port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
|
||||
webui_port = int(getattr(config, "webui_port", _DEFAULT_WEBUI_PORT))
|
||||
for label, p in (("langgraph dev", backend_port), ("WebUI", webui_port)):
|
||||
if not (1 <= p <= 65535):
|
||||
console.print(
|
||||
f"[red]Invalid {label} port {p}. Use an integer in [1, 65535].[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if webui_port == backend_port:
|
||||
# Same port → the backend would claim it first and npx would fail to
|
||||
# bind. Catch it here with a clear message instead of a cryptic error.
|
||||
console.print(
|
||||
f"[red]WebUI port and langgraph dev port must differ "
|
||||
f"(both are {webui_port}).[/red]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Change one with [bold]EvoSci config set webui_port <port>"
|
||||
"[/bold].[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# 3. Pre-flight the npx front-end requirement BEFORE starting the server,
|
||||
# so a missing Node toolchain fails fast with actionable guidance.
|
||||
npx = shutil.which("npx")
|
||||
if not npx:
|
||||
console.print(
|
||||
Panel(
|
||||
Text.from_markup(
|
||||
"[bold]Node.js / npx was not found on PATH.[/bold]\n\n"
|
||||
"The WebUI front-end ships as the npm package "
|
||||
"[cyan]@evoscientist/webui[/cyan] and is launched with "
|
||||
"[bold]npx[/bold].\n\n"
|
||||
"Install [bold]Node.js 24 LTS[/bold] (which includes npx), "
|
||||
"then re-run [bold]EvoSci[/bold] — or switch UI modes with "
|
||||
"[bold]EvoSci config set ui_backend tui[/bold]."
|
||||
),
|
||||
title="[bold red]WebUI unavailable[/bold red]",
|
||||
border_style="red",
|
||||
)
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# 4. Backend (langgraph dev): reuse an EvoSci server already on the port,
|
||||
# else start a fresh deploy-mode one (full MCP + async). Refuse a foreign
|
||||
# occupant — that's a configuration error, not something to silently share.
|
||||
started_proc = None
|
||||
if _is_port_occupied(backend_port):
|
||||
if is_langgraph_dev_running(port=backend_port):
|
||||
# Reuse an existing EvoSci server only when it serves THIS workspace
|
||||
# — mirror the sidecar guard in ensure_langgraph_dev so WebUI started
|
||||
# from workspace B never silently binds to a server pinned to
|
||||
# workspace A. No sidecar (older subprocess) → reuse, as before.
|
||||
sidecar = _read_workspace_sidecar()
|
||||
if (
|
||||
sidecar is not None
|
||||
and Path(sidecar["workspace"]).resolve() != Path(ws).resolve()
|
||||
):
|
||||
console.print(
|
||||
f"[red]Port {backend_port} is already serving a langgraph "
|
||||
f"dev for a different workspace "
|
||||
f"({_shorten(sidecar['workspace'])}).[/red]"
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Stop that EvoSci session, or launch from that "
|
||||
f"workspace ([bold]--workdir {sidecar['workspace']}[/bold])."
|
||||
f"[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
f"[green]✓[/green] Reusing langgraph dev already serving "
|
||||
f"port {backend_port}"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[red]Port {backend_port} is occupied by another process.[/red]"
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Free it (lsof -i :{backend_port}) or change it with "
|
||||
f"[bold]EvoSci config set langgraph_dev_port <port>[/bold].[/dim]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10))
|
||||
file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True))
|
||||
try:
|
||||
with console.status(
|
||||
"[dim]Starting langgraph dev (deploy mode: MCP + async)...[/dim]",
|
||||
spinner="dots",
|
||||
):
|
||||
started_proc = start_langgraph_dev(
|
||||
workspace_dir=Path(ws),
|
||||
port=backend_port,
|
||||
file_persistence=file_persistence,
|
||||
jobs_per_worker=jobs_per_worker,
|
||||
deploy_mode=True,
|
||||
)
|
||||
atexit.register(stop_langgraph_dev, started_proc)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]langgraph dev startup failed:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
console.print("[green]✓[/green] langgraph dev ready")
|
||||
|
||||
if _is_port_occupied(webui_port):
|
||||
console.print(
|
||||
f"[yellow]⚠ Port {webui_port} is already in use; the WebUI server "
|
||||
f"may fail to start. Change it with "
|
||||
f"[bold]EvoSci config set webui_port <port>[/bold].[/yellow]"
|
||||
)
|
||||
|
||||
# 5. Launch the front-end via npx in its own process group so the whole
|
||||
# tree (npx → node → next server) tears down cleanly on shutdown. The
|
||||
# package's own launcher prints progress and opens the browser; stdio is
|
||||
# inherited so it all shows in THIS terminal. EVOSCIENTIST_LANGGRAPH_DEV_PORT
|
||||
# lets the UI's config prefill point at our backend automatically. Secrets
|
||||
# are scrubbed — the browser UI never needs LLM provider API keys.
|
||||
webui_env = _scrubbed_env(
|
||||
{
|
||||
"EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port),
|
||||
"PORT": str(webui_port),
|
||||
}
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
Text.from_markup(
|
||||
f"[bold]Backend:[/bold] http://localhost:{backend_port} "
|
||||
f"[dim](langgraph dev — Assistant: EvoScientist)[/dim]\n"
|
||||
f"[bold]WebUI:[/bold] http://localhost:{webui_port} "
|
||||
f"[dim](opens in your browser)[/dim]\n"
|
||||
f"[bold]Logs:[/bold] {_shorten(str(_LOG_FILE))}\n\n"
|
||||
f"[dim]Fetching {_WEBUI_PACKAGE} via npx (first run may take a "
|
||||
f"moment)… Press Ctrl+C to stop.[/dim]"
|
||||
),
|
||||
title="[bold green]✓ EvoScientist WebUI[/bold green]",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
popen_kwargs: dict[str, Any] = {"env": webui_env}
|
||||
if os.name == "posix":
|
||||
popen_kwargs["start_new_session"] = True
|
||||
elif os.name == "nt":
|
||||
# New process group so the npx → node → next subtree can be killed as a
|
||||
# unit by taskkill /T in _stop_webui.
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
webui_proc = subprocess.Popen(
|
||||
[npx, "--yes", _WEBUI_PACKAGE, "--port", str(webui_port)],
|
||||
**popen_kwargs,
|
||||
)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Failed to launch WebUI via npx:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
atexit.register(_stop_webui, webui_proc)
|
||||
|
||||
# 6. Block on signal — same dual-gate as `EvoSci deploy` (threading.Event +
|
||||
# explicit SIGINT/SIGTERM handlers). Also exit if the front-end dies on its
|
||||
# own (e.g. the user closes it), so we don't leave the backend orphaned.
|
||||
shutdown_event = threading.Event()
|
||||
|
||||
def _handle_shutdown(signum: int, _frame: Any) -> None:
|
||||
shutdown_event.set()
|
||||
if signum == signal.SIGINT:
|
||||
signal.default_int_handler(signum, _frame)
|
||||
|
||||
_orig_sigint = signal.signal(signal.SIGINT, _handle_shutdown)
|
||||
_orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown)
|
||||
|
||||
try:
|
||||
while not shutdown_event.is_set():
|
||||
if webui_proc.poll() is not None:
|
||||
console.print("\n[dim]WebUI server exited.[/dim]")
|
||||
break
|
||||
shutdown_event.wait(timeout=0.5)
|
||||
except KeyboardInterrupt:
|
||||
shutdown_event.set()
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, _orig_sigint)
|
||||
signal.signal(signal.SIGTERM, _orig_sigterm)
|
||||
_stop_webui(webui_proc)
|
||||
# stop_langgraph_dev (if we started it) runs via atexit during
|
||||
# interpreter shutdown — don't claim "Stopped." before that fires.
|
||||
console.print(
|
||||
"\n[dim]Shutting down (background cleanup may take a few seconds)...[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _stop_webui(proc: subprocess.Popen) -> None:
|
||||
"""Terminate the WebUI process tree (idempotent)."""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
elif os.name == "nt":
|
||||
# taskkill /T terminates the whole child tree (node + next server).
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _scrubbed_env(extra: dict[str, str]) -> dict[str, str]:
|
||||
"""Inherit the parent environment minus secrets, then apply ``extra``.
|
||||
|
||||
The WebUI is a browser client that only talks to the local langgraph server
|
||||
— it has no use for LLM provider API keys. Stripping credential-bearing
|
||||
variables keeps them out of the npx-fetched front-end package and its
|
||||
transitive npm dependencies (defence-in-depth, especially with ``@latest``).
|
||||
Names are matched loosely (``*_KEY`` / ``*API_KEY*`` / ``*TOKEN*`` /
|
||||
``*SECRET*`` / ``*PASSWORD*``); node/npm essentials (PATH, HOME, NODE_*,
|
||||
npm_*, proxies, CA certs) carry none of these and pass through untouched.
|
||||
"""
|
||||
secret_hints = ("API_KEY", "TOKEN", "SECRET", "PASSWORD")
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if not (
|
||||
k.upper().endswith("_KEY")
|
||||
or any(hint in k.upper() for hint in secret_hints)
|
||||
)
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def _shorten(path: str) -> str:
|
||||
"""Replace ``$HOME`` prefix with ``~`` for compact display."""
|
||||
home = os.path.expanduser("~")
|
||||
if path.startswith(home):
|
||||
return "~" + path[len(home) :]
|
||||
return path
|
||||
@@ -1,8 +1,82 @@
|
||||
"""Tests for CLI interactive UI backend dispatch."""
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist.cli.commands import _is_fresh_interactive_session
|
||||
from EvoScientist.cli.interactive import cmd_interactive
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "thread_id", "expected"),
|
||||
[
|
||||
(None, None, True), # bare `EvoSci` → fresh → WebUI launches
|
||||
("what is 1+1", None, False), # `-p` one-shot → terminal (Rich CLI)
|
||||
(None, "47bcffcd", False), # `--resume <id>` → terminal (Rich CLI)
|
||||
("hi", "47bcffcd", False), # both → terminal
|
||||
("", None, True), # empty `-p` is falsy → treated as fresh
|
||||
],
|
||||
)
|
||||
def test_is_fresh_interactive_session(prompt, thread_id, expected):
|
||||
"""WebUI only launches for a fresh interactive session; `-p` / `--resume`
|
||||
fall back to the terminal."""
|
||||
assert _is_fresh_interactive_session(prompt, thread_id) is expected
|
||||
|
||||
|
||||
def _invoke_main(monkeypatch, argv):
|
||||
"""Invoke the EvoSci main callback with ui_backend=webui and all heavy setup
|
||||
mocked. Returns (calls, result): calls["dispatch"] is "webui" if run_webui
|
||||
ran, or ("cli", <ui_backend>) if cmd_interactive ran."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import EvoScientist.cli.commands as cmds
|
||||
import EvoScientist.cli.interactive as interactive_mod
|
||||
import EvoScientist.config as cfg_mod
|
||||
import EvoScientist.deploy.webui as webui_mod
|
||||
from EvoScientist.cli._app import app
|
||||
from EvoScientist.config.settings import EvoScientistConfig
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def _fake_config(overrides):
|
||||
cfg = EvoScientistConfig()
|
||||
# Mirror the real --ui override; default to webui for this test.
|
||||
cfg.ui_backend = overrides.get("ui_backend") or "webui"
|
||||
return cfg
|
||||
|
||||
monkeypatch.setattr(cfg_mod, "get_effective_config", _fake_config)
|
||||
monkeypatch.setattr(cfg_mod, "apply_config_to_env", lambda cfg: None)
|
||||
monkeypatch.setattr(cmds, "ensure_dirs", lambda: None)
|
||||
monkeypatch.setattr(cmds, "_ensure_async_subagent_server", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
webui_mod,
|
||||
"run_webui",
|
||||
lambda *a, **k: calls.__setitem__("dispatch", "webui"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
interactive_mod,
|
||||
"cmd_interactive",
|
||||
lambda **kw: calls.__setitem__("dispatch", ("cli", kw.get("ui_backend"))),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(app, argv, catch_exceptions=False)
|
||||
return calls, result
|
||||
|
||||
|
||||
def test_main_callback_launches_webui_for_fresh_session(monkeypatch):
|
||||
"""Bare `EvoSci` with ui_backend=webui opens the browser app."""
|
||||
calls, result = _invoke_main(monkeypatch, [])
|
||||
assert result.exit_code == 0
|
||||
assert calls.get("dispatch") == "webui"
|
||||
|
||||
|
||||
def test_main_callback_resume_falls_back_to_cli(monkeypatch):
|
||||
"""`EvoSci --resume <id>` with ui_backend=webui does NOT open the browser;
|
||||
it resumes the conversation in the Rich CLI (ui_backend forced to 'cli')."""
|
||||
calls, result = _invoke_main(monkeypatch, ["--resume", "abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert calls.get("dispatch") == ("cli", "cli")
|
||||
|
||||
|
||||
def test_cmd_interactive_dispatches_to_textual(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
|
||||
@@ -126,9 +126,9 @@ class TestSharedConstantsAlignment:
|
||||
def test_ui_constants_match_step_choices(self):
|
||||
from EvoScientist.config.onboard.constants import VALID_UI_BACKENDS
|
||||
|
||||
# _step_ui_backend hard-codes "tui" and "cli" — small enough to
|
||||
# check by direct lookup against the canonical set.
|
||||
assert VALID_UI_BACKENDS == frozenset({"tui", "cli"})
|
||||
# _step_ui_backend hard-codes "tui", "cli", and "webui" — small enough
|
||||
# to check by direct lookup against the canonical set.
|
||||
assert VALID_UI_BACKENDS == frozenset({"tui", "cli", "webui"})
|
||||
|
||||
def test_workspace_mode_constants_match_step_choices(self):
|
||||
from EvoScientist.config.onboard.constants import VALID_WORKSPACE_MODES
|
||||
|
||||
Reference in New Issue
Block a user