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