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:
@@ -155,7 +155,7 @@ The key (`webhook`) becomes the config section name. The value points to your `B
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
@@ -552,7 +552,7 @@ If not overridden, the base class returns `{"enabled": false}`.
|
||||
git clone https://github.com/you/nanobot-channel-webhook
|
||||
cd nanobot-channel-webhook
|
||||
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
|
||||
```
|
||||
|
||||
@@ -561,8 +561,8 @@ nanobot gateway # test end-to-end
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Source Enabled
|
||||
telegram builtin yes
|
||||
discord builtin no
|
||||
webhook plugin yes
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
|
||||
+64
-12
@@ -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`.
|
||||
|
||||
> [!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
|
||||
|
||||
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>
|
||||
<summary><b>Telegram</b></summary>
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable telegram
|
||||
```
|
||||
|
||||
**1. Create a bot**
|
||||
- Open Telegram, search `@BotFather`
|
||||
- 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.
|
||||
|
||||
**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**
|
||||
|
||||
Simply send this message to nanobot (replace `xxx@xxx` with your real email):
|
||||
@@ -233,14 +262,14 @@ nanobot gateway
|
||||
<details>
|
||||
<summary><b>Matrix (Element)</b></summary>
|
||||
|
||||
Install Matrix dependencies first:
|
||||
Enable Matrix support first:
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[matrix]"
|
||||
nanobot plugins enable matrix
|
||||
```
|
||||
|
||||
> [!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**
|
||||
|
||||
@@ -306,9 +335,7 @@ nanobot gateway
|
||||
Requires the WhatsApp optional dependencies:
|
||||
|
||||
```bash
|
||||
pip install "nanobot-ai[whatsapp]"
|
||||
# Source checkout:
|
||||
python -m pip install -e ".[whatsapp]"
|
||||
nanobot plugins enable whatsapp
|
||||
```
|
||||
|
||||
**1. Link device with QR**
|
||||
@@ -384,6 +411,7 @@ Uses **WebSocket** long connection — no public IP required.
|
||||
**Quick setup: QR login**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable feishu
|
||||
nanobot channels login feishu
|
||||
# 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**.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable qq
|
||||
```
|
||||
|
||||
**1. Register & create bot**
|
||||
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
|
||||
- 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
|
||||
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable napcat
|
||||
```
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
@@ -543,6 +583,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
|
||||
|
||||
Uses **Stream Mode** — no public IP required.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable dingtalk
|
||||
```
|
||||
|
||||
**1. Create a DingTalk bot**
|
||||
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
|
||||
- Create a new app -> Add **Robot** capability
|
||||
@@ -585,6 +631,12 @@ nanobot gateway
|
||||
|
||||
Uses **Socket Mode** — no public URL required.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable slack
|
||||
```
|
||||
|
||||
**1. Create a Slack app**
|
||||
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
|
||||
- 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.
|
||||
|
||||
**1. Install with WeChat support**
|
||||
**1. Enable WeChat support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[weixin]"
|
||||
nanobot plugins enable weixin
|
||||
```
|
||||
|
||||
**2. Configure**
|
||||
@@ -747,10 +799,10 @@ nanobot gateway
|
||||
>
|
||||
> Uses **WebSocket** long connection — no public IP required.
|
||||
|
||||
**1. Install the optional dependency**
|
||||
**1. Enable WeCom support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[wecom]"
|
||||
nanobot plugins enable wecom
|
||||
```
|
||||
|
||||
**2. Create a WeCom AI Bot**
|
||||
@@ -786,10 +838,10 @@ nanobot gateway
|
||||
> 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.
|
||||
|
||||
**1. Install the optional dependency**
|
||||
**1. Enable Microsoft Teams support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[msteams]"
|
||||
nanobot plugins enable msteams
|
||||
```
|
||||
|
||||
**2. Create a Teams / Azure bot app registration**
|
||||
|
||||
@@ -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 |
|
||||
| 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` |
|
||||
| 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 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.
|
||||
|
||||
## 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
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+16
-4
@@ -41,7 +41,7 @@ If you are not sure where a setting belongs, start from the task you are trying
|
||||
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
|
||||
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
|
||||
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
||||
| Open the bundled WebUI | `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) |
|
||||
| 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) |
|
||||
@@ -380,7 +380,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA
|
||||
Install the optional dependency:
|
||||
|
||||
```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:
|
||||
@@ -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.
|
||||
|
||||
> `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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
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**
|
||||
|
||||
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 |
|
||||
| `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`. |
|
||||
| `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) |
|
||||
|
||||
`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.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.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. |
|
||||
| `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
@@ -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.
|
||||
|
||||
> [!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
|
||||
> {
|
||||
> "gateway": { "host": "0.0.0.0" },
|
||||
> "channels": {
|
||||
> "websocket": {
|
||||
> "enabled": true,
|
||||
> "host": "0.0.0.0",
|
||||
> "port": 8765,
|
||||
> "tokenIssueSecret": "your-secret-here"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[api]"
|
||||
nanobot plugins enable api
|
||||
nanobot agent -m "Hello!"
|
||||
nanobot serve
|
||||
```
|
||||
|
||||
+1
-1
@@ -329,7 +329,7 @@ nanobot --version
|
||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[whatsapp]"
|
||||
nanobot plugins enable whatsapp
|
||||
```
|
||||
|
||||
## First-Run Troubleshooting
|
||||
|
||||
@@ -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.
|
||||
5. Paste the provider base URL if the wizard asks for one.
|
||||
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.
|
||||
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`.
|
||||
|
||||
@@ -225,7 +225,6 @@ Merge them into one object:
|
||||
},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
@@ -288,7 +287,6 @@ If this is a brand-new install and you have not configured anything else yet, re
|
||||
},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
|
||||
+3
-8
@@ -16,13 +16,13 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
|
||||
|
||||
### 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
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"path": "/",
|
||||
@@ -208,7 +208,7 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
|
||||
| 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. |
|
||||
| `port` | int | `8765` | Listen port. |
|
||||
| `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": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"port": 8765,
|
||||
"path": "/ws",
|
||||
"tokenIssuePath": "/auth/token",
|
||||
@@ -367,7 +366,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"websocketRequiresToken": false,
|
||||
@@ -384,7 +382,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"token": "my-shared-secret",
|
||||
"allowFrom": ["alice", "bob"]
|
||||
}
|
||||
@@ -400,7 +397,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"path": "/ws",
|
||||
@@ -421,7 +417,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"path": "/chat/ws",
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
|
||||
+42
-8
@@ -15,14 +15,14 @@ First confirm your provider and model can answer:
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`.
|
||||
Set `tokenIssueSecret` to the password you will enter in the WebUI login form:
|
||||
The local WebSocket channel is enabled by default because it serves the bundled
|
||||
WebUI. To require a browser login password, merge `tokenIssueSecret` into your
|
||||
existing `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
@@ -94,9 +94,20 @@ for provider setup and output behavior.
|
||||
## Apps
|
||||
|
||||
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
|
||||
on your machine; they do not modify the native apps themselves. MCP presets add
|
||||
predefined MCP server configurations.
|
||||
nanobot can call from a chat. Nanobot features can enable built-in channels and
|
||||
optional capabilities such as `bedrock` or `documents`. CLI Apps install local
|
||||
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
|
||||
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": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"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
|
||||
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
|
||||
|
||||
If the page does not open, check these in order:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
@@ -941,12 +942,19 @@ class CliAppManager:
|
||||
raise CliAppError("this CLI app uses an unsupported install strategy")
|
||||
|
||||
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,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
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]:
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
|
||||
@@ -217,7 +217,7 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
self.logger.error(
|
||||
"Stream SDK not installed. Run: pip install dingtalk-stream"
|
||||
"Stream SDK not installed. Run: nanobot plugins enable dingtalk"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ class DiscordChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Discord client."""
|
||||
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
|
||||
|
||||
if not self.config.token:
|
||||
|
||||
@@ -672,7 +672,7 @@ class FeishuChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Feishu bot with WebSocket long connection."""
|
||||
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
|
||||
|
||||
if not self.config.app_id or not self.config.app_secret:
|
||||
|
||||
@@ -25,6 +25,7 @@ from nanobot.bus.outbound_events import (
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
||||
from nanobot.config.schema import Config
|
||||
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",
|
||||
}
|
||||
|
||||
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:
|
||||
"""
|
||||
Manages chat channels and coordinates message routing.
|
||||
@@ -105,21 +121,32 @@ class ChannelManager:
|
||||
candidate_names = set(names)
|
||||
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
|
||||
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()
|
||||
for name in candidate_names:
|
||||
section = getattr(self.config.channels, name, None)
|
||||
section = section_for(name)
|
||||
if section is None:
|
||||
continue
|
||||
if (
|
||||
section.get("enabled", False)
|
||||
if isinstance(section, dict)
|
||||
else getattr(section, "enabled", False)
|
||||
):
|
||||
if _channel_config_enabled(name, section):
|
||||
enabled_names.add(name)
|
||||
|
||||
for name, cls in discover_enabled(enabled_names, _names=names).items():
|
||||
section = getattr(self.config.channels, name, None)
|
||||
for name, cls in discover_enabled(
|
||||
enabled_names,
|
||||
_names=names,
|
||||
warn_import_errors=True,
|
||||
).items():
|
||||
section = section_for(name)
|
||||
if section is None:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
@@ -45,7 +46,7 @@ try:
|
||||
from nio.exceptions import EncryptionError
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
|
||||
"Matrix dependencies not installed. Run: nanobot plugins enable matrix"
|
||||
) from e
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -200,7 +201,7 @@ class MatrixConfig(Base):
|
||||
password: str = ""
|
||||
access_token: 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")
|
||||
sync_stop_grace_seconds: int = 2
|
||||
max_media_bytes: int = 20 * 1024 * 1024
|
||||
|
||||
@@ -142,7 +142,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Teams webhook listener."""
|
||||
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
|
||||
|
||||
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:
|
||||
"""Validate inbound Bot Framework bearer token."""
|
||||
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 "):
|
||||
raise ValueError("missing bearer token")
|
||||
|
||||
@@ -195,7 +195,7 @@ class QQChannel(BaseChannel):
|
||||
"""Start the QQ bot with auto-reconnect loop."""
|
||||
redirect_lib_logging("botpy", level="WARNING")
|
||||
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
|
||||
|
||||
if not self.config.app_id or not self.config.secret:
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from nanobot.channels.base import BaseChannel
|
||||
|
||||
_INTERNAL = frozenset({"base", "manager", "registry"})
|
||||
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
|
||||
|
||||
|
||||
def discover_channel_names() -> list[str]:
|
||||
@@ -57,6 +58,7 @@ def discover_enabled(
|
||||
*,
|
||||
_names: list[str] | None = None,
|
||||
_include_all_external: bool = False,
|
||||
warn_import_errors: bool = False,
|
||||
) -> dict[str, type[BaseChannel]]:
|
||||
"""Return channels whose module names are in *enabled_names*.
|
||||
|
||||
@@ -72,10 +74,14 @@ def discover_enabled(
|
||||
try:
|
||||
result[modname] = load_channel_class(modname)
|
||||
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)
|
||||
shadowed = set(external) & set(result)
|
||||
shadowed = set(external) & set(names)
|
||||
if shadowed:
|
||||
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
||||
if _include_all_external:
|
||||
|
||||
@@ -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.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):
|
||||
"""WebSocket server channel configuration.
|
||||
@@ -80,7 +83,7 @@ class WebSocketConfig(Base):
|
||||
shared filesystem or an HTTP file server to access these files.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
enabled: bool = True
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
unix_socket_path: str = ""
|
||||
@@ -482,6 +485,7 @@ class WebSocketChannel(BaseChannel):
|
||||
handler,
|
||||
socket_path,
|
||||
process_request=process_request,
|
||||
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
||||
max_size=self.config.max_message_bytes,
|
||||
ping_interval=self.config.ping_interval_s,
|
||||
ping_timeout=self.config.ping_timeout_s,
|
||||
@@ -495,6 +499,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
process_request=process_request,
|
||||
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
||||
max_size=self.config.max_message_bytes,
|
||||
ping_interval=self.config.ping_interval_s,
|
||||
ping_timeout=self.config.ping_timeout_s,
|
||||
|
||||
@@ -103,7 +103,7 @@ class WecomChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the WeCom bot with WebSocket long connection."""
|
||||
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
|
||||
|
||||
if not self.config.bot_id or not self.config.secret:
|
||||
|
||||
@@ -72,7 +72,7 @@ def _load_neonize() -> _NeonizeAPI:
|
||||
from neonize.utils.jid import build_jid
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"'
|
||||
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
||||
) from exc
|
||||
|
||||
_NEONIZE_API = _NeonizeAPI(
|
||||
|
||||
+105
-40
@@ -38,6 +38,14 @@ _log_handler_id = logger.add(
|
||||
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.application import run_in_terminal # 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 rich.console import Console # 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.text import Text # 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.bus.outbound_events import ( # noqa: E402
|
||||
ProgressEvent,
|
||||
@@ -686,6 +696,33 @@ def _onboard_plugins(config_path: Path) -> None:
|
||||
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]:
|
||||
"""Return (resolved_model_name, preset_tag) for display strings."""
|
||||
resolved = config.resolve_preset()
|
||||
@@ -811,20 +848,15 @@ def serve(
|
||||
try:
|
||||
from aiohttp import web # noqa: F401
|
||||
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)
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.api.server import create_app
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
_set_nanobot_logs(verbose)
|
||||
|
||||
runtime_config = _load_runtime_config(config, workspace)
|
||||
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"),
|
||||
):
|
||||
"""Interact with the agent directly."""
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.service import CronService
|
||||
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 = CronService(cron_store_path)
|
||||
|
||||
if logs:
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
_set_nanobot_logs(logs)
|
||||
|
||||
try:
|
||||
agent_loop = AgentLoop.from_config(
|
||||
@@ -1728,42 +1755,80 @@ def channels_login(
|
||||
# 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")
|
||||
|
||||
|
||||
@plugins_app.command("list")
|
||||
def plugins_list():
|
||||
"""List all discovered channels (built-in and plugins)."""
|
||||
from nanobot.channels.registry import discover_all, discover_channel_names
|
||||
from nanobot.config.loader import load_config
|
||||
def plugins_list(
|
||||
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""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()
|
||||
builtin_names = set(discover_channel_names())
|
||||
all_channels = discover_all()
|
||||
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)
|
||||
|
||||
table = Table(title="Channel Plugins")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Source", style="magenta")
|
||||
table.add_column("Enabled")
|
||||
_print_enable_options(
|
||||
feature_support.optional_dependency_groups(),
|
||||
set(discover_channel_names()),
|
||||
discover_plugins(),
|
||||
load_config(resolved_config_path),
|
||||
)
|
||||
|
||||
for name in sorted(all_channels):
|
||||
cls = all_channels[name]
|
||||
source = "builtin" if name in builtin_names else "plugin"
|
||||
section = getattr(config.channels, name, None)
|
||||
if section is None:
|
||||
enabled = False
|
||||
elif isinstance(section, dict):
|
||||
enabled = section.get("enabled", False)
|
||||
else:
|
||||
enabled = getattr(section, "enabled", False)
|
||||
table.add_row(
|
||||
cls.display_name,
|
||||
source,
|
||||
"[green]yes[/green]" if enabled else "[dim]no[/dim]",
|
||||
|
||||
@plugins_app.command("enable")
|
||||
def plugins_enable(
|
||||
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
|
||||
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
|
||||
):
|
||||
"""Enable a nanobot 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()
|
||||
_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}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -377,6 +377,13 @@ class ToolsConfig(Base):
|
||||
"allow_local_preview_access",
|
||||
),
|
||||
) # 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)
|
||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ Two modes are supported, selected automatically:
|
||||
falls back to :class:`azure.identity.aio.DefaultAzureCredential` and
|
||||
acquires a bearer token scoped to
|
||||
``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
|
||||
@@ -55,7 +55,7 @@ class _AzureTokenProvider:
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Azure OpenAI AAD authentication requires the 'azure-identity' package. "
|
||||
"Install it with: pip install 'nanobot-ai[azure]'"
|
||||
"Run: nanobot plugins enable azure"
|
||||
) from exc
|
||||
|
||||
self._scope = scope
|
||||
|
||||
@@ -71,7 +71,7 @@ class BedrockProvider(LLMProvider):
|
||||
import boto3
|
||||
except ImportError as exc: # pragma: no cover - exercised only without boto3 installed
|
||||
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
|
||||
|
||||
session_kwargs: dict[str, Any] = {}
|
||||
|
||||
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
|
||||
**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.
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import email.utils
|
||||
import hmac
|
||||
import http
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -131,6 +132,70 @@ def is_localhost(connection: Any) -> bool:
|
||||
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:
|
||||
auth = headers.get("Authorization") or headers.get("authorization")
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
|
||||
@@ -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)
|
||||
@@ -17,9 +17,13 @@ from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
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.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.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 (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
@@ -80,7 +84,7 @@ class WebUISettingsRouter:
|
||||
self._runtime_capabilities = runtime_capabilities
|
||||
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":
|
||||
return self._handle_settings(request)
|
||||
if path == "/api/settings/usage":
|
||||
@@ -117,6 +121,12 @@ class WebUISettingsRouter:
|
||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||
if path == "/api/settings/cli-apps/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":
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
if path == "/api/settings/version-check":
|
||||
@@ -334,6 +344,51 @@ class WebUISettingsRouter:
|
||||
return self._error_response(status, message)
|
||||
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(
|
||||
self,
|
||||
request: WsRequest,
|
||||
|
||||
@@ -21,6 +21,7 @@ def _exception_chain_has_disconnect(exc: BaseException | None) -> bool:
|
||||
ConnectionAbortedError,
|
||||
ConnectionResetError,
|
||||
ConnectionClosed,
|
||||
EOFError,
|
||||
)):
|
||||
return True
|
||||
exc = exc.__cause__ or exc.__context__
|
||||
|
||||
@@ -231,7 +231,7 @@ class GatewayHTTPHandler:
|
||||
return self._handle_bootstrap(connection, request)
|
||||
|
||||
# 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:
|
||||
return response
|
||||
|
||||
|
||||
+43
-15
@@ -37,16 +37,6 @@ dependencies = [
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
"rich>=14.0.0,<15.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",
|
||||
"questionary>=2.0.0,<3.0.0",
|
||||
"mcp>=1.26.0,<2.0.0",
|
||||
@@ -57,12 +47,8 @@ dependencies = [
|
||||
"jinja2>=3.1.0,<4.0.0",
|
||||
"dulwich>=0.22.0,<1.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",
|
||||
"boto3>=1.43.0",
|
||||
"packaging>=24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -72,6 +58,41 @@ api = [
|
||||
azure = [
|
||||
"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-aibot-sdk-python>=0.1.5",
|
||||
]
|
||||
@@ -86,6 +107,7 @@ msteams = [
|
||||
|
||||
matrix = [
|
||||
"matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'",
|
||||
"matrix-nio>=0.25.2; sys_platform == 'win32'",
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
"mistune>=3.0.0,<4.0.0",
|
||||
"nh3>=0.2.17,<1.0.0",
|
||||
@@ -113,6 +135,12 @@ dev = [
|
||||
"pytest-cov>=6.0.0,<7.0.0",
|
||||
"ruff>=0.1.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]
|
||||
|
||||
@@ -62,7 +62,8 @@ class MockChannel(BaseChannel):
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return Config()
|
||||
"""Create a minimal config for testing."""
|
||||
return Config.model_validate({"channels": {"websocket": {"enabled": False}}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -21,7 +21,11 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
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.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
@@ -60,7 +64,8 @@ class _MockChannel(BaseChannel):
|
||||
|
||||
@pytest.fixture
|
||||
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)
|
||||
return mgr
|
||||
|
||||
@@ -291,14 +296,22 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
|
||||
|
||||
|
||||
async def _pump_one(manager: ChannelManager) -> None:
|
||||
"""Drive the dispatcher until the outbound queue drains, then cancel."""
|
||||
task = asyncio.create_task(manager._dispatch_outbound())
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.01)
|
||||
if manager.bus.outbound.qsize() == 0:
|
||||
"""Process currently queued messages through the reasoning dispatch branch."""
|
||||
|
||||
async def dispatch_one(msg: OutboundMessage) -> None:
|
||||
event = outbound_event_from_message(msg)
|
||||
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
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -72,6 +78,28 @@ def _make_entry_point(name: str, cls: type):
|
||||
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"
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -203,6 +231,23 @@ def test_discover_enabled_imports_only_enabled_builtins():
|
||||
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():
|
||||
from nanobot.channels.registry import discover_all
|
||||
|
||||
@@ -214,6 +259,20 @@ def test_discover_all_builtin_shadows_plugin():
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -245,6 +304,54 @@ async def test_manager_loads_plugin_from_dict_config():
|
||||
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
|
||||
async def test_base_channel_reads_current_transcription_config_each_call(
|
||||
tmp_path,
|
||||
@@ -510,6 +617,592 @@ def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
|
||||
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
|
||||
async def test_manager_skips_disabled_plugin():
|
||||
fake_config = SimpleNamespace(
|
||||
@@ -537,19 +1230,19 @@ async def test_manager_skips_disabled_plugin():
|
||||
|
||||
def test_builtin_channel_default_config():
|
||||
"""Built-in channels expose default_config() returning a dict with 'enabled': False."""
|
||||
from nanobot.channels.telegram import TelegramChannel
|
||||
cfg = TelegramChannel.default_config()
|
||||
from nanobot.channels.dingtalk import DingTalkChannel
|
||||
cfg = DingTalkChannel.default_config()
|
||||
assert isinstance(cfg, dict)
|
||||
assert cfg["enabled"] is False
|
||||
assert "token" in cfg
|
||||
assert "clientId" in cfg
|
||||
|
||||
|
||||
def test_builtin_channel_init_from_dict():
|
||||
"""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()
|
||||
ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus)
|
||||
assert ch.config.token == "test-tok"
|
||||
ch = DingTalkChannel({"enabled": False, "clientId": "test-id", "allowFrom": ["*"]}, bus)
|
||||
assert ch.config.client_id == "test-id"
|
||||
assert ch.config.allow_from == ["*"]
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -24,6 +25,10 @@ from nanobot.channels.matrix import (
|
||||
_ROOM_SEND_UNSET = object()
|
||||
|
||||
|
||||
def test_default_e2ee_matches_platform_support() -> None:
|
||||
assert MatrixConfig().e2ee_enabled is (sys.platform != "win32")
|
||||
|
||||
|
||||
class _DummyTask:
|
||||
def __init__(self) -> None:
|
||||
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
|
||||
)
|
||||
|
||||
channel = MatrixChannel(_make_config(device_id=""), MessageBus())
|
||||
channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus())
|
||||
await channel.start()
|
||||
|
||||
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:
|
||||
channel = MatrixChannel(_make_config(sas_verification=True), MessageBus())
|
||||
channel = MatrixChannel(_make_config(e2ee_enabled=True, sas_verification=True), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
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:
|
||||
_patch_key_verification_events(monkeypatch)
|
||||
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(),
|
||||
)
|
||||
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:
|
||||
_patch_key_verification_events(monkeypatch)
|
||||
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(),
|
||||
)
|
||||
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:
|
||||
_patch_key_verification_events(monkeypatch)
|
||||
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(),
|
||||
)
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
@@ -1203,7 +1220,7 @@ async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) ->
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
channel.client = client
|
||||
|
||||
|
||||
@@ -132,6 +132,36 @@ def bus() -> MagicMock:
|
||||
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)
|
||||
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
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:
|
||||
defaults = WebSocketChannel.default_config()
|
||||
assert defaults["enabled"] is False
|
||||
assert defaults["enabled"] is True
|
||||
assert defaults["host"] == "127.0.0.1"
|
||||
assert defaults["streaming"] is True
|
||||
assert defaults["allowFrom"] == ["*"]
|
||||
|
||||
@@ -15,9 +15,12 @@ from urllib.parse import quote, urlencode
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.cron.service import CronService
|
||||
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.manager import Session, SessionManager
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
@@ -26,6 +29,24 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic
|
||||
_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:
|
||||
for _ in range(100):
|
||||
port = random.randint(30_000, 60_000)
|
||||
@@ -140,6 +161,35 @@ def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
|
||||
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
|
||||
async def test_bootstrap_returns_token_for_localhost(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
@@ -538,6 +588,272 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
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
|
||||
async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
|
||||
bus: MagicMock,
|
||||
@@ -1668,8 +1984,9 @@ class _FakeConn:
|
||||
class _FakeReq:
|
||||
"""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.path = path
|
||||
|
||||
|
||||
_REMOTE = _FakeConn(("192.168.1.5", 12345))
|
||||
@@ -1677,6 +1994,43 @@ _LOCAL = _FakeConn(("127.0.0.1", 12345))
|
||||
_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:
|
||||
import pytest
|
||||
from pydantic_core import ValidationError
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -283,3 +283,28 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
|
||||
config = load_config(config_path)
|
||||
|
||||
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):
|
||||
"""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.
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ except ImportError:
|
||||
MSTEAMS_AVAILABLE = False
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
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):
|
||||
|
||||
@@ -26,9 +26,12 @@ def test_websocket_handshake_noise_filter_suppresses_disconnects() -> None:
|
||||
filter_ = WebSocketHandshakeNoiseFilter()
|
||||
wrapped = RuntimeError("wrapped")
|
||||
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, wrapped))
|
||||
assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, empty_handshake))
|
||||
|
||||
|
||||
def test_websocket_handshake_noise_filter_keeps_real_errors() -> None:
|
||||
|
||||
@@ -83,11 +83,14 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
checkVersion,
|
||||
createModelConfiguration,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
fetchAutomations,
|
||||
fetchSettings,
|
||||
fetchSettingsUsage,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchNanobotFeatures,
|
||||
fetchProviderModels,
|
||||
importMcpConfig,
|
||||
loginProviderOAuth,
|
||||
@@ -127,6 +130,8 @@ import type {
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetInfo,
|
||||
McpPresetsPayload,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
NetworkSafetySettingsUpdate,
|
||||
ProviderModelsPayload,
|
||||
SessionAutomationJob,
|
||||
@@ -152,11 +157,12 @@ export type SettingsSectionKey =
|
||||
|
||||
type LocalDensity = "comfortable" | "compact";
|
||||
type LocalActivityMode = "auto" | "expanded";
|
||||
type AppsKindFilter = "all" | "cli" | "mcp";
|
||||
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
|
||||
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
||||
type AutomationSort = "next" | "last" | "updated" | "name";
|
||||
type AutomationAction = "enable" | "disable" | "delete" | "run";
|
||||
type AppsCatalogItem =
|
||||
| { id: string; kind: "nanobot"; feature: NanobotFeatureInfo }
|
||||
| { id: string; kind: "cli"; app: CliAppInfo }
|
||||
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
||||
|
||||
@@ -259,7 +265,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
density: "comfortable",
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: true,
|
||||
brandLogos: false,
|
||||
};
|
||||
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
@@ -321,7 +327,7 @@ function readLocalPreferences(): LocalPreferences {
|
||||
density: parsed.density === "compact" ? "compact" : "comfortable",
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos !== false,
|
||||
brandLogos: parsed.brandLogos === true,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_LOCAL_PREFS;
|
||||
@@ -536,10 +542,12 @@ export function SettingsView({
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
|
||||
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
||||
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(() => initialSettings === null);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true);
|
||||
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
||||
const [automationsLoading, setAutomationsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -551,6 +559,8 @@ export function SettingsView({
|
||||
model: "",
|
||||
});
|
||||
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 [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
@@ -568,6 +578,8 @@ export function SettingsView({
|
||||
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
|
||||
const [cliAppsMessage, setCliAppsMessage] = 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 [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
|
||||
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
|
||||
@@ -731,6 +743,29 @@ export function SettingsView({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
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 (
|
||||
action: AutomationAction,
|
||||
job: SessionAutomationJob,
|
||||
@@ -1604,15 +1675,20 @@ export function SettingsView({
|
||||
return (
|
||||
<AppsCatalogSettings
|
||||
cliApps={cliApps}
|
||||
nanobotFeatures={nanobotFeatures}
|
||||
mcpPresets={mcpPresets}
|
||||
cliAppsLoading={cliAppsLoading}
|
||||
nanobotFeaturesLoading={nanobotFeaturesLoading}
|
||||
mcpPresetsLoading={mcpPresetsLoading}
|
||||
query={appsQuery}
|
||||
filter={appsKindFilter}
|
||||
cliActionKey={cliAppsAction}
|
||||
nanobotActionKey={nanobotFeatureAction}
|
||||
mcpActionKey={mcpPresetAction}
|
||||
cliMessage={cliAppsMessage}
|
||||
cliError={cliAppsError}
|
||||
nanobotMessage={nanobotFeaturesMessage}
|
||||
nanobotError={nanobotFeaturesError}
|
||||
cliFocusName={cliAppsFocusName}
|
||||
mcpMessage={mcpMessage}
|
||||
mcpError={mcpError}
|
||||
@@ -1624,10 +1700,13 @@ export function SettingsView({
|
||||
onQueryChange={setAppsQuery}
|
||||
onFilterChange={setAppsKindFilter}
|
||||
onCliAction={handleCliAppAction}
|
||||
onNanobotAction={handleNanobotFeatureAction}
|
||||
onMcpAction={handleMcpPresetAction}
|
||||
onDismissStatus={() => {
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
setNanobotFeaturesMessage(null);
|
||||
setNanobotFeaturesError(null);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
}}
|
||||
@@ -1733,6 +1812,15 @@ export function SettingsView({
|
||||
onSave={handleCreateModelConfiguration}
|
||||
/>
|
||||
|
||||
<NanobotFeatureInstallDialog
|
||||
feature={nanobotFeatureConfirm}
|
||||
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNanobotFeatureConfirm(null);
|
||||
}}
|
||||
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
|
||||
/>
|
||||
|
||||
<AutomationDeleteDialog
|
||||
job={automationPendingDelete}
|
||||
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 {
|
||||
if (!job) return false;
|
||||
return job.kind === "local_trigger"
|
||||
@@ -4906,15 +5052,20 @@ function formatAutomationInterval(ms: number, locale: string): string {
|
||||
|
||||
function AppsCatalogSettings({
|
||||
cliApps,
|
||||
nanobotFeatures,
|
||||
mcpPresets,
|
||||
cliAppsLoading,
|
||||
nanobotFeaturesLoading,
|
||||
mcpPresetsLoading,
|
||||
query,
|
||||
filter,
|
||||
cliActionKey,
|
||||
nanobotActionKey,
|
||||
mcpActionKey,
|
||||
cliMessage,
|
||||
cliError,
|
||||
nanobotMessage,
|
||||
nanobotError,
|
||||
cliFocusName,
|
||||
mcpMessage,
|
||||
mcpError,
|
||||
@@ -4926,6 +5077,7 @@ function AppsCatalogSettings({
|
||||
onQueryChange,
|
||||
onFilterChange,
|
||||
onCliAction,
|
||||
onNanobotAction,
|
||||
onMcpAction,
|
||||
onDismissStatus,
|
||||
onBackToChat,
|
||||
@@ -4939,15 +5091,20 @@ function AppsCatalogSettings({
|
||||
isRestarting,
|
||||
}: {
|
||||
cliApps: CliAppsPayload | null;
|
||||
nanobotFeatures: NanobotFeaturesPayload | null;
|
||||
mcpPresets: McpPresetsPayload | null;
|
||||
cliAppsLoading: boolean;
|
||||
nanobotFeaturesLoading: boolean;
|
||||
mcpPresetsLoading: boolean;
|
||||
query: string;
|
||||
filter: AppsKindFilter;
|
||||
cliActionKey: string | null;
|
||||
nanobotActionKey: string | null;
|
||||
mcpActionKey: string | null;
|
||||
cliMessage: string | null;
|
||||
cliError: string | null;
|
||||
nanobotMessage: string | null;
|
||||
nanobotError: string | null;
|
||||
cliFocusName: string | null;
|
||||
mcpMessage: string | null;
|
||||
mcpError: string | null;
|
||||
@@ -4959,6 +5116,7 @@ function AppsCatalogSettings({
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterChange: (value: AppsKindFilter) => 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;
|
||||
onDismissStatus: () => void;
|
||||
onBackToChat: () => void;
|
||||
@@ -4975,11 +5133,17 @@ function AppsCatalogSettings({
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const filterOptions = [
|
||||
{ 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: "mcp", label: tx("settings.apps.filterMcp", "MCP services") },
|
||||
];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
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 })),
|
||||
...(mcpPresets?.presets ?? []).map((preset) => ({
|
||||
id: `mcp:${preset.name}`,
|
||||
@@ -4996,13 +5160,22 @@ function AppsCatalogSettings({
|
||||
const focusedApp = cliFocusName
|
||||
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
|
||||
: null;
|
||||
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
|
||||
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
|
||||
const statusIsError = Boolean(cliError || mcpError);
|
||||
const loading =
|
||||
(cliAppsLoading || nanobotFeaturesLoading || mcpPresetsLoading) &&
|
||||
!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", {
|
||||
plugins: nanobotFeatures?.enabled_count ?? 0,
|
||||
cli: cliApps?.installed_count ?? 0,
|
||||
mcp: mcpPresets?.installed_count ?? 0,
|
||||
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
|
||||
defaultValue: "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -5012,7 +5185,7 @@ function AppsCatalogSettings({
|
||||
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"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>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
|
||||
@@ -5068,7 +5241,7 @@ function AppsCatalogSettings({
|
||||
|
||||
{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">
|
||||
<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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -5091,7 +5264,7 @@ function AppsCatalogSettings({
|
||||
|
||||
<section>
|
||||
<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">
|
||||
{items.length}
|
||||
</span>
|
||||
@@ -5104,7 +5277,14 @@ function AppsCatalogSettings({
|
||||
) : items.length ? (
|
||||
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
|
||||
{items.map((item) =>
|
||||
item.kind === "cli" ? (
|
||||
item.kind === "nanobot" ? (
|
||||
<NanobotFeatureCatalogRow
|
||||
key={item.id}
|
||||
feature={item.feature}
|
||||
actionKey={nanobotActionKey}
|
||||
onAction={onNanobotAction}
|
||||
/>
|
||||
) : item.kind === "cli" ? (
|
||||
<CliAppsCatalogRow
|
||||
key={item.id}
|
||||
app={item.app}
|
||||
@@ -5133,7 +5313,7 @@ function AppsCatalogSettings({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{filter !== "cli" ? (
|
||||
{filter === "all" || filter === "mcp" ? (
|
||||
<McpCustomServerPanel
|
||||
form={customMcpForm}
|
||||
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({
|
||||
app,
|
||||
actionKey,
|
||||
@@ -5553,14 +5819,27 @@ const AppsActionButton = forwardRef<HTMLButtonElement, {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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") {
|
||||
const app = item.app;
|
||||
return [
|
||||
@@ -5587,7 +5866,20 @@ function appsSearchText(item: AppsCatalogItem): string {
|
||||
preset.source ?? "",
|
||||
]
|
||||
.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({
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"missingCredential": "Configure this provider before enabling image generation."
|
||||
},
|
||||
"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",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "All",
|
||||
"filterPlugins": "Plugins",
|
||||
"filterCli": "CLI apps",
|
||||
"filterMcp": "MCP services",
|
||||
"enabledSummary": "{{count}} enabled",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Search Apps",
|
||||
"featured": "Featured",
|
||||
"featured": "Catalog",
|
||||
"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": {
|
||||
"filters": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
"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",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Todo",
|
||||
"filterPlugins": "Complementos",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Servicios MCP",
|
||||
"enabledSummary": "{{count}} activados",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Buscar apps",
|
||||
"featured": "Destacadas",
|
||||
"featured": "Catálogo",
|
||||
"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": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "Usar @{{name}} como servidor MCP"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "Modo de acceso al espacio de trabajo",
|
||||
"projectAria": "Elegir proyecto",
|
||||
"projectPlaceholder": "Seleccionar proyecto",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "Permiso predeterminado",
|
||||
"full": "Acceso completo"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "El espacio de trabajo no cambió",
|
||||
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "Espacio de trabajo predeterminado",
|
||||
"manual": "Pegar ruta",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "Usar ruta",
|
||||
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Ajoutez des CLI d’apps et services MCP utilisables par nanobot depuis le chat.",
|
||||
"description": "Activez des extensions, des adaptateurs d’apps locales et des serveurs d’outils connectés.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Tout",
|
||||
"filterPlugins": "Extensions",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Services MCP",
|
||||
"enabledSummary": "{{count}} activés",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Rechercher des apps",
|
||||
"featured": "À la une",
|
||||
"featured": "Catalogue",
|
||||
"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": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "Mode d’accès à l’espace de travail",
|
||||
"projectAria": "Choisir un projet",
|
||||
"projectPlaceholder": "Sélectionner un projet",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "Autorisation par défaut",
|
||||
"full": "Accès complet"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "Espace de travail par défaut",
|
||||
"manual": "Coller un chemin",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "Utiliser le chemin",
|
||||
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
|
||||
},
|
||||
"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",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Semua",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Aplikasi CLI",
|
||||
"filterMcp": "Layanan MCP",
|
||||
"enabledSummary": "{{count}} aktif",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Unggulan",
|
||||
"featured": "Katalog",
|
||||
"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": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "Mode akses workspace",
|
||||
"projectAria": "Pilih proyek",
|
||||
"projectPlaceholder": "Pilih proyek",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "Izin default",
|
||||
"full": "Akses penuh"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "Workspace tidak berubah",
|
||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "Workspace default",
|
||||
"manual": "Tempel path",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "Gunakan path",
|
||||
"absolutePathRequired": "Masukkan path folder absolut di mesin ini."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。",
|
||||
"description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "すべて",
|
||||
"filterPlugins": "プラグイン",
|
||||
"filterCli": "CLI アプリ",
|
||||
"filterMcp": "MCP サービス",
|
||||
"enabledSummary": "{{count}} 件有効",
|
||||
"caption": "CLI {{cli}} 件 · MCP {{mcp}} 件",
|
||||
"caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "注目",
|
||||
"featured": "カタログ",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "一致するアプリはありません。"
|
||||
"empty": "一致するアプリはありません。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "有効",
|
||||
"enable": "有効化",
|
||||
"disable": "無効化",
|
||||
"ready": "準備完了",
|
||||
"missingDependency": "サポート不足",
|
||||
"installSupport": "サポートをインストール",
|
||||
"installConfirmTitle": "{{name}} のサポートをインストールしますか?",
|
||||
"installConfirmDescription": "nanobot が {{name}} に必要なものを追加し、その後有効化します。続けますか?",
|
||||
"installConfirmAction": "インストールして有効化",
|
||||
"websocketRequired": "WebUI に必須",
|
||||
"channelDisabled": "チャンネルは無効",
|
||||
"notEnabled": "未有効"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "ワークスペースのアクセスモード",
|
||||
"projectAria": "プロジェクトを選択",
|
||||
"projectPlaceholder": "プロジェクトを選択",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "既定の権限",
|
||||
"full": "フルアクセス"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
@@ -1053,17 +1071,17 @@
|
||||
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。"
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "ワークスペースは変更されませんでした",
|
||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "既定のワークスペース",
|
||||
"manual": "パスを貼り付け",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "パスを使用",
|
||||
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
||||
},
|
||||
"apps": {
|
||||
"description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.",
|
||||
"description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "전체",
|
||||
"filterPlugins": "플러그인",
|
||||
"filterCli": "CLI 앱",
|
||||
"filterMcp": "MCP 서비스",
|
||||
"enabledSummary": "{{count}}개 활성화됨",
|
||||
"caption": "CLI {{cli}}개 · MCP {{mcp}}개",
|
||||
"caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "추천",
|
||||
"featured": "카탈로그",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "일치하는 앱이 없습니다."
|
||||
"empty": "일치하는 앱이 없습니다.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "활성화됨",
|
||||
"enable": "활성화",
|
||||
"disable": "비활성화",
|
||||
"ready": "준비됨",
|
||||
"missingDependency": "지원 패키지 없음",
|
||||
"installSupport": "지원 패키지 설치",
|
||||
"installConfirmTitle": "{{name}} 지원을 설치할까요?",
|
||||
"installConfirmDescription": "nanobot이 {{name}}에 필요한 것을 추가한 뒤 활성화합니다. 계속할까요?",
|
||||
"installConfirmAction": "설치하고 활성화",
|
||||
"websocketRequired": "WebUI 필수",
|
||||
"channelDisabled": "채널이 비활성화됨",
|
||||
"notEnabled": "비활성화됨"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "작업공간 접근 모드",
|
||||
"projectAria": "프로젝트 선택",
|
||||
"projectPlaceholder": "프로젝트 선택",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "기본 권한",
|
||||
"full": "전체 접근 권한"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
@@ -1053,17 +1071,17 @@
|
||||
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요."
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "작업공간이 변경되지 않았습니다",
|
||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "기본 작업공간",
|
||||
"manual": "경로 붙여넣기",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "경로 사용",
|
||||
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
"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",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Tất cả",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Ứng dụng CLI",
|
||||
"filterMcp": "Dịch vụ MCP",
|
||||
"enabledSummary": "{{count}} đã bật",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Nổi bật",
|
||||
"featured": "Danh mục",
|
||||
"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": {
|
||||
"filters": {
|
||||
@@ -965,11 +983,11 @@
|
||||
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Workspace access mode",
|
||||
"accessAria": "Chế độ truy cập workspace",
|
||||
"projectAria": "Chọn dự án",
|
||||
"projectPlaceholder": "Chọn dự án",
|
||||
"default": "Default Permission",
|
||||
"full": "Full Access"
|
||||
"default": "Quyền mặc định",
|
||||
"full": "Toàn quyền truy cập"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
"title": "Workspace không thay đổi",
|
||||
"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": {
|
||||
"dialog": {
|
||||
"defaultProject": "Default workspace",
|
||||
"defaultProject": "Workspace mặc định",
|
||||
"manual": "Dán đường dẫn",
|
||||
"manualPlaceholder": "/Users/name/project",
|
||||
"usePath": "Use Path",
|
||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||
"usePath": "Dùng đường dẫn",
|
||||
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"missingCredential": "启用图片生成前请先配置此提供商。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
|
||||
"description": "启用插件、本地应用适配器和已连接的工具服务。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 应用",
|
||||
"filterMcp": "MCP 服务",
|
||||
"enabledSummary": "已启用 {{count}} 个",
|
||||
"caption": "{{cli}} 个 CLI · {{mcp}} 个 MCP",
|
||||
"caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
|
||||
"searchPlaceholder": "搜索应用",
|
||||
"featured": "精选",
|
||||
"featured": "应用目录",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有匹配的应用。"
|
||||
"empty": "没有匹配的应用。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"ready": "就绪",
|
||||
"missingDependency": "缺少支持包",
|
||||
"installSupport": "安装支持包",
|
||||
"installConfirmTitle": "安装 {{name}} 支持?",
|
||||
"installConfirmDescription": "将安装并启用 {{name}} 支持。是否继续?",
|
||||
"installConfirmAction": "安装并启用",
|
||||
"websocketRequired": "WebUI 必需",
|
||||
"channelDisabled": "渠道已禁用",
|
||||
"notEnabled": "未启用"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
|
||||
@@ -458,18 +458,36 @@
|
||||
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "新增 nanobot 可在聊天中使用的 App CLI 和 MCP 服務。",
|
||||
"description": "啟用插件、本機應用適配器和已連接的工具服務。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"channelLabel": "通道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 應用",
|
||||
"filterMcp": "MCP 服務",
|
||||
"enabledSummary": "已啟用 {{count}} 個",
|
||||
"caption": "{{cli}} 個 CLI · {{mcp}} 個 MCP",
|
||||
"caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
|
||||
"searchPlaceholder": "搜尋應用",
|
||||
"featured": "精選",
|
||||
"featured": "應用目錄",
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合的應用。"
|
||||
"empty": "沒有符合的應用。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已啟用",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"ready": "就緒",
|
||||
"missingDependency": "缺少支援套件",
|
||||
"installSupport": "安裝支援套件",
|
||||
"installConfirmTitle": "安裝 {{name}} 支援?",
|
||||
"installConfirmDescription": "將安裝並啟用 {{name}} 支援。是否繼續?",
|
||||
"installConfirmAction": "安裝並啟用",
|
||||
"websocketRequired": "WebUI 必需",
|
||||
"channelDisabled": "通道已停用",
|
||||
"notEnabled": "未啟用"
|
||||
},
|
||||
"automations": {
|
||||
"filters": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
FilePreviewPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
NanobotFeaturesPayload,
|
||||
ModelConfigurationCreate,
|
||||
ModelConfigurationUpdate,
|
||||
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(
|
||||
token: string,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
|
||||
@@ -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 {
|
||||
name: string;
|
||||
label: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchNanobotFeatures,
|
||||
fetchProviderModels,
|
||||
fetchSessionAutomations,
|
||||
fetchSettingsUsage,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
runAutomationAction,
|
||||
runCliAppAction,
|
||||
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 () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -1527,6 +1527,10 @@ describe("App layout", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.settings-preferences",
|
||||
JSON.stringify({ brandLogos: true }),
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
|
||||
@@ -64,6 +64,18 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.sections.webuiSafety",
|
||||
"settings.sections.capabilities",
|
||||
"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.rows.theme",
|
||||
"settings.rows.language",
|
||||
@@ -105,6 +117,16 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.about.upToDate",
|
||||
"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> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -271,7 +293,7 @@ describe("webui i18n", () => {
|
||||
for (const [locale, resource] of Object.entries(resources)) {
|
||||
if (locale === "en") continue;
|
||||
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),
|
||||
);
|
||||
|
||||
|
||||
@@ -263,6 +263,204 @@ describe("SettingsView Apps catalog", () => {
|
||||
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 () => {
|
||||
const payload = settingsPayload();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user