mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
234da592f5 | ||
|
|
2d138b92fc |
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
| 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 --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 |
|
||||
|
||||
+3
-1
@@ -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,
|
||||
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
|
||||
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:
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Shared WebUI setup, URL, health, and browser helpers."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO
|
||||
|
||||
import typer
|
||||
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("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||
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(
|
||||
runtime: "GatewayRuntime",
|
||||
*,
|
||||
poll_hook: Callable[[], None] | None = None,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> 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)
|
||||
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:
|
||||
poll_hook()
|
||||
sleep(0.5)
|
||||
status = runtime.status()
|
||||
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]")
|
||||
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]")
|
||||
|
||||
|
||||
|
||||
@@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
||||
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
|
||||
log_path = tmp_path / "gateway.log"
|
||||
log_path.touch()
|
||||
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
return SimpleNamespace(running=True, log_path=log_path)
|
||||
|
||||
def stop(self):
|
||||
nonlocal stopped
|
||||
@@ -2679,10 +2681,88 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
|
||||
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:
|
||||
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:
|
||||
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
||||
|
||||
Reference in New Issue
Block a user