feat(cli): add safe WebUI first-run launcher (#4688)

This commit is contained in:
Xubin Ren
2026-07-03 18:58:04 +08:00
committed by GitHub
parent 5283ceae85
commit 067e0c4a40
12 changed files with 661 additions and 101 deletions
+8 -29
View File
@@ -42,7 +42,7 @@
|---|---|
| Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) |
| Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) |
| Open the bundled browser UI after the CLI works | [WebUI](#-webui) |
| Open the bundled browser UI | [WebUI](#-webui) |
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [Chat Apps](./docs/chat-apps.md) |
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
@@ -237,7 +237,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -358,14 +358,13 @@ For another provider, the same config shape still applies:
**3. Open the WebUI**
If Quick Start enabled the WebSocket channel, start the gateway:
Start the browser workbench:
```bash
nanobot gateway
nanobot webui
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`.
For manual or terminal-only setup, test one CLI message:
@@ -399,33 +398,13 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
</p>
**1. Enable the WebSocket channel in `~/.nanobot/config.json`**
Merge this block into your existing config:
```json
{
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
**2. Start the gateway**
**Open it**
```bash
nanobot gateway
nanobot webui
```
Use `nanobot gateway --background` for a local background process you can manage later with `nanobot gateway status`, `logs`, `restart`, and `stop`.
**3. Open the WebUI**
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
+3 -3
View File
@@ -2,7 +2,7 @@
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools.
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis.
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
@@ -38,7 +38,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
| Next goal | Read | First check |
|---|---|---|
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
| Use nanobot in a browser | [`webui.md`](./webui.md) | Run `nanobot webui` and open the local browser workbench |
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
@@ -49,7 +49,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
| Goal | Read | Outcome |
|---|---|---|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
| Open the bundled browser UI | [`webui.md`](./webui.md) | `nanobot webui`, chat workspace, Apps, Skills, Automations, and settings |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
+25 -4
View File
@@ -9,10 +9,11 @@ Use this page when you know what you want to run and need the command shape. For
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
@@ -81,9 +82,22 @@ Default paths:
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## WebUI
| 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 --background` | Start or reuse a background gateway, then open the WebUI |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
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.
## Gateway
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. 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.
| Command | Description |
|---|---|
@@ -196,7 +210,13 @@ See [`openai-api.md`](./openai-api.md) for request examples.
nanobot status
```
Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance.
Shows the config path, workspace path, active model, and provider summary without calling a model.
| Command | Description |
|---|---|
| `nanobot status` | Inspect the default instance |
| `nanobot status --config <path>` | Inspect a specific config |
| `nanobot status --config <path> --workspace <path>` | Inspect a specific config with a workspace override |
## Channels
@@ -207,6 +227,7 @@ Shows the default config path, workspace path, active model, and provider summar
| `nanobot channels login <channel>` | Run interactive login for supported channels |
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
| `nanobot channels login <channel> --config <path>` | Use a specific config file |
| `nanobot plugins list --config <path>` | Show plugin/channel enabled state for a specific config |
Examples:
+2 -2
View File
@@ -41,7 +41,7 @@ If you are not sure where a setting belongs, start from the task you are trying
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
| Open the bundled WebUI | Optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot gateway`, then open `http://127.0.0.1:8765` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot webui` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
| Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) |
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
@@ -49,7 +49,7 @@ If you are not sure where a setting belongs, start from the task you are trying
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
## Environment Variables for Secrets
+7
View File
@@ -22,6 +22,9 @@ Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. w
**Run instances:**
```bash
# Check one instance before starting it
nanobot status --config ~/.nanobot-telegram/config.json
# Instance A - Telegram bot
nanobot gateway --config ~/.nanobot-telegram/config.json
@@ -42,6 +45,9 @@ To open a CLI session against one of these instances locally:
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
# Open the browser workbench for a specific instance
nanobot webui -c ~/.nanobot-telegram/config.json
# Optional one-off workspace override
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
@@ -94,6 +100,7 @@ The copied base config can keep using the same `modelPresets` and `agents.defaul
Start separate instances:
```bash
nanobot status --config ~/.nanobot-telegram/config.json
nanobot gateway --config ~/.nanobot-telegram/config.json
nanobot gateway --config ~/.nanobot-discord/config.json
```
+5 -5
View File
@@ -32,7 +32,7 @@ On Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, go straight to [Open the WebUI](#5-open-the-webui).
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, go straight to [Open the WebUI](#5-open-the-webui).
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -235,13 +235,13 @@ Read it like this:
## 5. Open the WebUI
If Quick Start enabled the WebSocket channel, start the gateway:
Start the browser workbench:
```bash
nanobot gateway
nanobot webui
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal.
## 6. Test One CLI Message
@@ -342,6 +342,6 @@ nanobot plugins enable whatsapp
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. |
| WebUI does not open | Run `nanobot webui`; the browser UI uses port `8765`, not the gateway health port `18790`. |
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
+8 -8
View File
@@ -315,10 +315,10 @@ It is normal for most providers to say `not set`. Only the provider you selected
Start the local browser UI:
```bash
nanobot gateway
nanobot webui
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard or the `tokenIssueSecret` value from your manual config.
This starts nanobot and opens `http://127.0.0.1:8765` in your browser. Leave the terminal open while you use the WebUI. Enter the WebUI password you set in the wizard if the browser asks for one.
Send this first message in the browser:
@@ -335,10 +335,10 @@ Hello! How can I help you today?
If `nanobot` is not found, run:
```bash
python -m nanobot gateway
python -m nanobot webui
```
Use `python3 -m nanobot gateway` or `py -m nanobot gateway` if that is the Python command that worked in step 2.
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
@@ -367,21 +367,21 @@ Skip these until the first local message works:
## Next Steps
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app.
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot webui` open whenever you use the WebUI. Chat apps use the same gateway service underneath.
### Open the Browser UI Again
Run:
```bash
nanobot gateway
nanobot webui
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser.
Leave that terminal open; the browser should open automatically.
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
If `nanobot` is not found, run `python -m nanobot webui`, `python3 -m nanobot webui`, or `py -m nanobot webui`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
### Connect a Chat App
+3 -2
View File
@@ -31,7 +31,7 @@ If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram,
## How to Read `nanobot status`
`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary.
`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary.
The output has this shape:
@@ -90,9 +90,10 @@ Default workspace path:
~/.nanobot/workspace/
```
`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances:
`nanobot status` reads the default config unless you pass explicit paths. Use the same `--config` and `--workspace` across status checks and runtime commands when debugging multiple instances:
```bash
nanobot status --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
```
+26 -22
View File
@@ -1,28 +1,44 @@
# WebUI
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
works, when you want a persistent chat workspace, visible agent activity,
workspace controls, Apps, Skills, settings, and Automations in one place.
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
the `webui/` source directory when you are changing the frontend itself.
## Open the WebUI
First confirm your provider and model can answer:
Use the launcher:
```bash
nanobot agent -m "Hello!"
nanobot webui
```
The local WebSocket channel is enabled by default because it serves the bundled
WebUI. To require a browser login password, merge `tokenIssueSecret` into your
existing `~/.nanobot/config.json`:
`nanobot webui` creates the config/workspace when needed, checks provider setup,
offers Quick Start when the model provider is not ready, enables the local
WebSocket channel after confirmation, starts the gateway, and opens the browser.
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.
Run it in the background when you do not want to keep a terminal open:
```bash
nanobot webui --background
```
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
Manual config still works. Set `tokenIssueSecret` when you intentionally expose
the WebUI beyond localhost or want a browser password:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "127.0.0.1",
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
@@ -30,20 +46,8 @@ existing `~/.nanobot/config.json`:
}
```
If you are new to JSON snippets, see
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
Start the gateway:
```bash
nanobot gateway
```
Leave the gateway running and open
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
WebSocket channel on port `8765` by default. The gateway health endpoint,
`18790` by default, is not the browser UI.
Enter `tokenIssueSecret` when the WebUI asks for a password.
The WebUI is served by the WebSocket channel on port `8765` by default. The
gateway health endpoint, `18790` by default, is not the browser UI.
## What It Is For
+373 -26
View File
@@ -788,6 +788,240 @@ def _warn_deprecated_config_keys(config_path: Path | None) -> None:
)
def _load_inspection_config(
config: str | None = None,
workspace: str | None = None,
) -> tuple[Path, Config]:
"""Load config for diagnostic commands without resolving secret env refs."""
from nanobot.config.loader import get_config_path, load_config, set_config_path
config_path = None
if config:
config_path = Path(config).expanduser().resolve(strict=False)
set_config_path(config_path)
console.print(f"[dim]Using config: {config_path}[/dim]")
display_path = config_path or get_config_path()
try:
loaded = load_config(config_path)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
_warn_deprecated_config_keys(display_path)
if workspace:
loaded.agents.defaults.workspace = workspace
return display_path, loaded
def _confirm_webui_action(message: str, *, yes: bool) -> None:
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
if yes:
return
try:
interactive = sys.stdin.isatty()
except Exception:
interactive = False
if not interactive:
console.print(
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
"`nanobot onboard --wizard`.[/red]"
)
raise typer.Exit(1)
if not typer.confirm(message, default=True):
console.print("[yellow]WebUI setup cancelled.[/yellow]")
raise typer.Exit(1)
def _resolve_webui_config_path(config: str | None) -> Path:
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
from nanobot.config.loader import get_config_path, set_config_path
if not config:
return get_config_path()
config_path = Path(config).expanduser().resolve(strict=False)
set_config_path(config_path)
console.print(f"[dim]Using config: {config_path}[/dim]")
return config_path
def _load_webui_setup_config(config_path: Path) -> Config:
"""Load config for first-run mutation without resolving env-var placeholders."""
from nanobot.config.loader import load_config
try:
return load_config(config_path)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
def _provider_setup_error(config: Config) -> str | None:
"""Return the provider setup error, or None when the current model can start."""
from nanobot.config.loader import resolve_config_env_vars
from nanobot.providers.factory import build_provider_snapshot
try:
build_provider_snapshot(resolve_config_env_vars(config.model_copy(deep=True)))
except ValueError as exc:
return str(exc)
return None
def _webui_config_dict(config: Config) -> dict[str, Any]:
"""Return the current WebSocket config as a mutable alias-key dictionary."""
from nanobot.channels.websocket import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
return model.model_dump(by_alias=True, exclude_none=True)
def _host_for_local_browser(host: str) -> str:
"""Map bind hosts to a browser-openable local host."""
if host in {"0.0.0.0", ""}:
return "127.0.0.1"
if host == "::":
return "[::1]"
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _webui_browser_url(config: Config) -> str:
ws_cfg = _webui_config_dict(config)
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
port = int(ws_cfg.get("port") or 8765)
return f"http://{host}:{port}"
def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> bool:
"""Enable the local WebUI channel with safe localhost defaults."""
from nanobot.channels.websocket import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
changed = False
needs_enable = not model.enabled
needs_port = port is not None and model.port != port
if not needs_enable and not needs_port:
return False
target_port = port if port is not None else model.port
console.print()
console.print("[bold]Local WebUI setup[/bold]")
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
console.print(" Auth: localhost bootstrap issues short-lived WebSocket tokens")
console.print(
" LAN access requires an explicit host change plus a WebUI password in config."
)
_confirm_webui_action("Enable the local WebUI channel in this config?", yes=yes)
if not model.enabled:
model.enabled = True
changed = True
if model.host != "127.0.0.1":
model.host = "127.0.0.1"
changed = True
if port is not None and model.port != port:
model.port = port
changed = True
if not model.websocket_requires_token:
model.websocket_requires_token = True
changed = True
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
return changed
def _warn_webui_bind_scope(config: Config) -> None:
ws_cfg = _webui_config_dict(config)
host = str(ws_cfg.get("host") or "127.0.0.1")
if host in {"127.0.0.1", "localhost", "::1"}:
return
console.print(
"[yellow]Warning: WebUI is configured to bind outside localhost. "
"Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
)
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
"""Best-effort wait for the WebUI listener before opening a browser."""
import socket
import time
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
"""Open the WebUI in the user's default browser, with a copyable fallback."""
import webbrowser
if wait:
_wait_for_webui(url)
try:
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]")
except Exception as exc:
console.print(f"[yellow]Could not open browser ({exc}); visit {url}[/yellow]")
def _gateway_instance_command(
subcommand: str,
*,
config_path: Path,
workspace: str | None,
) -> str:
"""Return a copyable gateway command for the same config/workspace instance."""
import shlex
parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
if workspace:
workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
parts.extend(["--workspace", workspace_path])
return " ".join(shlex.quote(part) for part in parts)
def _run_quick_start_for_webui(config: Config, *, yes: bool) -> Config:
"""Offer the existing Quick Start flow when provider setup is missing."""
if yes:
console.print(
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
"provider credentials. Run `nanobot webui` interactively or "
"`nanobot onboard --wizard`.[/red]"
)
raise typer.Exit(1)
console.print()
console.print("[yellow]Model provider setup is not ready.[/yellow]")
console.print("Quick Start will ask for provider, API key/base URL, model, and WebUI password.")
_confirm_webui_action("Run Quick Start now?", yes=False)
from nanobot.cli.onboard import run_quick_start_onboard
try:
result = run_quick_start_onboard(config)
except RuntimeError as exc:
console.print(f"[red]Error: {exc}[/red]")
console.print("[yellow]Run `nanobot onboard --wizard` after installing wizard dependencies.[/yellow]")
raise typer.Exit(1) from exc
if not result.should_save:
console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
raise typer.Exit(1)
return result.config
def _migrate_cron_store(config: "Config") -> None:
"""One-time migration: move legacy global cron store into the workspace."""
from nanobot.config.paths import get_cron_dir
@@ -913,6 +1147,120 @@ def serve(
web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
# ============================================================================
# WebUI Launcher
# ============================================================================
@app.command()
def webui(
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
gateway_port: int | None = typer.Option(
None,
"--gateway-port",
help="Gateway health port",
),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
background: bool = typer.Option(False, "--background", help="Start gateway in the background"),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Apply safe local WebUI defaults without prompting",
),
) -> None:
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
from nanobot.config.loader import save_config
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
config_path = _resolve_webui_config_path(config)
created_config = not config_path.exists()
if created_config:
console.print(f"[yellow]No config found at {config_path}.[/yellow]")
_confirm_webui_action("Create a nanobot config and workspace now?", yes=yes)
setup_config = _load_webui_setup_config(config_path)
if workspace:
setup_config.agents.defaults.workspace = workspace
provider_error = _provider_setup_error(setup_config)
if provider_error:
console.print(f"[dim]Provider check: {provider_error}[/dim]")
setup_config = _run_quick_start_for_webui(setup_config, yes=yes)
if workspace:
setup_config.agents.defaults.workspace = workspace
try:
changed_webui = _ensure_local_webui_channel(setup_config, port=port, yes=yes)
_warn_webui_bind_scope(setup_config)
webui_url = _webui_browser_url(setup_config)
except ValueError as exc:
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
raise typer.Exit(1) from exc
if created_config or provider_error or changed_webui or workspace:
save_config(setup_config, config_path)
console.print(f"[green]✓[/green] Saved config: {config_path}")
workspace_path = get_workspace_path(setup_config.workspace_path)
workspace_path.mkdir(parents=True, exist_ok=True)
sync_workspace_templates(workspace_path)
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
console.print()
console.print(f"WebUI: [cyan]{webui_url}[/cyan]")
console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]")
if no_open:
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
if background:
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
result = runtime.start_background(
GatewayStartOptions(
port=effective_gateway_port,
workspace=workspace_arg,
config_path=config_arg,
)
)
if not result.ok and result.message != "gateway_already_running":
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
console.print(f"Logs: {result.status.log_path}")
raise typer.Exit(1)
if result.ok:
console.print("[green]Gateway started in the background.[/green]")
else:
console.print("[yellow]Gateway is already running in the background.[/yellow]")
console.print(
"Manage this instance: "
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
)
console.print(
"View logs: "
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url)
return
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
)
# ============================================================================
# Gateway / Server
# ============================================================================
@@ -1294,11 +1642,17 @@ def _run_gateway(
if not open_browser_url:
return
import webbrowser
from urllib.parse import urlparse
parsed = urlparse(open_browser_url)
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
target_port = parsed.port or port
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
reader, writer = await asyncio.open_connection(
config.gateway.host or "127.0.0.1", port
target_host,
target_port,
)
writer.close()
with suppress(Exception):
@@ -1684,24 +2038,19 @@ app.add_typer(channels_app, name="channels")
@channels_app.command("status")
def channels_status(
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Show channel status."""
from nanobot.channels.registry import discover_all
from nanobot.config.loader import load_config, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
config = load_config(resolved_config_path)
_, loaded = _load_inspection_config(config=config)
table = Table(title="Channel Status")
table.add_column("Channel", style="cyan")
table.add_column("Enabled")
for name, cls in sorted(discover_all().items()):
section = getattr(config.channels, name, None)
section = getattr(loaded.channels, name, None)
if section is None:
enabled = False
elif isinstance(section, dict):
@@ -1720,18 +2069,13 @@ def channels_status(
def channels_login(
channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"),
force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Authenticate with a channel via QR code or other interactive login."""
from nanobot.channels.registry import discover_all
from nanobot.config.loader import load_config, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
config = load_config(resolved_config_path)
channel_cfg = getattr(config.channels, channel_name, None) or {}
_, loaded = _load_inspection_config(config=config)
channel_cfg = getattr(loaded.channels, channel_name, None) or {}
# Validate channel exists
all_channels = discover_all()
@@ -1837,28 +2181,31 @@ def plugins_disable(
@app.command()
def status():
def status(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
):
"""Show nanobot status."""
from nanobot.config.loader import get_config_path, load_config
config_path = get_config_path()
config = load_config()
workspace = config.workspace_path
config_path, loaded = _load_inspection_config(config=config, workspace=workspace)
workspace_path = loaded.workspace_path
console.print(f"{__logo__} nanobot Status\n")
console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
console.print(
f"Workspace: {workspace_path} "
f"{'[green]✓[/green]' if workspace_path.exists() else '[red]✗[/red]'}"
)
if config_path.exists():
from nanobot.providers.registry import PROVIDERS
_model, _preset_tag = _model_display(config)
_model, _preset_tag = _model_display(loaded)
console.print(f"Model: {_model}{_preset_tag}")
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
p = getattr(loaded.providers, spec.name, None)
if p is None:
continue
if spec.is_oauth:
+9
View File
@@ -1980,3 +1980,12 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
return OnboardResult(config=original_config, should_save=False)
if answer == "[A] Advanced Settings":
_configure_advanced_settings(config)
def run_quick_start_onboard(initial_config: Config) -> OnboardResult:
"""Run the compact provider + local WebUI setup path directly."""
_get_questionary()
draft = initial_config.model_copy(deep=True)
if _configure_quick_start(draft):
return OnboardResult(config=draft, should_save=True)
return OnboardResult(config=initial_config, should_save=False)
+192
View File
@@ -264,6 +264,39 @@ def test_onboard_help_shows_workspace_and_config_options():
assert "--dir" not in stripped_output
def test_status_help_shows_workspace_and_config_options():
result = runner.invoke(app, ["status", "--help"])
assert result.exit_code == 0
stripped_output = _strip_ansi(result.stdout)
assert "--workspace" in stripped_output
assert "-w" in stripped_output
assert "--config" in stripped_output
assert "-c" in stripped_output
def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
config_path = tmp_path / "instance" / "config.json"
config_workspace = tmp_path / "config-workspace"
override_workspace = tmp_path / "override-workspace"
config = Config()
config.agents.defaults.workspace = str(config_workspace)
config_path.parent.mkdir(parents=True)
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)))
result = runner.invoke(
app,
["status", "--config", str(config_path), "--workspace", str(override_workspace)],
)
assert result.exit_code == 0
stripped_output = _strip_ansi(result.stdout)
compact_output = stripped_output.replace("\n", "")
assert str(config_path.resolve(strict=False)) in compact_output
assert str(override_workspace) in compact_output
assert str(config_workspace) not in compact_output
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
config_file, workspace_dir, _ = mock_paths
@@ -352,6 +385,33 @@ def test_config_dump_excludes_oauth_provider_blocks():
assert "githubCopilot" not in providers
def test_plugins_list_uses_explicit_config(monkeypatch, tmp_path: Path):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"channels": {"example": {"enabled": True}}}),
encoding="utf-8",
)
monkeypatch.setattr(
"nanobot.channels.registry.discover_channel_names",
lambda: ["example"],
)
monkeypatch.setattr(
"nanobot.channels.registry.discover_plugins",
lambda: {},
)
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {},
)
result = runner.invoke(app, ["plugins", "list", "--config", str(config_path)])
assert result.exit_code == 0
stripped_output = _strip_ansi(result.stdout)
assert "example" in stripped_output
assert "yes" in stripped_output
def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch):
token_path = tmp_path / "auth" / "codex.json"
lock_path = token_path.with_suffix(".lock")
@@ -1493,6 +1553,15 @@ def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapsho
)
def _patch_webui_provider_ready(monkeypatch) -> None:
provider = _fake_provider()
def _snapshot(config: Config, **_kwargs) -> ProviderSnapshot:
return _test_provider_snapshot(provider, config)
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -1540,6 +1609,129 @@ def _patch_cli_command_runtime(
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
def test_webui_yes_creates_config_and_enables_local_websocket(
monkeypatch,
tmp_path: Path,
) -> None:
config_file = tmp_path / "instance" / "config.json"
workspace = tmp_path / "workspace"
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr(
"nanobot.cli.commands.sync_workspace_templates",
lambda path: seen.__setitem__("templates", path),
)
def _fake_run_gateway(config: Config, **kwargs) -> None:
seen["gateway_config"] = config
seen["gateway_kwargs"] = kwargs
monkeypatch.setattr("nanobot.cli.commands._run_gateway", _fake_run_gateway)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--port",
"8899",
"--gateway-port",
"18888",
"--yes",
"--no-open",
],
)
assert result.exit_code == 0
data = json.loads(config_file.read_text(encoding="utf-8"))
websocket = data["channels"]["websocket"]
assert websocket["enabled"] is True
assert websocket["host"] == "127.0.0.1"
assert websocket["port"] == 8899
assert websocket["websocketRequiresToken"] is True
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
def _missing_provider(_config: Config, **_kwargs) -> ProviderSnapshot:
raise ValueError("No API key configured for provider 'custom'.")
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _missing_provider)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
assert "provider/model setup is incomplete" in result.stdout
assert not config_file.exists()
def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path: Path) -> None:
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
seen["start_options"] = options
status = GatewayStatus(
running=True,
pid=123,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
port=options.port,
reason="running",
)
return RuntimeResult(True, "gateway_started_background", status)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.commands._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--background",
"--gateway-port",
"18889",
"--yes",
],
)
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "nanobot gateway status --config" in compact_output
assert "--workspace" in compact_output
options = seen["start_options"]
assert isinstance(options, GatewayStartOptions)
assert options.port == 18889
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
assert seen["opened_url"] == "http://127.0.0.1:8765"
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
pytest.importorskip("aiohttp")