Add optional Nanobot plugin controls (#4396)

* feat: add optional nanobot features

* test: update azure install hint expectation

* fix: validate optional feature extras

maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command.

* fix: bound optional feature installs

maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely.

* feat: slim optional channel dependencies

* fix: log optional install commands

* fix(webui): gate remote feature installs

* docs: clarify webhook plugin example

* fix(webui): harden optional feature installs

* fix: install optional deps without package fallback

* fix(cli): refine plugin feature controls

* fix(webui): count enabled nanobot features

* fix(webui): allow slow feature install routes

* fix(webui): allow disabling websocket channel

* fix(plugins): simplify optional feature controls

* fix(webui): polish apps catalog states

* fix(webui): confirm nanobot support installs

* fix(webui): polish nanobot install dialog

* fix(webui): suppress empty websocket handshakes

* fix(webui): clarify apps plugin summary

* fix(webui): localize workspace access copy

* fix(plugins): polish optional feature controls (#4691)

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
chengyongru
2026-07-03 18:17:52 +08:00
committed by GitHub
co-authored by Xubin Ren
parent 00cc0da530
commit 5283ceae85
61 changed files with 3061 additions and 258 deletions
+6 -6
View File
@@ -155,7 +155,7 @@ The key (`webhook`) becomes the config section name. The value points to your `B
```bash ```bash
python -m pip install -e . python -m pip install -e .
nanobot plugins list # verify "Webhook" shows as "plugin" nanobot plugins list # verify the installed example plugin appears as "webhook"
nanobot onboard # auto-adds default config for detected plugins nanobot onboard # auto-adds default config for detected plugins
``` ```
@@ -552,7 +552,7 @@ If not overridden, the base class returns `{"enabled": false}`.
git clone https://github.com/you/nanobot-channel-webhook git clone https://github.com/you/nanobot-channel-webhook
cd nanobot-channel-webhook cd nanobot-channel-webhook
python -m pip install -e . python -m pip install -e .
nanobot plugins list # should show "Webhook" as "plugin" nanobot plugins list # should show the installed example plugin as "webhook"
nanobot gateway # test end-to-end nanobot gateway # test end-to-end
``` ```
@@ -561,8 +561,8 @@ nanobot gateway # test end-to-end
```bash ```bash
$ nanobot plugins list $ nanobot plugins list
Name Source Enabled Name Type Enabled
telegram builtin yes discord channel no
discord builtin no telegram channel yes
webhook plugin yes webhook channel yes
``` ```
+64 -12
View File
@@ -12,6 +12,21 @@ If that fails, fix installation, config, provider, or model setup first with [`q
Most examples below are snippets to merge into `~/.nanobot/config.json`. Most examples below are snippets to merge into `~/.nanobot/config.json`.
> [!NOTE]
> If you are upgrading from a version where chat app SDKs were installed by default,
> install the channel extra in the same Python environment before enabling or
> restarting that channel:
>
> ```bash
> nanobot plugins enable <channel>
> ```
>
> Replace `<channel>` with names such as `telegram`, `slack`, `feishu`,
> `dingtalk`, `matrix`, `qq`, `napcat`, `weixin`, `wecom`, or `msteams`.
> To turn a channel off later, run `nanobot plugins disable <channel>`.
> nanobot keeps the saved settings, but stops loading that channel after the
> next restart.
## Common Setup Pattern ## Common Setup Pattern
Every chat app uses the same shape: Every chat app uses the same shape:
@@ -59,6 +74,12 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
<details> <details>
<summary><b>Telegram</b></summary> <summary><b>Telegram</b></summary>
**Install the optional channel dependency**
```bash
nanobot plugins enable telegram
```
**1. Create a bot** **1. Create a bot**
- Open Telegram, search `@BotFather` - Open Telegram, search `@BotFather`
- Send `/newbot`, follow prompts - Send `/newbot`, follow prompts
@@ -123,6 +144,14 @@ Telegram uses long polling by default. To receive updates through a webhook, exp
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
**Install the optional realtime dependency**
```bash
nanobot plugins enable mochat
```
Without this extra, Mochat still works through HTTP polling.
**1. Ask nanobot to set up Mochat for you** **1. Ask nanobot to set up Mochat for you**
Simply send this message to nanobot (replace `xxx@xxx` with your real email): Simply send this message to nanobot (replace `xxx@xxx` with your real email):
@@ -233,14 +262,14 @@ nanobot gateway
<details> <details>
<summary><b>Matrix (Element)</b></summary> <summary><b>Matrix (Element)</b></summary>
Install Matrix dependencies first: Enable Matrix support first:
```bash ```bash
python -m pip install "nanobot-ai[matrix]" nanobot plugins enable matrix
``` ```
> [!NOTE] > [!NOTE]
> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel and is skipped by the `matrix` extra on `sys_platform == 'win32'`. The command above will still succeed on Windows but without `matrix-nio` installed, so enabling the Matrix channel will fail at startup. Use macOS, Linux, or WSL2. > Matrix encryption is disabled by default on Windows because `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel. Use macOS, Linux, or WSL2 if you need Matrix E2EE.
**1. Create/choose a Matrix account** **1. Create/choose a Matrix account**
@@ -306,9 +335,7 @@ nanobot gateway
Requires the WhatsApp optional dependencies: Requires the WhatsApp optional dependencies:
```bash ```bash
pip install "nanobot-ai[whatsapp]" nanobot plugins enable whatsapp
# Source checkout:
python -m pip install -e ".[whatsapp]"
``` ```
**1. Link device with QR** **1. Link device with QR**
@@ -384,6 +411,7 @@ Uses **WebSocket** long connection — no public IP required.
**Quick setup: QR login** **Quick setup: QR login**
```bash ```bash
nanobot plugins enable feishu
nanobot channels login feishu nanobot channels login feishu
# Use --force to create/sign in with a new bot # Use --force to create/sign in with a new bot
``` ```
@@ -454,6 +482,12 @@ nanobot gateway
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**. Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
**Install the optional channel dependency**
```bash
nanobot plugins enable qq
```
**1. Register & create bot** **1. Register & create bot**
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise) - Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
- Create a new bot application - Create a new bot application
@@ -506,6 +540,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
- Copy the forward websocket server's token - Copy the forward websocket server's token
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts - (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
**Install the optional channel dependency**
```bash
nanobot plugins enable napcat
```
**2. Configure** **2. Configure**
```json ```json
@@ -543,6 +583,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
Uses **Stream Mode** — no public IP required. Uses **Stream Mode** — no public IP required.
**Install the optional channel dependency**
```bash
nanobot plugins enable dingtalk
```
**1. Create a DingTalk bot** **1. Create a DingTalk bot**
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/) - Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
- Create a new app -> Add **Robot** capability - Create a new app -> Add **Robot** capability
@@ -585,6 +631,12 @@ nanobot gateway
Uses **Socket Mode** — no public URL required. Uses **Socket Mode** — no public URL required.
**Install the optional channel dependency**
```bash
nanobot plugins enable slack
```
**1. Create a Slack app** **1. Create a Slack app**
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch" - Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
- Pick a name and select your workspace - Pick a name and select your workspace
@@ -695,10 +747,10 @@ nanobot gateway
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required. Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
**1. Install with WeChat support** **1. Enable WeChat support**
```bash ```bash
python -m pip install "nanobot-ai[weixin]" nanobot plugins enable weixin
``` ```
**2. Configure** **2. Configure**
@@ -747,10 +799,10 @@ nanobot gateway
> >
> Uses **WebSocket** long connection — no public IP required. > Uses **WebSocket** long connection — no public IP required.
**1. Install the optional dependency** **1. Enable WeCom support**
```bash ```bash
python -m pip install "nanobot-ai[wecom]" nanobot plugins enable wecom
``` ```
**2. Create a WeCom AI Bot** **2. Create a WeCom AI Bot**
@@ -786,10 +838,10 @@ nanobot gateway
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence. > Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy. > Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
**1. Install the optional dependency** **1. Enable Microsoft Teams support**
```bash ```bash
python -m pip install "nanobot-ai[msteams]" nanobot plugins enable msteams
``` ```
**2. Create a Teams / Azure bot app registration** **2. Create a Teams / Azure bot app registration**
+18
View File
@@ -16,6 +16,7 @@ Use this page when you know what you want to run and need the command shape. For
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session | | 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` | | 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` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat | | Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot | | Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
@@ -217,6 +218,23 @@ nanobot channels status
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup. See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
## Optional Features
Use these commands when you want nanobot to add or remove a built-in capability
without hand-editing JSON. Enabling may install the support package first.
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
saved settings and turns the channel off.
| Command | Description |
|---|---|
| `nanobot plugins list` | Show available channels and optional capabilities |
| `nanobot plugins enable <name>` | Install missing support and enable the feature or channel |
| `nanobot plugins enable <name> --logs` | Show package install logs while enabling |
| `nanobot plugins disable <channel>` | Turn off a channel without deleting its saved settings |
| `nanobot plugins list --config <path>` | Read a specific config file |
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
## Provider OAuth ## Provider OAuth
| Command | Description | | Command | Description |
+16 -4
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) | | 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) | | 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) | | 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 | `channels.websocket.enabled`, 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 | 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) |
| 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) | | 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 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) | | 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) |
@@ -380,7 +380,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA
Install the optional dependency: Install the optional dependency:
```bash ```bash
python -m pip install 'nanobot-ai[azure]' nanobot plugins enable azure
``` ```
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds: `DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
@@ -395,7 +395,7 @@ python -m pip install 'nanobot-ai[azure]'
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request. The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `python -m pip install 'nanobot-ai[azure]'`. > `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `nanobot plugins enable azure`.
</details> </details>
@@ -439,6 +439,17 @@ Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock m
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface. This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
Install Bedrock support first:
```bash
nanobot plugins enable bedrock
```
> [!NOTE]
> If you configured Bedrock before `boto3` became an optional dependency, run
> `nanobot plugins enable bedrock` after upgrading. Otherwise the provider will
> fail when it first tries to create a Bedrock client.
**1. Configure credentials** **1. Configure credentials**
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs: Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
@@ -1511,7 +1522,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. | | `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. | | `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Install parser dependencies with `nanobot plugins enable documents`. If you used document parsing before those parsers became optional, run that command after upgrading. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`. `channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
@@ -1906,6 +1917,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. | | `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. | | `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
+1 -2
View File
@@ -38,14 +38,13 @@ Restart the deployed process after editing `config.json`. Long-running processes
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher. > Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
> [!IMPORTANT] > [!IMPORTANT]
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret: > The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with a secret:
> >
> ```json > ```json
> { > {
> "gateway": { "host": "0.0.0.0" }, > "gateway": { "host": "0.0.0.0" },
> "channels": { > "channels": {
> "websocket": { > "websocket": {
> "enabled": true,
> "host": "0.0.0.0", > "host": "0.0.0.0",
> "port": 8765, > "port": 8765,
> "tokenIssueSecret": "your-secret-here" > "tokenIssueSecret": "your-secret-here"
+1 -1
View File
@@ -3,7 +3,7 @@
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash ```bash
python -m pip install "nanobot-ai[api]" nanobot plugins enable api
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
nanobot serve nanobot serve
``` ```
+1 -1
View File
@@ -329,7 +329,7 @@ nanobot --version
If you use WhatsApp from a source checkout, keep the optional dependencies installed: If you use WhatsApp from a source checkout, keep the optional dependencies installed:
```bash ```bash
python -m pip install -e ".[whatsapp]" nanobot plugins enable whatsapp
``` ```
## First-Run Troubleshooting ## First-Run Troubleshooting
+2 -4
View File
@@ -181,11 +181,11 @@ For the first setup, choose `[Q] Quick Start`. It configures the recommended loc
4. Paste your API key if the wizard asks for one. 4. Paste your API key if the wizard asks for one.
5. Paste the provider base URL if the wizard asks for one. 5. Paste the provider base URL if the wizard asks for one.
6. Paste a model ID that provider can run. 6. Paste a model ID that provider can run.
7. Confirm that Quick Start should enable the WebSocket channel for the local WebUI. 7. Confirm that Quick Start should configure the local WebUI.
8. Set the WebUI password when prompted. 8. Set the WebUI password when prompted.
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. 9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
The recommended path enables `channels.websocket` for the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run. The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`. If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
@@ -225,7 +225,6 @@ Merge them into one object:
}, },
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password", "tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true "websocketRequiresToken": true
} }
@@ -288,7 +287,6 @@ If this is a brand-new install and you have not configured anything else yet, re
}, },
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password", "tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true "websocketRequiresToken": true
} }
+3 -8
View File
@@ -16,13 +16,13 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
### 1. Configure ### 1. Configure
Add to `config.json` under `channels.websocket`: The WebSocket channel is enabled by default. Add only the fields you want to
override under `channels.websocket`:
```json ```json
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
"path": "/", "path": "/",
@@ -208,7 +208,7 @@ All fields go under `channels.websocket` in `config.json`.
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable the WebSocket server. | | `enabled` | bool | `true` | Enable the WebSocket server. Set to `false` only when you intentionally do not want the bundled WebUI/WebSocket surface. |
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. | | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
| `port` | int | `8765` | Listen port. | | `port` | int | `8765` | Listen port. |
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). | | `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
@@ -272,7 +272,6 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"port": 8765, "port": 8765,
"path": "/ws", "path": "/ws",
"tokenIssuePath": "/auth/token", "tokenIssuePath": "/auth/token",
@@ -367,7 +366,6 @@ Outbound `message` events may include a `media` field containing local filesyste
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"websocketRequiresToken": false, "websocketRequiresToken": false,
@@ -384,7 +382,6 @@ Outbound `message` events may include a `media` field containing local filesyste
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"token": "my-shared-secret", "token": "my-shared-secret",
"allowFrom": ["alice", "bob"] "allowFrom": ["alice", "bob"]
} }
@@ -400,7 +397,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"path": "/ws", "path": "/ws",
@@ -421,7 +417,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"path": "/chat/ws", "path": "/chat/ws",
"allowFrom": ["*"] "allowFrom": ["*"]
} }
+42 -8
View File
@@ -15,14 +15,14 @@ First confirm your provider and model can answer:
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`. The local WebSocket channel is enabled by default because it serves the bundled
Set `tokenIssueSecret` to the password you will enter in the WebUI login form: WebUI. To require a browser login password, merge `tokenIssueSecret` into your
existing `~/.nanobot/config.json`:
```json ```json
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password", "tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true "websocketRequiresToken": true
} }
@@ -94,9 +94,20 @@ for provider setup and output behavior.
## Apps ## Apps
Open Apps from the sidebar or settings navigation to manage integrations that Open Apps from the sidebar or settings navigation to manage integrations that
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs nanobot can call from a chat. Nanobot features can enable built-in channels and
on your machine; they do not modify the native apps themselves. MCP presets add optional capabilities such as `bedrock` or `documents`. CLI Apps install local
predefined MCP server configurations. adapters that nanobot runs on your machine; they do not modify the native apps
themselves. MCP presets add predefined MCP server configurations.
Enabling a Nanobot feature may install Python packages into the environment
running nanobot. By default, the WebUI can install missing packages only when
you open it on the same machine as nanobot. If you open the WebUI from another
device, a domain name, a tunnel, or a reverse proxy, package install is blocked
unless you explicitly allow it with `tools.webuiAllowRemotePackageInstall`.
Optional feature installs use your existing pip download settings. If PyPI is
slow or unavailable from your network, configure pip or set `PIP_INDEX_URL`
before starting nanobot.
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
@@ -187,7 +198,6 @@ channel to all interfaces and set a token or token issue secret:
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"tokenIssueSecret": "your-secret-here" "tokenIssueSecret": "your-secret-here"
@@ -201,12 +211,36 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`http://<your-ip>:8765` from the other device and enter the secret in the login `http://<your-ip>:8765` from the other device and enter the secret in the login
form. form.
Remote WebUI clients can view Apps and toggle already-installed features with a
valid token, but they cannot install missing Python packages by default. To allow
trusted remote admins to install optional feature dependencies from the WebUI,
opt in explicitly:
```json
{
"tools": {
"webuiAllowRemotePackageInstall": true
}
}
```
Use this only for a private deployment where every authenticated WebUI user is
trusted to change the Python environment that nanobot runs in. If you publish
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
as remote access and leave package installs disabled unless that is intentional.
Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`.
Leave remote package installs disabled when the WebUI is exposed beyond a
private, trusted network.
## Troubleshooting ## Troubleshooting
If the page does not open, check these in order: If the page does not open, check these in order:
1. `nanobot agent -m "Hello!"` works in the same Python environment. 1. `nanobot agent -m "Hello!"` works in the same Python environment.
2. The WebSocket channel is enabled in `~/.nanobot/config.json`. 2. `~/.nanobot/config.json` does not explicitly set `channels.websocket.enabled` to `false`.
3. `nanobot gateway` is still running. 3. `nanobot gateway` is still running.
4. You are opening port `8765`, not the gateway health port. 4. You are opening port `8765`, not the gateway health port.
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret. 5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
+9 -1
View File
@@ -17,6 +17,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir from nanobot.config.paths import get_runtime_subdir
@@ -941,12 +942,19 @@ class CliAppManager:
raise CliAppError("this CLI app uses an unsupported install strategy") raise CliAppError("this CLI app uses an unsupported install strategy")
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
return subprocess.run( command = subprocess.list2cmdline(argv)
logger.info("CLI Apps: running {}", command)
result = subprocess.run(
argv, argv,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
) )
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
output = (result.stderr or result.stdout or "").strip()
if output:
logger.info("CLI Apps command output:\n{}", _truncate(output, 4000))
return result
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]: def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
entry_point = str(app.get("entry_point") or "") entry_point = str(app.get("entry_point") or "")
+1 -1
View File
@@ -217,7 +217,7 @@ class DingTalkChannel(BaseChannel):
try: try:
if not DINGTALK_AVAILABLE: if not DINGTALK_AVAILABLE:
self.logger.error( self.logger.error(
"Stream SDK not installed. Run: pip install dingtalk-stream" "Stream SDK not installed. Run: nanobot plugins enable dingtalk"
) )
return return
+1 -1
View File
@@ -405,7 +405,7 @@ class DiscordChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Discord client.""" """Start the Discord client."""
if not DISCORD_AVAILABLE: if not DISCORD_AVAILABLE:
self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") self.logger.error("discord.py not installed. Run: nanobot plugins enable discord")
return return
if not self.config.token: if not self.config.token:
+1 -1
View File
@@ -672,7 +672,7 @@ class FeishuChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection.""" """Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE: if not FEISHU_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install lark-oapi") self.logger.error("SDK not installed. Run: nanobot plugins enable feishu")
return return
if not self.config.app_id or not self.config.app_secret: if not self.config.app_id or not self.config.app_secret:
+35 -8
View File
@@ -25,6 +25,7 @@ from nanobot.bus.outbound_events import (
) )
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
@@ -51,6 +52,21 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
"show_reasoning": "showReasoning", "show_reasoning": "showReasoning",
} }
def _default_channel_config(name: str) -> dict[str, Any] | None:
if name != "websocket":
return None
from nanobot.channels.websocket import WebSocketChannel
return WebSocketChannel.default_config()
def _channel_config_enabled(name: str, section: Any) -> bool:
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
return bool(getattr(section, "enabled", default_enabled))
class ChannelManager: class ChannelManager:
""" """
Manages chat channels and coordinates message routing. Manages chat channels and coordinates message routing.
@@ -105,21 +121,32 @@ class ChannelManager:
candidate_names = set(names) candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {} extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys()) candidate_names.update(extra.keys())
default_sections: dict[str, Any] = {}
def section_for(name: str) -> Any:
section = getattr(self.config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
enabled_names: set[str] = set() enabled_names: set[str] = set()
for name in candidate_names: for name in candidate_names:
section = getattr(self.config.channels, name, None) section = section_for(name)
if section is None: if section is None:
continue continue
if ( if _channel_config_enabled(name, section):
section.get("enabled", False)
if isinstance(section, dict)
else getattr(section, "enabled", False)
):
enabled_names.add(name) enabled_names.add(name)
for name, cls in discover_enabled(enabled_names, _names=names).items(): for name, cls in discover_enabled(
section = getattr(self.config.channels, name, None) enabled_names,
_names=names,
warn_import_errors=True,
).items():
section = section_for(name)
if section is None: if section is None:
continue continue
try: try:
+3 -2
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import json import json
import mimetypes import mimetypes
import sys
import time import time
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
@@ -45,7 +46,7 @@ try:
from nio.exceptions import EncryptionError from nio.exceptions import EncryptionError
except ImportError as e: except ImportError as e:
raise ImportError( raise ImportError(
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]" "Matrix dependencies not installed. Run: nanobot plugins enable matrix"
) from e ) from e
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -200,7 +201,7 @@ class MatrixConfig(Base):
password: str = "" password: str = ""
access_token: str = "" access_token: str = ""
device_id: str = "" device_id: str = ""
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled") e2ee_enabled: bool = Field(default=sys.platform != "win32", alias="e2eeEnabled")
sas_verification: bool = Field(default=False, alias="sasVerification") sas_verification: bool = Field(default=False, alias="sasVerification")
sync_stop_grace_seconds: int = 2 sync_stop_grace_seconds: int = 2
max_media_bytes: int = 20 * 1024 * 1024 max_media_bytes: int = 20 * 1024 * 1024
+2 -2
View File
@@ -142,7 +142,7 @@ class MSTeamsChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Teams webhook listener.""" """Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE: if not MSTEAMS_AVAILABLE:
self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") self.logger.error("PyJWT not installed. Run: nanobot plugins enable msteams")
return return
if not self.config.app_id or not self.config.app_password: if not self.config.app_id or not self.config.app_password:
@@ -458,7 +458,7 @@ class MSTeamsChannel(BaseChannel):
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None: async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
"""Validate inbound Bot Framework bearer token.""" """Validate inbound Bot Framework bearer token."""
if not MSTEAMS_AVAILABLE: if not MSTEAMS_AVAILABLE:
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]") raise RuntimeError("PyJWT not installed. Run: nanobot plugins enable msteams")
if not auth_header.lower().startswith("bearer "): if not auth_header.lower().startswith("bearer "):
raise ValueError("missing bearer token") raise ValueError("missing bearer token")
+1 -1
View File
@@ -195,7 +195,7 @@ class QQChannel(BaseChannel):
"""Start the QQ bot with auto-reconnect loop.""" """Start the QQ bot with auto-reconnect loop."""
redirect_lib_logging("botpy", level="WARNING") redirect_lib_logging("botpy", level="WARNING")
if not QQ_AVAILABLE: if not QQ_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install qq-botpy") self.logger.error("SDK not installed. Run: nanobot plugins enable qq")
return return
if not self.config.app_id or not self.config.secret: if not self.config.app_id or not self.config.secret:
+8 -2
View File
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
_INTERNAL = frozenset({"base", "manager", "registry"}) _INTERNAL = frozenset({"base", "manager", "registry"})
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
def discover_channel_names() -> list[str]: def discover_channel_names() -> list[str]:
@@ -57,6 +58,7 @@ def discover_enabled(
*, *,
_names: list[str] | None = None, _names: list[str] | None = None,
_include_all_external: bool = False, _include_all_external: bool = False,
warn_import_errors: bool = False,
) -> dict[str, type[BaseChannel]]: ) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*. """Return channels whose module names are in *enabled_names*.
@@ -72,10 +74,14 @@ def discover_enabled(
try: try:
result[modname] = load_channel_class(modname) result[modname] = load_channel_class(modname)
except ImportError as e: except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e) message = "Enabled built-in channel '{}' is not available: {}"
if warn_import_errors:
logger.warning(message, modname, e)
else:
logger.debug(message, modname, e)
external = discover_plugins(None if _include_all_external else enabled_names) external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(result) shadowed = set(external) & set(names)
if shadowed: if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed) logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
if _include_all_external: if _include_all_external:
+6 -1
View File
@@ -59,6 +59,9 @@ from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.transcription_ws import webui_transcription_event from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger from nanobot.webui.websocket_logging import websockets_server_logger
# Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
class WebSocketConfig(Base): class WebSocketConfig(Base):
"""WebSocket server channel configuration. """WebSocket server channel configuration.
@@ -80,7 +83,7 @@ class WebSocketConfig(Base):
shared filesystem or an HTTP file server to access these files. shared filesystem or an HTTP file server to access these files.
""" """
enabled: bool = False enabled: bool = True
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 8765 port: int = 8765
unix_socket_path: str = "" unix_socket_path: str = ""
@@ -482,6 +485,7 @@ class WebSocketChannel(BaseChannel):
handler, handler,
socket_path, socket_path,
process_request=process_request, process_request=process_request,
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
max_size=self.config.max_message_bytes, max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s, ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s, ping_timeout=self.config.ping_timeout_s,
@@ -495,6 +499,7 @@ class WebSocketChannel(BaseChannel):
self.config.host, self.config.host,
self.config.port, self.config.port,
process_request=process_request, process_request=process_request,
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
max_size=self.config.max_message_bytes, max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s, ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s, ping_timeout=self.config.ping_timeout_s,
+1 -1
View File
@@ -103,7 +103,7 @@ class WecomChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection.""" """Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE: if not WECOM_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]") self.logger.error("SDK not installed. Run: nanobot plugins enable wecom")
return return
if not self.config.bot_id or not self.config.secret: if not self.config.bot_id or not self.config.secret:
+1 -1
View File
@@ -72,7 +72,7 @@ def _load_neonize() -> _NeonizeAPI:
from neonize.utils.jid import build_jid from neonize.utils.jid import build_jid
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"' "WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
) from exc ) from exc
_NEONIZE_API = _NeonizeAPI( _NEONIZE_API = _NeonizeAPI(
+105 -40
View File
@@ -38,6 +38,14 @@ _log_handler_id = logger.add(
filter=lambda record: record["extra"].setdefault("channel", "-") or True, filter=lambda record: record["extra"].setdefault("channel", "-") or True,
) )
def _set_nanobot_logs(enabled: bool) -> None:
if enabled:
logger.enable("nanobot")
else:
logger.disable("nanobot")
from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402 from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402
from prompt_toolkit.application import run_in_terminal # noqa: E402 from prompt_toolkit.application import run_in_terminal # noqa: E402
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402 from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
@@ -45,10 +53,12 @@ from prompt_toolkit.history import FileHistory # noqa: E402
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402 from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
from rich.console import Console # noqa: E402 from rich.console import Console # noqa: E402
from rich.markdown import Markdown # noqa: E402 from rich.markdown import Markdown # noqa: E402
from rich.markup import escape # noqa: E402
from rich.table import Table # noqa: E402 from rich.table import Table # noqa: E402
from rich.text import Text # noqa: E402 from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402 from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent, ProgressEvent,
@@ -686,6 +696,33 @@ def _onboard_plugins(config_path: Path) -> None:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
def _print_enable_options(
extras: dict[str, list[str] | None],
builtin_channels: set[str],
plugin_channels: dict[str, Any],
config: Config,
) -> None:
table = Table(title="Available Features")
table.add_column("Name", style="cyan")
table.add_column("Type")
table.add_column("Enabled")
for item in sorted(builtin_channels | set(plugin_channels) | set(extras)):
is_channel = item in builtin_channels or item in plugin_channels
enabled = (
feature_support.channel_enabled(config, item)
if is_channel
else feature_support.extra_installed(item, extras[item])
)
table.add_row(
item,
"channel" if is_channel else "feature",
"[green]yes[/green]" if enabled else "[dim]no[/dim]",
)
console.print(table)
def _model_display(config: Config) -> tuple[str, str]: def _model_display(config: Config) -> tuple[str, str]:
"""Return (resolved_model_name, preset_tag) for display strings.""" """Return (resolved_model_name, preset_tag) for display strings."""
resolved = config.resolve_preset() resolved = config.resolve_preset()
@@ -811,20 +848,15 @@ def serve(
try: try:
from aiohttp import web # noqa: F401 from aiohttp import web # noqa: F401
except ImportError: except ImportError:
console.print("[red]aiohttp is required. Install with: pip install 'nanobot-ai[api]'[/red]") console.print("[red]aiohttp is required. Install with: nanobot plugins enable api[/red]")
raise typer.Exit(1) raise typer.Exit(1)
from loguru import logger
from nanobot.api.server import create_app from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
if verbose: _set_nanobot_logs(verbose)
logger.enable("nanobot")
else:
logger.disable("nanobot")
runtime_config = _load_runtime_config(config, workspace) runtime_config = _load_runtime_config(config, workspace)
api_cfg = runtime_config.api api_cfg = runtime_config.api
@@ -1392,8 +1424,6 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
): ):
"""Interact with the agent directly.""" """Interact with the agent directly."""
from loguru import logger
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
@@ -1411,10 +1441,7 @@ def agent(
cron_store_path = config.workspace_path / "cron" / "jobs.json" cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
if logs: _set_nanobot_logs(logs)
logger.enable("nanobot")
else:
logger.disable("nanobot")
try: try:
agent_loop = AgentLoop.from_config( agent_loop = AgentLoop.from_config(
@@ -1728,42 +1755,80 @@ def channels_login(
# Plugin Commands # Plugin Commands
# ============================================================================ # ============================================================================
plugins_app = typer.Typer(help="Manage channel plugins") plugins_app = typer.Typer(help="Manage optional nanobot features")
app.add_typer(plugins_app, name="plugins") app.add_typer(plugins_app, name="plugins")
@plugins_app.command("list") @plugins_app.command("list")
def plugins_list(): def plugins_list(
"""List all discovered channels (built-in and plugins).""" config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
from nanobot.channels.registry import discover_all, discover_channel_names ):
from nanobot.config.loader import load_config """List optional nanobot features."""
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config, set_config_path
config = load_config() resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
builtin_names = set(discover_channel_names()) if resolved_config_path is not None:
all_channels = discover_all() set_config_path(resolved_config_path)
table = Table(title="Channel Plugins") _print_enable_options(
table.add_column("Name", style="cyan") feature_support.optional_dependency_groups(),
table.add_column("Source", style="magenta") set(discover_channel_names()),
table.add_column("Enabled") discover_plugins(),
load_config(resolved_config_path),
)
for name in sorted(all_channels):
cls = all_channels[name] @plugins_app.command("enable")
source = "builtin" if name in builtin_names else "plugin" def plugins_enable(
section = getattr(config.channels, name, None) name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
if section is None: config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
enabled = False logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
elif isinstance(section, dict): ):
enabled = section.get("enabled", False) """Enable a nanobot feature."""
else: from nanobot.config.loader import get_config_path, set_config_path
enabled = getattr(section, "enabled", False)
table.add_row( resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
cls.display_name, if resolved_config_path is not None:
source, set_config_path(resolved_config_path)
"[green]yes[/green]" if enabled else "[dim]no[/dim]", resolved_config_path = resolved_config_path or get_config_path()
_set_nanobot_logs(logs)
try:
payload = feature_support.enable_optional_feature(
name,
config_path=resolved_config_path,
runner=feature_support.run_install_command,
) )
except feature_support.OptionalFeatureError as exc:
console.print(f"[red]{escape(exc.message)}[/red]")
raise typer.Exit(1) from exc
console.print(table) message = payload.get("last_action", {}).get("message") or f"Enabled feature '{name}'"
console.print(f"[green]{escape(message)}[/green]")
@plugins_app.command("disable")
def plugins_disable(
name: str = typer.Argument(..., help="Channel name (e.g. telegram, matrix, slack)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Disable a nanobot channel feature."""
from nanobot.config.loader import get_config_path, 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)
resolved_config_path = resolved_config_path or get_config_path()
try:
payload = feature_support.disable_optional_feature(name, config_path=resolved_config_path)
except feature_support.OptionalFeatureError as exc:
console.print(f"[red]{escape(exc.message)}[/red]")
raise typer.Exit(1) from exc
message = payload.get("last_action", {}).get("message") or f"Disabled channel '{name}'"
console.print(f"[green]{escape(message)}[/green] in {resolved_config_path}")
# ============================================================================ # ============================================================================
+7
View File
@@ -377,6 +377,13 @@ class ToolsConfig(Base):
"allow_local_preview_access", "allow_local_preview_access",
), ),
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads ) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
webui_allow_remote_package_install: bool = Field(
default=False,
validation_alias=AliasChoices(
"webuiAllowRemotePackageInstall",
"webui_allow_remote_package_install",
),
) # allow non-local WebUI clients to install optional Python packages
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
+434
View File
@@ -0,0 +1,434 @@
"""Optional nanobot feature discovery and enablement."""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any
from loguru import logger
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
class OptionalFeatureError(Exception):
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass
class InstallResult:
ok: bool
label: str
pip_cmd: list[str]
failed_cmd: list[str] | None = None
output: str = ""
_INSTALL_TIMEOUT_SECONDS = 300
_LOG_OUTPUT_LIMIT = 4000
def load_pyproject(path: Path) -> dict[str, Any]:
try:
import tomllib
return tomllib.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
try:
from importlib.metadata import metadata, requires
except Exception:
return {}
try:
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
for raw in requires("nanobot-ai") or []:
try:
req = Requirement(raw)
except Exception:
continue
if not req.marker:
continue
for extra, deps in groups.items():
if deps is not None and req.marker.evaluate({"extra": extra}):
deps.append(raw)
return groups
except Exception:
return {}
def optional_dependency_groups() -> dict[str, list[str] | None]:
root = Path(__file__).resolve().parents[1]
project = load_pyproject(root / "pyproject.toml").get("project", {})
deps = project.get("optional-dependencies", {})
if isinstance(deps, dict) and deps:
return {
name: list(values)
for name, values in deps.items()
if name != "dev" and isinstance(values, list)
}
return optional_dependency_groups_from_metadata()
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
install_args: list[str] = []
for raw in deps:
try:
req = Requirement(raw)
except Exception:
install_args.append(raw)
continue
if req.marker and not req.marker.evaluate({"extra": extra}):
continue
req.marker = None
install_args.append(str(req))
return install_args
def install_args_for_extra(
extra: str,
deps: list[str] | None,
) -> tuple[list[str], str]:
if deps:
install_args = _install_requirements_for_extra(extra, deps)
if install_args:
return install_args, f"{extra} support"
return [], f"{extra} support"
target = f"nanobot-ai[{extra}]"
return [target], f'"{target}"'
def _requirement_installed(req: Requirement, extra: str, seen: set[tuple[str, str]]) -> bool:
if req.marker and not req.marker.evaluate({"extra": extra}):
return True
key = (
canonicalize_name(req.name),
",".join(sorted(canonicalize_name(value) for value in req.extras)),
)
if key in seen:
return True
seen.add(key)
try:
dist = distribution(req.name)
except PackageNotFoundError:
return False
if req.specifier and not req.specifier.contains(dist.version, prereleases=True):
return False
for requested_extra in req.extras:
if not _extra_dependencies_installed(dist, requested_extra, seen):
return False
return True
def _extra_dependencies_installed(
dist: Any,
requested_extra: str,
seen: set[tuple[str, str]],
) -> bool:
normalized = canonicalize_name(requested_extra)
provided = {
canonicalize_name(value)
for value in (dist.metadata.get_all("Provides-Extra") or [])
}
if provided and normalized not in provided:
return False
matched = False
for raw in dist.requires or []:
try:
req = Requirement(raw)
except Exception:
continue
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
continue
matched = True
if not _requirement_installed(req, requested_extra, seen):
return False
return matched or bool(provided)
def requirement_installed(raw: str, extra: str = "") -> bool:
return _requirement_installed(Requirement(raw), extra, set())
def extra_installed(extra: str, deps: list[str] | None) -> bool:
if deps is None:
return True
return all(requirement_installed(dep, extra) for dep in deps)
def run_install_command(argv: list[str]) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=_INSTALL_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout
stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else exc.stderr
message = f"Timed out after {_INSTALL_TIMEOUT_SECONDS}s"
stderr = "\n".join(part for part in ((stderr or "").rstrip(), message) if part)
return subprocess.CompletedProcess(argv, 124, stdout=stdout or "", stderr=stderr)
def command_text(argv: list[str]) -> str:
return subprocess.list2cmdline([str(part) for part in argv])
def _log_completed_command(label: str, proc: subprocess.CompletedProcess[str]) -> None:
logger.info("{} exited with code {}", label, proc.returncode)
output = (proc.stderr or proc.stdout or "").strip()
if output:
logger.info("{} output:\n{}", label, output[:_LOG_OUTPUT_LIMIT])
def missing_pip(proc: subprocess.CompletedProcess[str]) -> bool:
return "no module named pip" in f"{proc.stdout}\n{proc.stderr}".lower()
def install_extra(
extra: str,
deps: list[str] | None,
*,
runner: Any = run_install_command,
) -> InstallResult:
import importlib
install_args, label = install_args_for_extra(extra, deps)
pip_cmd = [sys.executable, "-m", "pip", "install", *install_args]
if not install_args:
logger.info("Optional feature '{}' has no installable dependencies for this platform", extra)
return InstallResult(True, label, pip_cmd)
logger.info("Installing optional feature '{}': {}", extra, command_text(pip_cmd))
proc = runner(pip_cmd)
_log_completed_command(f"Optional feature '{extra}' install", proc)
if proc.returncode == 0:
importlib.invalidate_caches()
return InstallResult(True, label, pip_cmd)
failed_cmd = pip_cmd
failed_proc = proc
if missing_pip(proc):
ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd))
ensure_proc = runner(ensure_cmd)
_log_completed_command(f"Optional feature '{extra}' ensurepip", ensure_proc)
if ensure_proc.returncode == 0:
logger.info("Retrying optional feature '{}': {}", extra, command_text(pip_cmd))
proc = runner(pip_cmd)
_log_completed_command(f"Optional feature '{extra}' install retry", proc)
if proc.returncode == 0:
importlib.invalidate_caches()
return InstallResult(True, label, pip_cmd)
failed_cmd = pip_cmd
failed_proc = proc
else:
failed_cmd = ensure_cmd
failed_proc = ensure_proc
output = (failed_proc.stderr or failed_proc.stdout or "").strip()
return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output)
def read_config_data(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
return json.load(f)
def write_config_data(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
merged = dict(defaults)
for key, value in existing.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_missing_defaults(value, merged[key])
else:
merged[key] = value
return merged
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get(channel_name, {})
if not isinstance(existing, dict):
existing = {}
merged = merge_missing_defaults(existing, defaults)
merged["enabled"] = True
channels[channel_name] = merged
write_config_data(config_path, data)
def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get(channel_name, {})
if not isinstance(existing, dict):
existing = {}
existing["enabled"] = False
channels[channel_name] = existing
write_config_data(config_path, data)
def channel_enabled(config: Config, name: str) -> bool:
section = getattr(config.channels, name, None)
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if section is None:
return default_enabled
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
return bool(getattr(section, "enabled", default_enabled))
def optional_features_payload(
*,
config: Config | None = None,
last_action: dict[str, Any] | None = None,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config
config = config or load_config()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
features: list[dict[str, Any]] = []
for name in sorted(builtin_channels | set(plugin_channels) | set(extras)):
is_channel = name in builtin_channels or name in plugin_channels
installed = extra_installed(name, extras[name]) if name in extras else True
enabled = channel_enabled(config, name) if is_channel else installed
ready = bool(enabled and installed)
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
features.append(
{
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": is_channel or name in extras,
}
)
payload = {
"features": features,
"enabled_count": sum(1 for feature in features if feature["enabled"]),
}
if last_action:
payload["last_action"] = last_action
return payload
def enable_optional_feature(
name: str,
*,
config_path: Path | None = None,
allow_install: bool = True,
runner: Any = run_install_command,
) -> dict[str, Any]:
from nanobot.channels.registry import (
discover_channel_names,
discover_plugins,
load_channel_class,
)
from nanobot.config.loader import get_config_path
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
known = builtin_channels | set(plugin_channels) | set(extras)
if name not in known:
available = ", ".join(sorted(known))
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name in extras and not extra_installed(name, extras[name]):
if not allow_install:
raise OptionalFeatureError(
"Installing optional features from a remote WebUI is disabled. "
"Run this action from localhost or set tools.webuiAllowRemotePackageInstall to true.",
status=403,
)
result = install_extra(
name,
extras[name],
runner=runner,
)
if not result.ok:
failed = command_text(result.failed_cmd or result.pip_cmd)
detail = f": {result.output}" if result.output else ""
raise OptionalFeatureError(f"Failed: {failed}{detail}", status=500)
if name in builtin_channels:
try:
channel_cls = load_channel_class(name)
except Exception as exc:
raise OptionalFeatureError(
f"Channel '{name}' is not importable after enable: {exc}",
status=500,
) from exc
enable_channel_config(config_path, name, channel_cls.default_config())
message = f"Enabled channel '{name}'"
elif name in plugin_channels:
enable_channel_config(config_path, name, plugin_channels[name].default_config())
message = f"Enabled channel '{name}'"
else:
message = f"Enabled feature '{name}'"
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras)
return payload
def disable_optional_feature(
name: str,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import get_config_path
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
known_channels = builtin_channels | set(plugin_channels)
known = known_channels | set(extras)
if name not in known:
available = ", ".join(sorted(known))
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name not in known_channels:
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
disable_channel_config(config_path, name)
payload = optional_features_payload(
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
)
payload["requires_restart"] = True
return payload
+2 -2
View File
@@ -14,7 +14,7 @@ Two modes are supported, selected automatically:
falls back to :class:`azure.identity.aio.DefaultAzureCredential` and falls back to :class:`azure.identity.aio.DefaultAzureCredential` and
acquires a bearer token scoped to acquires a bearer token scoped to
``https://cognitiveservices.azure.com/.default``. ``azure-identity`` ``https://cognitiveservices.azure.com/.default``. ``azure-identity``
is an optional dependency installed via ``pip install nanobot-ai[azure]``. is an optional dependency installed via ``nanobot plugins enable azure``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -55,7 +55,7 @@ class _AzureTokenProvider:
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
"Azure OpenAI AAD authentication requires the 'azure-identity' package. " "Azure OpenAI AAD authentication requires the 'azure-identity' package. "
"Install it with: pip install 'nanobot-ai[azure]'" "Run: nanobot plugins enable azure"
) from exc ) from exc
self._scope = scope self._scope = scope
+1 -1
View File
@@ -71,7 +71,7 @@ class BedrockProvider(LLMProvider):
import boto3 import boto3
except ImportError as exc: # pragma: no cover - exercised only without boto3 installed except ImportError as exc: # pragma: no cover - exercised only without boto3 installed
raise RuntimeError( raise RuntimeError(
"AWS Bedrock provider requires boto3. Install it with `pip install boto3`." "AWS Bedrock provider requires boto3. Run `nanobot plugins enable bedrock`."
) from exc ) from exc
session_kwargs: dict[str, Any] = {} session_kwargs: dict[str, Any] = {}
+1 -1
View File
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
**Question 2 — Optional dependencies:** **Question 2 — Optional dependencies:**
``` ```
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, wecom, weixin, msteams, matrix, discord, langsmith, pdf" question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf"
``` ```
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names. Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
+65
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import email.utils import email.utils
import hmac import hmac
import http import http
import ipaddress
import json import json
import re import re
from typing import Any from typing import Any
@@ -131,6 +132,70 @@ def is_localhost(connection: Any) -> bool:
return host in {"127.0.0.1", "::1", "localhost"} return host in {"127.0.0.1", "::1", "localhost"}
def _host_without_port(value: str) -> str:
value = value.strip().strip('"').strip("'")
if not value:
return ""
if value.startswith("["):
end = value.find("]")
return value[1:end] if end > 0 else value
if value.count(":") == 1:
host, port = value.rsplit(":", 1)
if port.isdigit():
return host
return value
def is_loopback_host(value: str) -> bool:
host = _host_without_port(value)
if host.startswith("::ffff:"):
host = host[7:]
host = host.rstrip(".").lower()
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _split_comma_header(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _forwarded_header_values(value: str, key: str) -> list[str]:
values: list[str] = []
for entry in _split_comma_header(value):
for part in entry.split(";"):
name, sep, raw = part.partition("=")
if sep and name.strip().lower() == key:
cleaned = raw.strip().strip('"')
if cleaned:
values.append(cleaned)
return values
def _all_forwarded_values_are_loopback(headers: Any) -> bool:
checks: list[str] = []
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-For")))
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Real-IP")))
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-Host")))
forwarded = case_insensitive_header(headers, "Forwarded")
checks.extend(_forwarded_header_values(forwarded, "for"))
checks.extend(_forwarded_header_values(forwarded, "host"))
return all(is_loopback_host(value) for value in checks)
def is_local_browser_request(connection: Any, headers: Any) -> bool:
"""Return True only for a local TCP peer presenting a local browser origin."""
if not is_localhost(connection):
return False
host = case_insensitive_header(headers, "Host")
if not is_loopback_host(host):
return False
return _all_forwarded_values_are_loopback(headers)
def bearer_token(headers: Any) -> str | None: def bearer_token(headers: Any) -> str | None:
auth = headers.get("Authorization") or headers.get("authorization") auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "): if auth and auth.lower().startswith("bearer "):
+40
View File
@@ -0,0 +1,40 @@
"""Nanobot optional feature helpers for WebUI Settings."""
from __future__ import annotations
from typing import Any
from nanobot.optional_features import (
OptionalFeatureError,
disable_optional_feature,
enable_optional_feature,
optional_features_payload,
)
from nanobot.webui.http_utils import query_first
QueryParams = dict[str, list[str]]
def nanobot_features_payload() -> dict[str, Any]:
return optional_features_payload()
def nanobot_features_action(
action: str,
query: QueryParams,
*,
allow_install: bool = True,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(name, allow_install=allow_install)
if action == "disable":
if name == "websocket":
raise OptionalFeatureError(
"The WebUI websocket channel cannot be disabled from WebUI. "
"Use `nanobot plugins disable websocket` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(name)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
+56 -1
View File
@@ -17,9 +17,13 @@ from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.optional_features import OptionalFeatureError
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.http_utils import query_first as _query_first
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.nanobot_features_api import nanobot_features_action, nanobot_features_payload
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import (
WebUISettingsError, WebUISettingsError,
create_model_configuration, create_model_configuration,
@@ -80,7 +84,7 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities self._runtime_capabilities = runtime_capabilities
self._restart_sections: set[str] = set() self._restart_sections: set[str] = set()
async def dispatch(self, request: WsRequest, path: str) -> Response | None: async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings": if path == "/api/settings":
return self._handle_settings(request) return self._handle_settings(request)
if path == "/api/settings/usage": if path == "/api/settings/usage":
@@ -117,6 +121,12 @@ class WebUISettingsRouter:
return await self._handle_settings_cli_apps_action(request, "uninstall") return await self._handle_settings_cli_apps_action(request, "uninstall")
if path == "/api/settings/cli-apps/test": if path == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test") return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/nanobot-features":
return await self._handle_settings_nanobot_features(request)
if path == "/api/settings/nanobot-features/enable":
return await self._handle_settings_nanobot_features_action(connection, request, "enable")
if path == "/api/settings/nanobot-features/disable":
return await self._handle_settings_nanobot_features_action(connection, request, "disable")
if path == "/api/settings/mcp-presets": if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request) return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check": if path == "/api/settings/version-check":
@@ -334,6 +344,51 @@ class WebUISettingsRouter:
return self._error_response(status, message) return self._error_response(status, message)
return self._json_response(payload) return self._json_response(payload)
async def _handle_settings_nanobot_features(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(nanobot_features_payload)
except Exception:
self.logger.exception("failed to load nanobot features")
return self._error_response(500, "failed to load nanobot features")
return self._json_response(payload)
async def _handle_settings_nanobot_features_action(
self,
connection: Any,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(
nanobot_features_action,
action,
self._query(request),
allow_install=action != "enable"
or self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("nanobot feature action '{}' failed", action)
return self._error_response(status, message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool:
if _is_local_browser_request(connection, request.headers):
return True
try:
return bool(load_config().tools.webui_allow_remote_package_install)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
async def _handle_settings_mcp_presets( async def _handle_settings_mcp_presets(
self, self,
request: WsRequest, request: WsRequest,
+1
View File
@@ -21,6 +21,7 @@ def _exception_chain_has_disconnect(exc: BaseException | None) -> bool:
ConnectionAbortedError, ConnectionAbortedError,
ConnectionResetError, ConnectionResetError,
ConnectionClosed, ConnectionClosed,
EOFError,
)): )):
return True return True
exc = exc.__cause__ or exc.__context__ exc = exc.__cause__ or exc.__context__
+1 -1
View File
@@ -231,7 +231,7 @@ class GatewayHTTPHandler:
return self._handle_bootstrap(connection, request) return self._handle_bootstrap(connection, request)
# Settings routes (delegated) # Settings routes (delegated)
response = await self.settings_routes.dispatch(request, got) response = await self.settings_routes.dispatch(connection, request, got)
if response is not None: if response is not None:
return response return response
+43 -15
View File
@@ -37,16 +37,6 @@ dependencies = [
"lxml-html-clean>=0.4.0,<1.0.0", "lxml-html-clean>=0.4.0,<1.0.0",
"rich>=14.0.0,<15.0.0", "rich>=14.0.0,<15.0.0",
"croniter>=6.0.0,<7.0.0", "croniter>=6.0.0,<7.0.0",
"dingtalk-stream>=0.24.0,<1.0.0",
"python-telegram-bot[socks,webhooks]>=22.6,<23.0",
"lark-oapi>=1.5.0,<2.0.0",
"socksio>=1.0.0,<2.0.0",
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
"qq-botpy>=1.2.0,<2.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'",
"prompt-toolkit>=3.0.50,<4.0.0", "prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0", "questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0", "mcp>=1.26.0,<2.0.0",
@@ -57,12 +47,8 @@ dependencies = [
"jinja2>=3.1.0,<4.0.0", "jinja2>=3.1.0,<4.0.0",
"dulwich>=0.22.0,<1.0.0", "dulwich>=0.22.0,<1.0.0",
"pyyaml>=6.0,<7.0.0", "pyyaml>=6.0,<7.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
"filelock>=3.25.2", "filelock>=3.25.2",
"boto3>=1.43.0", "packaging>=24.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -72,6 +58,41 @@ api = [
azure = [ azure = [
"azure-identity>=1.19.0,<2.0.0", "azure-identity>=1.19.0,<2.0.0",
] ]
bedrock = [
"boto3>=1.43.0",
]
dingtalk = [
"dingtalk-stream>=0.24.0,<1.0.0",
]
documents = [
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
feishu = [
"lark-oapi>=1.5.0,<2.0.0",
]
mochat = [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
]
napcat = [
"aiohttp>=3.9.0,<4.0.0",
]
qq = [
"aiohttp>=3.9.0,<4.0.0",
"qq-botpy>=1.2.0,<2.0.0",
]
slack = [
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
telegram = [
"python-telegram-bot[socks,webhooks]>=22.6,<23.0",
"socksio>=1.0.0,<2.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'",
]
wecom = [ wecom = [
"wecom-aibot-sdk-python>=0.1.5", "wecom-aibot-sdk-python>=0.1.5",
] ]
@@ -86,6 +107,7 @@ msteams = [
matrix = [ matrix = [
"matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'",
"matrix-nio>=0.25.2; sys_platform == 'win32'",
"aiohttp>=3.9.0,<4.0.0", "aiohttp>=3.9.0,<4.0.0",
"mistune>=3.0.0,<4.0.0", "mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0", "nh3>=0.2.17,<1.0.0",
@@ -113,6 +135,12 @@ dev = [
"pytest-cov>=6.0.0,<7.0.0", "pytest-cov>=6.0.0,<7.0.0",
"ruff>=0.1.0", "ruff>=0.1.0",
"pymupdf>=1.25.0", "pymupdf>=1.25.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
] ]
[project.scripts] [project.scripts]
@@ -62,7 +62,8 @@ class MockChannel(BaseChannel):
@pytest.fixture @pytest.fixture
def config(): def config():
return Config() """Create a minimal config for testing."""
return Config.model_validate({"channels": {"websocket": {"enabled": False}}})
@pytest.fixture @pytest.fixture
@@ -21,7 +21,11 @@ from unittest.mock import AsyncMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event from nanobot.bus.outbound_events import (
ProgressEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
@@ -60,7 +64,8 @@ class _MockChannel(BaseChannel):
@pytest.fixture @pytest.fixture
def manager() -> ChannelManager: def manager() -> ChannelManager:
mgr = ChannelManager(Config(), MessageBus()) config = Config.model_validate({"channels": {"websocket": {"enabled": False}}})
mgr = ChannelManager(config, MessageBus())
mgr.channels["mock"] = _MockChannel({}, mgr.bus) mgr.channels["mock"] = _MockChannel({}, mgr.bus)
return mgr return mgr
@@ -291,14 +296,22 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
async def _pump_one(manager: ChannelManager) -> None: async def _pump_one(manager: ChannelManager) -> None:
"""Drive the dispatcher until the outbound queue drains, then cancel.""" """Process currently queued messages through the reasoning dispatch branch."""
task = asyncio.create_task(manager._dispatch_outbound())
for _ in range(50): async def dispatch_one(msg: OutboundMessage) -> None:
await asyncio.sleep(0.01) event = outbound_event_from_message(msg)
if manager.bus.outbound.qsize() == 0: if isinstance(event, ProgressEvent) and (
event.reasoning_delta
or event.reasoning_end
or event.reasoning
):
channel = manager.channels.get(msg.channel)
if channel is not None and channel.show_reasoning:
await manager._send_with_retry(channel, msg)
await dispatch_one(await asyncio.wait_for(manager.bus.consume_outbound(), timeout=1.0))
while True:
try:
await dispatch_one(manager.bus.outbound.get_nowait())
except asyncio.QueueEmpty:
break break
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
+699 -6
View File
@@ -3,6 +3,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import subprocess
import sys
import tomllib
from importlib.metadata import PackageNotFoundError
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -72,6 +78,28 @@ def _make_entry_point(name: str, cls: type):
return ep return ep
def _stub_optional_feature_cli(
monkeypatch: pytest.MonkeyPatch,
*,
extras: dict[str, list[str] | None],
installed: bool,
commands: list[list[str]] | None = None,
channels: list[str] | None = None,
channel_cls: type[BaseChannel] | None = None,
) -> None:
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: channels or [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
if channel_cls is not None:
monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: channel_cls)
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: extras)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: installed)
if commands is not None:
monkeypatch.setattr(
"nanobot.optional_features.run_install_command",
lambda argv: commands.append(argv) or subprocess.CompletedProcess(argv, 0, "", ""),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ChannelsConfig extra="allow" # ChannelsConfig extra="allow"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -203,6 +231,23 @@ def test_discover_enabled_imports_only_enabled_builtins():
assert loaded == ["enabled"] assert loaded == ["enabled"]
def test_discover_enabled_warns_for_enabled_builtin_import_errors():
from nanobot.channels.registry import discover_enabled
with (
patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing sdk")),
patch(_EP_TARGET, return_value=[]),
patch("nanobot.channels.registry.logger.warning") as warning,
):
result = discover_enabled({"matrix"}, _names=["matrix"], warn_import_errors=True)
assert result == {}
warning.assert_called_once()
assert warning.call_args.args[0] == "Enabled built-in channel '{}' is not available: {}"
assert warning.call_args.args[1] == "matrix"
assert "missing sdk" in str(warning.call_args.args[2])
def test_discover_all_builtin_shadows_plugin(): def test_discover_all_builtin_shadows_plugin():
from nanobot.channels.registry import discover_all from nanobot.channels.registry import discover_all
@@ -214,6 +259,20 @@ def test_discover_all_builtin_shadows_plugin():
assert result["telegram"] is not _FakeTelegram assert result["telegram"] is not _FakeTelegram
def test_discover_all_builtin_name_shadows_plugin_when_dependency_missing():
from nanobot.channels.registry import discover_all
ep = _make_entry_point("telegram", _FakeTelegram)
with (
patch("nanobot.channels.registry.discover_channel_names", return_value=["telegram"]),
patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing")),
patch(_EP_TARGET, return_value=[ep]),
):
result = discover_all()
assert "telegram" not in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Manager _init_channels with dict config (plugin scenario) # Manager _init_channels with dict config (plugin scenario)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -245,6 +304,54 @@ async def test_manager_loads_plugin_from_dict_config():
assert isinstance(mgr.channels["fakeplugin"], _FakePlugin) assert isinstance(mgr.channels["fakeplugin"], _FakePlugin)
def test_manager_loads_websocket_from_default_config():
from nanobot.channels.manager import ChannelManager
class _FakeWebSocket(_FakePlugin):
name = "websocket"
display_name = "WebSocket"
def __init__(self, config, bus, *, gateway):
super().__init__(config, bus)
self.gateway = gateway
seen_enabled: set[str] = set()
def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False):
seen_enabled.update(enabled_names)
return {"websocket": _FakeWebSocket} if "websocket" in enabled_names else {}
with (
patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]),
patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled),
):
mgr = ChannelManager(Config(), MessageBus(), webui_static_dist=False)
assert "websocket" in seen_enabled
assert mgr.channels["websocket"].config["enabled"] is True
assert mgr.channels["websocket"].config["host"] == "127.0.0.1"
def test_manager_respects_explicitly_disabled_websocket_config():
from nanobot.channels.manager import ChannelManager
seen_enabled: set[str] = set()
def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False):
seen_enabled.update(enabled_names)
return {}
config = Config.model_validate({"channels": {"websocket": {"enabled": False}}})
with (
patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]),
patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled),
):
mgr = ChannelManager(config, MessageBus(), webui_static_dist=False)
assert "websocket" not in seen_enabled
assert "websocket" not in mgr.channels
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_base_channel_reads_current_transcription_config_each_call( async def test_base_channel_reads_current_transcription_config_each_call(
tmp_path, tmp_path,
@@ -510,6 +617,592 @@ def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
assert seen["config_path"] == config_path.resolve() assert seen["config_path"] == config_path.resolve()
def test_plugins_list_shows_available_features(monkeypatch):
from typer.testing import CliRunner
from nanobot.cli.commands import app
from nanobot.config.schema import Config
runner = CliRunner()
config = Config.model_validate({"channels": {"weixin": {"enabled": True}}})
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"weixin": ["qrcode[pil]>=8.0"], "bedrock": ["boto3>=1.43.0"]},
)
result = runner.invoke(app, ["plugins", "list"])
assert result.exit_code == 0
assert "Available Features" in result.stdout
assert "weixin" in result.stdout
assert "bedrock" in result.stdout
assert "channel" in result.stdout
assert "feature" in result.stdout
assert " - " not in result.stdout
def test_plugins_enable_channel_installs_extra_and_writes_config(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app
class _WeixinChannel(_FakePlugin):
name = "weixin"
display_name = "Weixin"
@classmethod
def default_config(cls):
return {"enabled": False, "token": "", "allowFrom": []}
commands: list[list[str]] = []
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"channels": {"weixin": {"enabled": False, "token": "keep"}}}),
encoding="utf-8",
)
runner = CliRunner()
_stub_optional_feature_cli(
monkeypatch,
extras={"weixin": ["qrcode[pil]>=8.0", "pycryptodome>=3.20.0"]},
installed=False,
commands=commands,
channels=["weixin"],
channel_cls=_WeixinChannel,
)
result = runner.invoke(app, ["plugins", "enable", "weixin", "--config", str(config_path)])
assert result.exit_code == 0
assert commands == [
[sys.executable, "-m", "pip", "install", "qrcode[pil]>=8.0", "pycryptodome>=3.20.0"]
]
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["weixin"]["enabled"] is True
assert data["channels"]["weixin"]["token"] == "keep"
assert data["channels"]["weixin"]["allowFrom"] == []
def test_plugins_enable_extra_without_channel_only_installs(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli import commands as cli_commands
from nanobot.cli.commands import app
commands: list[list[str]] = []
log_flags: list[bool] = []
config_path = tmp_path / "config.json"
original_set_logs = cli_commands._set_nanobot_logs
def _set_logs(enabled: bool) -> None:
log_flags.append(enabled)
original_set_logs(enabled)
runner = CliRunner()
_stub_optional_feature_cli(
monkeypatch,
extras={"bedrock": ["boto3>=1.43.0"]},
installed=False,
commands=commands,
)
monkeypatch.setattr("nanobot.cli.commands._set_nanobot_logs", _set_logs)
result = runner.invoke(app, ["plugins", "enable", "bedrock", "--config", str(config_path)])
assert result.exit_code == 0
assert log_flags == [False]
assert commands == [[sys.executable, "-m", "pip", "install", "boto3>=1.43.0"]]
assert "Installing optional feature" not in result.output
assert not config_path.exists()
def test_plugins_enable_logs_option_enables_nanobot_logs(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli import commands as cli_commands
from nanobot.cli.commands import app
config_path = tmp_path / "config.json"
log_flags: list[bool] = []
original_set_logs = cli_commands._set_nanobot_logs
def _set_logs(enabled: bool) -> None:
log_flags.append(enabled)
original_set_logs(enabled)
runner = CliRunner()
_stub_optional_feature_cli(
monkeypatch,
extras={"bedrock": ["boto3>=1.43.0"]},
installed=False,
commands=[],
)
monkeypatch.setattr("nanobot.cli.commands._set_nanobot_logs", _set_logs)
result = runner.invoke(
app,
["plugins", "enable", "bedrock", "--logs", "--config", str(config_path)],
)
assert result.exit_code == 0
assert log_flags == [True]
assert "Enabled feature 'bedrock'" in result.output
def test_plugins_enable_skips_install_when_extra_is_present(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app
commands: list[list[str]] = []
config_path = tmp_path / "config.json"
runner = CliRunner()
_stub_optional_feature_cli(
monkeypatch,
extras={"bedrock": ["boto3>=1.43.0"]},
installed=True,
commands=commands,
)
result = runner.invoke(app, ["plugins", "enable", "bedrock", "--config", str(config_path)])
assert result.exit_code == 0
assert commands == []
assert not config_path.exists()
def test_plugins_disable_channel_writes_config(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}),
encoding="utf-8",
)
runner = CliRunner()
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
result = runner.invoke(app, ["plugins", "disable", "matrix", "--config", str(config_path)])
assert result.exit_code == 0
assert "Disabled channel 'matrix'" in result.output
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["enabled"] is False
assert data["channels"]["matrix"]["homeserver"] == "keep"
def test_plugins_disable_rejects_non_channel_and_allows_websocket(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app
config_path = tmp_path / "config.json"
runner = CliRunner()
monkeypatch.setattr(
"nanobot.channels.registry.discover_channel_names",
lambda: ["matrix", "websocket"],
)
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"bedrock": ["boto3>=1.43.0"]},
)
non_channel = runner.invoke(
app,
["plugins", "disable", "bedrock", "--config", str(config_path)],
)
websocket = runner.invoke(
app,
["plugins", "disable", "websocket", "--config", str(config_path)],
)
assert non_channel.exit_code == 1
assert "Feature 'bedrock' cannot be disabled" in non_channel.output
assert websocket.exit_code == 0
assert "Disabled channel 'websocket'" in websocket.output
assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["websocket"][
"enabled"
] is False
def test_enable_optional_feature_blocks_install_when_disallowed(monkeypatch, tmp_path):
from nanobot.optional_features import OptionalFeatureError, enable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"bedrock": ["boto3>=1.43.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False)
with pytest.raises(OptionalFeatureError) as exc:
enable_optional_feature("bedrock", config_path=config_path, allow_install=False)
assert exc.value.status == 403
assert "remote WebUI is disabled" in exc.value.message
assert not config_path.exists()
def test_enable_optional_feature_skips_install_when_dependency_present(
monkeypatch,
tmp_path,
):
from nanobot.optional_features import InstallResult, enable_optional_feature
config_path = tmp_path / "config.json"
install_calls: list[str] = []
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"bedrock": ["boto3>=1.43.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
def _install_extra(
name: str,
deps: list[str] | None,
*,
runner,
) -> InstallResult:
install_calls.append(name)
return InstallResult(True, f"{name} support", ["python", "-m", "pip", "install", name])
monkeypatch.setattr("nanobot.optional_features.install_extra", _install_extra)
payload = enable_optional_feature("bedrock", config_path=config_path, allow_install=False)
assert install_calls == []
assert payload["last_action"]["message"] == "Enabled feature 'bedrock'"
assert payload["requires_restart"] is True
assert not config_path.exists()
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
from nanobot.optional_features import (
InstallResult,
OptionalFeatureError,
enable_optional_feature,
)
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"bedrock": ["boto3>=1.43.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False)
monkeypatch.setattr(
"nanobot.optional_features.install_extra",
lambda _name, _deps, *, runner: InstallResult(
False,
"bedrock support",
["python", "-m", "pip", "install", "boto3>=1.43.0"],
failed_cmd=["python", "-m", "pip", "install", "boto3>=1.43.0"],
output="network unavailable",
),
)
with pytest.raises(OptionalFeatureError) as exc:
enable_optional_feature("bedrock", config_path=config_path)
assert exc.value.status == 500
assert "Failed:" in exc.value.message
assert "network unavailable" in exc.value.message
assert not config_path.exists()
def test_disable_optional_feature_rejects_unknown_features_and_non_channels(
monkeypatch,
tmp_path,
):
from nanobot.optional_features import OptionalFeatureError, disable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.channels.registry.discover_channel_names",
lambda: ["matrix", "websocket"],
)
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"bedrock": ["boto3>=1.43.0"]},
)
with pytest.raises(OptionalFeatureError) as unknown:
disable_optional_feature("missing", config_path=config_path)
assert unknown.value.status == 404
assert "Unknown feature: missing" in unknown.value.message
with pytest.raises(OptionalFeatureError) as non_channel:
disable_optional_feature("bedrock", config_path=config_path)
assert non_channel.value.status == 400
assert non_channel.value.message == "Feature 'bedrock' cannot be disabled"
assert not config_path.exists()
def test_disable_optional_feature_writes_channel_disabled(monkeypatch, tmp_path):
from nanobot.optional_features import disable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config_path.write_text(
json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix", "websocket"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = disable_optional_feature("matrix", config_path=config_path)
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["enabled"] is False
assert data["channels"]["matrix"]["homeserver"] == "keep"
assert payload["last_action"]["message"] == "Disabled channel 'matrix'"
assert payload["requires_restart"] is True
payload = disable_optional_feature("websocket", config_path=config_path)
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["websocket"]["enabled"] is False
assert payload["last_action"]["message"] == "Disabled channel 'websocket'"
def test_optional_features_payload_counts_enabled_channel_with_missing_dependency(
monkeypatch,
):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({"channels": {"matrix": {"enabled": True}}})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"matrix": ["matrix-nio>=0.25.2"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False)
payload = optional_features_payload(config=config)
matrix = payload["features"][0]
assert matrix["name"] == "matrix"
assert matrix["enabled"] is True
assert matrix["installed"] is False
assert matrix["ready"] is False
assert payload["enabled_count"] == 1
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
from nanobot import optional_features
calls: list[list[str]] = []
def _run(argv: list[str]) -> subprocess.CompletedProcess[str]:
calls.append(argv)
if len(calls) == 1:
return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip")
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
assert optional_features.install_extra("weixin", None, runner=_run).ok is True
assert calls == [
[sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"],
[sys.executable, "-m", "ensurepip", "--upgrade"],
[sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"],
]
def test_install_extra_logs_command_and_output(monkeypatch):
from nanobot import optional_features
records: list[str] = []
class _Logger:
def info(self, message: str, *args: object) -> None:
records.append(message.format(*args))
def _run(argv: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(argv, 0, stdout="install ok", stderr="")
monkeypatch.setattr(optional_features, "logger", _Logger())
result = optional_features.install_extra("weixin", ["qrcode[pil]>=8.0"], runner=_run)
assert result.ok is True
assert any("Installing optional feature 'weixin':" in record for record in records)
assert any("Optional feature 'weixin' install exited with code 0" in record for record in records)
assert any("install ok" in record for record in records)
def test_run_install_command_returns_failure_on_timeout(monkeypatch):
from nanobot import optional_features
def _run(*args, **kwargs):
raise subprocess.TimeoutExpired(["pip"], 300, output="partial", stderr=b"still running")
monkeypatch.setattr(optional_features.subprocess, "run", _run)
result = optional_features.run_install_command(["pip"])
assert result.returncode == 124
assert result.stdout == "partial"
assert result.stderr == "still running\nTimed out after 300s"
def test_optional_dependency_metadata_for_enable():
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["optional-dependencies"]
required = data["project"]["dependencies"]
assert "boto3>=1.43.0" not in data["project"]["dependencies"]
assert deps["bedrock"] == ["boto3>=1.43.0"]
for dep_name in (
"aiohttp",
"dingtalk-stream",
"lark-oapi",
"msgpack",
"openpyxl",
"pypdf",
"python-telegram-bot",
"python-docx",
"python-pptx",
"python-socketio",
"qq-botpy",
"slack-sdk",
"slackify-markdown",
):
assert not any(dep.startswith(dep_name) for dep in required)
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
assert deps["documents"] == [
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
assert deps["mochat"] == [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
]
assert deps["napcat"] == ["aiohttp>=3.9.0,<4.0.0"]
assert deps["qq"] == ["aiohttp>=3.9.0,<4.0.0", "qq-botpy>=1.2.0,<2.0.0"]
assert deps["slack"] == [
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"])
assert any(
dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep
for dep in deps["matrix"]
)
def test_optional_dependency_groups_falls_back_to_package_metadata(monkeypatch):
from nanobot import optional_features
class _Metadata:
def get_all(self, key: str):
assert key == "Provides-Extra"
return ["bedrock", "dev"]
monkeypatch.setattr(optional_features, "load_pyproject", lambda _path: {})
monkeypatch.setattr("importlib.metadata.metadata", lambda _name: _Metadata())
monkeypatch.setattr(
"importlib.metadata.requires",
lambda _name: [
"packaging>=24.0",
"boto3>=1.43.0; extra == 'bedrock'",
"pytest>=8.0; extra == 'dev'",
],
)
deps = optional_features.optional_dependency_groups()
assert deps == {"bedrock": ["boto3>=1.43.0; extra == 'bedrock'"]}
assert optional_features.install_args_for_extra("bedrock", deps["bedrock"]) == (
["boto3>=1.43.0"],
"bedrock support",
)
def test_install_args_for_extra_resolves_metadata_markers_for_current_platform():
from nanobot import optional_features
current_platform = sys.platform
deps = [
f"current-platform-package>=1.0; sys_platform == '{current_platform}' and extra == 'matrix'",
"other-platform-package>=1.0; sys_platform == 'never' and extra == 'matrix'",
]
assert optional_features.install_args_for_extra("matrix", deps) == (
["current-platform-package>=1.0"],
"matrix support",
)
def test_requirement_installed_validates_requested_extras(monkeypatch):
from nanobot import optional_features
class _Metadata:
def __init__(self, extras: list[str] | None = None) -> None:
self._extras = extras or []
def get_all(self, key: str):
assert key == "Provides-Extra"
return self._extras
class _Distribution:
def __init__(
self,
version: str,
*,
requires: list[str] | None = None,
extras: list[str] | None = None,
) -> None:
self.version = version
self.requires = requires or []
self.metadata = _Metadata(extras)
installed: dict[str, _Distribution] = {
"qrcode": _Distribution(
"8.2",
requires=["pillow>=9.1; extra == 'pil'"],
extras=["pil"],
),
}
def _distribution(name: str) -> _Distribution:
normalized = name.lower()
if normalized not in installed:
raise PackageNotFoundError(name)
return installed[normalized]
monkeypatch.setattr(optional_features, "distribution", _distribution)
assert optional_features.requirement_installed("qrcode>=8.0") is True
assert optional_features.requirement_installed("qrcode[pil]>=8.0") is False
installed["pillow"] = _Distribution("10.0")
assert optional_features.requirement_installed("qrcode[pil]>=8.0") is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_manager_skips_disabled_plugin(): async def test_manager_skips_disabled_plugin():
fake_config = SimpleNamespace( fake_config = SimpleNamespace(
@@ -537,19 +1230,19 @@ async def test_manager_skips_disabled_plugin():
def test_builtin_channel_default_config(): def test_builtin_channel_default_config():
"""Built-in channels expose default_config() returning a dict with 'enabled': False.""" """Built-in channels expose default_config() returning a dict with 'enabled': False."""
from nanobot.channels.telegram import TelegramChannel from nanobot.channels.dingtalk import DingTalkChannel
cfg = TelegramChannel.default_config() cfg = DingTalkChannel.default_config()
assert isinstance(cfg, dict) assert isinstance(cfg, dict)
assert cfg["enabled"] is False assert cfg["enabled"] is False
assert "token" in cfg assert "clientId" in cfg
def test_builtin_channel_init_from_dict(): def test_builtin_channel_init_from_dict():
"""Built-in channels accept a raw dict and convert to Pydantic internally.""" """Built-in channels accept a raw dict and convert to Pydantic internally."""
from nanobot.channels.telegram import TelegramChannel from nanobot.channels.dingtalk import DingTalkChannel
bus = MessageBus() bus = MessageBus()
ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus) ch = DingTalkChannel({"enabled": False, "clientId": "test-id", "allowFrom": ["*"]}, bus)
assert ch.config.token == "test-tok" assert ch.config.client_id == "test-id"
assert ch.config.allow_from == ["*"] assert ch.config.allow_from == ["*"]
+23 -6
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -24,6 +25,10 @@ from nanobot.channels.matrix import (
_ROOM_SEND_UNSET = object() _ROOM_SEND_UNSET = object()
def test_default_e2ee_matches_platform_support() -> None:
assert MatrixConfig().e2ee_enabled is (sys.platform != "win32")
class _DummyTask: class _DummyTask:
def __init__(self) -> None: def __init__(self) -> None:
self.cancelled = False self.cancelled = False
@@ -293,7 +298,7 @@ async def test_start_skips_load_store_when_device_id_missing(
"nanobot.channels.matrix.asyncio.create_task", _fake_create_task "nanobot.channels.matrix.asyncio.create_task", _fake_create_task
) )
channel = MatrixChannel(_make_config(device_id=""), MessageBus()) channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus())
await channel.start() await channel.start()
assert len(clients) == 1 assert len(clients) == 1
@@ -320,7 +325,7 @@ async def test_register_event_callbacks_uses_media_base_filter() -> None:
def test_register_to_device_callbacks_when_sas_verification_enabled() -> None: def test_register_to_device_callbacks_when_sas_verification_enabled() -> None:
channel = MatrixChannel(_make_config(sas_verification=True), MessageBus()) channel = MatrixChannel(_make_config(e2ee_enabled=True, sas_verification=True), MessageBus())
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
channel.client = client channel.client = client
@@ -348,7 +353,11 @@ def test_register_to_device_callbacks_skips_when_e2ee_disabled() -> None:
async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None: async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch) _patch_key_verification_events(monkeypatch)
channel = MatrixChannel( channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True), _make_config(
allow_from=["@alice:matrix.org"],
e2ee_enabled=True,
sas_verification=True,
),
MessageBus(), MessageBus(),
) )
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
@@ -367,7 +376,11 @@ async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> Non
async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None: async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch) _patch_key_verification_events(monkeypatch)
channel = MatrixChannel( channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True), _make_config(
allow_from=["@alice:matrix.org"],
e2ee_enabled=True,
sas_verification=True,
),
MessageBus(), MessageBus(),
) )
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
@@ -403,7 +416,11 @@ async def test_sas_verification_ignores_when_disabled(monkeypatch) -> None:
async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None: async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None:
_patch_key_verification_events(monkeypatch) _patch_key_verification_events(monkeypatch)
channel = MatrixChannel( channel = MatrixChannel(
_make_config(allow_from=["@alice:matrix.org"], sas_verification=True), _make_config(
allow_from=["@alice:matrix.org"],
e2ee_enabled=True,
sas_verification=True,
),
MessageBus(), MessageBus(),
) )
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
@@ -1203,7 +1220,7 @@ async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_clears_typing_after_send() -> None: async def test_send_clears_typing_after_send() -> None:
channel = MatrixChannel(_make_config(), MessageBus()) channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus())
client = _FakeAsyncClient("", "", "", None) client = _FakeAsyncClient("", "", "", None)
channel.client = client channel.client = client
+31 -1
View File
@@ -132,6 +132,36 @@ def bus() -> MagicMock:
return b return b
@pytest.mark.asyncio
async def test_start_extends_http_open_timeout_for_slow_settings_routes(
bus,
monkeypatch,
) -> None:
import nanobot.channels.websocket as websocket_module
channel = _ch(bus, port=0)
seen: dict[str, Any] = {}
class Server:
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
async def fake_serve(*args: Any, **kwargs: Any) -> Server:
seen.update(kwargs)
assert channel._stop_event is not None
channel._stop_event.set()
return Server()
monkeypatch.setattr(websocket_module, "serve", fake_serve)
await channel.start()
assert seen["open_timeout"] >= 300
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None: def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
@@ -285,7 +315,7 @@ def test_ssl_context_requires_both_cert_and_key_files() -> None:
def test_default_config_includes_safe_bind_and_streaming() -> None: def test_default_config_includes_safe_bind_and_streaming() -> None:
defaults = WebSocketChannel.default_config() defaults = WebSocketChannel.default_config()
assert defaults["enabled"] is False assert defaults["enabled"] is True
assert defaults["host"] == "127.0.0.1" assert defaults["host"] == "127.0.0.1"
assert defaults["streaming"] is True assert defaults["streaming"] is True
assert defaults["allowFrom"] == ["*"] assert defaults["allowFrom"] == ["*"]
+355 -1
View File
@@ -15,9 +15,12 @@ from urllib.parse import quote, urlencode
import httpx import httpx
import pytest import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult
from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.triggers.local_store import LocalTriggerStore from nanobot.triggers.local_store import LocalTriggerStore
@@ -26,6 +29,24 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic
_PORT = 29900 _PORT = 29900
class _MatrixChannel(BaseChannel):
name = "matrix"
display_name = "Matrix"
@classmethod
def default_config(cls) -> dict[str, Any]:
return {"enabled": False, "allowFrom": []}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
def _free_port() -> int: def _free_port() -> int:
for _ in range(100): for _ in range(100):
port = random.randint(30_000, 60_000) port = random.randint(30_000, 60_000)
@@ -140,6 +161,35 @@ def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
return sm return sm
def _stub_matrix_feature(
monkeypatch: pytest.MonkeyPatch,
config_path: Path,
*,
deps: list[str] | None = None,
installed: bool = True,
install_calls: list[str] | None = None,
channels: list[str] | None = None,
) -> None:
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.channels.registry.discover_channel_names",
lambda: channels or ["matrix"],
)
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: _MatrixChannel)
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"matrix": deps if deps is not None else []},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: installed)
if install_calls is not None:
monkeypatch.setattr(
"nanobot.optional_features.install_extra",
lambda name, _deps, *, runner: install_calls.append(name)
or InstallResult(True, f"{name} support", ["python", "-m", "pip", "install", name]),
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bootstrap_returns_token_for_localhost( async def test_bootstrap_returns_token_for_localhost(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path
@@ -538,6 +588,272 @@ async def test_cli_apps_routes_require_token_and_return_payload(
await server_task await server_task
@pytest.mark.asyncio
async def test_nanobot_feature_routes_require_token_and_enable(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
_stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"])
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features")
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
catalog = await _http_get(
"http://127.0.0.1:29916/api/settings/nanobot-features",
headers=auth,
)
assert catalog.status_code == 200
features = {feature["name"]: feature for feature in catalog.json()["features"]}
assert features["matrix"]["status"] == "not_enabled"
assert features["websocket"]["enabled"] is True
assert features["websocket"]["ready"] is True
enabled = await _http_get(
"http://127.0.0.1:29916/api/settings/nanobot-features/enable?name=matrix",
headers=auth,
)
assert enabled.status_code == 200
body = enabled.json()
assert body["last_action"]["message"] == "Enabled channel 'matrix'"
assert body["restart_required_sections"] == ["runtime"]
disabled_websocket = await _http_get(
"http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=websocket",
headers=auth,
)
assert disabled_websocket.status_code == 400
assert "cannot be disabled from WebUI" in disabled_websocket.text
assert "websocket" not in json.loads(config_path.read_text(encoding="utf-8"))["channels"]
disabled = await _http_get(
"http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=matrix",
headers=auth,
)
assert disabled.status_code == 200
body = disabled.json()
assert body["last_action"]["message"] == "Disabled channel 'matrix'"
assert body["restart_required_sections"] == ["runtime"]
assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][
"enabled"
] is False
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
install_calls: list[str] = []
_stub_matrix_feature(
monkeypatch,
config_path,
deps=["matrix-nio>=0.25.2"],
installed=False,
install_calls=install_calls,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_token(300, api_token=True)
path = "/api/settings/nanobot-features/enable?name=matrix"
request = _FakeReq({"Authorization": f"Bearer {token}"}, path=path)
blocked = await channel.gateway.http.settings_routes.dispatch(
_REMOTE,
request,
"/api/settings/nanobot-features/enable",
)
assert blocked is not None
assert blocked.status_code == 403
assert "remote WebUI is disabled" in blocked.body.decode()
assert install_calls == []
config_path.write_text(
json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}),
encoding="utf-8",
)
allowed = await channel.gateway.http.settings_routes.dispatch(
_REMOTE,
request,
"/api/settings/nanobot-features/enable",
)
assert allowed is not None
assert allowed.status_code == 200
assert install_calls == ["matrix"]
@pytest.mark.asyncio
async def test_nanobot_feature_local_install_allowed_by_default(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
install_calls: list[str] = []
_stub_matrix_feature(
monkeypatch,
config_path,
deps=["matrix-nio>=0.25.2"],
installed=False,
install_calls=install_calls,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_token(300, api_token=True)
request = _FakeReq(
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
path="/api/settings/nanobot-features/enable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert response is not None
assert response.status_code == 200
assert install_calls == ["matrix"]
assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][
"enabled"
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
install_calls: list[str] = []
_stub_matrix_feature(
monkeypatch,
config_path,
deps=["matrix-nio>=0.25.2"],
installed=False,
install_calls=install_calls,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_token(300, api_token=True)
request = _FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "nanobot.example",
"X-Forwarded-For": "203.0.113.42",
},
path="/api/settings/nanobot-features/enable?name=matrix",
)
blocked = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert blocked is not None
assert blocked.status_code == 403
assert install_calls == []
config_path.write_text(
json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}),
encoding="utf-8",
)
allowed = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert allowed is not None
assert allowed.status_code == 200
assert install_calls == ["matrix"]
@pytest.mark.asyncio
async def test_nanobot_feature_remote_enable_without_install_is_allowed(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
install_calls: list[str] = []
_stub_matrix_feature(
monkeypatch,
config_path,
deps=["matrix-nio>=0.25.2"],
installed=True,
install_calls=install_calls,
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_token(300, api_token=True)
request = _FakeReq(
{"Authorization": f"Bearer {token}"},
path="/api/settings/nanobot-features/enable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_REMOTE,
request,
"/api/settings/nanobot-features/enable",
)
assert response is not None
assert response.status_code == 200
assert install_calls == []
assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][
"enabled"
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_remote_disable_does_not_need_install_policy(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}),
encoding="utf-8",
)
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"], installed=False)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_token(300, api_token=True)
request = _FakeReq(
{"Authorization": f"Bearer {token}"},
path="/api/settings/nanobot-features/disable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_REMOTE,
request,
"/api/settings/nanobot-features/disable",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["enabled"] is False
assert data["channels"]["matrix"]["homeserver"] == "keep"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cli_apps_catalog_does_not_block_other_webui_http_routes( async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
bus: MagicMock, bus: MagicMock,
@@ -1668,8 +1984,9 @@ class _FakeConn:
class _FakeReq: class _FakeReq:
"""Minimal request stub with configurable headers.""" """Minimal request stub with configurable headers."""
def __init__(self, headers: dict[str, str] | None = None): def __init__(self, headers: dict[str, str] | None = None, *, path: str = "/"):
self.headers = headers or {} self.headers = headers or {}
self.path = path
_REMOTE = _FakeConn(("192.168.1.5", 12345)) _REMOTE = _FakeConn(("192.168.1.5", 12345))
@@ -1677,6 +1994,43 @@ _LOCAL = _FakeConn(("127.0.0.1", 12345))
_NO_HEADERS = _FakeReq() _NO_HEADERS = _FakeReq()
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request
assert is_local_browser_request(_LOCAL, {"Host": "127.0.0.1:8765"}) is True
assert is_local_browser_request(_LOCAL, {"Host": "localhost:8765"}) is True
assert (
is_local_browser_request(
_LOCAL,
{"Host": "localhost:8765", "X-Forwarded-For": "127.0.0.1"},
)
is True
)
assert is_local_browser_request(_REMOTE, {"Host": "127.0.0.1:8765"}) is False
assert is_local_browser_request(_LOCAL, {"Host": "nanobot.example"}) is False
assert (
is_local_browser_request(
_LOCAL,
{"Host": "127.0.0.1:8765", "X-Forwarded-For": "203.0.113.42"},
)
is False
)
assert (
is_local_browser_request(
_LOCAL,
{"Host": "127.0.0.1:8765", "X-Forwarded-Host": "nanobot.example"},
)
is False
)
assert (
is_local_browser_request(
_LOCAL,
{"Host": "127.0.0.1:8765", "Forwarded": "for=203.0.113.42;host=nanobot.example"},
)
is False
)
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
import pytest import pytest
from pydantic_core import ValidationError from pydantic_core import ValidationError
+36
View File
@@ -348,6 +348,42 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8") assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
def test_run_argv_logs_command_exit_and_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.apps.cli import service as cli_service
manager = _manager(tmp_path)
records: list[str] = []
class _Logger:
def info(self, message: str, *args: object) -> None:
records.append(message.format(*args))
def fake_run(
argv: list[str],
*,
capture_output: bool,
text: bool,
timeout: int,
) -> subprocess.CompletedProcess[str]:
assert capture_output is True
assert text is True
assert timeout == 5
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
monkeypatch.setattr(cli_service, "logger", _Logger())
monkeypatch.setattr(cli_service.subprocess, "run", fake_run)
result = manager._run_argv(["python", "-m", "pip", "install", "sample"], timeout=5)
assert result.returncode == 0
assert any(record.startswith("CLI Apps: running ") for record in records)
assert any("command exited with code 0" in record for record in records)
assert any("installed ok" in record for record in records)
def test_install_records_available_cli_without_reinstalling( def test_install_records_available_cli_without_reinstalling(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+25
View File
@@ -283,3 +283,28 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
config = load_config(config_path) config = load_config(config_path)
assert config.tools.webui_allow_local_service_access is False assert config.tools.webui_allow_local_service_access is False
def test_load_config_defaults_remote_package_install_to_disabled(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
config = load_config(config_path)
assert config.tools.webui_allow_remote_package_install is False
def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None:
camel_path = tmp_path / "camel.json"
camel_path.write_text(
json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}),
encoding="utf-8",
)
snake_path = tmp_path / "snake.json"
snake_path.write_text(
json.dumps({"tools": {"webui_allow_remote_package_install": True}}),
encoding="utf-8",
)
assert load_config(camel_path).tools.webui_allow_remote_package_install is True
assert load_config(snake_path).tools.webui_allow_remote_package_install is True
@@ -156,7 +156,7 @@ def test_init_explicit_key_does_not_construct_credential(monkeypatch):
def test_init_missing_key_without_azure_identity_raises(monkeypatch): def test_init_missing_key_without_azure_identity_raises(monkeypatch):
"""Clear RuntimeError with pip-install hint when azure-identity is missing.""" """Clear RuntimeError with install hint when azure-identity is missing."""
# Force the import inside _AzureTokenProvider to fail. # Force the import inside _AzureTokenProvider to fail.
real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
@@ -166,7 +166,7 @@ def test_init_missing_key_without_azure_identity_raises(monkeypatch):
return real_import(name, *args, **kwargs) return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import): with patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(RuntimeError, match=r"pip install 'nanobot-ai\[azure\]'"): with pytest.raises(RuntimeError, match=r"nanobot plugins enable azure"):
AzureOpenAIProvider(api_key="", api_base="https://res.openai.azure.com") AzureOpenAIProvider(api_key="", api_base="https://res.openai.azure.com")
+6 -2
View File
@@ -11,7 +11,11 @@ except ImportError:
MSTEAMS_AVAILABLE = False MSTEAMS_AVAILABLE = False
if not MSTEAMS_AVAILABLE: if not MSTEAMS_AVAILABLE:
pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True) pytest.skip(
"MSTeams dependencies not installed (PyJWT, cryptography). "
"Run: nanobot plugins enable msteams",
allow_module_level=True,
)
import jwt import jwt
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
@@ -903,7 +907,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
await ch.start() await ch.start()
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"] assert errors == ["PyJWT not installed. Run: nanobot plugins enable msteams"]
def test_save_refs_prunes_webchat_and_stale_refs(make_channel): def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
@@ -26,9 +26,12 @@ def test_websocket_handshake_noise_filter_suppresses_disconnects() -> None:
filter_ = WebSocketHandshakeNoiseFilter() filter_ = WebSocketHandshakeNoiseFilter()
wrapped = RuntimeError("wrapped") wrapped = RuntimeError("wrapped")
wrapped.__cause__ = BrokenPipeError(32, "Broken pipe") wrapped.__cause__ = BrokenPipeError(32, "Broken pipe")
empty_handshake = RuntimeError("wrapped")
empty_handshake.__cause__ = EOFError("connection closed while reading HTTP request line")
assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, BrokenPipeError())) assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, BrokenPipeError()))
assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, wrapped)) assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, wrapped))
assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, empty_handshake))
def test_websocket_handshake_noise_filter_keeps_real_errors() -> None: def test_websocket_handshake_noise_filter_keeps_real_errors() -> None:
+305 -13
View File
@@ -83,11 +83,14 @@ import { Textarea } from "@/components/ui/textarea";
import { import {
checkVersion, checkVersion,
createModelConfiguration, createModelConfiguration,
disableNanobotFeature,
enableNanobotFeature,
fetchAutomations, fetchAutomations,
fetchSettings, fetchSettings,
fetchSettingsUsage, fetchSettingsUsage,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchNanobotFeatures,
fetchProviderModels, fetchProviderModels,
importMcpConfig, importMcpConfig,
loginProviderOAuth, loginProviderOAuth,
@@ -127,6 +130,8 @@ import type {
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
McpPresetInfo, McpPresetInfo,
McpPresetsPayload, McpPresetsPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
ProviderModelsPayload, ProviderModelsPayload,
SessionAutomationJob, SessionAutomationJob,
@@ -152,11 +157,12 @@ export type SettingsSectionKey =
type LocalDensity = "comfortable" | "compact"; type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded"; type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "cli" | "mcp"; type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationSort = "next" | "last" | "updated" | "name";
type AutomationAction = "enable" | "disable" | "delete" | "run"; type AutomationAction = "enable" | "disable" | "delete" | "run";
type AppsCatalogItem = type AppsCatalogItem =
| { id: string; kind: "nanobot"; feature: NanobotFeatureInfo }
| { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo }; | { id: string; kind: "mcp"; preset: McpPresetInfo };
@@ -259,7 +265,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable", density: "comfortable",
activityMode: "auto", activityMode: "auto",
codeWrap: true, codeWrap: true,
brandLogos: true, brandLogos: false,
}; };
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
{ value: "auto", label: "Auto" }, { value: "auto", label: "Auto" },
@@ -321,7 +327,7 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable", density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false, codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false, brandLogos: parsed.brandLogos === true,
}; };
} catch { } catch {
return DEFAULT_LOCAL_PREFS; return DEFAULT_LOCAL_PREFS;
@@ -536,10 +542,12 @@ export function SettingsView({
const { token } = useClient(); const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings); const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null); const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null); const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [automations, setAutomations] = useState<AutomationsPayload | null>(null); const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
const [loading, setLoading] = useState(() => initialSettings === null); const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true); const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [automationsLoading, setAutomationsLoading] = useState(false); const [automationsLoading, setAutomationsLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -551,6 +559,8 @@ export function SettingsView({
model: "", model: "",
}); });
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null); const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [nanobotFeatureAction, setNanobotFeatureAction] = useState<string | null>(null);
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null); const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null); const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false); const [webSearchSaving, setWebSearchSaving] = useState(false);
@@ -568,6 +578,8 @@ export function SettingsView({
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next"); const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null); const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null); const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesMessage, setNanobotFeaturesMessage] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null); const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all"); const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
const [mcpMessage, setMcpMessage] = useState<string | null>(null); const [mcpMessage, setMcpMessage] = useState<string | null>(null);
@@ -731,6 +743,29 @@ export function SettingsView({
}; };
}, [activeSection, token]); }, [activeSection, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setNanobotFeaturesLoading(true);
fetchNanobotFeatures(token)
.then((payload) => {
if (!cancelled) {
setNanobotFeatures(payload);
setNanobotFeaturesError(null);
}
})
.catch((err) => {
const message = (err as Error).message;
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
})
.finally(() => {
if (!cancelled) setNanobotFeaturesLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, token]);
useEffect(() => { useEffect(() => {
if (activeSection !== "apps") return; if (activeSection !== "apps") return;
let cancelled = false; let cancelled = false;
@@ -1330,6 +1365,42 @@ export function SettingsView({
} }
}; };
const handleNanobotFeatureAction = async (
action: "enable" | "disable",
name: string,
confirmed = false,
) => {
const feature = nanobotFeatures?.features.find((item) => item.name === name);
if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) {
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
setNanobotFeatureConfirm(feature);
return;
}
const key = `${action}:${name}`;
setNanobotFeatureAction(key);
setNanobotFeatureConfirm(null);
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(token, name)
: await disableNanobotFeature(token, name);
setNanobotFeatures(payload);
setNanobotFeaturesMessage(payload.last_action?.message ?? null);
if (
payload.requires_restart ||
payload.features.some((feature) => feature.name === name && feature.requires_restart)
) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
} catch (err) {
setNanobotFeaturesError((err as Error).message);
} finally {
setNanobotFeatureAction(null);
}
};
const handleAutomationAction = async ( const handleAutomationAction = async (
action: AutomationAction, action: AutomationAction,
job: SessionAutomationJob, job: SessionAutomationJob,
@@ -1604,15 +1675,20 @@ export function SettingsView({
return ( return (
<AppsCatalogSettings <AppsCatalogSettings
cliApps={cliApps} cliApps={cliApps}
nanobotFeatures={nanobotFeatures}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading} cliAppsLoading={cliAppsLoading}
nanobotFeaturesLoading={nanobotFeaturesLoading}
mcpPresetsLoading={mcpPresetsLoading} mcpPresetsLoading={mcpPresetsLoading}
query={appsQuery} query={appsQuery}
filter={appsKindFilter} filter={appsKindFilter}
cliActionKey={cliAppsAction} cliActionKey={cliAppsAction}
nanobotActionKey={nanobotFeatureAction}
mcpActionKey={mcpPresetAction} mcpActionKey={mcpPresetAction}
cliMessage={cliAppsMessage} cliMessage={cliAppsMessage}
cliError={cliAppsError} cliError={cliAppsError}
nanobotMessage={nanobotFeaturesMessage}
nanobotError={nanobotFeaturesError}
cliFocusName={cliAppsFocusName} cliFocusName={cliAppsFocusName}
mcpMessage={mcpMessage} mcpMessage={mcpMessage}
mcpError={mcpError} mcpError={mcpError}
@@ -1624,10 +1700,13 @@ export function SettingsView({
onQueryChange={setAppsQuery} onQueryChange={setAppsQuery}
onFilterChange={setAppsKindFilter} onFilterChange={setAppsKindFilter}
onCliAction={handleCliAppAction} onCliAction={handleCliAppAction}
onNanobotAction={handleNanobotFeatureAction}
onMcpAction={handleMcpPresetAction} onMcpAction={handleMcpPresetAction}
onDismissStatus={() => { onDismissStatus={() => {
setCliAppsMessage(null); setCliAppsMessage(null);
setCliAppsError(null); setCliAppsError(null);
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
setMcpMessage(null); setMcpMessage(null);
setMcpError(null); setMcpError(null);
}} }}
@@ -1733,6 +1812,15 @@ export function SettingsView({
onSave={handleCreateModelConfiguration} onSave={handleCreateModelConfiguration}
/> />
<NanobotFeatureInstallDialog
feature={nanobotFeatureConfirm}
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
onOpenChange={(open) => {
if (!open) setNanobotFeatureConfirm(null);
}}
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
/>
<AutomationDeleteDialog <AutomationDeleteDialog
job={automationPendingDelete} job={automationPendingDelete}
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`} deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
@@ -4340,6 +4428,64 @@ function AutomationDeleteDialog({
); );
} }
function NanobotFeatureInstallDialog({
feature,
installing,
onOpenChange,
onConfirm,
}: {
feature: NanobotFeatureInfo | null;
installing: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (feature: NanobotFeatureInfo) => void | Promise<void>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const name = feature?.display_name || feature?.name || "";
return (
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl sm:rounded-[28px]"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
</DialogTitle>
<DialogDescription className="mt-3 max-w-[20rem] text-center text-[14px] leading-6 text-muted-foreground">
{tx(
"settings.nanobotFeatures.installConfirmDescription",
"nanobot will add what {{name}} needs, then turn it on. Continue?",
{ name },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={installing}
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button
type="button"
onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean { function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
if (!job) return false; if (!job) return false;
return job.kind === "local_trigger" return job.kind === "local_trigger"
@@ -4906,15 +5052,20 @@ function formatAutomationInterval(ms: number, locale: string): string {
function AppsCatalogSettings({ function AppsCatalogSettings({
cliApps, cliApps,
nanobotFeatures,
mcpPresets, mcpPresets,
cliAppsLoading, cliAppsLoading,
nanobotFeaturesLoading,
mcpPresetsLoading, mcpPresetsLoading,
query, query,
filter, filter,
cliActionKey, cliActionKey,
nanobotActionKey,
mcpActionKey, mcpActionKey,
cliMessage, cliMessage,
cliError, cliError,
nanobotMessage,
nanobotError,
cliFocusName, cliFocusName,
mcpMessage, mcpMessage,
mcpError, mcpError,
@@ -4926,6 +5077,7 @@ function AppsCatalogSettings({
onQueryChange, onQueryChange,
onFilterChange, onFilterChange,
onCliAction, onCliAction,
onNanobotAction,
onMcpAction, onMcpAction,
onDismissStatus, onDismissStatus,
onBackToChat, onBackToChat,
@@ -4939,15 +5091,20 @@ function AppsCatalogSettings({
isRestarting, isRestarting,
}: { }: {
cliApps: CliAppsPayload | null; cliApps: CliAppsPayload | null;
nanobotFeatures: NanobotFeaturesPayload | null;
mcpPresets: McpPresetsPayload | null; mcpPresets: McpPresetsPayload | null;
cliAppsLoading: boolean; cliAppsLoading: boolean;
nanobotFeaturesLoading: boolean;
mcpPresetsLoading: boolean; mcpPresetsLoading: boolean;
query: string; query: string;
filter: AppsKindFilter; filter: AppsKindFilter;
cliActionKey: string | null; cliActionKey: string | null;
nanobotActionKey: string | null;
mcpActionKey: string | null; mcpActionKey: string | null;
cliMessage: string | null; cliMessage: string | null;
cliError: string | null; cliError: string | null;
nanobotMessage: string | null;
nanobotError: string | null;
cliFocusName: string | null; cliFocusName: string | null;
mcpMessage: string | null; mcpMessage: string | null;
mcpError: string | null; mcpError: string | null;
@@ -4959,6 +5116,7 @@ function AppsCatalogSettings({
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void; onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onNanobotAction: (action: "enable" | "disable", name: string) => void;
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void; onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onDismissStatus: () => void; onDismissStatus: () => void;
onBackToChat: () => void; onBackToChat: () => void;
@@ -4975,11 +5133,17 @@ function AppsCatalogSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const filterOptions = [ const filterOptions = [
{ value: "all", label: tx("settings.apps.filterAll", "All") }, { value: "all", label: tx("settings.apps.filterAll", "All") },
{ value: "nanobot", label: tx("settings.apps.filterPlugins", "Plugins") },
{ value: "cli", label: tx("settings.apps.filterCli", "App CLIs") }, { value: "cli", label: tx("settings.apps.filterCli", "App CLIs") },
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") }, { value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") },
]; ];
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const items: AppsCatalogItem[] = [ const items: AppsCatalogItem[] = [
...(nanobotFeatures?.features ?? []).map((feature) => ({
id: `nanobot:${feature.name}`,
kind: "nanobot" as const,
feature,
})),
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
...(mcpPresets?.presets ?? []).map((preset) => ({ ...(mcpPresets?.presets ?? []).map((preset) => ({
id: `mcp:${preset.name}`, id: `mcp:${preset.name}`,
@@ -4996,13 +5160,22 @@ function AppsCatalogSettings({
const focusedApp = cliFocusName const focusedApp = cliFocusName
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
: null; : null;
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; const loading =
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); (cliAppsLoading || nanobotFeaturesLoading || mcpPresetsLoading) &&
const statusIsError = Boolean(cliError || mcpError); !cliApps &&
!nanobotFeatures &&
!mcpPresets;
const statusMessage =
cliError ||
nanobotError ||
mcpError ||
(!focusedApp ? cliMessage || nanobotMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || nanobotError || mcpError);
const caption = t("settings.apps.caption", { const caption = t("settings.apps.caption", {
plugins: nanobotFeatures?.enabled_count ?? 0,
cli: cliApps?.installed_count ?? 0, cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0, mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP", defaultValue: "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
}); });
return ( return (
@@ -5012,7 +5185,7 @@ function AppsCatalogSettings({
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground"> <p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx( {tx(
"settings.apps.description", "settings.apps.description",
"Add local app adapters and connected tool servers that nanobot can use from chat.", "Enable plugins, local app adapters, and connected tool servers.",
)} )}
</p> </p>
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span> <span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
@@ -5068,7 +5241,7 @@ function AppsCatalogSettings({
{requiresRestartPending ? ( {requiresRestartPending ? (
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")}</span> <span>{tx("settings.apps.restartRequired", "Restart nanobot to apply updated apps and features.")}</span>
{onRestart ? ( {onRestart ? (
<Button <Button
type="button" type="button"
@@ -5091,7 +5264,7 @@ function AppsCatalogSettings({
<section> <section>
<div className="flex items-center justify-between border-b border-border/45 pb-3"> <div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle> <SettingsSectionTitle>{tx("settings.apps.featured", "Catalog")}</SettingsSectionTitle>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground"> <span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{items.length} {items.length}
</span> </span>
@@ -5104,7 +5277,14 @@ function AppsCatalogSettings({
) : items.length ? ( ) : items.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2"> <div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{items.map((item) => {items.map((item) =>
item.kind === "cli" ? ( item.kind === "nanobot" ? (
<NanobotFeatureCatalogRow
key={item.id}
feature={item.feature}
actionKey={nanobotActionKey}
onAction={onNanobotAction}
/>
) : item.kind === "cli" ? (
<CliAppsCatalogRow <CliAppsCatalogRow
key={item.id} key={item.id}
app={item.app} app={item.app}
@@ -5133,7 +5313,7 @@ function AppsCatalogSettings({
)} )}
</section> </section>
{filter !== "cli" ? ( {filter === "all" || filter === "mcp" ? (
<McpCustomServerPanel <McpCustomServerPanel
form={customMcpForm} form={customMcpForm}
configImport={mcpConfigImport} configImport={mcpConfigImport}
@@ -5150,6 +5330,92 @@ function AppsCatalogSettings({
); );
} }
function NanobotFeatureCatalogRow({
feature,
actionKey,
onAction,
}: {
feature: NanobotFeatureInfo;
actionKey: string | null;
onAction: (action: "enable" | "disable", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const enableBusy = actionKey === `enable:${feature.name}`;
const disableBusy = actionKey === `disable:${feature.name}`;
const description = nanobotFeatureStatusLabel(feature, tx);
const missingSupport = feature.enabled && !feature.installed;
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
const enabledLabel =
feature.type === "channel" && feature.name === "websocket"
? tx("settings.nanobotFeatures.websocketRequired", "Required for WebUI")
: tx("settings.nanobotFeatures.enabled", "Enabled");
const enableLabel = feature.installed
? tx("settings.nanobotFeatures.enable", "Enable")
: installSupportLabel;
return (
<article className="group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/55 bg-card text-muted-foreground shadow-sm">
<Bot className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{feature.display_name}
</h3>
<AppsTypeBadge>
{feature.type === "channel"
? tx("settings.apps.channelLabel", "Channel")
: tx("settings.apps.featureLabel", "Feature")}
</AppsTypeBadge>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{missingSupport && feature.install_supported ? (
<AppsActionButton
ariaLabel={installSupportLabel}
busy={enableBusy}
onClick={() => onAction("enable", feature.name)}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.enabled && feature.type === "channel" && feature.name !== "websocket" ? (
<AppsActionButton
ariaLabel={tx("settings.nanobotFeatures.disable", "Disable")}
busy={disableBusy}
tone="danger"
onClick={() => onAction("disable", feature.name)}
>
<X className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.enabled ? (
<AppsActionButton
ariaLabel={enabledLabel}
disabled
tone="installed"
>
<Check className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.install_supported ? (
<AppsActionButton
ariaLabel={enableLabel}
busy={enableBusy}
onClick={() => onAction("enable", feature.name)}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : (
<AppsActionButton ariaLabel={tx("settings.cliApps.unavailable", "Unavailable")} disabled>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
)}
</div>
</article>
);
}
function CliAppsCatalogRow({ function CliAppsCatalogRow({
app, app,
actionKey, actionKey,
@@ -5553,14 +5819,27 @@ const AppsActionButton = forwardRef<HTMLButtonElement, {
}); });
function appsTitle(item: AppsCatalogItem): string { function appsTitle(item: AppsCatalogItem): string {
if (item.kind === "nanobot") return item.feature.display_name;
return item.kind === "cli" ? item.app.display_name : item.preset.display_name; return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
} }
function appsReady(item: AppsCatalogItem): boolean { function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "nanobot") return item.feature.enabled;
return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured; return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
} }
function appsSearchText(item: AppsCatalogItem): string { function appsSearchText(item: AppsCatalogItem): string {
if (item.kind === "nanobot") {
const feature = item.feature;
return [
feature.display_name,
feature.name,
feature.type,
feature.status,
]
.join(" ")
.toLowerCase();
}
if (item.kind === "cli") { if (item.kind === "cli") {
const app = item.app; const app = item.app;
return [ return [
@@ -5587,7 +5866,20 @@ function appsSearchText(item: AppsCatalogItem): string {
preset.source ?? "", preset.source ?? "",
] ]
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
}
function nanobotFeatureStatusLabel(
feature: NanobotFeatureInfo,
tx: (key: string, fallback: string) => string,
): string {
if (feature.ready && feature.type === "channel" && feature.name === "websocket") {
return tx("settings.nanobotFeatures.websocketRequired", "Required for WebUI");
}
if (feature.ready) return tx("settings.nanobotFeatures.ready", "Ready");
if (!feature.installed) return tx("settings.nanobotFeatures.missingDependency", "Support missing");
if (feature.type === "channel") return tx("settings.nanobotFeatures.channelDisabled", "Channel is disabled");
return tx("settings.nanobotFeatures.notEnabled", "Not enabled");
} }
function McpCustomServerPanel({ function McpCustomServerPanel({
+22 -4
View File
@@ -458,18 +458,36 @@
"missingCredential": "Configure this provider before enabling image generation." "missingCredential": "Configure this provider before enabling image generation."
}, },
"apps": { "apps": {
"description": "Add app CLIs and MCP services nanobot can use from chat.", "description": "Enable plugins, local app adapters, and connected tool servers.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "All", "filterAll": "All",
"filterPlugins": "Plugins",
"filterCli": "CLI apps", "filterCli": "CLI apps",
"filterMcp": "MCP services", "filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled", "enabledSummary": "{{count}} enabled",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps", "searchPlaceholder": "Search Apps",
"featured": "Featured", "featured": "Catalog",
"loading": "Loading Apps...", "loading": "Loading Apps...",
"empty": "No apps match this filter." "empty": "No apps match this filter.",
"restartRequired": "Restart nanobot to apply updated apps and features."
},
"nanobotFeatures": {
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"ready": "Ready",
"missingDependency": "Support missing",
"installSupport": "Install support",
"installConfirmTitle": "Install support for {{name}}?",
"installConfirmDescription": "nanobot will add what {{name}} needs, then turn it on. Continue?",
"installConfirmAction": "Install and enable",
"websocketRequired": "Required for WebUI",
"channelDisabled": "Channel is disabled",
"notEnabled": "Not enabled"
}, },
"automations": { "automations": {
"filters": { "filters": {
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo." "thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
}, },
"apps": { "apps": {
"description": "Agrega CLI de apps y servicios MCP que nanobot puede usar desde el chat.", "description": "Activa complementos, adaptadores locales de apps y servidores de herramientas conectados.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Todo", "filterAll": "Todo",
"filterPlugins": "Complementos",
"filterCli": "Apps CLI", "filterCli": "Apps CLI",
"filterMcp": "Servicios MCP", "filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados", "enabledSummary": "{{count}} activados",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Buscar apps", "searchPlaceholder": "Buscar apps",
"featured": "Destacadas", "featured": "Catálogo",
"loading": "Cargando apps...", "loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro." "empty": "Ninguna app coincide con este filtro.",
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
},
"nanobotFeatures": {
"enabled": "Activado",
"enable": "Activar",
"disable": "Desactivar",
"ready": "Listo",
"missingDependency": "Falta soporte",
"installSupport": "Instalar soporte",
"installConfirmTitle": "¿Instalar soporte para {{name}}?",
"installConfirmDescription": "nanobot añadirá lo que {{name}} necesita y luego lo activará. ¿Continuar?",
"installConfirmAction": "Instalar y activar",
"websocketRequired": "Requerido para WebUI",
"channelDisabled": "El canal está desactivado",
"notEnabled": "No activado"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Usar @{{name}} como servidor MCP" "mcpDescription": "Usar @{{name}} como servidor MCP"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "Modo de acceso al espacio de trabajo",
"projectAria": "Elegir proyecto", "projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto", "projectPlaceholder": "Seleccionar proyecto",
"default": "Default Permission", "default": "Permiso predeterminado",
"full": "Full Access" "full": "Acceso completo"
} }
}, },
"scrollToBottom": "Desplazarse al final", "scrollToBottom": "Desplazarse al final",
@@ -1053,17 +1071,17 @@
"body": "El servidor rechazó tu último mensaje por superar el tamaño permitido. Quita algunas imágenes o usa archivos más pequeños y vuelve a enviarlo." "body": "El servidor rechazó tu último mensaje por superar el tamaño permitido. Quita algunas imágenes o usa archivos más pequeños y vuelve a enviarlo."
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "El espacio de trabajo no cambió",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "Espacio de trabajo predeterminado",
"manual": "Pegar ruta", "manual": "Pegar ruta",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "Usar ruta",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
} }
} }
} }
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation." "thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
}, },
"apps": { "apps": {
"description": "Ajoutez des CLI dapps et services MCP utilisables par nanobot depuis le chat.", "description": "Activez des extensions, des adaptateurs dapps locales et des serveurs doutils connectés.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Tout", "filterAll": "Tout",
"filterPlugins": "Extensions",
"filterCli": "Apps CLI", "filterCli": "Apps CLI",
"filterMcp": "Services MCP", "filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés", "enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps", "searchPlaceholder": "Rechercher des apps",
"featured": "À la une", "featured": "Catalogue",
"loading": "Chargement des apps...", "loading": "Chargement des apps...",
"empty": "Aucune app ne correspond." "empty": "Aucune app ne correspond.",
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
},
"nanobotFeatures": {
"enabled": "Activé",
"enable": "Activer",
"disable": "Désactiver",
"ready": "Prêt",
"missingDependency": "Support manquant",
"installSupport": "Installer le support",
"installConfirmTitle": "Installer le support pour {{name}} ?",
"installConfirmDescription": "nanobot ajoutera ce dont {{name}} a besoin, puis l'activera. Continuer ?",
"installConfirmAction": "Installer et activer",
"websocketRequired": "Requis pour la WebUI",
"channelDisabled": "Canal désactivé",
"notEnabled": "Non activé"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Utiliser @{{name}} comme serveur MCP" "mcpDescription": "Utiliser @{{name}} comme serveur MCP"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "Mode daccès à lespace de travail",
"projectAria": "Choisir un projet", "projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet", "projectPlaceholder": "Sélectionner un projet",
"default": "Default Permission", "default": "Autorisation par défaut",
"full": "Full Access" "full": "Accès complet"
} }
}, },
"scrollToBottom": "Faire défiler vers le bas", "scrollToBottom": "Faire défiler vers le bas",
@@ -1053,17 +1071,17 @@
"body": "Le serveur a rejeté votre dernier message car il dépasse la taille autorisée. Retirez des images ou choisissez des fichiers plus légers, puis renvoyez-le." "body": "Le serveur a rejeté votre dernier message car il dépasse la taille autorisée. Retirez des images ou choisissez des fichiers plus légers, puis renvoyez-le."
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "Lespace de travail na pas changé",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "Espace de travail par défaut",
"manual": "Coller un chemin", "manual": "Coller un chemin",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "Utiliser le chemin",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
} }
} }
} }
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan." "thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
}, },
"apps": { "apps": {
"description": "Tambahkan CLI aplikasi dan layanan MCP yang dapat digunakan nanobot dari chat.", "description": "Aktifkan plugin, adaptor aplikasi lokal, dan server alat terhubung.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Semua", "filterAll": "Semua",
"filterPlugins": "Plugin",
"filterCli": "Aplikasi CLI", "filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP", "filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif", "enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi", "searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan", "featured": "Katalog",
"loading": "Memuat aplikasi...", "loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok." "empty": "Tidak ada aplikasi yang cocok.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
},
"nanobotFeatures": {
"enabled": "Aktif",
"enable": "Aktifkan",
"disable": "Nonaktifkan",
"ready": "Siap",
"missingDependency": "Dukungan belum terpasang",
"installSupport": "Instal dukungan",
"installConfirmTitle": "Instal dukungan untuk {{name}}?",
"installConfirmDescription": "nanobot akan menambahkan yang dibutuhkan {{name}}, lalu mengaktifkannya. Lanjutkan?",
"installConfirmAction": "Instal dan aktifkan",
"websocketRequired": "Wajib untuk WebUI",
"channelDisabled": "Kanal dinonaktifkan",
"notEnabled": "Belum aktif"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Gunakan @{{name}} sebagai server MCP" "mcpDescription": "Gunakan @{{name}} sebagai server MCP"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "Mode akses workspace",
"projectAria": "Pilih proyek", "projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek", "projectPlaceholder": "Pilih proyek",
"default": "Default Permission", "default": "Izin default",
"full": "Full Access" "full": "Akses penuh"
} }
}, },
"scrollToBottom": "Gulir ke bawah", "scrollToBottom": "Gulir ke bawah",
@@ -1053,17 +1071,17 @@
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi." "body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "Workspace tidak berubah",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "Workspace default",
"manual": "Tempel path", "manual": "Tempel path",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "Gunakan path",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "Masukkan path folder absolut di mesin ini."
} }
} }
} }
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。" "thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
}, },
"apps": { "apps": {
"description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。", "description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "すべて", "filterAll": "すべて",
"filterPlugins": "プラグイン",
"filterCli": "CLI アプリ", "filterCli": "CLI アプリ",
"filterMcp": "MCP サービス", "filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効", "enabledSummary": "{{count}} 件有効",
"caption": "CLI {{cli}} 件 · MCP {{mcp}} 件", "caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
"searchPlaceholder": "アプリを検索", "searchPlaceholder": "アプリを検索",
"featured": "注目", "featured": "カタログ",
"loading": "アプリを読み込み中...", "loading": "アプリを読み込み中...",
"empty": "一致するアプリはありません。" "empty": "一致するアプリはありません。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
},
"nanobotFeatures": {
"enabled": "有効",
"enable": "有効化",
"disable": "無効化",
"ready": "準備完了",
"missingDependency": "サポート不足",
"installSupport": "サポートをインストール",
"installConfirmTitle": "{{name}} のサポートをインストールしますか?",
"installConfirmDescription": "nanobot が {{name}} に必要なものを追加し、その後有効化します。続けますか?",
"installConfirmAction": "インストールして有効化",
"websocketRequired": "WebUI に必須",
"channelDisabled": "チャンネルは無効",
"notEnabled": "未有効"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "@{{name}} を MCP サーバーとして使用" "mcpDescription": "@{{name}} を MCP サーバーとして使用"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "ワークスペースのアクセスモード",
"projectAria": "プロジェクトを選択", "projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択", "projectPlaceholder": "プロジェクトを選択",
"default": "Default Permission", "default": "既定の権限",
"full": "Full Access" "full": "フルアクセス"
} }
}, },
"scrollToBottom": "一番下へスクロール", "scrollToBottom": "一番下へスクロール",
@@ -1053,17 +1071,17 @@
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。" "body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。"
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "ワークスペースは変更されませんでした",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "既定のワークスペース",
"manual": "パスを貼り付け", "manual": "パスを貼り付け",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "パスを使用",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
} }
} }
} }
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다." "thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
}, },
"apps": { "apps": {
"description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.", "description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "전체", "filterAll": "전체",
"filterPlugins": "플러그인",
"filterCli": "CLI 앱", "filterCli": "CLI 앱",
"filterMcp": "MCP 서비스", "filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨", "enabledSummary": "{{count}}개 활성화됨",
"caption": "CLI {{cli}}개 · MCP {{mcp}}개", "caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
"searchPlaceholder": "앱 검색", "searchPlaceholder": "앱 검색",
"featured": "추천", "featured": "카탈로그",
"loading": "앱을 불러오는 중...", "loading": "앱을 불러오는 중...",
"empty": "일치하는 앱이 없습니다." "empty": "일치하는 앱이 없습니다.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
},
"nanobotFeatures": {
"enabled": "활성화됨",
"enable": "활성화",
"disable": "비활성화",
"ready": "준비됨",
"missingDependency": "지원 패키지 없음",
"installSupport": "지원 패키지 설치",
"installConfirmTitle": "{{name}} 지원을 설치할까요?",
"installConfirmDescription": "nanobot이 {{name}}에 필요한 것을 추가한 뒤 활성화합니다. 계속할까요?",
"installConfirmAction": "설치하고 활성화",
"websocketRequired": "WebUI 필수",
"channelDisabled": "채널이 비활성화됨",
"notEnabled": "비활성화됨"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "@{{name}}을 MCP 서버로 사용" "mcpDescription": "@{{name}}을 MCP 서버로 사용"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "작업공간 접근 모드",
"projectAria": "프로젝트 선택", "projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택", "projectPlaceholder": "프로젝트 선택",
"default": "Default Permission", "default": "기본 권한",
"full": "Full Access" "full": "전체 접근 권한"
} }
}, },
"scrollToBottom": "맨 아래로 스크롤", "scrollToBottom": "맨 아래로 스크롤",
@@ -1053,17 +1071,17 @@
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요." "body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요."
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "작업공간이 변경되지 않았습니다",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "기본 작업공간",
"manual": "경로 붙여넣기", "manual": "경로 붙여넣기",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "경로 사용",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
} }
} }
} }
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận." "thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
}, },
"apps": { "apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP mà nanobot có thể dùng trong chat.", "description": "Bật plugin, bộ chuyển đổi ứng dụng cục bộ và máy chủ công cụ đã kết nối.",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Tất cả", "filterAll": "Tất cả",
"filterPlugins": "Plugin",
"filterCli": "Ứng dụng CLI", "filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP", "filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật", "enabledSummary": "{{count}} đã bật",
"caption": "{{cli}} CLI · {{mcp}} MCP", "caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Tìm ứng dụng", "searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật", "featured": "Danh mục",
"loading": "Đang tải ứng dụng...", "loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng phù hợp." "empty": "Không có ứng dụng phù hợp.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
},
"nanobotFeatures": {
"enabled": "Đã bật",
"enable": "Bật",
"disable": "Tắt",
"ready": "Sẵn sàng",
"missingDependency": "Thiếu gói hỗ trợ",
"installSupport": "Cài gói hỗ trợ",
"installConfirmTitle": "Cài hỗ trợ cho {{name}}?",
"installConfirmDescription": "nanobot sẽ thêm những gì {{name}} cần, rồi bật tính năng này. Tiếp tục?",
"installConfirmAction": "Cài và bật",
"websocketRequired": "Bắt buộc cho WebUI",
"channelDisabled": "Kênh đang tắt",
"notEnabled": "Chưa bật"
}, },
"automations": { "automations": {
"filters": { "filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Dùng @{{name}} như máy chủ MCP" "mcpDescription": "Dùng @{{name}} như máy chủ MCP"
}, },
"workspace": { "workspace": {
"accessAria": "Workspace access mode", "accessAria": "Chế độ truy cập workspace",
"projectAria": "Chọn dự án", "projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án", "projectPlaceholder": "Chọn dự án",
"default": "Default Permission", "default": "Quyền mặc định",
"full": "Full Access" "full": "Toàn quyền truy cập"
} }
}, },
"scrollToBottom": "Cuộn xuống cuối", "scrollToBottom": "Cuộn xuống cuối",
@@ -1053,17 +1071,17 @@
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại." "body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "title": "Workspace không thay đổi",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway." "body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó."
} }
}, },
"workspace": { "workspace": {
"dialog": { "dialog": {
"defaultProject": "Default workspace", "defaultProject": "Workspace mặc định",
"manual": "Dán đường dẫn", "manual": "Dán đường dẫn",
"manualPlaceholder": "/Users/name/project", "manualPlaceholder": "/Users/name/project",
"usePath": "Use Path", "usePath": "Dùng đường dẫn",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
} }
} }
} }
+22 -4
View File
@@ -458,18 +458,36 @@
"missingCredential": "启用图片生成前请先配置此提供商。" "missingCredential": "启用图片生成前请先配置此提供商。"
}, },
"apps": { "apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。", "description": "启用插件、本地应用适配器和已连接的工具服务。",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "全部", "filterAll": "全部",
"filterPlugins": "插件",
"filterCli": "CLI 应用", "filterCli": "CLI 应用",
"filterMcp": "MCP 服务", "filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个", "enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} 个 CLI · {{mcp}} 个 MCP", "caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
"searchPlaceholder": "搜索应用", "searchPlaceholder": "搜索应用",
"featured": "精选", "featured": "应用目录",
"loading": "正在加载应用...", "loading": "正在加载应用...",
"empty": "没有匹配的应用。" "empty": "没有匹配的应用。",
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
},
"nanobotFeatures": {
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"ready": "就绪",
"missingDependency": "缺少支持包",
"installSupport": "安装支持包",
"installConfirmTitle": "安装 {{name}} 支持?",
"installConfirmDescription": "将安装并启用 {{name}} 支持。是否继续?",
"installConfirmAction": "安装并启用",
"websocketRequired": "WebUI 必需",
"channelDisabled": "渠道已禁用",
"notEnabled": "未启用"
}, },
"automations": { "automations": {
"filters": { "filters": {
+22 -4
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。" "thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
}, },
"apps": { "apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI 和 MCP 服務。", "description": "啟用插件、本機應用適配器和已連接的工具服務。",
"cliLabel": "CLI", "cliLabel": "CLI",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "通道",
"featureLabel": "能力",
"filterAll": "全部", "filterAll": "全部",
"filterPlugins": "插件",
"filterCli": "CLI 應用", "filterCli": "CLI 應用",
"filterMcp": "MCP 服務", "filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個", "enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} 個 CLI · {{mcp}} 個 MCP", "caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
"searchPlaceholder": "搜尋應用", "searchPlaceholder": "搜尋應用",
"featured": "精選", "featured": "應用目錄",
"loading": "正在載入應用...", "loading": "正在載入應用...",
"empty": "沒有符合的應用。" "empty": "沒有符合的應用。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
},
"nanobotFeatures": {
"enabled": "已啟用",
"enable": "啟用",
"disable": "停用",
"ready": "就緒",
"missingDependency": "缺少支援套件",
"installSupport": "安裝支援套件",
"installConfirmTitle": "安裝 {{name}} 支援?",
"installConfirmDescription": "將安裝並啟用 {{name}} 支援。是否繼續?",
"installConfirmAction": "安裝並啟用",
"websocketRequired": "WebUI 必需",
"channelDisabled": "通道已停用",
"notEnabled": "未啟用"
}, },
"automations": { "automations": {
"filters": { "filters": {
+39
View File
@@ -6,6 +6,7 @@ import type {
FilePreviewPayload, FilePreviewPayload,
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
McpPresetsPayload, McpPresetsPayload,
NanobotFeaturesPayload,
ModelConfigurationCreate, ModelConfigurationCreate,
ModelConfigurationUpdate, ModelConfigurationUpdate,
NetworkSafetySettingsUpdate, NetworkSafetySettingsUpdate,
@@ -358,6 +359,44 @@ export async function fetchInstalledCliApps(
); );
} }
export async function fetchNanobotFeatures(
token: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function enableNanobotFeature(
token: string,
name: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/enable?${query}`,
token,
);
}
export async function disableNanobotFeature(
token: string,
name: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/disable?${query}`,
token,
);
}
export async function runCliAppAction( export async function runCliAppAction(
token: string, token: string,
action: "install" | "update" | "uninstall" | "test", action: "install" | "update" | "uninstall" | "test",
+23
View File
@@ -624,6 +624,29 @@ export interface CliAppsPayload {
}; };
} }
export interface NanobotFeatureInfo {
name: string;
display_name: string;
type: "channel" | "feature" | string;
enabled: boolean;
installed: boolean;
ready: boolean;
status: "enabled" | "missing_dependency" | "not_enabled" | string;
install_supported: boolean;
requires_restart: boolean;
}
export interface NanobotFeaturesPayload {
features: NanobotFeatureInfo[];
enabled_count: number;
requires_restart?: boolean;
last_action?: {
ok: boolean;
message: string;
enabled?: boolean;
};
}
export interface McpPresetField { export interface McpPresetField {
name: string; name: string;
label: string; label: string;
+37
View File
@@ -8,6 +8,7 @@ import {
fetchCliApps, fetchCliApps,
fetchInstalledCliApps, fetchInstalledCliApps,
fetchMcpPresets, fetchMcpPresets,
fetchNanobotFeatures,
fetchProviderModels, fetchProviderModels,
fetchSessionAutomations, fetchSessionAutomations,
fetchSettingsUsage, fetchSettingsUsage,
@@ -21,6 +22,8 @@ import {
listSlashCommands, listSlashCommands,
loginProviderOAuth, loginProviderOAuth,
logoutProviderOAuth, logoutProviderOAuth,
disableNanobotFeature,
enableNanobotFeature,
runAutomationAction, runAutomationAction,
runCliAppAction, runCliAppAction,
runMcpPresetAction, runMcpPresetAction,
@@ -441,6 +444,40 @@ describe("webui API helpers", () => {
); );
}); });
it("reads and toggles nanobot optional features", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
features: [],
enabled_count: 0,
}),
} as Response);
await expect(fetchNanobotFeatures("tok")).resolves.toMatchObject({ features: [] });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await enableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await disableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reads MCP presets and serializes actions", async () => { it("reads MCP presets and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,
+4
View File
@@ -1527,6 +1527,10 @@ describe("App layout", () => {
}), }),
); );
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ brandLogos: true }),
);
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+23 -1
View File
@@ -64,6 +64,18 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.webuiSafety", "settings.sections.webuiSafety",
"settings.sections.capabilities", "settings.sections.capabilities",
"settings.sections.apps", "settings.sections.apps",
"settings.apps.description",
"settings.apps.filterPlugins",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.nanobotFeatures.disable",
"settings.nanobotFeatures.ready",
"settings.nanobotFeatures.missingDependency",
"settings.nanobotFeatures.installConfirmTitle",
"settings.nanobotFeatures.installConfirmDescription",
"settings.nanobotFeatures.installConfirmAction",
"settings.nanobotFeatures.channelDisabled",
"settings.nanobotFeatures.notEnabled",
"settings.sections.about", "settings.sections.about",
"settings.rows.theme", "settings.rows.theme",
"settings.rows.language", "settings.rows.language",
@@ -105,6 +117,16 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.about.upToDate", "settings.about.upToDate",
"settings.about.updateAvailable", "settings.about.updateAvailable",
]; ];
const LOCALIZED_WORKSPACE_COPY_KEYS = [
"thread.composer.workspace.accessAria",
"thread.composer.workspace.default",
"thread.composer.workspace.full",
"errors.workspaceScopeRejected.title",
"errors.workspaceScopeRejected.body",
"workspace.dialog.defaultProject",
"workspace.dialog.usePath",
"workspace.dialog.absolutePathRequired",
];
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
} }
@@ -271,7 +293,7 @@ describe("webui i18n", () => {
for (const [locale, resource] of Object.entries(resources)) { for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue; if (locale === "en") continue;
const current = flattenResource(resource.common); const current = flattenResource(resource.common);
const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter( const leaked = [...LOCALIZED_SETTINGS_COPY_KEYS, ...LOCALIZED_WORKSPACE_COPY_KEYS].filter(
(key) => current.get(key) === english.get(key), (key) => current.get(key) === english.get(key),
); );
+198
View File
@@ -263,6 +263,204 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
}); });
it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: false,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
});
}
if (url === "/api/settings/nanobot-features/enable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
});
}
if (url === "/api/settings/nanobot-features/disable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
requires_restart: true,
last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false },
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Matrix")).toBeInTheDocument();
expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument();
expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.anything(),
);
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Enabled channel 'matrix'")).toBeInTheDocument();
expect(screen.getByText("Restart nanobot to apply updated apps and features.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Disabled channel 'matrix'")).toBeInTheDocument();
});
it("shows enabled nanobot channels with missing support as enabled", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
});
}
if (url === "/api/settings/nanobot-features/enable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Matrix")).toBeInTheDocument();
expect(screen.getByText("1 Plugin · 0 CLI · 0 MCP")).toBeInTheDocument();
expect(screen.getByText("Support missing")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("does not offer to disable the websocket channel", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "websocket",
display_name: "Websocket",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Websocket")).toBeInTheDocument();
expect(screen.getByText("Required for WebUI")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Disable" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Required for WebUI" })).toBeDisabled();
expect(fetchMock).not.toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=websocket",
expect.anything(),
);
});
it("publishes the latest settings payload to the shell", async () => { it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload(); const payload = settingsPayload();
const onSettingsChange = vi.fn(); const onSettingsChange = vi.fn();