Compare commits

..
4 changed files with 171 additions and 10 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `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, open `http://127.0.0.1:8765`, and follow new gateway logs |
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits | | `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates | | `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 |
+3 -1
View File
@@ -23,7 +23,9 @@ one is missing, starts or joins the same on-demand gateway used by the native
TUI, and opens the browser. With a fresh config, TUI, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings it can open before a model is configured so you can finish setup in **Settings
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so → Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
it is not available from other devices on your LAN. it is not available from other devices on your LAN. While the launcher remains
attached, it mirrors new log output from that exact gateway instance in the
terminal without replaying older logs.
After model setup, explicitly promote the shared gateway when you do not want to keep a client open: After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
+83 -4
View File
@@ -1,12 +1,14 @@
"""Shared WebUI setup, URL, health, and browser helpers.""" """Shared WebUI setup, URL, health, and browser helpers."""
import os
import subprocess import subprocess
import sys import sys
import time import time
import webbrowser import webbrowser
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, BinaryIO
import typer import typer
from pydantic import ValidationError from pydantic import ValidationError
@@ -457,27 +459,104 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
console.print("[green]WebUI is attached to the shared gateway.[/green]") console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]") console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print( console.print(
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]" "[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops "
"only when the last local client exits.[/dim]"
) )
_LOG_ANCHOR_BYTES = 64
@dataclass
class _GatewayLogCursor:
offset: int = 0
identity: tuple[int, int] | None = None
anchor: bytes = b""
pending: bytes = b""
def _log_anchor(handle: BinaryIO, offset: int) -> bytes:
size = min(offset, _LOG_ANCHOR_BYTES)
handle.seek(offset - size)
return handle.read(size)
def _start_gateway_log_cursor(log_path: Path) -> _GatewayLogCursor:
"""Start following at the current end of *log_path*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
offset = stat.st_size
return _GatewayLogCursor(
offset=offset,
identity=(stat.st_dev, stat.st_ino),
anchor=_log_anchor(handle, offset),
)
except OSError:
return _GatewayLogCursor()
def _read_new_gateway_logs(
log_path: Path,
cursor: _GatewayLogCursor,
*,
flush: bool = False,
) -> list[str]:
"""Read complete gateway log lines appended after *cursor*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
identity = (stat.st_dev, stat.st_ino)
reset = cursor.identity != identity or stat.st_size < cursor.offset
if not reset and cursor.offset:
reset = _log_anchor(handle, cursor.offset) != cursor.anchor
if reset:
cursor.offset = 0
cursor.pending = b""
handle.seek(cursor.offset)
chunk = handle.read()
cursor.offset = handle.tell()
cursor.identity = identity
cursor.anchor = _log_anchor(handle, cursor.offset)
except OSError:
return []
parts = (cursor.pending + chunk).split(b"\n")
cursor.pending = parts.pop()
if flush and cursor.pending:
parts.append(cursor.pending)
cursor.pending = b""
return [part.removesuffix(b"\r").decode("utf-8", errors="replace") for part in parts]
def _attach_to_background_gateway( def _attach_to_background_gateway(
runtime: "GatewayRuntime", runtime: "GatewayRuntime",
*, *,
poll_hook: Callable[[], None] | None = None, poll_hook: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep, sleep: Callable[[float], None] = time.sleep,
) -> None: ) -> None:
"""Keep a WebUI launcher attached without taking ownership of the gateway.""" """Keep the launcher attached and mirror this gateway's new log output."""
status = runtime.status()
log_path = status.log_path
cursor = _start_gateway_log_cursor(log_path)
_print_webui_foreground_lifecycle(attached=True) _print_webui_foreground_lifecycle(attached=True)
try: try:
while runtime.status().running: while status.running:
for line in _read_new_gateway_logs(log_path, cursor):
console.print(line, markup=False, highlight=False)
if poll_hook is not None: if poll_hook is not None:
poll_hook() poll_hook()
sleep(0.5) sleep(0.5)
status = runtime.status()
except KeyboardInterrupt: except KeyboardInterrupt:
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("\n[yellow]WebUI launcher detached.[/yellow]") console.print("\n[yellow]WebUI launcher detached.[/yellow]")
return return
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("[yellow]Gateway stopped.[/yellow]") console.print("[yellow]Gateway stopped.[/yellow]")
+84 -4
View File
@@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
assert seen["lease_release_wait_for_stop"] is False assert seen["lease_release_wait_for_stop"] is False
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None: def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None:
stopped = False stopped = False
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def stop(self): def stop(self):
nonlocal stopped nonlocal stopped
@@ -2679,10 +2681,88 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
assert "WebUI launcher detached" in rendered assert "WebUI launcher detached" in rendered
def test_attach_to_background_gateway_checks_owned_sidecar() -> None: def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
polls = 0
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def _append_then_interrupt(_seconds: float) -> None:
nonlocal polls
if polls == 0:
with log_path.open("a", encoding="utf-8") as handle:
handle.write("[websocket] live log\n")
polls += 1
return
raise KeyboardInterrupt
cli_webui_support._attach_to_background_gateway(
_FakeRuntime(),
sleep=_append_then_interrupt,
)
output = capsys.readouterr().out
assert "[websocket] live log" in output
assert "historical log" not in output
def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("a much longer historical log line\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["fresh log"]
assert cursor.offset == log_path.stat().st_size
def test_read_new_gateway_logs_detects_fast_rewrite_past_offset(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("first fresh log\nsecond fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["first fresh log", "second fresh log"]
def test_read_new_gateway_logs_waits_for_complete_utf8_line(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
encoded = "模型 ready\n".encode()
log_path.write_bytes(encoded[:2])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == []
with log_path.open("ab") as handle:
handle.write(encoded[2:])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == ["模型 ready"]
def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None:
log_path = tmp_path / "missing.log"
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == []
assert cursor.offset == 0
def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True, log_path=log_path)
def sidecar_exited() -> None: def sidecar_exited() -> None:
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)") raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")