mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 14:58:39 +03:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7703cd22eb | ||
|
|
247c474e64 | ||
|
|
86c7508607 | ||
|
|
a2979c3a4b | ||
|
|
d5e0df6963 | ||
|
|
57d81bc1cd | ||
|
|
3778e7e628 | ||
|
|
cac39477ba | ||
|
|
eab017766b | ||
|
|
52e0a6a1e3 | ||
|
|
8e77f3f8a4 | ||
|
|
b3b0517611 | ||
|
|
c281e090d0 | ||
|
|
85a452e5c7 | ||
|
|
05d73803e7 | ||
|
|
5d733b1c7c | ||
|
|
71a99b0780 | ||
|
|
43511decc9 | ||
|
|
8dd2059be3 | ||
|
|
7b1646f58c | ||
|
|
e620944150 | ||
|
|
66316f21da |
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
||||
|
||||
## 🌐 WebUI
|
||||
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
@@ -250,9 +250,10 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
|
||||
Use it to:
|
||||
|
||||
- keep separate topics for different tasks and projects;
|
||||
- use temporary chats when a conversation should not be saved to history or memory;
|
||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||
- switch models and workspaces without leaving the conversation;
|
||||
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
||||
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
||||
|
||||
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||
|
||||
|
||||
+2
-1
@@ -19,7 +19,7 @@ The recommended first-run path is:
|
||||
3. Configure a provider and model in **Settings → Models**.
|
||||
4. Send `Hello!` before configuring anything else.
|
||||
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for Agent Plugins, CLI Apps, and MCP integrations.
|
||||
|
||||
## Add One Capability
|
||||
|
||||
@@ -32,6 +32,7 @@ Pick the row that matches what you want to accomplish next:
|
||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||
| Manage Agent Plugins, CLI Apps, or MCP integrations | [WebUI Apps](./webui.md#apps) |
|
||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Generate images | [Image Generation](./image-generation.md) |
|
||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||
|
||||
@@ -201,8 +201,10 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
| Agent Plugin | Add a v1 package under `<workspace>/plugins/` and enable it from Apps |
|
||||
| MCP | Add `tools.mcpServers` config or bundle the server in an Agent Plugin |
|
||||
| Skill | Add workspace skills under `<workspace>/skills/`, bundle them in an Agent Plugin, or add built-in skills under `nanobot/skills/` |
|
||||
| CLI App | Add it to the CLI Apps catalog; the installer owns its executable lifecycle and writes a skills-only Agent Plugin |
|
||||
|
||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||
|
||||
|
||||
@@ -132,6 +132,26 @@ Dream is a periodic consolidation job. It reads accumulated history and updates
|
||||
|
||||
See [`memory.md`](./memory.md) for the detailed design.
|
||||
|
||||
## Apps and Agent Plugins
|
||||
|
||||
Agent Plugins are nanobot's common package and activation boundary for
|
||||
installable capabilities. They organize existing extension types instead of
|
||||
replacing them:
|
||||
|
||||
| Part | Role |
|
||||
|---|---|
|
||||
| Agent Plugin | Installable package that can bundle skills, MCP servers, or both |
|
||||
| Skill | Workflow guidance loaded progressively or invoked with `$skill-name` |
|
||||
| MCP server | Runtime tools exposed to the agent |
|
||||
| CLI App | Locally managed executable whose adapter is packaged and activated like a plugin |
|
||||
| Apps | WebUI surface for reviewing and managing these capabilities |
|
||||
|
||||
Native providers, channels, built-in tools, standalone workspace skills, and
|
||||
directly configured MCP servers keep their existing extension paths. See
|
||||
[`webui.md#apps`](./webui.md#apps) for the user-facing flow and
|
||||
[`configuration.md#agent-plugins-v1`](./configuration.md#agent-plugins-v1) for
|
||||
the package contract.
|
||||
|
||||
## Tools and Safety
|
||||
|
||||
Tools are discovered automatically from built-in modules and plugin entry points. Common tool groups include:
|
||||
|
||||
+60
-5
@@ -330,7 +330,11 @@ By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normal
|
||||
|
||||
Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
||||
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes
|
||||
ordinary fields through as the SDK `extra_body` value; list-valued `extraBody.tools` is handled
|
||||
specially and appended after generated function tools. With Responses, configure it in Responses
|
||||
API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends
|
||||
`extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1971,15 +1975,52 @@ Add MCP servers to your `config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Two transport modes are supported:
|
||||
MCP servers can run locally over stdio or connect remotely over HTTP:
|
||||
|
||||
| Mode | Config | Example |
|
||||
| Connection | Config | Example |
|
||||
|------|--------|---------|
|
||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
||||
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
|
||||
|
||||
Remote HTTP servers may use browser OAuth instead of static headers. In the
|
||||
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
|
||||
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
|
||||
**Connect**. For manual configuration, add `auth: "oauth"` and open
|
||||
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
|
||||
the config automatically on first click.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "oauth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot opens the server's authorization page and handles the callback through
|
||||
the gateway. The tools become available immediately when hot reload succeeds;
|
||||
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
|
||||
registration data are stored in the nanobot data directory under
|
||||
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
|
||||
from Apps also removes its saved OAuth credentials. Normal gateway startup never
|
||||
opens a browser or registers a new OAuth client when credentials are
|
||||
missing—interactive authorization starts only after a user clicks **Connect**.
|
||||
|
||||
For a remotely accessed WebUI, HTTPS is recommended. Configure
|
||||
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
|
||||
nanobot can register the matching HTTPS callback and finish automatically. A
|
||||
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
|
||||
nanobot instead registers a localhost callback and asks you to paste the complete
|
||||
callback URL from the browser address bar after authorization.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
|
||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||
|
||||
@@ -2306,6 +2347,20 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) under `<workspace>/plugins/`; a v1 package has `plugin.json` and may add `mcp.json`, `skills/<name>/SKILL.md`, or both. Agent Plugins are the common package and activation boundary for installable capabilities; they do not replace native providers, channels, tools, standalone workspace skills, or directly configured MCP servers.
|
||||
|
||||
Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence.
|
||||
Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit
|
||||
`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored.
|
||||
An enabled package is treated as immutable: changing any packaged file disables it until the user
|
||||
reviews and enables it again. Runtime state belongs under `PLUGIN_DATA`, not the package root.
|
||||
|
||||
Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||
|
||||
CLI Apps use the same skills-only package layout while their installer manages executables, updates, and removal. Future catalogs can place packages before using this activation path.
|
||||
|
||||
## Tool Hint Max Length
|
||||
|
||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||
|
||||
@@ -30,10 +30,15 @@ remote HTTP endpoint.
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui` and open **Apps**.
|
||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
|
||||
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
|
||||
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
|
||||
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
|
||||
return automatically. From a remote plain-HTTP WebUI, copy the complete
|
||||
localhost callback URL from the browser address bar and paste it into nanobot.
|
||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||
4. Save and restart when prompted.
|
||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
|
||||
|
||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||
|
||||
@@ -58,12 +63,16 @@ Restart nanobot and ask a question that requires the MCP tool.
|
||||
- Prefer `enabledTools` over exposing every tool by default.
|
||||
- Use `toolTimeout` for slow MCP operations.
|
||||
- Use HTTP MCP only for endpoints you trust.
|
||||
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
|
||||
browser connection from **Apps → MCP**.
|
||||
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Stdio MCP starts a local process; review the command before enabling it.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
|
||||
token exchange, and redirects.
|
||||
- OAuth credentials live in the nanobot data directory, not in `config.json`.
|
||||
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||
- Do not place secrets in command arguments when environment variables or
|
||||
headers can be used.
|
||||
|
||||
@@ -100,6 +100,29 @@ Gateway-style setup for model IDs served through OpenRouter.
|
||||
|
||||
Use the model ID exactly as OpenRouter lists it.
|
||||
|
||||
To opt into OpenRouter server-managed search and fetch, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"extraBody": {
|
||||
"tools": [
|
||||
{ "type": "openrouter:web_search" },
|
||||
{ "type": "openrouter:web_fetch" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chat Completions-compatible OpenRouter
|
||||
[server tools](https://openrouter.ai/docs/guides/features/server-tools), such as those above, are
|
||||
appended to nanobot's generated functions. This keeps unrelated local tools such as `write_file`
|
||||
available in the same request. Responses-only server tools require an API surface that the
|
||||
OpenRouter provider does not currently enable.
|
||||
|
||||
### Eden AI Gateway
|
||||
|
||||
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
|
||||
|
||||
@@ -270,6 +270,12 @@ http://127.0.0.1:8765
|
||||
|
||||
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
||||
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
|
||||
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
|
||||
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
|
||||
|
||||
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
||||
|
||||
## Chat App Problems
|
||||
|
||||
+77
-28
@@ -1,10 +1,10 @@
|
||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
||||
|
||||
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||
one place.
|
||||
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
||||
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
||||
settings, and Automations in one place.
|
||||
|
||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||
the `webui/` source directory when you are changing the frontend itself.
|
||||
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
||||
|
||||
| Area | Use it for |
|
||||
|---|---|
|
||||
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
|
||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||
|
||||
@@ -90,6 +90,10 @@ workspace selection, and linked automations. Use a new topic when you want a
|
||||
separate context; use fork when you want to continue from an existing point
|
||||
without changing the original thread.
|
||||
|
||||
Drag a topic within its current sidebar group to keep frequently used work in
|
||||
your preferred order. Drag a topic from the sidebar into the composer when you
|
||||
want to reference it in the next message instead of switching to it.
|
||||
|
||||
The message timeline shows both user-visible replies and agent activity. Long
|
||||
tool or reasoning sections can be expanded when you need the details.
|
||||
|
||||
@@ -103,6 +107,28 @@ File previews follow the active session access mode. Restricted workspace access
|
||||
previews only files under the selected workspace. Full Access can preview files
|
||||
outside the workspace when that access mode is allowed by the gateway.
|
||||
|
||||
## Temporary Chats
|
||||
|
||||
Use a temporary chat for a conversation that should not be added to nanobot's
|
||||
topic history or long-term memory:
|
||||
|
||||
1. Select **New topic**.
|
||||
2. Select the **Temporary chat** control in the page header.
|
||||
3. Send the first message.
|
||||
|
||||
You can keep more than one temporary chat open and switch between them under
|
||||
**Temporary chats** in the sidebar while the current WebUI connection remains
|
||||
open. Reloading or closing the page, restarting the gateway, or losing the
|
||||
WebSocket connection ends all of them. They cannot be recovered afterward.
|
||||
|
||||
Temporary does not mean consequence-free. Requests still go to the configured
|
||||
model provider, and tools can still change files, run commands, or affect
|
||||
external services. Temporary chats always use the default workspace in
|
||||
Restricted mode; the project picker and Full Access are unavailable. Commands
|
||||
and tools that create durable goals, automations, or subagent work are also
|
||||
unavailable. Use a regular topic when you need reusable context, scheduled work,
|
||||
or a result you must retain.
|
||||
|
||||
## Workspace and Access
|
||||
|
||||
Use the workspace picker before starting project-specific work. This gives the
|
||||
@@ -145,7 +171,8 @@ clients.
|
||||
The composer supports plain messages, image attachments, voice input when
|
||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
||||
reference; plain text that happens to start with `@` does not attach history.
|
||||
reference, or drag that topic from the sidebar into the composer. Plain text
|
||||
that happens to start with `@` does not attach history.
|
||||
Restricted chats offer topics from the same project, while Full Access chats can
|
||||
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
||||
is relevant and can link it in the response. The model badge shows the current
|
||||
@@ -171,14 +198,23 @@ Test a new channel with a private DM. When a supported channel sends a pairing c
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
Open Apps from the sidebar to review and manage installable capabilities. The
|
||||
default **Ready** view shows only capabilities that can be used immediately:
|
||||
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||
- **Agent Plugins** are local packages that can bundle skills, MCP servers, or
|
||||
both. A package under `<workspace>/plugins/` is installed but remains inactive
|
||||
until you enable it in Apps.
|
||||
- **CLI Apps** are local command-line adapters that nanobot runs on your
|
||||
machine. Their installer manages the executable and exposes its adapter
|
||||
through the same plugin activation model. Installing an adapter does not
|
||||
modify the native desktop or web app it connects to.
|
||||
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
||||
headers. After saving an OAuth server, choose **Connect** to open its sign-in
|
||||
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
|
||||
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
|
||||
field for pasting the complete localhost callback URL.
|
||||
|
||||
Apps intentionally does not list nanobot runtime support packages such as
|
||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||
@@ -187,6 +223,7 @@ are not tools that can be attached to a turn with `@`. Manage them from
|
||||
included in nanobot and activate automatically when a file is attached. The
|
||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||
That command manages nanobot runtime extras, not Agent Plugin packages.
|
||||
|
||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||
@@ -199,15 +236,26 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||
It is an optional integration and does not replace nanobot's built-in web search
|
||||
provider; mention `@parallel-search` when a turn should use it.
|
||||
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
After a CLI App or MCP server is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message. Plugin-provided skills participate
|
||||
in normal skill discovery and can be invoked with `$skill-name`.
|
||||
|
||||
## Skills
|
||||
|
||||
The Skills view shows the skill instructions available to the agent, including
|
||||
built-in skills and workspace-provided skills. Check this view when you want to
|
||||
know whether nanobot already has a focused workflow for a task before you ask it
|
||||
to perform that task.
|
||||
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
||||
You can search and filter them, inspect their instructions and setup
|
||||
requirements, enable or disable them, and delete workspace skills you no longer
|
||||
want.
|
||||
|
||||
Open **Skills → Discover** to browse or search skills from skills.sh and
|
||||
SkillHub. A marketplace skill is copied into the active agent workspace after
|
||||
you confirm the installation. skills.sh installation requires Node.js with
|
||||
`npx`; SkillHub installation does not.
|
||||
|
||||
Marketplace skills are third-party instructions and may include executable
|
||||
scripts. Review the source and instructions before installing one, and enable
|
||||
only skills you trust with the same files, tools, and credentials available to
|
||||
your agent.
|
||||
|
||||
## Automations
|
||||
|
||||
@@ -295,10 +343,10 @@ trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||
reverse proxy and use that proxy's HTTPS URL.
|
||||
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
environment through the WebUI, opt in explicitly:
|
||||
Remote WebUI clients with a valid token can view and use Apps and installed
|
||||
skills. Actions that install missing nanobot support packages or third-party
|
||||
marketplace skills are blocked by default. To let trusted remote administrators
|
||||
perform those installations through the WebUI, opt in explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -309,12 +357,13 @@ environment through the WebUI, opt in explicitly:
|
||||
```
|
||||
|
||||
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.
|
||||
trusted to change nanobot's Python environment and install workspace skill
|
||||
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
||||
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
||||
package and skill installs disabled unless that is intentional.
|
||||
|
||||
Optional feature installs use pip's configured package index, including
|
||||
`PIP_INDEX_URL`.
|
||||
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
|
||||
|
||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||
private, trusted network.
|
||||
|
||||
+14
-4
@@ -36,6 +36,7 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.turn_delivery import (
|
||||
TurnDelivery,
|
||||
@@ -197,6 +198,11 @@ class AgentLoop:
|
||||
def tool_names(self) -> list[str]:
|
||||
return self.tools.tool_names
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]:
|
||||
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
||||
return self._last_usage
|
||||
|
||||
@property
|
||||
def provider(self) -> LLMProvider:
|
||||
"""Provider selected for future turn admissions."""
|
||||
@@ -448,7 +454,6 @@ class AgentLoop:
|
||||
if model_preset:
|
||||
self.set_model_preset(model_preset, publish_update=False)
|
||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||
self._runtime_vars: dict[str, Any] = {}
|
||||
self._current_iteration: int = 0
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
@@ -480,6 +485,8 @@ class AgentLoop:
|
||||
config,
|
||||
provider_snapshot_loader,
|
||||
)
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
|
||||
return cls(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -494,7 +501,7 @@ class AgentLoop:
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||
channels_config=config.channels,
|
||||
timezone=defaults.timezone,
|
||||
unified_session=defaults.unified_session,
|
||||
@@ -623,10 +630,13 @@ class AgentLoop:
|
||||
loader = ToolLoader()
|
||||
registered = loader.load(ctx, self.tools)
|
||||
|
||||
# MyTool needs runtime state reference — manual registration
|
||||
# MyTool receives only the explicit runtime-control capability.
|
||||
if self.tools_config.my.enable:
|
||||
self.tools.register(
|
||||
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||
MyTool(
|
||||
runtime_control=AgentRuntimeControl(self),
|
||||
modify_allowed=self.tools_config.my.allow_set,
|
||||
)
|
||||
)
|
||||
registered.append("my")
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Load and activate locally installed Agent Plugin packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||
|
||||
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
|
||||
_MAX_LOGO_BYTES = 256 * 1024
|
||||
_SKILL_CACHE: dict[tuple[Path, Path], tuple[tuple[str, Path], ...]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPlugin:
|
||||
"""A validated, locally installed Agent Plugins v1 package."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
description: str
|
||||
repository: str
|
||||
display_name: str
|
||||
category: str
|
||||
accent_color: str | None
|
||||
logo: str | None
|
||||
permissions: tuple[str, ...]
|
||||
mcp_servers: tuple[str, ...] = ()
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
def _installed_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return installed packages found under ``<workspace>/plugins/*``."""
|
||||
workspace = workspace.expanduser().resolve()
|
||||
root = _contained(workspace / "plugins", workspace, directory=True)
|
||||
if root is None:
|
||||
return []
|
||||
plugins: dict[str, AgentPlugin | None] = {}
|
||||
for candidate in _children(root, "Agent Plugins directory"):
|
||||
plugin_root = _contained(candidate, root, directory=True)
|
||||
if plugin_root is None:
|
||||
continue
|
||||
plugin = _load_manifest(plugin_root)
|
||||
if plugin is not None:
|
||||
if plugin.name in plugins:
|
||||
logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name)
|
||||
plugins[plugin.name] = None
|
||||
else:
|
||||
plugins[plugin.name] = plugin
|
||||
return [plugin for plugin in plugins.values() if plugin is not None]
|
||||
|
||||
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||
"""Verify and return skills from plugins the user has explicitly enabled."""
|
||||
skills = [
|
||||
skill
|
||||
for plugin in _installed_plugins(workspace)
|
||||
if _enabled(workspace, plugin)
|
||||
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||
]
|
||||
_SKILL_CACHE[_skill_cache_key(workspace)] = tuple(skills)
|
||||
return skills
|
||||
|
||||
|
||||
def enabled_agent_plugin_skill_dirs(workspace: Path) -> tuple[Path, ...]:
|
||||
"""Return the last verified skill roots, verifying once on a cache miss."""
|
||||
key = _skill_cache_key(workspace)
|
||||
skills = _SKILL_CACHE.get(key)
|
||||
if skills is None:
|
||||
skills = tuple(enabled_agent_plugin_skills(workspace))
|
||||
return tuple(path.parent for _name, path in skills)
|
||||
|
||||
|
||||
def _skill_cache_key(workspace: Path) -> tuple[Path, Path]:
|
||||
return (
|
||||
workspace.expanduser().resolve(),
|
||||
get_config_path().expanduser().resolve(),
|
||||
)
|
||||
|
||||
|
||||
def _invalidate_skill_cache(workspace: Path) -> None:
|
||||
_SKILL_CACHE.pop(_skill_cache_key(workspace), None)
|
||||
|
||||
|
||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
payload = _read_object(plugin_root / "plugin.json", plugin_root)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
||||
return None
|
||||
name = payload.get("name")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or len(name) > 64
|
||||
or _PLUGIN_NAME.fullmatch(name) is None
|
||||
):
|
||||
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
|
||||
return None
|
||||
extension = payload.get("extensions")
|
||||
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||
nanobot_value = extension_payload.get("dev.nanobot")
|
||||
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||
return AgentPlugin(
|
||||
name=name,
|
||||
root=plugin_root,
|
||||
description=_string(payload.get("description")),
|
||||
repository=_string(payload.get("repository")),
|
||||
display_name=_string(nanobot.get("displayName")) or name,
|
||||
category=_string(nanobot.get("category")) or "Plugin",
|
||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||
permissions=_string_tuple(nanobot.get("permissions")),
|
||||
)
|
||||
|
||||
|
||||
def agent_plugin_mcp_servers(
|
||||
workspace: Path,
|
||||
configured: dict[str, MCPServerConfig] | None = None,
|
||||
) -> dict[str, MCPServerConfig]:
|
||||
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||
|
||||
User configuration wins on the unlikely event of a namespaced collision.
|
||||
"""
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for plugin in _installed_plugins(workspace):
|
||||
if not _enabled(workspace, plugin):
|
||||
continue
|
||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||
for name, server in plugin_servers.items():
|
||||
# ``--`` cannot occur in a valid plugin identity, so multi-server
|
||||
# namespaces cannot collide with a single-server plugin name.
|
||||
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}--{name}"
|
||||
servers[host_name] = server
|
||||
configured = configured or {}
|
||||
if collisions := servers.keys() & configured.keys():
|
||||
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
|
||||
return servers | configured
|
||||
|
||||
|
||||
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return component and lifecycle state for discovered plugins."""
|
||||
return [
|
||||
replace(
|
||||
plugin,
|
||||
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||
enabled=_enabled(workspace, plugin),
|
||||
)
|
||||
for plugin in _installed_plugins(workspace)
|
||||
]
|
||||
|
||||
|
||||
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
|
||||
"""Enable or disable one installed plugin."""
|
||||
plugin = next((item for item in _installed_plugins(workspace) if item.name == name), None)
|
||||
if plugin is None:
|
||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
marker = data / "enabled"
|
||||
if enabled:
|
||||
activation = _activation_marker(plugin)
|
||||
if activation is None:
|
||||
raise RuntimeError(f"Agent Plugin '{name}' changed while it was being enabled")
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||
items = cast(list[object], value) if isinstance(value, list) else []
|
||||
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||
|
||||
|
||||
def _accent_color(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||
|
||||
|
||||
def _plugin_logo(value: object, plugin_root: Path) -> str | None:
|
||||
"""Resolve nanobot's optional packaged logo extension."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.startswith("./"):
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
logo = _contained(plugin_root / value[2:], plugin_root)
|
||||
try:
|
||||
data = logo.read_bytes() if logo is not None else b""
|
||||
suffix = logo.suffix.lower() if logo is not None else ""
|
||||
if len(data) <= _MAX_LOGO_BYTES and (
|
||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||
):
|
||||
mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:]
|
||||
return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
except OSError:
|
||||
pass
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||
payload = _read_object(plugin.root / "mcp.json", plugin.root)
|
||||
if payload is None:
|
||||
return {}
|
||||
raw_servers = payload.get("mcpServers")
|
||||
if (
|
||||
payload.keys() != {"$schema", "mcpServers"}
|
||||
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
|
||||
or not isinstance(raw_servers, dict)
|
||||
):
|
||||
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||
return {}
|
||||
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||
continue
|
||||
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||
if server is None:
|
||||
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||
continue
|
||||
servers[name] = server
|
||||
return servers
|
||||
|
||||
|
||||
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
payload = cast(dict[str, object], raw)
|
||||
if payload.keys() - _MCP_SERVER_FIELDS:
|
||||
return None
|
||||
try:
|
||||
server = MCPServerConfig.model_validate(payload)
|
||||
except ValidationError:
|
||||
return None
|
||||
command = _stdio_command(server.command, root)
|
||||
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||
if server.type != "stdio" or command is None or cwd is None:
|
||||
return None
|
||||
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
|
||||
return None
|
||||
return server.model_copy(
|
||||
update={
|
||||
"command": command,
|
||||
"args": [_expand(item, root, data) for item in server.args],
|
||||
"env": {
|
||||
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PLUGIN_ROOT": str(root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
},
|
||||
"cwd": str(cwd),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stdio_command(value: object, root: Path) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
executable = _contained(root / value[2:], root)
|
||||
return str(executable) if executable is not None else None
|
||||
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||
if value is None:
|
||||
return root
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
return _contained(root / value[2:], root, directory=True)
|
||||
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||
relative = value[len(placeholder):].lstrip("/")
|
||||
candidate = (base / relative).resolve()
|
||||
if not candidate.is_relative_to(base):
|
||||
return None
|
||||
if base == data:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
candidate.chmod(0o700)
|
||||
return candidate if candidate.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _expand(value: str, root: Path, data: Path) -> str:
|
||||
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
|
||||
|
||||
|
||||
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||
current = get_config_path().expanduser().resolve().parent
|
||||
for segment in ("plugin-data", workspace_id, name):
|
||||
path = current / segment
|
||||
if create:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
resolved = path.resolve(strict=create)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
|
||||
if not resolved.is_relative_to(current):
|
||||
raise RuntimeError("Agent Plugin data directory escapes its parent")
|
||||
if create:
|
||||
resolved.chmod(0o700)
|
||||
current = resolved
|
||||
return current
|
||||
|
||||
|
||||
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
|
||||
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
|
||||
try:
|
||||
if not marker.is_file():
|
||||
return False
|
||||
current = marker.read_text(encoding="utf-8")
|
||||
activation = _activation_marker(plugin)
|
||||
if activation is None:
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
if current == activation:
|
||||
return True
|
||||
if current == str(plugin.root):
|
||||
marker.write_text(activation, encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
return True
|
||||
marker.unlink(missing_ok=True)
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
except OSError:
|
||||
_invalidate_skill_cache(workspace)
|
||||
return False
|
||||
|
||||
|
||||
def _activation_marker(plugin: AgentPlugin) -> str | None:
|
||||
"""Bind activation to one immutable package snapshot."""
|
||||
digest = sha256()
|
||||
try:
|
||||
for candidate in sorted(plugin.root.rglob("*")):
|
||||
relative = candidate.relative_to(plugin.root).as_posix()
|
||||
digest.update(relative.encode())
|
||||
if candidate.is_symlink():
|
||||
digest.update(b"\0link\0")
|
||||
digest.update(candidate.readlink().as_posix().encode())
|
||||
elif candidate.is_file():
|
||||
digest.update(b"\0file\0")
|
||||
digest.update(candidate.read_bytes())
|
||||
elif candidate.is_dir():
|
||||
digest.update(b"\0dir\0")
|
||||
else:
|
||||
return None
|
||||
digest.update(b"\0")
|
||||
except OSError:
|
||||
return None
|
||||
return json.dumps(
|
||||
{"fingerprint": digest.hexdigest(), "root": str(plugin.root)},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
|
||||
if skills_root is None:
|
||||
return []
|
||||
|
||||
skills: list[tuple[str, Path]] = []
|
||||
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||
skill_root = _contained(candidate, skills_root, directory=True)
|
||||
if skill_root is None:
|
||||
continue
|
||||
skill_file = _contained(skill_root / "SKILL.md", plugin_root)
|
||||
if skill_file is None:
|
||||
continue
|
||||
try:
|
||||
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
metadata = None
|
||||
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||
continue
|
||||
skills.append((candidate.name, skill_file))
|
||||
return skills
|
||||
|
||||
|
||||
def _children(root: Path, label: str) -> list[Path]:
|
||||
try:
|
||||
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect {}: {}", label, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
expected_kind = resolved.is_dir() if directory else resolved.is_file()
|
||||
return resolved if expected_kind and resolved.is_relative_to(root) else None
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
|
||||
contained = _contained(path, root)
|
||||
if contained is None:
|
||||
return None
|
||||
try:
|
||||
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
|
||||
return None
|
||||
return cast(dict[str, object], value) if isinstance(value, dict) else None
|
||||
+66
-30
@@ -17,9 +17,35 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
def parse_skill_metadata(content: str) -> dict[str, object] | None:
|
||||
"""Parse a skill document's YAML frontmatter."""
|
||||
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
|
||||
|
||||
|
||||
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
|
||||
"""Return whether metadata satisfies the Agent Skills identity contract."""
|
||||
description = metadata.get("description")
|
||||
return (
|
||||
metadata.get("name") == name
|
||||
and len(name) <= 64
|
||||
and _SKILL_NAME.fullmatch(name) is not None
|
||||
and isinstance(description, str)
|
||||
and 1 <= len(description.strip()) <= 1024
|
||||
)
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Loader for agent skills.
|
||||
@@ -34,6 +60,15 @@ class SkillsLoader:
|
||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
|
||||
def _skill_aliases(self) -> dict[str, str]:
|
||||
"""Return compatibility aliases owned by installed CLI Apps."""
|
||||
from nanobot.apps.cli import CliAppManager
|
||||
|
||||
try:
|
||||
return CliAppManager(workspace=self.workspace).installed_skill_aliases()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||
if not base.exists():
|
||||
return []
|
||||
@@ -60,15 +95,33 @@ class SkillsLoader:
|
||||
Returns:
|
||||
List of skill info dicts with 'name', 'path', 'source'.
|
||||
"""
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||
|
||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
workspace_names = {entry["name"] for entry in skills}
|
||||
seen_names = {entry["name"] for entry in skills}
|
||||
for name, path in plugin_skills:
|
||||
if name in seen_names:
|
||||
continue
|
||||
skills.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"source": "plugin",
|
||||
}
|
||||
)
|
||||
seen_names.add(name)
|
||||
if self.builtin_skills and self.builtin_skills.exists():
|
||||
skills.extend(
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||
)
|
||||
|
||||
if self.disabled_skills:
|
||||
skills = [s for s in skills if s["name"] not in self.disabled_skills]
|
||||
disabled = set(self.disabled_skills)
|
||||
for legacy, canonical in self._skill_aliases().items():
|
||||
if legacy in disabled or canonical in disabled:
|
||||
disabled.update((legacy, canonical))
|
||||
skills = [s for s in skills if s["name"] not in disabled]
|
||||
|
||||
if filter_unavailable:
|
||||
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
||||
@@ -84,14 +137,11 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Skill content or None if not found.
|
||||
"""
|
||||
roots = [self.workspace_skills]
|
||||
if self.builtin_skills:
|
||||
roots.append(self.builtin_skills)
|
||||
for root in roots:
|
||||
path = root / name / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
skills = self.list_skills(filter_unavailable=False)
|
||||
available = {skill["name"] for skill in skills}
|
||||
resolved = name if name in available else self._skill_aliases().get(name, name)
|
||||
entry = next((skill for skill in skills if skill["name"] == resolved), None)
|
||||
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
"""
|
||||
@@ -118,9 +168,11 @@ class SkillsLoader:
|
||||
entry["name"]
|
||||
for entry in self.list_skills(filter_unavailable=True)
|
||||
}
|
||||
aliases = self._skill_aliases()
|
||||
invoked: list[str] = []
|
||||
for match in _SKILL_REFERENCE.finditer(text):
|
||||
name = match.group(1)
|
||||
requested = match.group(1)
|
||||
name = requested if requested in available else aliases.get(requested, requested)
|
||||
if name in available and name not in invoked:
|
||||
invoked.append(name)
|
||||
return invoked
|
||||
@@ -145,6 +197,7 @@ class SkillsLoader:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
@@ -278,21 +331,4 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Metadata dict or None.
|
||||
"""
|
||||
content = self.load_skill(name)
|
||||
if not content or not content.startswith("---"):
|
||||
return None
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||
# keep values as-is so downstream consumers get correct types.
|
||||
metadata: dict[str, object] = {}
|
||||
for key, value in cast(dict[object, object], parsed).items():
|
||||
metadata[str(key)] = value
|
||||
return metadata
|
||||
return parse_skill_metadata(self.load_skill(name) or "")
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypedDict
|
||||
@@ -157,6 +158,10 @@ class SubagentManager:
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
|
||||
"""Return the observable task statuses used by runtime-control snapshots."""
|
||||
return self._task_statuses
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||
warnings.warn(
|
||||
|
||||
@@ -148,9 +148,19 @@ class _FsTool(Tool):
|
||||
)
|
||||
|
||||
def _resolve_read(self, path: str) -> Path:
|
||||
plugin_skill_dirs: list[Path] = []
|
||||
if self._workspace is not None:
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skill_dirs
|
||||
|
||||
try:
|
||||
plugin_skill_dirs = list(
|
||||
enabled_agent_plugin_skill_dirs(Path(self._workspace))
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_read_allowed_dirs,
|
||||
[*self._extra_read_allowed_dirs, *plugin_skill_dirs],
|
||||
self._extra_read_allowed_files,
|
||||
include_media_dir=True,
|
||||
extra_files_require_allowed_root=True,
|
||||
@@ -827,7 +837,8 @@ class EditFileTool(_FsTool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. Use this for narrow text substitutions "
|
||||
"old_text with new_text. When replacing text in an existing file, "
|
||||
"old_text and new_text must be different. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||
"multiple times, provide more context or set occurrence, line_hint, "
|
||||
@@ -862,9 +873,12 @@ class EditFileTool(_FsTool):
|
||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||
|
||||
fp = self._resolve_write(path)
|
||||
file_exists = fp.exists()
|
||||
if file_exists and old_text == new_text:
|
||||
return ToolResult.error("Error: new_text must be different from old_text.")
|
||||
|
||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||
if not fp.exists():
|
||||
if not file_exists:
|
||||
if old_text == "":
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_SKIP_MODULES = frozenset({
|
||||
"base", "schema", "registry", "context", "loader", "config",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
||||
})
|
||||
|
||||
|
||||
|
||||
+102
-43
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
|
||||
from mcp.types import Prompt, Resource
|
||||
from mcp.types import Tool as MCPToolDefinition
|
||||
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
# Transient connection errors that warrant a single retry.
|
||||
@@ -184,6 +185,25 @@ def _is_transient(exc: BaseException) -> bool:
|
||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||
|
||||
|
||||
def _is_transient_connection_failure(exc: BaseException) -> bool:
|
||||
if isinstance(exc, BaseExceptionGroup):
|
||||
group = cast(BaseExceptionGroup[BaseException], exc)
|
||||
return bool(group.exceptions) and all(
|
||||
_is_transient_connection_failure(nested) for nested in group.exceptions
|
||||
)
|
||||
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
|
||||
|
||||
|
||||
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
|
||||
if _is_transient_connection_failure(exc):
|
||||
logger.warning("MCP server '{}': transient connection failure", name)
|
||||
logger.opt(exception=exc).debug(
|
||||
"MCP server '{}' transient connection failure details", name
|
||||
)
|
||||
return
|
||||
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
|
||||
|
||||
|
||||
def _is_session_terminated(exc: BaseException) -> bool:
|
||||
"""Return True when the MCP SDK reports a dead client session."""
|
||||
if _is_transient(exc):
|
||||
@@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
|
||||
mcp_servers: "dict[str, MCPServerConfig]",
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
|
||||
) -> dict[str, MCPConnection]:
|
||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||
|
||||
@@ -975,11 +998,8 @@ async def connect_mcp_servers(
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
async def open_single_server(
|
||||
name: str, cfg: "MCPServerConfig"
|
||||
) -> tuple[str, AsyncExitStack | None]:
|
||||
server_stack = AsyncExitStack()
|
||||
await server_stack.__aenter__()
|
||||
|
||||
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||
) -> bool:
|
||||
try:
|
||||
transport_type = cfg.type
|
||||
if not transport_type:
|
||||
@@ -991,8 +1011,7 @@ async def connect_mcp_servers(
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
return False
|
||||
|
||||
if transport_type in {"sse", "streamableHttp"}:
|
||||
ok, error = validate_url_target(cfg.url)
|
||||
@@ -1003,8 +1022,30 @@ async def connect_mcp_servers(
|
||||
_redact_url(cfg.url),
|
||||
error,
|
||||
)
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
return False
|
||||
|
||||
oauth_auth: httpx.Auth | None = None
|
||||
if cfg.auth == "oauth":
|
||||
if transport_type not in {"sse", "streamableHttp"}:
|
||||
logger.warning(
|
||||
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
|
||||
name,
|
||||
)
|
||||
return False
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
MCPAuthorizationRequiredError,
|
||||
create_mcp_oauth_auth,
|
||||
)
|
||||
|
||||
try:
|
||||
oauth_auth = await create_mcp_oauth_auth(
|
||||
name,
|
||||
cfg.url,
|
||||
(oauth_handlers or {}).get(name),
|
||||
)
|
||||
except MCPAuthorizationRequiredError:
|
||||
logger.info("MCP server '{}': waiting for browser authorization", name)
|
||||
return False
|
||||
|
||||
if transport_type == "stdio":
|
||||
command, args, env = _normalize_windows_stdio_command(
|
||||
@@ -1022,8 +1063,7 @@ async def connect_mcp_servers(
|
||||
elif transport_type == "sse":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
return False
|
||||
|
||||
def httpx_client_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
@@ -1044,31 +1084,37 @@ async def connect_mcp_servers(
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
|
||||
sse_kwargs: dict[str, Any] = {
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
sse_kwargs["auth"] = oauth_auth
|
||||
read, write = await server_stack.enter_async_context(
|
||||
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
||||
sse_client(cfg.url, **sse_kwargs)
|
||||
)
|
||||
elif transport_type == "streamableHttp":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
return False
|
||||
|
||||
http_client_kwargs: dict[str, Any] = {
|
||||
"headers": cfg.headers or None,
|
||||
"event_hooks": {"request": [_validate_mcp_request_url]},
|
||||
"follow_redirects": True,
|
||||
"timeout": httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
http_client_kwargs["auth"] = oauth_auth
|
||||
http_client = await server_stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.headers or None,
|
||||
event_hooks={"request": [_validate_mcp_request_url]},
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
httpx.AsyncClient(**http_client_kwargs)
|
||||
)
|
||||
read, write, _ = await server_stack.enter_async_context(
|
||||
streamable_http_client(cfg.url, http_client=http_client)
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
return False
|
||||
|
||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||
@@ -1171,7 +1217,7 @@ async def connect_mcp_servers(
|
||||
logger.info(
|
||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||
)
|
||||
return name, server_stack
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
hint = ""
|
||||
@@ -1190,10 +1236,8 @@ async def connect_mcp_servers(
|
||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||
)
|
||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||
with suppress(Exception):
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
_log_mcp_connection_failure(name, e, hint)
|
||||
return False
|
||||
|
||||
async def connect_single_server(
|
||||
name: str, cfg: "MCPServerConfig"
|
||||
@@ -1203,30 +1247,30 @@ async def connect_mcp_servers(
|
||||
close_requested = asyncio.Event()
|
||||
|
||||
async def own_connection() -> None:
|
||||
stack: AsyncExitStack | None = None
|
||||
try:
|
||||
_, stack = await open_single_server(name, cfg)
|
||||
if not ready.done():
|
||||
ready.set_result(stack is not None)
|
||||
if stack is not None:
|
||||
await close_requested.wait()
|
||||
async with AsyncExitStack() as stack:
|
||||
connected = await open_single_server(name, cfg, stack)
|
||||
if not ready.done():
|
||||
ready.set_result(connected)
|
||||
if connected:
|
||||
await close_requested.wait()
|
||||
except BaseException as exc:
|
||||
if not ready.done():
|
||||
ready.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
if stack is not None:
|
||||
await stack.aclose()
|
||||
|
||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||
connection = _OwnedMCPConnection(owner, close_requested)
|
||||
try:
|
||||
connected = await ready
|
||||
except BaseException:
|
||||
except BaseException as exc:
|
||||
close_requested.set()
|
||||
owner.cancel()
|
||||
with suppress(BaseException):
|
||||
await asyncio.shield(owner)
|
||||
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
|
||||
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
|
||||
return name, None
|
||||
raise
|
||||
if not connected:
|
||||
await connection.aclose()
|
||||
@@ -1239,7 +1283,7 @@ async def connect_mcp_servers(
|
||||
try:
|
||||
result = await connect_single_server(name, cfg)
|
||||
except Exception as e:
|
||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
||||
_log_mcp_connection_failure(name, e)
|
||||
continue
|
||||
if result[1] is not None:
|
||||
server_stacks[result[0]] = result[1]
|
||||
@@ -1296,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
next_servers = agent_plugin_mcp_servers(
|
||||
config.workspace_path,
|
||||
config.tools.mcp_servers,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
@@ -1312,6 +1360,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
current_servers = dict(state._mcp_servers)
|
||||
current_names = set(current_servers)
|
||||
next_names = set(next_servers)
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in next_servers.items()
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
removed = sorted(current_names - next_names)
|
||||
added = sorted(next_names - current_names)
|
||||
changed = sorted(
|
||||
@@ -1329,9 +1384,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
||||
if name not in state._mcp_stacks
|
||||
and name not in set(added) | set(changed)
|
||||
and name not in authorization_pending
|
||||
)
|
||||
to_connect_names = sorted(
|
||||
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
|
||||
)
|
||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||
connected: dict[str, MCPConnection] = {}
|
||||
if to_connect:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""OAuth support for remote MCP servers.
|
||||
|
||||
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
|
||||
different lifecycle and storage contract, so sharing a higher-level workflow
|
||||
would couple unrelated extension boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from filelock import FileLock
|
||||
from loguru import logger
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
|
||||
_STORE_VERSION = 1
|
||||
_STORE_LOCK_TIMEOUT_S = 15
|
||||
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
|
||||
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
|
||||
_LOGO_URI = AnyHttpUrl(
|
||||
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||
"webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
|
||||
|
||||
class _StoredServer(TypedDict, total=False):
|
||||
server_fingerprint: str
|
||||
write_lease: str
|
||||
tokens: dict[str, Any]
|
||||
client_info: dict[str, Any]
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class _CredentialStore(TypedDict):
|
||||
version: int
|
||||
servers: dict[str, _StoredServer]
|
||||
generations: dict[str, str]
|
||||
|
||||
|
||||
class MCPAuthorizationRequiredError(RuntimeError):
|
||||
"""Raised when a background MCP connection needs interactive authorization."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPOAuthHandlers:
|
||||
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
|
||||
|
||||
redirect_uri: str
|
||||
redirect_handler: Callable[[str], Awaitable[None]]
|
||||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
|
||||
reset_credentials: bool = False
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return get_data_dir() / "auth" / "mcp.json"
|
||||
|
||||
|
||||
def _server_fingerprint(server_url: str) -> str:
|
||||
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _empty_store() -> _CredentialStore:
|
||||
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
|
||||
|
||||
|
||||
def _stored_server(value: object) -> _StoredServer | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
raw = cast(dict[object, object], value)
|
||||
entry: _StoredServer = {}
|
||||
fingerprint = raw.get("server_fingerprint")
|
||||
if isinstance(fingerprint, str):
|
||||
entry["server_fingerprint"] = fingerprint
|
||||
write_lease = raw.get("write_lease")
|
||||
if isinstance(write_lease, str) and write_lease:
|
||||
entry["write_lease"] = write_lease
|
||||
redirect_uri = raw.get("redirect_uri")
|
||||
if isinstance(redirect_uri, str):
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
tokens = raw.get("tokens")
|
||||
if isinstance(tokens, dict):
|
||||
token_values = cast(dict[object, object], tokens)
|
||||
if all(isinstance(key, str) for key in token_values):
|
||||
entry["tokens"] = cast(dict[str, Any], token_values)
|
||||
client_info = raw.get("client_info")
|
||||
if isinstance(client_info, dict):
|
||||
client_values = cast(dict[object, object], client_info)
|
||||
if all(isinstance(key, str) for key in client_values):
|
||||
entry["client_info"] = cast(dict[str, Any], client_values)
|
||||
return entry
|
||||
|
||||
|
||||
def _read_store_unlocked(path: Path) -> _CredentialStore:
|
||||
try:
|
||||
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||
except FileNotFoundError:
|
||||
return _empty_store()
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
|
||||
return _empty_store()
|
||||
if not isinstance(raw, dict):
|
||||
return _empty_store()
|
||||
payload = cast(dict[object, object], raw)
|
||||
raw_servers = payload.get("servers")
|
||||
if not isinstance(raw_servers, dict):
|
||||
return _empty_store()
|
||||
servers: dict[str, _StoredServer] = {}
|
||||
for name, value in cast(dict[object, object], raw_servers).items():
|
||||
entry = _stored_server(value)
|
||||
if isinstance(name, str) and entry is not None:
|
||||
servers[name] = entry
|
||||
generations: dict[str, str] = {}
|
||||
raw_generations = payload.get("generations")
|
||||
if isinstance(raw_generations, dict):
|
||||
for name, value in cast(dict[object, object], raw_generations).items():
|
||||
if isinstance(name, str) and isinstance(value, str) and value:
|
||||
generations[name] = value
|
||||
return {
|
||||
"version": _STORE_VERSION,
|
||||
"servers": servers,
|
||||
"generations": generations,
|
||||
}
|
||||
|
||||
|
||||
def _with_store_lock(path: Path) -> FileLock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
|
||||
|
||||
|
||||
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
os.chmod(path.parent, 0o700)
|
||||
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class MCPOAuthStorage:
|
||||
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
|
||||
|
||||
def __init__(self, server_name: str, server_url: str) -> None:
|
||||
self.server_name = server_name
|
||||
self.server_fingerprint = _server_fingerprint(server_url)
|
||||
self._observed_generation = self._read_generation_sync()
|
||||
self._write_lease: str | None = None
|
||||
|
||||
def _read_generation_sync(self) -> str | None:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
# Writes replace the whole file atomically, so this observes either side
|
||||
# of a concurrent deletion without blocking the async connection path.
|
||||
return _read_store_unlocked(path)["generations"].get(self.server_name)
|
||||
|
||||
def _generation_is_current(self, payload: _CredentialStore) -> bool:
|
||||
return payload["generations"].get(self.server_name) == self._observed_generation
|
||||
|
||||
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
|
||||
servers = payload["servers"]
|
||||
entry = servers.get(self.server_name)
|
||||
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _bind_entry_unlocked(
|
||||
self,
|
||||
payload: _CredentialStore,
|
||||
*,
|
||||
create: bool,
|
||||
) -> tuple[_StoredServer | None, bool]:
|
||||
if not self._generation_is_current(payload):
|
||||
return None, False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if self._write_lease is not None:
|
||||
if entry is None or entry.get("write_lease") != self._write_lease:
|
||||
return None, False
|
||||
return entry, False
|
||||
if entry is None:
|
||||
if not create:
|
||||
return None, False
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry = _StoredServer(
|
||||
server_fingerprint=self.server_fingerprint,
|
||||
write_lease=self._write_lease,
|
||||
)
|
||||
payload["servers"][self.server_name] = entry
|
||||
return entry, True
|
||||
write_lease = entry.get("write_lease")
|
||||
changed = not isinstance(write_lease, str) or not write_lease
|
||||
if changed:
|
||||
write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = write_lease
|
||||
self._write_lease = write_lease
|
||||
return entry, changed
|
||||
|
||||
def _read_entry_sync(self) -> _StoredServer | None:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
entry, changed = self._bind_entry_unlocked(payload, create=False)
|
||||
if changed:
|
||||
_write_store_unlocked(path, payload)
|
||||
return entry
|
||||
|
||||
def _update_entry_sync(
|
||||
self,
|
||||
update: Callable[[_StoredServer], None],
|
||||
*,
|
||||
create: bool = True,
|
||||
claim: bool = False,
|
||||
) -> bool:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
if claim:
|
||||
# A browser flow owns subsequent SDK writes until another flow
|
||||
# claims the entry or the configured server is removed.
|
||||
if not self._generation_is_current(payload):
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential claim for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if entry is None:
|
||||
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
|
||||
payload["servers"][self.server_name] = entry
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = self._write_lease
|
||||
else:
|
||||
entry, _ = self._bind_entry_unlocked(payload, create=create)
|
||||
if entry is None:
|
||||
if self._write_lease is not None:
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential update for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
update(entry)
|
||||
payload["version"] = _STORE_VERSION
|
||||
_write_store_unlocked(path, payload)
|
||||
return True
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthToken.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
raw = tokens.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["tokens"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def clear_tokens(self) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry.pop("tokens", None)
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update, create=False)
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("client_info") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthClientInformationFull.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
raw = client_info.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["client_info"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def redirect_uri(self) -> str | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
value = entry.get("redirect_uri") if entry is not None else None
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
changed = entry.get("redirect_uri") != redirect_uri
|
||||
if reset:
|
||||
entry.pop("tokens", None)
|
||||
entry.pop("client_info", None)
|
||||
elif changed:
|
||||
# Dynamic registrations bind a client to its redirect URI.
|
||||
entry.pop("client_info", None)
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
|
||||
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
|
||||
if not claimed:
|
||||
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
entry = self._read_entry_sync()
|
||||
raw_tokens = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw_tokens, dict):
|
||||
return False
|
||||
tokens = cast(dict[str, object], raw_tokens)
|
||||
access_token = tokens.get("access_token")
|
||||
return isinstance(access_token, str) and bool(access_token)
|
||||
|
||||
|
||||
async def _missing_callback() -> tuple[str, str | None]:
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
|
||||
async def create_mcp_oauth_auth(
|
||||
server_name: str,
|
||||
server_url: str,
|
||||
handlers: MCPOAuthHandlers | None = None,
|
||||
) -> OAuthClientProvider:
|
||||
"""Build the official MCP SDK OAuth provider for one configured server."""
|
||||
storage = MCPOAuthStorage(server_name, server_url)
|
||||
if handlers is not None:
|
||||
await storage.prepare_redirect_uri(
|
||||
handlers.redirect_uri,
|
||||
reset=handlers.reset_credentials,
|
||||
)
|
||||
redirect_uri = handlers.redirect_uri
|
||||
redirect_handler = handlers.redirect_handler
|
||||
callback_handler = handlers.callback_handler
|
||||
else:
|
||||
if not await asyncio.to_thread(storage.has_credentials):
|
||||
# Do not perform discovery or dynamic registration from a background
|
||||
# startup. Interactive OAuth begins only after an explicit user action.
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
|
||||
|
||||
async def authorization_required(_authorization_url: str) -> None:
|
||||
await storage.clear_tokens()
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
redirect_handler = authorization_required
|
||||
callback_handler = _missing_callback
|
||||
|
||||
metadata = OAuthClientMetadata(
|
||||
redirect_uris=[AnyUrl(redirect_uri)],
|
||||
token_endpoint_auth_method="none",
|
||||
client_name="nanobot",
|
||||
client_uri=_CLIENT_URI,
|
||||
logo_uri=_LOGO_URI,
|
||||
software_id="https://github.com/HKUDS/nanobot",
|
||||
)
|
||||
return OAuthClientProvider(
|
||||
server_url,
|
||||
metadata,
|
||||
storage,
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
|
||||
"""Return whether this exact configured MCP instance has an access token."""
|
||||
return MCPOAuthStorage(server_name, server_url).has_credentials()
|
||||
|
||||
|
||||
def delete_mcp_oauth_credentials(server_name: str) -> bool:
|
||||
"""Delete credentials for one config name without touching other MCP instances."""
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
servers = payload["servers"]
|
||||
removed = servers.pop(server_name, None) is not None
|
||||
# Rotate even when no entry exists so a flow created before removal cannot
|
||||
# claim the name later and resurrect credentials.
|
||||
payload["generations"][server_name] = secrets.token_urlsafe(24)
|
||||
_write_store_unlocked(path, payload)
|
||||
return removed
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Explicit runtime state boundary used by :class:`MyTool`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
|
||||
RUNTIME_SNAPSHOT_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"model_presets",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"current_iteration",
|
||||
"_current_iteration",
|
||||
"tool_names",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
"_last_usage",
|
||||
})
|
||||
|
||||
RUNTIME_COMMAND_KEYS = frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSnapshot:
|
||||
"""Detached, allowlisted values available to self-inspection."""
|
||||
|
||||
model: str
|
||||
model_preset: str | None
|
||||
model_presets: dict[str, dict[str, object]]
|
||||
max_iterations: int
|
||||
context_window_tokens: int
|
||||
workspace: Path | str
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
current_iteration: int
|
||||
tool_names: list[str]
|
||||
web_config: dict[str, object]
|
||||
exec_config: dict[str, object]
|
||||
subagent_statuses: dict[str, dict[str, object]]
|
||||
last_usage: dict[str, int]
|
||||
scratchpad: dict[str, JsonValue]
|
||||
|
||||
def as_mapping(self) -> Mapping[str, object]:
|
||||
"""Return the fixed public names understood by ``MyTool``."""
|
||||
values: dict[str, object] = {
|
||||
"model": self.model,
|
||||
"model_preset": self.model_preset,
|
||||
"model_presets": self.model_presets,
|
||||
"max_iterations": self.max_iterations,
|
||||
"context_window_tokens": self.context_window_tokens,
|
||||
"workspace": self.workspace,
|
||||
"provider_retry_mode": self.provider_retry_mode,
|
||||
"max_tool_result_chars": self.max_tool_result_chars,
|
||||
"current_iteration": self.current_iteration,
|
||||
"_current_iteration": self.current_iteration,
|
||||
"tool_names": self.tool_names,
|
||||
"web_config": self.web_config,
|
||||
"exec_config": self.exec_config,
|
||||
"subagents": {"_task_statuses": self.subagent_statuses},
|
||||
"_last_usage": self.last_usage,
|
||||
}
|
||||
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
|
||||
return values
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeControl(Protocol):
|
||||
"""The complete runtime capability exposed to ``MyTool``."""
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot: ...
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
def set_max_iterations(self, value: int) -> None: ...
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None: ...
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None: ...
|
||||
|
||||
def set_workspace_display(self, value: str) -> None: ...
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
|
||||
|
||||
|
||||
class _RuntimeControlTarget(Protocol):
|
||||
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
|
||||
|
||||
max_iterations: int
|
||||
provider_retry_mode: str
|
||||
max_tool_result_chars: int
|
||||
web_config: WebToolsConfig
|
||||
exec_config: ExecToolConfig
|
||||
subagents: SubagentManager
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
|
||||
|
||||
|
||||
class AgentRuntimeControl:
|
||||
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
|
||||
|
||||
def __init__(self, target: _RuntimeControlTarget) -> None:
|
||||
self.__target = target
|
||||
self.__scratchpad: dict[str, JsonValue] = {}
|
||||
self.__workspace_display: str | None = None
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
target = self.__target
|
||||
return RuntimeSnapshot(
|
||||
model=target.model,
|
||||
model_preset=target.model_preset,
|
||||
model_presets=_snapshot_model_presets(target.model_presets),
|
||||
max_iterations=target.max_iterations,
|
||||
context_window_tokens=target.context_window_tokens,
|
||||
workspace=(
|
||||
self.__workspace_display
|
||||
if self.__workspace_display is not None
|
||||
else target.workspace
|
||||
),
|
||||
provider_retry_mode=target.provider_retry_mode,
|
||||
max_tool_result_chars=target.max_tool_result_chars,
|
||||
current_iteration=target.current_iteration,
|
||||
tool_names=list(target.tool_names),
|
||||
web_config=_snapshot_web_config(target.web_config),
|
||||
exec_config=_snapshot_exec_config(target.exec_config),
|
||||
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
||||
last_usage=dict(target.last_usage),
|
||||
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
||||
)
|
||||
|
||||
def set_model(self, model: str) -> LLMRuntime:
|
||||
return self.__target.set_runtime_model(model)
|
||||
|
||||
def set_model_preset(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
session_key: str | None,
|
||||
) -> LLMRuntime:
|
||||
if session_key is not None:
|
||||
return self.__target.set_session_model_preset(session_key, name)
|
||||
return self.__target.set_model_preset(name)
|
||||
|
||||
def set_max_iterations(self, value: int) -> None:
|
||||
self.__target.max_iterations = value
|
||||
self.__target.subagents.max_iterations = value
|
||||
|
||||
def set_context_window_tokens(self, value: int) -> LLMRuntime:
|
||||
return self.__target.set_runtime_context_window(value)
|
||||
|
||||
def set_provider_retry_mode(self, value: str) -> None:
|
||||
self.__target.provider_retry_mode = value
|
||||
|
||||
def set_max_tool_result_chars(self, value: int) -> None:
|
||||
self.__target.max_tool_result_chars = value
|
||||
|
||||
def set_workspace_display(self, value: str) -> None:
|
||||
"""Preserve MyTool display compatibility without changing path enforcement."""
|
||||
self.__workspace_display = value
|
||||
|
||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
|
||||
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
|
||||
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
|
||||
self.__scratchpad[key] = value
|
||||
|
||||
|
||||
def _snapshot_model_presets(
|
||||
presets: Mapping[str, ModelPresetConfig],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
name: {
|
||||
"label": preset.label,
|
||||
"model": preset.model,
|
||||
"provider": preset.provider,
|
||||
"max_tokens": preset.max_tokens,
|
||||
"context_window_tokens": preset.context_window_tokens,
|
||||
"temperature": preset.temperature,
|
||||
"reasoning_effort": preset.reasoning_effort,
|
||||
}
|
||||
for name, preset in presets.items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
|
||||
"proxy": "<configured>" if config.proxy else config.proxy,
|
||||
"user_agent": config.user_agent,
|
||||
"search": {
|
||||
"provider": config.search.provider,
|
||||
"base_url": config.search.base_url,
|
||||
"max_results": config.search.max_results,
|
||||
"timeout": config.search.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.fetch.use_jina_reader,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
|
||||
return {
|
||||
"enable": config.enable,
|
||||
"timeout": config.timeout,
|
||||
"path_prepend": config.path_prepend,
|
||||
"path_append": config.path_append,
|
||||
"sandbox": config.sandbox,
|
||||
"sandbox_ro_binds": list(config.sandbox_ro_binds),
|
||||
"sandbox_rw_binds": list(config.sandbox_rw_binds),
|
||||
"allowed_env_keys": list(config.allowed_env_keys),
|
||||
"allow_patterns": list(config.allow_patterns),
|
||||
"deny_patterns": list(config.deny_patterns),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_statuses(
|
||||
manager: SubagentManager,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
task_id: _snapshot_subagent_status(status)
|
||||
for task_id, status in manager.runtime_statuses().items()
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": status.task_id,
|
||||
"label": status.label,
|
||||
"task_description": status.task_description,
|
||||
"started_at": status.started_at,
|
||||
"phase": status.phase,
|
||||
"iteration": status.iteration,
|
||||
"tool_events": [dict(event) for event in status.tool_events],
|
||||
"usage": dict(status.usage),
|
||||
"stop_reason": status.stop_reason,
|
||||
"error": status.error,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {key: _snapshot_json_value(value) for key, value in values.items()}
|
||||
|
||||
|
||||
def _snapshot_json_value(value: JsonValue) -> JsonValue:
|
||||
if isinstance(value, list):
|
||||
return [_snapshot_json_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _snapshot_json_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
@@ -1,76 +0,0 @@
|
||||
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class RuntimeState(Protocol):
|
||||
"""Minimum contract that MyTool requires from its runtime state provider.
|
||||
|
||||
In practice, this is always satisfied by ``AgentLoop``. MyTool also
|
||||
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
|
||||
for dot-path inspection and modification; those paths are validated at
|
||||
runtime rather than by this protocol.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_iterations(self) -> int: ...
|
||||
|
||||
@property
|
||||
def current_iteration(self) -> int: ...
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path: ...
|
||||
|
||||
@property
|
||||
def provider_retry_mode(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_tool_result_chars(self) -> int: ...
|
||||
|
||||
@property
|
||||
def context_window_tokens(self) -> int: ...
|
||||
|
||||
@property
|
||||
def web_config(self) -> WebToolsConfig: ...
|
||||
|
||||
@property
|
||||
def exec_config(self) -> ExecToolConfig: ...
|
||||
|
||||
@property
|
||||
def subagents(self) -> SubagentManager: ...
|
||||
|
||||
@property
|
||||
def _runtime_vars(self) -> dict[str, Any]: ...
|
||||
|
||||
@property
|
||||
def _last_usage(self) -> dict[str, int]: ...
|
||||
|
||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
session_key: str,
|
||||
name: str,
|
||||
) -> LLMRuntime: ...
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None: ...
|
||||
+213
-182
@@ -1,8 +1,7 @@
|
||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||
|
||||
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||
# Tool.execute accepts heterogeneous schemas.
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +13,13 @@ from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
JsonValue,
|
||||
RuntimeControl,
|
||||
RuntimeSnapshot,
|
||||
)
|
||||
from nanobot.config_base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
|
||||
allow_set: bool = False
|
||||
|
||||
|
||||
def _has_real_attr(obj: Any, key: str) -> bool:
|
||||
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
||||
if isinstance(obj, dict):
|
||||
return key in obj
|
||||
d = getattr(obj, "__dict__", None)
|
||||
if d is not None and key in d:
|
||||
return True
|
||||
for cls in type(obj).__mro__:
|
||||
if key in cls.__dict__:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
|
||||
return isinstance(value, SubagentStatus)
|
||||
|
||||
|
||||
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
return all(
|
||||
field in value
|
||||
for field in ("task_id", "label", "task_description", "started_at", "phase")
|
||||
)
|
||||
|
||||
|
||||
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
mapping = cast(Mapping[object, object], value)
|
||||
return all(isinstance(key, str) for key in mapping)
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
"""Check and set the agent loop's runtime configuration."""
|
||||
|
||||
@@ -79,7 +87,10 @@ class MyTool(Tool):
|
||||
|
||||
READ_ONLY = frozenset({
|
||||
"subagents", # observable but replacing it would break the system
|
||||
"tool_names",
|
||||
"current_iteration",
|
||||
"_current_iteration", # updated by runner only
|
||||
"_last_usage",
|
||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||
"model_presets", # config-derived catalog; changes require config reload
|
||||
@@ -103,13 +114,6 @@ class MyTool(Tool):
|
||||
"private_key", "access_token", "refresh_token", "auth",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _is_sensitive_field_name(cls, name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in cls._SENSITIVE_NAMES or any(
|
||||
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
||||
)
|
||||
|
||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||
@@ -123,15 +127,15 @@ class MyTool(Tool):
|
||||
"context_window_tokens",
|
||||
})
|
||||
|
||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||
self._runtime_state = runtime_state
|
||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
||||
self._runtime_control = runtime_control
|
||||
self._modify_allowed = modify_allowed
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
result._runtime_state = self._runtime_state
|
||||
result._runtime_control = self._runtime_control
|
||||
result._modify_allowed = self._modify_allowed
|
||||
return result
|
||||
|
||||
@@ -208,9 +212,12 @@ class MyTool(Tool):
|
||||
# Path resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||
def _resolve_path(
|
||||
self,
|
||||
snapshot: RuntimeSnapshot,
|
||||
path: str,
|
||||
) -> tuple[object | None, str | None]:
|
||||
parts = path.split(".")
|
||||
obj: Any = self._runtime_state
|
||||
for part in parts:
|
||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||
return None, f"'{part}' is not accessible"
|
||||
@@ -218,17 +225,13 @@ class MyTool(Tool):
|
||||
return None, f"'{part}' is not accessible"
|
||||
if part.lower() in self._SENSITIVE_NAMES:
|
||||
return None, f"'{part}' is not accessible"
|
||||
try:
|
||||
if isinstance(obj, Mapping):
|
||||
mapping = cast(Mapping[str, Any], obj)
|
||||
if part in mapping:
|
||||
obj = mapping[part]
|
||||
else:
|
||||
return None, f"'{part}' not found in mapping"
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
return None, f"'{part}' not found: {e}"
|
||||
obj: object = snapshot.as_mapping()
|
||||
for part in parts:
|
||||
if not _is_string_mapping(obj):
|
||||
return None, f"'{part}' not found"
|
||||
if part not in obj:
|
||||
return None, f"'{part}' not found in mapping"
|
||||
obj = obj[part]
|
||||
return obj, None
|
||||
|
||||
@staticmethod
|
||||
@@ -242,20 +245,48 @@ class MyTool(Tool):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||
elapsed = time.monotonic() - st.started_at
|
||||
tool_summary = ", ".join(
|
||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||
) or "none"
|
||||
def _format_status(
|
||||
st: "SubagentStatus | Mapping[str, object]",
|
||||
indent: str = " ",
|
||||
) -> str:
|
||||
if isinstance(st, Mapping):
|
||||
started_at = st.get("started_at", time.monotonic())
|
||||
raw_events = st.get("tool_events", [])
|
||||
phase = st.get("phase", "unknown")
|
||||
iteration = st.get("iteration", 0)
|
||||
usage = st.get("usage", {})
|
||||
error = st.get("error")
|
||||
stop_reason = st.get("stop_reason")
|
||||
else:
|
||||
started_at = st.started_at
|
||||
raw_events = st.tool_events
|
||||
phase = st.phase
|
||||
iteration = st.iteration
|
||||
usage = st.usage
|
||||
error = st.error
|
||||
stop_reason = st.stop_reason
|
||||
elapsed = time.monotonic() - (
|
||||
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
|
||||
)
|
||||
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
|
||||
tool_summaries: list[str] = []
|
||||
for raw_event in tool_events[-5:]:
|
||||
if not isinstance(raw_event, Mapping):
|
||||
continue
|
||||
event = cast(Mapping[str, object], raw_event)
|
||||
tool_summaries.append(
|
||||
f"{event.get('name', '?')}({event.get('status', '?')})"
|
||||
)
|
||||
tool_summary = ", ".join(tool_summaries) or "none"
|
||||
lines = [
|
||||
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}tools: {tool_summary}",
|
||||
f"{indent}usage: {st.usage or 'n/a'}",
|
||||
f"{indent}usage: {usage or 'n/a'}",
|
||||
]
|
||||
if st.error:
|
||||
lines.append(f"{indent}error: {st.error}")
|
||||
if st.stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||
if error:
|
||||
lines.append(f"{indent}error: {error}")
|
||||
if stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
@@ -264,29 +295,38 @@ class MyTool(Tool):
|
||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||
# SubagentManager: delegate to its _task_statuses dict
|
||||
task_statuses = getattr(val, "_task_statuses", None)
|
||||
if isinstance(task_statuses, dict):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if _is_subagent_status_snapshot(val):
|
||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
||||
if isinstance(val, Mapping):
|
||||
mapping = cast(Mapping[object, object], val)
|
||||
else:
|
||||
mapping = None
|
||||
if mapping and set(mapping) == {"_task_statuses"}:
|
||||
task_statuses = mapping["_task_statuses"]
|
||||
if isinstance(task_statuses, Mapping):
|
||||
return MyTool._format_value(task_statuses, key)
|
||||
if (
|
||||
mapping
|
||||
and _is_subagent_status(next(iter(mapping.values())))
|
||||
and (
|
||||
_is_subagent_status(next(iter(mapping.values())))
|
||||
or _is_subagent_status_snapshot(next(iter(mapping.values())))
|
||||
)
|
||||
):
|
||||
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
||||
prefix = f"{key}: " if key else ""
|
||||
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||
for tid, st in status_mapping.items():
|
||||
detail = MyTool._format_status(st, " ")
|
||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||
for tid, st in mapping.items():
|
||||
if _is_subagent_status(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
label = st.label
|
||||
elif _is_subagent_status_snapshot(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
label = st.get("label", "?")
|
||||
else:
|
||||
continue
|
||||
lines.append(f" [{tid}] '{label}'\n{detail}")
|
||||
return "\n".join(lines)
|
||||
dynamic_value = cast(Any, val)
|
||||
if hasattr(dynamic_value, "tool_names"):
|
||||
tool_names: Any = getattr(dynamic_value, "tool_names")
|
||||
return f"tools: {len(tool_names)} registered — {tool_names}"
|
||||
# Scalar types — repr is fine
|
||||
if isinstance(val, (str, int, float, bool, type(None))):
|
||||
r = repr(val)
|
||||
@@ -311,32 +351,6 @@ class MyTool(Tool):
|
||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||
r = repr(sequence)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||
value_type = type(cast(object, val))
|
||||
cls_name = value_type.__name__
|
||||
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
||||
if isinstance(model_fields, Mapping) and model_fields:
|
||||
fields = list(cast(Mapping[str, object], model_fields).keys())
|
||||
if len(fields) <= 8:
|
||||
# Small config objects: show field=value pairs
|
||||
pairs: list[str] = []
|
||||
for f in fields:
|
||||
fv = getattr(val, f, "?")
|
||||
if MyTool._is_sensitive_field_name(f):
|
||||
continue
|
||||
if isinstance(fv, (str, int, float, bool, type(None))):
|
||||
pairs.append(f"{f}={fv!r}")
|
||||
else:
|
||||
pairs.append(f"{f}=<{type(fv).__name__}>")
|
||||
preview = ", ".join(pairs)
|
||||
return f"{key}: {preview}" if key else preview
|
||||
else:
|
||||
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
||||
fields = [name for name in attributes if not name.startswith("__")]
|
||||
if fields:
|
||||
preview = ", ".join(str(f) for f in fields[:20])
|
||||
suffix = ", ..." if len(fields) > 20 else ""
|
||||
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
|
||||
@@ -366,7 +380,12 @@ class MyTool(Tool):
|
||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||
return False, None
|
||||
return True, getattr(runtime, key)
|
||||
values: dict[str, object] = {
|
||||
"model": runtime.model,
|
||||
"model_preset": runtime.model_preset,
|
||||
"context_window_tokens": runtime.context_window_tokens,
|
||||
}
|
||||
return True, values[key]
|
||||
|
||||
def _inspect(self, key: str | None) -> str:
|
||||
if not key:
|
||||
@@ -375,62 +394,64 @@ class MyTool(Tool):
|
||||
request_ctx = current_request_context()
|
||||
if request_ctx is None:
|
||||
return ToolResult.error("Error: current request context is unavailable")
|
||||
request_values: dict[str, str | None] = {
|
||||
"channel": request_ctx.channel,
|
||||
"chat_id": request_ctx.chat_id,
|
||||
"sender_id": request_ctx.sender_id,
|
||||
}
|
||||
if key == "request":
|
||||
return self._format_value(
|
||||
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||
key,
|
||||
)
|
||||
return self._format_value(request_values, key)
|
||||
field = key.removeprefix("request.")
|
||||
if field not in self._REQUEST_FIELDS:
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(getattr(request_ctx, field), key)
|
||||
return self._format_value(request_values[field], key)
|
||||
if "." not in key:
|
||||
found, value = self._current_runtime_value(key)
|
||||
if found:
|
||||
return self._format_value(value, key)
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
top = key.split(".")[0]
|
||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||
obj, err = self._resolve_path(key)
|
||||
obj, err = self._resolve_path(snapshot, key)
|
||||
if err:
|
||||
# "scratchpad" alias for _runtime_vars
|
||||
if key == "scratchpad":
|
||||
rv = self._runtime_state._runtime_vars
|
||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return (
|
||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
||||
if snapshot.scratchpad
|
||||
else "scratchpad is empty"
|
||||
)
|
||||
if "." not in key and key in snapshot.scratchpad:
|
||||
return self._format_value(snapshot.scratchpad[key], key)
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
# Guard against mock auto-generated attributes
|
||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||
if key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(obj, key)
|
||||
|
||||
def _inspect_all(self) -> str:
|
||||
state = self._runtime_state
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
parts: list[str] = []
|
||||
# RESTRICTED keys
|
||||
for k in self.RESTRICTED:
|
||||
found, value = self._current_runtime_value(k)
|
||||
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||
parts.append(self._format_value(value if found else values[k], k))
|
||||
found, value = self._current_runtime_value("model_preset")
|
||||
parts.append(self._format_value(
|
||||
value if found else state.model_preset,
|
||||
value if found else snapshot.model_preset,
|
||||
"model_preset",
|
||||
))
|
||||
# Other useful top-level keys shown in description
|
||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||
if _has_real_attr(state, k):
|
||||
parts.append(self._format_value(getattr(state, k, None), k))
|
||||
# Token usage
|
||||
usage = state._last_usage
|
||||
if usage:
|
||||
parts.append(self._format_value(usage, "_last_usage"))
|
||||
rv = state._runtime_vars
|
||||
if rv:
|
||||
parts.append(self._format_value(rv, "scratchpad"))
|
||||
for k in (
|
||||
"workspace",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"_current_iteration",
|
||||
"web_config",
|
||||
"exec_config",
|
||||
"subagents",
|
||||
):
|
||||
parts.append(self._format_value(values[k], k))
|
||||
if snapshot.last_usage:
|
||||
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
|
||||
if snapshot.scratchpad:
|
||||
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
|
||||
return "\n".join(parts)
|
||||
|
||||
# -- modify --
|
||||
@@ -454,48 +475,49 @@ class MyTool(Tool):
|
||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||
parent, err = self._resolve_path(parent_path)
|
||||
snapshot = self._runtime_control.snapshot()
|
||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
||||
if err:
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if isinstance(parent, dict):
|
||||
parent[leaf] = value
|
||||
else:
|
||||
setattr(parent, leaf, value)
|
||||
self._audit("modify", f"{key} = {value!r}")
|
||||
return f"Set {key} = {value!r}"
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
if key == "model_preset":
|
||||
return self._modify_model_preset(value)
|
||||
if key in self.RESTRICTED:
|
||||
return self._modify_restricted(key, value)
|
||||
return self._modify_free(key, value)
|
||||
if key in RUNTIME_COMMAND_KEYS:
|
||||
return self._modify_runtime_setting(key, value)
|
||||
if key in RUNTIME_SNAPSHOT_KEYS:
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
return self._modify_scratchpad(key, value)
|
||||
|
||||
def _modify_model_preset(self, value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||
name = value.strip()
|
||||
session_key = current_request_session_key()
|
||||
old = self._runtime_control.snapshot().model_preset
|
||||
try:
|
||||
runtime = self._runtime_control.set_model_preset(
|
||||
name,
|
||||
session_key=session_key,
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
message = str(exc.args[0]) if exc.args else str(exc)
|
||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||
if session_key:
|
||||
try:
|
||||
runtime = self._runtime_state.set_session_model_preset(
|
||||
session_key,
|
||||
name,
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
message = str(exc.args[0]) if exc.args else str(exc)
|
||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||
self._audit("modify", f"model_preset = {name!r}")
|
||||
return (
|
||||
f"Set model_preset = {name!r} for the next turn; "
|
||||
f"model will be {runtime.model!r}; "
|
||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||
)
|
||||
result = self._modify_free("model_preset", name)
|
||||
if isinstance(result, ToolResult) and result.is_error:
|
||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
||||
return (
|
||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
||||
)
|
||||
|
||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||
@@ -508,7 +530,7 @@ class MyTool(Tool):
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||
old = getattr(self._runtime_state, key)
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
if "min" in spec and value < spec["min"]:
|
||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||
if "max" in spec and value > spec["max"]:
|
||||
@@ -521,41 +543,46 @@ class MyTool(Tool):
|
||||
"during an active session; use a configured model_preset"
|
||||
)
|
||||
if key == "model":
|
||||
self._runtime_state.set_runtime_model(cast(str, value))
|
||||
self._runtime_control.set_model(cast(str, value))
|
||||
elif key == "context_window_tokens":
|
||||
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
||||
else:
|
||||
setattr(self._runtime_state, key, value)
|
||||
if key == "max_iterations" and hasattr(
|
||||
self._runtime_state,
|
||||
"_sync_subagent_runtime_limits",
|
||||
):
|
||||
self._runtime_state._sync_subagent_runtime_limits()
|
||||
self._runtime_control.set_max_iterations(cast(int, value))
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_free(self, key: str, value: Any) -> str:
|
||||
if _has_real_attr(self._runtime_state, key):
|
||||
old = getattr(self._runtime_state, key)
|
||||
if isinstance(old, (str, int, float, bool)):
|
||||
old_t: type[Any] = type(old)
|
||||
new_t = cast(type[Any], type(value))
|
||||
if old_t is float and new_t is int:
|
||||
pass # int → float coercion allowed
|
||||
elif old_t is not new_t:
|
||||
self._audit(
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||
try:
|
||||
setattr(self._runtime_state, key, value)
|
||||
except (ValueError, KeyError) as e:
|
||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||
self._audit("modify", f"REJECTED {key}: {message}")
|
||||
return ToolResult.error(f"Error: {message}")
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||
if key == "workspace":
|
||||
if not isinstance(value, str):
|
||||
return ToolResult.error(
|
||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
||||
)
|
||||
self._runtime_control.set_workspace_display(value)
|
||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
||||
return f"Set workspace = {value!r} (was {old!r})"
|
||||
old_t = type(old)
|
||||
new_t = cast(type[Any], type(value))
|
||||
if old_t is float and new_t is int:
|
||||
pass
|
||||
elif old_t is not new_t:
|
||||
self._audit(
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return ToolResult.error(
|
||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||
)
|
||||
if key == "provider_retry_mode":
|
||||
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
||||
elif key == "max_tool_result_chars":
|
||||
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled runtime command: {key}")
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
||||
if callable(value):
|
||||
self._audit("modify", f"REJECTED callable {key}")
|
||||
return ToolResult.error("Error: cannot store callable values")
|
||||
@@ -563,12 +590,16 @@ class MyTool(Tool):
|
||||
if err:
|
||||
self._audit("modify", f"REJECTED {key}: {err}")
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||
try:
|
||||
self._runtime_control.set_scratchpad(
|
||||
key,
|
||||
cast(JsonValue, value),
|
||||
max_keys=self._MAX_RUNTIME_KEYS,
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||
old = self._runtime_state._runtime_vars.get(key)
|
||||
self._runtime_state._runtime_vars[key] = value
|
||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
||||
return f"Set scratchpad.{key} = {value!r}"
|
||||
|
||||
@classmethod
|
||||
|
||||
+68
-18
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
@@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within
|
||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||
_CATALOG_SOURCES = (
|
||||
@@ -210,11 +212,27 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
if not legacy:
|
||||
clean = clean.replace("_", "-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||
"""Return a CLI App's skill path, including the legacy location."""
|
||||
canonical = _plugin_skill_relative_path(name)
|
||||
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||
return legacy
|
||||
return canonical
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
@@ -442,6 +460,16 @@ class CliAppManager:
|
||||
"""Return registry names explicitly installed through CLI Apps."""
|
||||
return sorted(str(name) for name in self._load_installed())
|
||||
|
||||
def installed_skill_aliases(self) -> dict[str, str]:
|
||||
"""Map pre-plugin CLI App skill names to their portable identities."""
|
||||
aliases: dict[str, str] = {}
|
||||
for name in self.installed_names():
|
||||
legacy = _skill_name(name, legacy=True)
|
||||
canonical = _skill_name(name)
|
||||
if legacy != canonical:
|
||||
aliases[legacy] = canonical
|
||||
return aliases
|
||||
|
||||
def _fetch_registry(
|
||||
self,
|
||||
url: str,
|
||||
@@ -613,7 +641,7 @@ class CliAppManager:
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"source": str(data.get("source") or ""),
|
||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||
"tool": "run_cli_app",
|
||||
}
|
||||
)
|
||||
@@ -639,9 +667,6 @@ class CliAppManager:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
@@ -677,7 +702,7 @@ class CliAppManager:
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"brand_color": brand_color,
|
||||
"skill_installed": self._skill_path(name).is_file(),
|
||||
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||
}
|
||||
|
||||
@@ -713,7 +738,8 @@ class CliAppManager:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
||||
skill_path = _plugin_skill_relative_path(name)
|
||||
plugin_path = f"plugins/{_skill_name(name)}"
|
||||
capabilities = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -726,13 +752,13 @@ class CliAppManager:
|
||||
install = compact_dict({
|
||||
"supported": install_supported,
|
||||
"strategy": strategy,
|
||||
"managed_paths": [skill_path],
|
||||
"managed_paths": [plugin_path],
|
||||
"verification": ["entry_point_available"] if entry_point else [],
|
||||
})
|
||||
remove = compact_dict({
|
||||
"supported": strategy != "unsupported",
|
||||
"strategy": strategy,
|
||||
"managed_paths": [skill_path],
|
||||
"managed_paths": [plugin_path],
|
||||
"verification": (
|
||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||
if strategy not in {"bundled", "unsupported"}
|
||||
@@ -1032,11 +1058,10 @@ class CliAppManager:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
return f"""---
|
||||
name: {_safe_skill_name(name)}
|
||||
description: >-
|
||||
{description}
|
||||
name: {_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
---
|
||||
|
||||
# {display}
|
||||
@@ -1056,10 +1081,17 @@ Prefer machine-readable output when the CLI supports `--json`.
|
||||
"""
|
||||
|
||||
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
|
||||
name = str(app.get("name") or "unknown")
|
||||
skill_name = _skill_name(name)
|
||||
metadata = parse_skill_metadata(content)
|
||||
if metadata is None or not valid_skill_metadata(metadata | {"name": skill_name}, skill_name):
|
||||
content = self._fallback_skill(app)
|
||||
content, replaced = re.subn(r"(?m)^name\s*:.*$", f"name: {skill_name}", content, count=1)
|
||||
if not replaced:
|
||||
content = content.replace("---\n", f"---\nname: {skill_name}\n", 1)
|
||||
marker = "<!-- nanobot-cli-app-note -->"
|
||||
if marker in content:
|
||||
return content
|
||||
name = str(app.get("name") or "unknown")
|
||||
note = f"""{marker}
|
||||
## Nanobot execution
|
||||
|
||||
@@ -1073,24 +1105,42 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
return note + "\n" + content
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
path = self._skill_path(str(app["name"]))
|
||||
name = str(app["name"])
|
||||
path = self.workspace / _plugin_skill_relative_path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||
|
||||
installed = self._load_installed()
|
||||
entry = self._installed_entry(app)
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
|
||||
@@ -20,6 +20,8 @@ def runtime_lines_for_request(
|
||||
"""Return CLI App annotations from an immutable request snapshot."""
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||
|
||||
structured_items = cast(list[Any], structured)
|
||||
mentions = [
|
||||
cast(Mapping[str, Any], item) for item in structured_items
|
||||
@@ -32,7 +34,7 @@ def runtime_lines_for_request(
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||
|
||||
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
|
||||
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
|
||||
setError(null);
|
||||
try {
|
||||
onFeaturesUpdate(
|
||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||
);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
||||
@@ -101,8 +101,14 @@ class ChannelManager:
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
config_path: Path | None = None,
|
||||
):
|
||||
if config_path is None:
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
self.config = config
|
||||
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||
self.bus = bus
|
||||
self._session_manager = session_manager
|
||||
self._cron_service = cron_service
|
||||
@@ -170,6 +176,7 @@ class ChannelManager:
|
||||
static_dist_path=static_path,
|
||||
workspace_path=workspace,
|
||||
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
||||
config_path=self._config_path,
|
||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||
runtime_model_name=self._webui_runtime_model_name,
|
||||
runtime_surface=self._webui_runtime_surface,
|
||||
|
||||
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_default: dict[ServerConnection, str] = {}
|
||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||
self._webui_connections: set[ServerConnection] = set()
|
||||
# Request/reply mutations aren't replayed across reconnects. Tasks may
|
||||
# finish after a client-side deadline so an already-started mutation
|
||||
# isn't ambiguously cancelled halfway through.
|
||||
self._webui_request_tasks: dict[
|
||||
tuple[ServerConnection, str],
|
||||
asyncio.Task[None],
|
||||
] = {}
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._server_task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
|
||||
) -> None:
|
||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||
t = envelope.get("type")
|
||||
if t == "webui_request":
|
||||
await self._start_webui_request(connection, envelope)
|
||||
return
|
||||
if t == "new_chat":
|
||||
new_id = str(uuid.uuid4())
|
||||
scope = await self._workspace_scope_or_error(
|
||||
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
async def _start_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
envelope: dict[str, Any],
|
||||
) -> None:
|
||||
request_id = envelope.get("request_id")
|
||||
if not isinstance(request_id, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9._:-]{1,128}",
|
||||
request_id,
|
||||
) is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="invalid webui request_id",
|
||||
)
|
||||
return
|
||||
if connection not in self._webui_connections:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=403,
|
||||
message="access_denied",
|
||||
)
|
||||
return
|
||||
|
||||
action = envelope.get("action")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(action, str) or re.fullmatch(
|
||||
r"[a-z][a-z0-9_.]{0,127}",
|
||||
action,
|
||||
) is None:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="invalid WebUI mutation action",
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=400,
|
||||
message="WebUI mutation payload must be an object",
|
||||
)
|
||||
return
|
||||
|
||||
key = (connection, request_id)
|
||||
if key in self._webui_request_tasks:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=409,
|
||||
message="duplicate WebUI request_id",
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(
|
||||
self._complete_webui_request(
|
||||
connection,
|
||||
request_id,
|
||||
action,
|
||||
cast(dict[str, Any], payload),
|
||||
)
|
||||
)
|
||||
self._webui_request_tasks[key] = task
|
||||
|
||||
async def _complete_webui_request(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
response = await self._http_router.dispatch_webui_mutation(
|
||||
connection,
|
||||
action,
|
||||
payload,
|
||||
)
|
||||
status = response.status_code
|
||||
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
||||
if 200 <= status < 300:
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=502,
|
||||
message="WebUI mutation returned an invalid response",
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=status,
|
||||
message=body or response.reason_phrase,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.exception("WebUI mutation '{}' failed", action)
|
||||
await self._send_webui_response(
|
||||
connection,
|
||||
request_id,
|
||||
status=500,
|
||||
message="WebUI mutation failed",
|
||||
)
|
||||
finally:
|
||||
self._webui_request_tasks.pop((connection, request_id), None)
|
||||
|
||||
async def _send_webui_response(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
request_id: str,
|
||||
*,
|
||||
result: Any = None,
|
||||
status: int | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if status is None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=True,
|
||||
result=result,
|
||||
)
|
||||
return
|
||||
await self._send_event(
|
||||
connection,
|
||||
"webui_response",
|
||||
request_id=request_id,
|
||||
ok=False,
|
||||
error={
|
||||
"status": status,
|
||||
"message": message or "WebUI mutation failed",
|
||||
},
|
||||
)
|
||||
|
||||
async def _workspace_scope_or_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.warning("server task error during shutdown: {}", e)
|
||||
self._server_task = None
|
||||
mutation_tasks = tuple(self._webui_request_tasks.values())
|
||||
for task in mutation_tasks:
|
||||
task.cancel()
|
||||
if mutation_tasks:
|
||||
await asyncio.gather(*mutation_tasks, return_exceptions=True)
|
||||
self._webui_request_tasks.clear()
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
http_error as _http_error,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
http_json_response as _http_json_response,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
issue_route_secret_matches as _issue_route_secret_matches,
|
||||
)
|
||||
@@ -119,6 +129,46 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
)
|
||||
|
||||
|
||||
async def _connect_when_ready(url: str) -> Any:
|
||||
while True:
|
||||
try:
|
||||
return await websockets.connect(url)
|
||||
except OSError:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
async def _webui_mutate(
|
||||
client: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
request_id = f"test-{uuid.uuid4().hex}"
|
||||
await client.send(json.dumps({
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": action,
|
||||
"payload": payload or {},
|
||||
}))
|
||||
while True:
|
||||
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
||||
if envelope.get("event") != "webui_response":
|
||||
continue
|
||||
if envelope.get("request_id") != request_id:
|
||||
continue
|
||||
if envelope.get("ok") is True:
|
||||
status = 200
|
||||
body = envelope.get("result")
|
||||
else:
|
||||
error = envelope.get("error") or {}
|
||||
status = int(error.get("status") or 500)
|
||||
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
||||
return httpx.Response(
|
||||
status,
|
||||
json=body,
|
||||
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||
channel = _ch(MessageBus())
|
||||
@@ -857,6 +907,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
||||
assert client_connection not in channel._webui_connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_json_response({"saved": True})
|
||||
)
|
||||
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-1",
|
||||
"action": "settings.provider.update",
|
||||
"payload": {"provider": "openrouter", "apiKey": "secret"},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
|
||||
conn,
|
||||
"settings.provider.update",
|
||||
{"provider": "openrouter", "apiKey": "secret"},
|
||||
)
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-1",
|
||||
"ok": True,
|
||||
"result": {"saved": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel._webui_connections.add(conn)
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||
return_value=_http_error(400, "invalid settings payload")
|
||||
)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-2",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-2",
|
||||
"ok": False,
|
||||
"error": {"status": 400, "message": "invalid settings payload"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_request_requires_bootstrap_authenticated_connection(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"static-token-client",
|
||||
{
|
||||
"type": "webui_request",
|
||||
"request_id": "request-3",
|
||||
"action": "settings.agent.update",
|
||||
"payload": {},
|
||||
},
|
||||
)
|
||||
|
||||
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": "request-3",
|
||||
"ok": False,
|
||||
"error": {"status": 403, "message": "access_denied"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
bus: MagicMock,
|
||||
@@ -866,23 +1008,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.request = SimpleNamespace(headers=Headers())
|
||||
channel._webui_connections.add(conn)
|
||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||
request_id = "sidebar-large-state"
|
||||
envelope = {
|
||||
"type": "set_sidebar_state",
|
||||
"state": {
|
||||
"type": "webui_request",
|
||||
"request_id": request_id,
|
||||
"action": "sidebar.update",
|
||||
"payload": {"state": {
|
||||
"session_order": session_order,
|
||||
"view": {"sort": "manual"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
assert len(json.dumps(envelope).encode()) > 8_192
|
||||
|
||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||
|
||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||
assert saved["session_order"] == session_order
|
||||
assert saved["view"]["sort"] == "manual"
|
||||
conn.send.assert_not_awaited()
|
||||
assert json.loads(conn.send.await_args.args[0]) == {
|
||||
"event": "webui_response",
|
||||
"request_id": request_id,
|
||||
"ok": True,
|
||||
"result": saved,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2721,10 +2873,13 @@ async def test_end_to_end_client_receives_ready_and_agent_sees_inbound(bus: Magi
|
||||
channel = _ch(bus, port=port)
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=tester") as client:
|
||||
client = await asyncio.wait_for(
|
||||
_connect_when_ready(f"ws://127.0.0.1:{port}/ws?client_id=tester"),
|
||||
timeout=5,
|
||||
)
|
||||
async with client:
|
||||
ready_raw = await client.recv()
|
||||
ready = json.loads(ready_raw)
|
||||
assert ready["event"] == "ready"
|
||||
@@ -2887,7 +3042,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
webui_client = None
|
||||
try:
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
|
||||
)
|
||||
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
|
||||
assert ready["event"] == "ready"
|
||||
|
||||
settings = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
@@ -2971,11 +3134,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert unknown_api.status_code == 404
|
||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||
|
||||
provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-test",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
)
|
||||
assert provider_updated.status_code == 200
|
||||
provider_body = provider_updated.json()
|
||||
@@ -2985,22 +3151,18 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_body["image_generation"]["provider_configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
custom_provider_created = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||
headers={
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": json.dumps(
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
"apiKey": "sk-company",
|
||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
),
|
||||
custom_provider_created = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.create",
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
"apiKey": "sk-company",
|
||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
},
|
||||
)
|
||||
assert custom_provider_created.status_code == 200
|
||||
@@ -3015,11 +3177,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert "sk-company" not in custom_provider_created.text
|
||||
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
local_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||
)
|
||||
assert local_provider_updated.status_code == 200
|
||||
local_provider_body = local_provider_updated.json()
|
||||
@@ -3029,38 +3190,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||
assert "localhost:1337" in local_provider_updated.text
|
||||
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{
|
||||
"model": "atomic_chat/test",
|
||||
"provider": "atomic_chat",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"tool_hint_max_length": 120,
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
updated_body = updated.json()
|
||||
assert updated_body["requires_restart"] is True
|
||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||
|
||||
preset_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model_preset=deep",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
preset_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "deep"},
|
||||
)
|
||||
assert preset_updated.status_code == 200
|
||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
|
||||
bad_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model_preset=missing",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
bad_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.agent.update",
|
||||
{"model_preset": "missing"},
|
||||
)
|
||||
assert bad_preset.status_code == 400
|
||||
|
||||
created_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
created_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
@@ -3074,11 +3241,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||
|
||||
updated_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/update"
|
||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
updated_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
"name": "fast-writing",
|
||||
"label": "Codex",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-5.5",
|
||||
},
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
@@ -3089,11 +3260,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
call_order_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-call-order/update"
|
||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
call_order_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_call_order.update",
|
||||
{"order": ["fast-writing", "deep"]},
|
||||
)
|
||||
assert call_order_updated.status_code == 200
|
||||
call_order_body = call_order_updated.json()
|
||||
@@ -3101,20 +3271,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
duplicate_preset = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
"label": "Fast writing",
|
||||
"provider": "openai",
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
},
|
||||
)
|
||||
assert duplicate_preset.status_code == 409
|
||||
|
||||
search_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
search_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{
|
||||
"provider": "searxng",
|
||||
"base_url": "https://search.example.com",
|
||||
"max_results": 8,
|
||||
"timeout": 45,
|
||||
"use_jina_reader": False,
|
||||
},
|
||||
)
|
||||
assert search_updated.status_code == 200
|
||||
search_body = search_updated.json()
|
||||
@@ -3126,10 +3303,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert search_body["web_search"]["max_results"] == 8
|
||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||
|
||||
network_safety_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
network_safety_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
"webui_allow_local_service_access": False,
|
||||
"webui_default_access_mode": "full",
|
||||
},
|
||||
)
|
||||
assert network_safety_updated.status_code == 200
|
||||
network_safety_body = network_safety_updated.json()
|
||||
@@ -3139,13 +3319,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||
|
||||
image_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
||||
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
||||
"&max_images_per_turn=3",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
image_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
"enabled": True,
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-image-1",
|
||||
"default_aspect_ratio": "16:9",
|
||||
"default_image_size": "2K",
|
||||
"max_images_per_turn": 3,
|
||||
},
|
||||
)
|
||||
assert image_updated.status_code == 200
|
||||
image_body = image_updated.json()
|
||||
@@ -3157,11 +3341,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert image_body["image_generation"]["default_image_size"] == "2K"
|
||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||
|
||||
image_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
image_provider_updated = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.update",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"apiKey": "sk-or-next",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
)
|
||||
assert image_provider_updated.status_code == 200
|
||||
assert image_provider_updated.json()["requires_restart"] is True
|
||||
@@ -3169,17 +3356,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert "sk-or-next" not in image_provider_updated.text
|
||||
assert image_reload.await_count == 2
|
||||
|
||||
bad_web = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
bad_web = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.web_search.update",
|
||||
{"provider": "duckduckgo", "max_results": 99},
|
||||
)
|
||||
assert bad_web.status_code == 400
|
||||
|
||||
bad_image = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/image-generation/update?provider=missing",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
bad_image = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"provider": "missing"},
|
||||
)
|
||||
assert bad_image.status_code == 400
|
||||
|
||||
@@ -3216,6 +3403,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.tools.image_generation.default_image_size == "2K"
|
||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3248,11 +3437,17 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -3260,6 +3455,8 @@ async def test_image_settings_hot_reload_without_restart(
|
||||
assert response.json()["restart_required_sections"] == []
|
||||
image_reload.assert_awaited_once_with(bus)
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -3291,17 +3488,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
webui_client = None
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||
webui_client = await websockets.connect(
|
||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||
)
|
||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||
response = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.image_generation.update",
|
||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["requires_restart"] is True
|
||||
assert response.json()["restart_required_sections"] == ["image"]
|
||||
finally:
|
||||
if webui_client is not None:
|
||||
await webui_client.close()
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class WeixinConnectSession:
|
||||
channel: WeixinChannel
|
||||
current_poll_base_url: str
|
||||
refresh_count: int
|
||||
force: bool
|
||||
created_wall: float
|
||||
deadline: float
|
||||
last_error: str | None = None
|
||||
@@ -72,7 +73,7 @@ class WeixinConnectStore:
|
||||
|
||||
channel.connect_open_client()
|
||||
try:
|
||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
||||
except Exception as exc:
|
||||
await self._close_channel(channel)
|
||||
raise ChannelConnectError(
|
||||
@@ -89,6 +90,7 @@ class WeixinConnectStore:
|
||||
channel=channel,
|
||||
current_poll_base_url=channel.connect_base_url,
|
||||
refresh_count=0,
|
||||
force=force,
|
||||
created_wall=now_wall,
|
||||
deadline=time.monotonic() + 600,
|
||||
)
|
||||
@@ -187,7 +189,7 @@ class WeixinConnectStore:
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = (
|
||||
await session.channel.connect_fetch_qr_code()
|
||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
@@ -204,6 +206,17 @@ class WeixinConnectStore:
|
||||
)
|
||||
|
||||
if status == "binded_redirect":
|
||||
if session.force:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": (
|
||||
"Unable to complete a new WeChat login. "
|
||||
"Start again and scan with the account you want to connect."
|
||||
),
|
||||
}
|
||||
if not session.channel.connect_load_state():
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
@@ -234,7 +247,7 @@ class WeixinConnectStore:
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = (
|
||||
await session.channel.connect_fetch_qr_code()
|
||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
||||
break
|
||||
return tokens
|
||||
|
||||
async def _fetch_qr_code(self) -> tuple[str, str]:
|
||||
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
||||
local_tokens = self._local_token_list()
|
||||
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||
"""Fetch a QR code without existing credentials when forced."""
|
||||
local_tokens = [] if force else self._local_token_list()
|
||||
data = await self._api_post(
|
||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||
{"local_token_list": local_tokens},
|
||||
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||
|
||||
async def _qr_login(self) -> bool:
|
||||
"""Perform QR code login flow. Returns True on success."""
|
||||
async def _qr_login(self, *, force: bool = False) -> bool:
|
||||
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
||||
try:
|
||||
refresh_count = 0
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
self._print_qr_code(scan_url)
|
||||
current_poll_base_url = self.config.base_url
|
||||
verify_code = ""
|
||||
@@ -825,11 +825,16 @@ class WeixinChannel(BaseChannel):
|
||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
self.logger.warning("WeChat verification failed too many times")
|
||||
return False
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
current_poll_base_url = self.config.base_url
|
||||
self._print_qr_code(scan_url)
|
||||
continue
|
||||
elif status == "binded_redirect":
|
||||
if force:
|
||||
self.logger.error(
|
||||
"Forced WeChat login returned an existing binding without new credentials"
|
||||
)
|
||||
return False
|
||||
if self._token or self._load_state():
|
||||
self.logger.info("WeChat account is already connected")
|
||||
return True
|
||||
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
|
||||
MAX_QR_REFRESH_COUNT,
|
||||
)
|
||||
return False
|
||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||
current_poll_base_url = self.config.base_url
|
||||
verify_code = ""
|
||||
self._print_qr_code(scan_url)
|
||||
@@ -893,8 +898,8 @@ class WeixinChannel(BaseChannel):
|
||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||
self._running = True
|
||||
|
||||
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
||||
return await self._fetch_qr_code()
|
||||
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||
return await self._fetch_qr_code(force=force)
|
||||
|
||||
async def connect_poll_qr_code(
|
||||
self,
|
||||
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
|
||||
if force:
|
||||
self._token = ""
|
||||
self._get_updates_buf = ""
|
||||
if self._token or self._load_state():
|
||||
if self._token or (not force and self._load_state()):
|
||||
return True
|
||||
|
||||
# Initialize HTTP client for the login flow
|
||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||
self._running = True # Enable polling loop in _qr_login()
|
||||
try:
|
||||
return await self._qr_login()
|
||||
return await self._qr_login(force=force)
|
||||
finally:
|
||||
self._running = False
|
||||
if self._client:
|
||||
|
||||
@@ -25,7 +25,9 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
return "qr-1", "https://qr.example/1"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -86,14 +88,31 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-reconnect", "https://qr.example/reconnect"
|
||||
observed_force: list[bool] = []
|
||||
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
observed_force.append(force)
|
||||
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, str]:
|
||||
return {"status": "expired"}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
refreshed = await store.poll(started["session_id"])
|
||||
|
||||
assert refreshed["status"] == "pending"
|
||||
assert observed_force == [True, True]
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
cancelled = await store.cancel(started["session_id"])
|
||||
assert cancelled["status"] == "cancelled"
|
||||
@@ -116,7 +135,9 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
||||
poll_started = asyncio.Event()
|
||||
release_poll = asyncio.Event()
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
return "qr-cancel", "https://qr.example/cancel"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -162,7 +183,9 @@ async def test_weixin_connect_store_handles_verification_code(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
return "qr-verify", "https://qr.example/verify"
|
||||
|
||||
responses = [
|
||||
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -221,7 +244,12 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
assert force is True
|
||||
return "qr-existing", "https://qr.example/existing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -237,8 +265,8 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||
started = await store.start(force=True)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "succeeded"
|
||||
assert "already connected" in completed["message"]
|
||||
assert completed["status"] == "failed"
|
||||
assert "new WeChat login" in completed["message"]
|
||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
||||
|
||||
|
||||
@@ -255,7 +283,9 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
async def fake_fetch_qr_code(
|
||||
self: WeixinChannel, **_kwargs: Any
|
||||
) -> tuple[str, str]:
|
||||
return "qr-missing", "https://qr.example/missing"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
@@ -268,7 +298,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
started = await store.start(force=False)
|
||||
completed = await store.poll(started["session_id"])
|
||||
|
||||
assert completed["status"] == "failed"
|
||||
|
||||
@@ -196,6 +196,86 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
|
||||
persisted = {
|
||||
"token": "persisted-token",
|
||||
"get_updates_buf": "persisted-cursor",
|
||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
|
||||
"base_url": "https://persisted.example",
|
||||
}
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps(persisted),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._print_qr_code = lambda _url: None
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
]
|
||||
)
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "expired"},
|
||||
{"status": "binded_redirect"},
|
||||
]
|
||||
)
|
||||
|
||||
ok = await channel.login(force=True)
|
||||
|
||||
assert ok is False
|
||||
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
|
||||
[],
|
||||
[],
|
||||
]
|
||||
assert channel._token == ""
|
||||
assert channel._get_updates_buf == ""
|
||||
assert channel._context_tokens == {}
|
||||
assert channel._typing_tickets == {}
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"token": "persisted-token",
|
||||
"get_updates_buf": "persisted-cursor",
|
||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||
"base_url": "https://persisted.example",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._qr_login = AsyncMock(return_value=False)
|
||||
|
||||
ok = await channel.login(force=False)
|
||||
|
||||
assert ok is True
|
||||
channel._qr_login.assert_not_awaited()
|
||||
assert channel._token == "persisted-token"
|
||||
assert channel._get_updates_buf == "persisted-cursor"
|
||||
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
|
||||
assert channel.config.base_url == "https://persisted.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||
channel, bus = _make_channel()
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
import {
|
||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||
@@ -64,6 +65,7 @@ export function WeixinPanel({
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginPanelProps) {
|
||||
const { client } = useClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const channelTx = channelTranslator(t, "weixin");
|
||||
@@ -150,7 +152,7 @@ export function WeixinPanel({
|
||||
setSaveState("idle");
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
context.token,
|
||||
client,
|
||||
"weixin",
|
||||
channelValuesForSave(editableFieldsRef.current, values),
|
||||
{ enable: context.enabled },
|
||||
@@ -168,7 +170,7 @@ export function WeixinPanel({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -669,6 +669,7 @@ def _run_gateway(
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
webui_skill_state_action=_webui_skill_state_action,
|
||||
config_path=Path(config_path),
|
||||
)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
|
||||
@@ -373,6 +373,7 @@ class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
||||
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||
|
||||
@@ -447,6 +447,28 @@ def _merge_unique_list(base: object, override: object) -> object:
|
||||
return result
|
||||
|
||||
|
||||
def _merge_chat_extra_body(
|
||||
kwargs: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Merge configured Chat Completions fields without clobbering tools."""
|
||||
regular_extra = {key: value for key, value in extra_body.items() if key != "tools"}
|
||||
merged = dict(kwargs)
|
||||
if regular_extra:
|
||||
existing = kwargs.get("extra_body", {})
|
||||
merged["extra_body"] = _deep_merge(existing, regular_extra)
|
||||
|
||||
if "tools" in extra_body:
|
||||
current_tools = kwargs.get("tools")
|
||||
configured_tools = extra_body["tools"]
|
||||
if isinstance(current_tools, list) and isinstance(configured_tools, list):
|
||||
merged["tools"] = [*current_tools, *configured_tools]
|
||||
else:
|
||||
merged["tools"] = configured_tools
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_responses_extra_body(
|
||||
body: dict[str, Any],
|
||||
extra_body: dict[str, Any],
|
||||
@@ -968,14 +990,11 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
# Merge user-configured extra_body last so it can override or
|
||||
# extend provider-specific defaults (e.g. chat_template_kwargs,
|
||||
# guided_json, repetition_penalty). Uses recursive merge so
|
||||
# nested dicts like {"chat_template_kwargs": {"enable_thinking": false}}
|
||||
# do not clobber sibling keys already set by thinking-style logic.
|
||||
# Merge user-configured extra_body last so ordinary fields can override
|
||||
# provider defaults. Keep configured tools at the top level: the SDK
|
||||
# otherwise lets extra_body.tools replace nanobot's generated functions.
|
||||
if self._extra_body:
|
||||
existing = kwargs.get("extra_body", {})
|
||||
kwargs["extra_body"] = _deep_merge(existing, self._extra_body)
|
||||
kwargs = _merge_chat_extra_body(kwargs, self._extra_body)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
@@ -89,8 +90,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _manager() -> CliAppManager:
|
||||
config = load_config()
|
||||
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
cli_cfg = config.tools.cli_apps
|
||||
return CliAppManager(
|
||||
workspace=config.workspace_path,
|
||||
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
|
||||
)
|
||||
|
||||
|
||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
manager = _manager()
|
||||
async def cli_apps_payload(
|
||||
*,
|
||||
installed_only: bool = False,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
if installed_only:
|
||||
return manager.installed_payload()
|
||||
payload = manager.payload(cache_only=True)
|
||||
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
def cli_apps_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise CliAppError("missing CLI app name")
|
||||
manager = _manager()
|
||||
manager = _manager(config_path) if config_path is not None else _manager()
|
||||
if action == "install":
|
||||
return manager.install(name)
|
||||
if action == "update":
|
||||
|
||||
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
@@ -29,6 +31,7 @@ class GatewayServices:
|
||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||
|
||||
http: GatewayHTTPHandler
|
||||
settings: WebUISettingsServices
|
||||
tokens: GatewayTokenStore
|
||||
media: WebUIMediaGateway
|
||||
ingress: WebUIIngressPolicy
|
||||
@@ -50,6 +53,7 @@ def build_gateway_services(
|
||||
static_dist_path: Path | None,
|
||||
workspace_path: Path,
|
||||
default_restrict_to_workspace: bool,
|
||||
config_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None,
|
||||
runtime_surface: str,
|
||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||
@@ -63,6 +67,7 @@ def build_gateway_services(
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||
tokens = GatewayTokenStore()
|
||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||
@@ -102,6 +107,7 @@ def build_gateway_services(
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
workspaces=workspaces,
|
||||
settings=settings,
|
||||
skills_workspace_path=workspace_path,
|
||||
disabled_skills=disabled_skills,
|
||||
cron_service=cron_service,
|
||||
@@ -115,6 +121,7 @@ def build_gateway_services(
|
||||
)
|
||||
return GatewayServices(
|
||||
http=http,
|
||||
settings=settings,
|
||||
tokens=tokens,
|
||||
media=media,
|
||||
ingress=ingress,
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Gateway-owned browser authorization flows for remote MCP servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
|
||||
|
||||
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.security.network import validate_url_target
|
||||
from nanobot.webui.http_utils import is_loopback_host
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
_FLOW_TTL_S = 300
|
||||
_START_WAIT_S = 20
|
||||
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
|
||||
|
||||
|
||||
class McpOAuthError(Exception):
|
||||
"""Safe WebUI error for an MCP OAuth request."""
|
||||
|
||||
def __init__(self, message: str, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class _OAuthCallbackError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _McpOAuthFlow:
|
||||
flow_id: str
|
||||
name: str
|
||||
cfg: MCPServerConfig
|
||||
redirect_uri: str
|
||||
manual_callback: bool
|
||||
expires_at: float
|
||||
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
|
||||
task: asyncio.Task[bool] | None = None
|
||||
authorization_url: str | None = None
|
||||
state: str | None = None
|
||||
callback_received: bool = False
|
||||
error: str | None = None
|
||||
reload_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
|
||||
cleaned = redirect_uri.strip()
|
||||
parsed = urlsplit(cleaned)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
|
||||
if (
|
||||
not parsed.netloc
|
||||
or not parsed.hostname
|
||||
or parsed.path != MCP_OAUTH_CALLBACK_PATH
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL")
|
||||
return cleaned, parsed, port
|
||||
|
||||
|
||||
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
|
||||
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
|
||||
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme == "https":
|
||||
return cleaned
|
||||
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
|
||||
return cleaned
|
||||
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
|
||||
|
||||
|
||||
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
|
||||
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
|
||||
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
|
||||
return validate_mcp_oauth_redirect_uri(cleaned), False
|
||||
|
||||
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
|
||||
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
|
||||
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
|
||||
|
||||
|
||||
class McpOAuthManager:
|
||||
"""Own short-lived browser flows while the gateway process is running."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._flows: dict[str, _McpOAuthFlow] = {}
|
||||
self._states: dict[str, str] = {}
|
||||
|
||||
async def start(
|
||||
self,
|
||||
name: str,
|
||||
cfg: MCPServerConfig,
|
||||
redirect_uri: str,
|
||||
*,
|
||||
reload_mcp: McpReload,
|
||||
reset_credentials: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
self._prune()
|
||||
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
|
||||
await self._cancel_name(name)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
now = time.monotonic()
|
||||
flow = _McpOAuthFlow(
|
||||
flow_id=secrets.token_urlsafe(24),
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
redirect_uri=redirect_uri,
|
||||
manual_callback=manual_callback,
|
||||
expires_at=now + _FLOW_TTL_S,
|
||||
callback_result=loop.create_future(),
|
||||
)
|
||||
self._flows[flow.flow_id] = flow
|
||||
handlers = MCPOAuthHandlers(
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
|
||||
callback_handler=lambda: self._wait_for_callback(flow),
|
||||
reset_credentials=reset_credentials,
|
||||
)
|
||||
flow.task = asyncio.create_task(
|
||||
self._connect_and_reload(flow, handlers, reload_mcp),
|
||||
name=f"mcp-oauth:{name}",
|
||||
)
|
||||
|
||||
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{ready_waiter, flow.task},
|
||||
timeout=_START_WAIT_S,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
ready_waiter.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ready_waiter
|
||||
return self._payload(flow)
|
||||
|
||||
async def status(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
return self._payload(flow)
|
||||
|
||||
def submit_callback(
|
||||
self,
|
||||
*,
|
||||
state: str,
|
||||
code: str | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
self._prune()
|
||||
flow_id = self._states.pop(state, None)
|
||||
if flow_id is None:
|
||||
raise McpOAuthError("This MCP authorization request has expired", status=410)
|
||||
flow = self._flow(flow_id)
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None or callback_result.done():
|
||||
raise McpOAuthError("This MCP authorization callback was already used", status=409)
|
||||
|
||||
flow.callback_received = True
|
||||
if error:
|
||||
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
|
||||
flow.error = f"Authorization was not completed ({safe_error})."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
elif not code or len(code) > 8192:
|
||||
flow.error = "The MCP server did not return an authorization code."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
else:
|
||||
callback_result.set_result((code, state))
|
||||
return flow.name
|
||||
|
||||
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
|
||||
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
parsed = urlsplit(callback_url.strip())
|
||||
expected = urlsplit(flow.redirect_uri)
|
||||
if (
|
||||
not parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.scheme != expected.scheme
|
||||
or parsed.netloc != expected.netloc
|
||||
or parsed.path != expected.path
|
||||
):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
try:
|
||||
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
) from exc
|
||||
|
||||
states = query.get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or state != flow.state:
|
||||
raise McpOAuthError(
|
||||
"This callback belongs to a different or expired authorization request. "
|
||||
"Start again.",
|
||||
status=410,
|
||||
)
|
||||
|
||||
codes = query.get("code", [])
|
||||
errors = query.get("error", [])
|
||||
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
code = codes[0] if len(codes) == 1 else None
|
||||
error = errors[0] if len(errors) == 1 else None
|
||||
if (not code and not error) or (code is not None and len(code) > 8192):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
|
||||
self.submit_callback(state=state, code=code, error=error)
|
||||
return self._payload(flow)
|
||||
|
||||
async def cancel(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
await self._cancel_flow(flow)
|
||||
return self._payload(flow)
|
||||
|
||||
async def _receive_authorization_url(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
authorization_url: str,
|
||||
) -> None:
|
||||
parsed = urlsplit(authorization_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
ok, _error = validate_url_target(authorization_url)
|
||||
if not ok:
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
states = parse_qs(parsed.query).get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or len(state) > 512:
|
||||
flow.error = "The MCP server returned an invalid authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
if state in self._states:
|
||||
flow.error = "The MCP server reused an OAuth state value."
|
||||
raise McpOAuthError(flow.error)
|
||||
flow.authorization_url = authorization_url
|
||||
flow.state = state
|
||||
self._states[state] = flow.flow_id
|
||||
flow.authorization_ready.set()
|
||||
|
||||
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None:
|
||||
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
|
||||
remaining = max(0.1, flow.expires_at - time.monotonic())
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
flow.error = "MCP authorization timed out."
|
||||
raise _OAuthCallbackError(flow.error) from exc
|
||||
|
||||
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
|
||||
connections: dict[str, MCPConnection] = {}
|
||||
try:
|
||||
connections = await connect_mcp_servers(
|
||||
{flow.name: flow.cfg},
|
||||
ToolRegistry(),
|
||||
oauth_handlers={flow.name: handlers},
|
||||
)
|
||||
succeeded = flow.name in connections
|
||||
if not succeeded and flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return succeeded
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return False
|
||||
finally:
|
||||
for connection in connections.values():
|
||||
with suppress(Exception):
|
||||
await connection.aclose()
|
||||
|
||||
async def _connect_and_reload(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
handlers: MCPOAuthHandlers,
|
||||
reload_mcp: McpReload,
|
||||
) -> bool:
|
||||
succeeded = await self._connect(flow, handlers)
|
||||
if not succeeded:
|
||||
return False
|
||||
try:
|
||||
flow.reload_result = await reload_mcp()
|
||||
failed = flow.reload_result.get("failed")
|
||||
if (
|
||||
not flow.reload_result.get("ok")
|
||||
and not flow.reload_result.get("requires_restart")
|
||||
and isinstance(failed, list)
|
||||
and flow.name in failed
|
||||
):
|
||||
flow.reload_result = await reload_mcp()
|
||||
except Exception:
|
||||
flow.reload_result = {
|
||||
"ok": False,
|
||||
"message": "Signed in, but nanobot could not activate the MCP tools.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
return True
|
||||
|
||||
def _flow(self, flow_id: str) -> _McpOAuthFlow:
|
||||
flow = self._flows.get(flow_id)
|
||||
if flow is None:
|
||||
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
|
||||
return flow
|
||||
|
||||
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
|
||||
task = flow.task
|
||||
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
|
||||
if task is not None and task.cancelled():
|
||||
status = "cancelled"
|
||||
elif task is not None and task.done():
|
||||
try:
|
||||
succeeded = task.result()
|
||||
except Exception:
|
||||
succeeded = False
|
||||
if not succeeded:
|
||||
status = "failed"
|
||||
elif flow.reload_result is None:
|
||||
status = "authorized"
|
||||
elif flow.reload_result.get("ok") or (
|
||||
isinstance(connected, list) and flow.name in connected
|
||||
):
|
||||
status = "connected"
|
||||
else:
|
||||
status = "authorized"
|
||||
elif flow.callback_received:
|
||||
status = "connecting"
|
||||
elif flow.authorization_url:
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "starting"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"flow_id": flow.flow_id,
|
||||
"name": flow.name,
|
||||
"status": status,
|
||||
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
|
||||
}
|
||||
if flow.manual_callback:
|
||||
payload["completion_input"] = "callback_url"
|
||||
if flow.authorization_url and status == "authorization_required":
|
||||
payload["authorization_url"] = flow.authorization_url
|
||||
if flow.error:
|
||||
payload["error"] = flow.error
|
||||
if flow.reload_result is not None:
|
||||
payload["hot_reload"] = flow.reload_result
|
||||
return payload
|
||||
|
||||
async def _cancel_name(self, name: str) -> None:
|
||||
for flow in list(self._flows.values()):
|
||||
if flow.name == name and flow.task is not None and not flow.task.done():
|
||||
await self._cancel_flow(flow)
|
||||
|
||||
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
task = flow.task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with suppress(BaseException):
|
||||
await task
|
||||
|
||||
def _prune(self) -> None:
|
||||
now = time.monotonic()
|
||||
for flow_id, flow in list(self._flows.items()):
|
||||
if flow.expires_at > now:
|
||||
continue
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
if flow.task is not None and not flow.task.done():
|
||||
flow.task.cancel()
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is not None and not callback_result.done():
|
||||
callback_result.cancel()
|
||||
self._flows.pop(flow_id, None)
|
||||
@@ -14,8 +14,17 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Mapping, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.plugins import (
|
||||
AgentPlugin,
|
||||
discover_agent_plugins,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
)
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||
@@ -25,6 +34,9 @@ from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsConfig
|
||||
|
||||
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||
_SECRET_QUERY_RE = re.compile(
|
||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||
@@ -48,7 +60,6 @@ _MAX_TEST_TOOLS = 16
|
||||
_DEFAULT_TEST_TIMEOUT = 20
|
||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
@@ -334,6 +345,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
||||
),
|
||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
||||
),
|
||||
McpPreset(
|
||||
name="xmind",
|
||||
display_name="Xmind",
|
||||
category="productivity",
|
||||
description="Create, read, and edit cloud mind maps through Xmind.",
|
||||
docs_url="https://xmind.com/user-guide/xmind-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="xmind.com",
|
||||
brand_color="#F4B41A",
|
||||
requires="Xmind account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Xmind OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="notion",
|
||||
display_name="Notion",
|
||||
category="productivity",
|
||||
description="Read and update your Notion workspace through Notion MCP.",
|
||||
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="notion.so",
|
||||
brand_color="#111111",
|
||||
requires="Notion account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.notion.com/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Notion OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="linear",
|
||||
display_name="Linear",
|
||||
category="productivity",
|
||||
description="Find and manage Linear issues, projects, and comments.",
|
||||
docs_url="https://linear.app/docs/mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="linear.app",
|
||||
brand_color="#5E6AD2",
|
||||
requires="Linear account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.linear.app/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Linear OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="github",
|
||||
display_name="GitHub",
|
||||
@@ -654,6 +722,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
||||
return "not_installed" if preset.install_supported else "coming_soon"
|
||||
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||
return "missing_credentials"
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
|
||||
return "authorization_required"
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
return "missing_dependency"
|
||||
return "configured"
|
||||
@@ -699,6 +769,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": preset.transport,
|
||||
"auth": server.auth if server and server.auth else None,
|
||||
"command": server.command if server and server.command else None,
|
||||
"args": list(server.args) if server and server.command else None,
|
||||
"url": _connection_summary(server) if server and server.url else None,
|
||||
@@ -749,6 +820,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"command": cfg.command or None,
|
||||
"url": _connection_summary(cfg) if cfg.url else None,
|
||||
})
|
||||
@@ -776,7 +848,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||
cfg = configured_servers.get(preset.name)
|
||||
status = _status_for(preset, cfg)
|
||||
configured = cfg is not None and status not in {"missing_credentials"}
|
||||
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
|
||||
logo_url = _favicon_url(preset.brand_domain)
|
||||
return {
|
||||
"name": preset.name,
|
||||
@@ -785,6 +857,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
||||
"description": preset.description,
|
||||
"docs_url": preset.docs_url,
|
||||
"transport": preset.transport,
|
||||
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||
"requires": preset.requires,
|
||||
"note": preset.note,
|
||||
"install_supported": preset.install_supported,
|
||||
@@ -811,7 +884,11 @@ def _custom_payload(
|
||||
transport = cfg.type
|
||||
if not transport:
|
||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
||||
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||
configured = status != "authorization_required"
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": name,
|
||||
@@ -819,12 +896,13 @@ def _custom_payload(
|
||||
"description": "Custom MCP server from nanobot config.",
|
||||
"docs_url": "",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"requires": "",
|
||||
"note": "",
|
||||
"install_supported": True,
|
||||
"installed": True,
|
||||
"configured": True,
|
||||
"available": _config_available(cfg),
|
||||
"configured": configured,
|
||||
"available": configured and _config_available(cfg),
|
||||
"status": status,
|
||||
"logo_url": None,
|
||||
"brand_color": "#64748B",
|
||||
@@ -837,12 +915,37 @@ def _custom_payload(
|
||||
}
|
||||
|
||||
|
||||
def _agent_plugin_payload(plugin: AgentPlugin) -> dict[str, Any]:
|
||||
return {
|
||||
"name": f"plugin-{plugin.name}",
|
||||
"display_name": plugin.display_name,
|
||||
"category": plugin.category,
|
||||
"description": plugin.description or "Agent Plugin",
|
||||
"docs_url": plugin.repository,
|
||||
"transport": "stdio",
|
||||
"requires": ", ".join(plugin.permissions),
|
||||
"note": "",
|
||||
"install_supported": False,
|
||||
"installed": True,
|
||||
"configured": True,
|
||||
"enabled": plugin.enabled,
|
||||
"available": plugin.enabled,
|
||||
"status": "enabled" if plugin.enabled else "disabled",
|
||||
"logo_url": plugin.logo,
|
||||
"brand_color": plugin.accent_color,
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(plugin.mcp_servers),
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
|
||||
def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
tool_preview: Mapping[str, list[str]] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
known = _known_preset_names()
|
||||
preset_rows = [
|
||||
_preset_payload(preset, config.tools.mcp_servers)
|
||||
@@ -854,9 +957,16 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
if name not in known
|
||||
]
|
||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||
plugin_rows = [
|
||||
_agent_plugin_payload(plugin)
|
||||
for plugin in discover_agent_plugins(config.workspace_path)
|
||||
if f"plugin-{plugin.name}" not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows],
|
||||
"installed_count": len(config.tools.mcp_servers),
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
"installed_count": len(config.tools.mcp_servers)
|
||||
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||
}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
@@ -928,7 +1038,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
||||
await stack.aclose()
|
||||
|
||||
|
||||
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
async def mcp_presets_test_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||
|
||||
@@ -941,16 +1055,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
display_name = _display_name_for(name, preset)
|
||||
|
||||
try:
|
||||
config = resolve_config_env_vars(load_config())
|
||||
config = resolve_config_env_vars(
|
||||
load_config(config_path) if config_path is not None else load_config(),
|
||||
config_path=config_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return mcp_presets_payload(last_action={
|
||||
"ok": False,
|
||||
"message": _scrub_test_error(str(exc)),
|
||||
"error": _scrub_test_error(str(exc)),
|
||||
"tool_count": 0,
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
})
|
||||
return mcp_presets_payload(
|
||||
last_action={
|
||||
"ok": False,
|
||||
"message": _scrub_test_error(str(exc)),
|
||||
"error": _scrub_test_error(str(exc)),
|
||||
"tool_count": 0,
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
@@ -968,7 +1088,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action)
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
last_action = {
|
||||
@@ -979,7 +1099,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
"tool_names": [],
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
return mcp_presets_payload(last_action=last_action)
|
||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||
|
||||
registry = ToolRegistry()
|
||||
stacks: dict[str, Any] = {}
|
||||
@@ -1040,7 +1160,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||
|
||||
tool_names = last_action.get("tool_names", [])
|
||||
preview = {name: tool_names} if tool_names else None
|
||||
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||
return mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
tool_preview=preview,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||
@@ -1109,6 +1233,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
||||
return normalized # type: ignore[return-value]
|
||||
|
||||
|
||||
def _normalize_auth(
|
||||
value: object,
|
||||
*,
|
||||
transport: Literal["stdio", "sse", "streamableHttp"],
|
||||
url: str,
|
||||
headers: Mapping[str, str],
|
||||
) -> Literal["oauth"] | None:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw and url and not headers:
|
||||
normalized_url = url.rstrip("/")
|
||||
if any(
|
||||
preset.server is not None
|
||||
and preset.server.auth == "oauth"
|
||||
and preset.server.url.rstrip("/") == normalized_url
|
||||
for preset in MCP_PRESETS
|
||||
):
|
||||
raw = "oauth"
|
||||
if not raw:
|
||||
return None
|
||||
if raw != "oauth":
|
||||
raise McpPresetError("unsupported MCP auth type")
|
||||
if transport == "stdio":
|
||||
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
|
||||
return "oauth"
|
||||
|
||||
|
||||
def _validated_server_name(name: str) -> str:
|
||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||
raise McpPresetError("invalid MCP server name")
|
||||
@@ -1124,6 +1274,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("stdio MCP servers require a command")
|
||||
if transport in {"sse", "streamableHttp"} and not url:
|
||||
raise McpPresetError("remote MCP servers require a URL")
|
||||
headers = _parse_string_map(_query_first(query, "headers"))
|
||||
auth = _normalize_auth(
|
||||
_query_first(query, "auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=headers,
|
||||
)
|
||||
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||
if raw_timeout:
|
||||
@@ -1133,12 +1290,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||
cfg = MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=_parse_string_list(_query_first(query, "args")),
|
||||
env=_parse_string_map(_query_first(query, "env")),
|
||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=_parse_string_map(_query_first(query, "headers")),
|
||||
headers=headers,
|
||||
tool_timeout=tool_timeout,
|
||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||
)
|
||||
@@ -1183,6 +1341,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
headers = cast(dict[object, object], headers_value)
|
||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
||||
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
|
||||
typed_headers = cast(dict[str, str], headers)
|
||||
auth = _normalize_auth(
|
||||
server.get("auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=typed_headers,
|
||||
)
|
||||
if not isinstance(enabled_tools_value, list):
|
||||
enabled_tools_value = ["*"]
|
||||
else:
|
||||
@@ -1191,12 +1356,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
enabled_tools_value = ["*"]
|
||||
return server_name, MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=cast(list[str], args),
|
||||
env=cast(dict[str, str], env),
|
||||
cwd=cwd if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=cast(dict[str, str], headers),
|
||||
headers=typed_headers,
|
||||
tool_timeout=timeout_int,
|
||||
enabled_tools=cast(list[str], enabled_tools_value),
|
||||
)
|
||||
@@ -1221,24 +1387,54 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
||||
return out
|
||||
|
||||
|
||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def _oauth_credentials_replaced(
|
||||
previous: MCPServerConfig | None,
|
||||
replacement: MCPServerConfig,
|
||||
) -> bool:
|
||||
if previous is None or previous.auth != "oauth":
|
||||
return False
|
||||
return replacement.auth != "oauth" or replacement.url != previous.url
|
||||
|
||||
|
||||
def custom_mcp_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
if action == "custom":
|
||||
name, cfg = _custom_server_from_query(query)
|
||||
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
save_config(config, config_path)
|
||||
if delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
if action in {"import", "import-cursor"}:
|
||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||
delete_credentials = [
|
||||
name
|
||||
for name, cfg in servers.items()
|
||||
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
]
|
||||
config.tools.mcp_servers.update(servers)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action={
|
||||
"ok": True,
|
||||
"message": f"Imported {len(servers)} MCP server(s).",
|
||||
})
|
||||
save_config(config, config_path)
|
||||
for name in delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action={
|
||||
"ok": True,
|
||||
"message": f"Imported {len(servers)} MCP server(s).",
|
||||
},
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1249,29 +1445,61 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
raise McpPresetError("unknown MCP server", status=404)
|
||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||
|
||||
|
||||
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
def ensure_mcp_oauth_server(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> tuple[str, MCPServerConfig]:
|
||||
"""Materialize an OAuth preset on first click and return its saved config."""
|
||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
preset = _preset_by_name(name)
|
||||
if preset.server is None or preset.server.auth != "oauth":
|
||||
raise McpPresetError("MCP server does not support browser authorization", status=409)
|
||||
cfg = _materialize_server(preset, query, None)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
|
||||
raise McpPresetError("MCP server is not configured for OAuth", status=409)
|
||||
return name, cfg
|
||||
|
||||
|
||||
def mcp_presets_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
raise McpPresetError("missing MCP preset name")
|
||||
preset = _preset_by_name_optional(name)
|
||||
|
||||
config = load_config()
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
existing = config.tools.mcp_servers.get(name)
|
||||
|
||||
if action == "enable":
|
||||
if preset is None:
|
||||
raise McpPresetError("unknown MCP preset", status=404)
|
||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||
save_config(config)
|
||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||
save_config(config, config_path)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_action_message(action, preset),
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1287,7 +1515,8 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
except OSError as exc:
|
||||
cleanup_error = str(exc)
|
||||
del config.tools.mcp_servers[name]
|
||||
save_config(config)
|
||||
save_config(config, config_path)
|
||||
delete_mcp_oauth_credentials(name)
|
||||
last_action = (
|
||||
_action_message(action, preset)
|
||||
if preset is not None
|
||||
@@ -1303,7 +1532,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||
)
|
||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||
payload = mcp_presets_payload(last_action=last_action)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=last_action,
|
||||
config_path=config_path,
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
return payload
|
||||
|
||||
@@ -1339,13 +1571,44 @@ async def mcp_presets_settings_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
reload_mcp: McpReload | None = None,
|
||||
config: WebUISettingsConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
config_path = config.path if config is not None else None
|
||||
if action is None:
|
||||
return mcp_presets_payload()
|
||||
return mcp_presets_payload(config_path=config_path)
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if name.startswith("plugin-"):
|
||||
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||
plugin_name = name.removeprefix("plugin-")
|
||||
plugins = discover_agent_plugins(plugin_config.workspace_path)
|
||||
plugin = next((item for item in plugins if item.name == plugin_name), None)
|
||||
if name not in plugin_config.tools.mcp_servers and plugin is not None:
|
||||
if action not in {"enable", "disable"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
await asyncio.to_thread(
|
||||
set_agent_plugin_enabled,
|
||||
plugin_config.workspace_path,
|
||||
plugin_name,
|
||||
action == "enable",
|
||||
)
|
||||
verb = "enabled" if action == "enable" else "disabled"
|
||||
payload = mcp_presets_payload(
|
||||
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
|
||||
config_path=config_path,
|
||||
)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
if action == "test":
|
||||
return await mcp_presets_test_action(query)
|
||||
if action in _CUSTOM_ACTIONS:
|
||||
return await mcp_presets_test_action(query, config_path=config_path)
|
||||
if config is not None:
|
||||
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||
payload = await asyncio.to_thread(
|
||||
config.run_serialized,
|
||||
lambda path: operation(action, query, config_path=path),
|
||||
)
|
||||
elif action in _CUSTOM_ACTIONS:
|
||||
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||
else:
|
||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
@@ -15,8 +16,13 @@ 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_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||
if config_path is None:
|
||||
return optional_features_payload()
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return optional_features_payload(config=load_config(config_path))
|
||||
|
||||
|
||||
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||
@@ -32,13 +38,19 @@ def nanobot_features_action(
|
||||
query: QueryParams,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (query_first(query, "name") or "").strip()
|
||||
instance_id = nanobot_feature_instance_target(query)
|
||||
if not name:
|
||||
raise OptionalFeatureError("missing feature name")
|
||||
if action == "enable":
|
||||
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||
return enable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
allow_install=allow_install,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
if action == "disable":
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
@@ -50,5 +62,9 @@ def nanobot_features_action(
|
||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||
status=400,
|
||||
)
|
||||
return disable_optional_feature(name, instance_id=instance_id)
|
||||
return disable_optional_feature(
|
||||
name,
|
||||
config_path=config_path,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||
|
||||
+245
-2154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
||||
"""Capability settings domain logic for Web, media, network, and API features."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
|
||||
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
|
||||
from nanobot.audio.transcription import resolve_transcription_config
|
||||
from nanobot.audio.transcription_registry import (
|
||||
resolve_transcription_provider,
|
||||
transcription_provider_names,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import (
|
||||
OptionalFeatureError,
|
||||
extra_installed,
|
||||
optional_dependency_groups,
|
||||
)
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
image_gen_provider_names,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.security.network import is_loopback_host
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
SettingsRequest,
|
||||
SettingsRouteResult,
|
||||
WebUISettingsError,
|
||||
parse_bool,
|
||||
query_first,
|
||||
query_first_alias,
|
||||
)
|
||||
from nanobot.webui.settings_models import (
|
||||
OAuthStatusReader,
|
||||
mask_secret_hint,
|
||||
provider_configured_for_settings,
|
||||
)
|
||||
from nanobot.webui.workspaces import (
|
||||
read_webui_default_access_mode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
SettingsOperation = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilitySettingsOperations:
|
||||
update_web_search: SettingsOperation
|
||||
update_api: SettingsOperation
|
||||
update_image: SettingsOperation
|
||||
update_transcription: SettingsOperation
|
||||
update_network: SettingsOperation
|
||||
nanobot_features_action: SettingsOperation
|
||||
api_runtime: Callable[[], ApiRuntime]
|
||||
reload_image: Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class CapabilitySettingsPayload(TypedDict):
|
||||
web_search: dict[str, Any]
|
||||
web: dict[str, Any]
|
||||
api: dict[str, Any]
|
||||
observability: dict[str, Any]
|
||||
image_generation: dict[str, Any]
|
||||
transcription: dict[str, Any]
|
||||
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
"1:1",
|
||||
"3:4",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"16:9",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"21:9",
|
||||
}
|
||||
|
||||
|
||||
def _image_generation_provider_rows(
|
||||
config: Config,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in image_gen_provider_names():
|
||||
image_provider = get_image_gen_provider(name)
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
configured = (
|
||||
provider_configured_for_settings(spec, provider_config, oauth_status)
|
||||
if spec is not None and provider_config is not None
|
||||
else bool(getattr(provider_config, "api_key", None))
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": configured,
|
||||
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
|
||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": (
|
||||
spec.default_api_base if spec and spec.default_api_base else None
|
||||
),
|
||||
"models": list(image_provider.model_options) if image_provider else [],
|
||||
"default_model": (
|
||||
image_provider.model_options[0]
|
||||
if image_provider and image_provider.model_options
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name in transcription_provider_names():
|
||||
spec = find_by_name(name)
|
||||
provider_config = getattr(config.providers, name, None)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.label if spec is not None else name,
|
||||
"configured": bool(getattr(provider_config, "api_key", None)),
|
||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||
"api_base": getattr(provider_config, "api_base", None),
|
||||
"default_api_base": (
|
||||
spec.default_api_base if spec and spec.default_api_base else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def capability_settings_payload(
|
||||
config: Config,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> CapabilitySettingsPayload:
|
||||
search_config = config.tools.web.search
|
||||
image_config = config.tools.image_generation
|
||||
transcription = resolve_transcription_config(config)
|
||||
search_provider = (
|
||||
search_config.provider
|
||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||
else "duckduckgo"
|
||||
)
|
||||
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
|
||||
selected_image_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in image_providers
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"web_search": {
|
||||
"provider": search_provider,
|
||||
"api_key_hint": mask_secret_hint(search_config.api_key),
|
||||
"base_url": search_config.base_url or None,
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||
},
|
||||
"web": {
|
||||
"enable": config.tools.web.enable,
|
||||
"proxy": config.tools.web.proxy,
|
||||
"user_agent": config.tools.web.user_agent,
|
||||
"search": {
|
||||
"max_results": search_config.max_results,
|
||||
"timeout": search_config.timeout,
|
||||
},
|
||||
"fetch": {
|
||||
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||
},
|
||||
},
|
||||
"api": {
|
||||
"host": config.api.host,
|
||||
"port": config.api.port,
|
||||
"timeout": config.api.timeout,
|
||||
"api_key_hint": mask_secret_hint(config.api.api_key),
|
||||
},
|
||||
"observability": {
|
||||
"provider": "langfuse",
|
||||
"configured": bool(
|
||||
os.environ.get("LANGFUSE_SECRET_KEY")
|
||||
and os.environ.get("LANGFUSE_PUBLIC_KEY")
|
||||
),
|
||||
"base_url": os.environ.get("LANGFUSE_BASE_URL")
|
||||
or "https://cloud.langfuse.com",
|
||||
},
|
||||
"image_generation": {
|
||||
"enabled": image_config.enabled,
|
||||
"provider": image_config.provider,
|
||||
"provider_configured": bool(
|
||||
selected_image_provider and selected_image_provider["configured"]
|
||||
),
|
||||
"model": image_config.model,
|
||||
"default_aspect_ratio": image_config.default_aspect_ratio,
|
||||
"default_image_size": image_config.default_image_size,
|
||||
"max_images_per_turn": image_config.max_images_per_turn,
|
||||
"save_dir": image_config.save_dir,
|
||||
"providers": image_providers,
|
||||
},
|
||||
"transcription": {
|
||||
"enabled": transcription.enabled,
|
||||
"provider": transcription.provider,
|
||||
"provider_configured": transcription.configured,
|
||||
"model": transcription.model,
|
||||
"language": transcription.language,
|
||||
"max_duration_sec": transcription.max_duration_sec,
|
||||
"max_upload_mb": transcription.max_upload_mb,
|
||||
"providers": _transcription_provider_rows(config),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_network_safety_settings(
|
||||
config: Config,
|
||||
query: QueryParams,
|
||||
) -> tuple[bool, str | None]:
|
||||
raw_allow = (
|
||||
query_first_alias(
|
||||
query,
|
||||
"webui_allow_local_service_access",
|
||||
"webuiAllowLocalServiceAccess",
|
||||
)
|
||||
or query_first_alias(
|
||||
query,
|
||||
"allow_local_preview_access",
|
||||
"allowLocalPreviewAccess",
|
||||
)
|
||||
)
|
||||
raw_default_access_mode = query_first_alias(
|
||||
query,
|
||||
"webui_default_access_mode",
|
||||
"webuiDefaultAccessMode",
|
||||
)
|
||||
if raw_allow is None and raw_default_access_mode is None:
|
||||
raise WebUISettingsError(
|
||||
"webui_allow_local_service_access or webui_default_access_mode is required"
|
||||
)
|
||||
|
||||
changed = False
|
||||
if raw_allow is not None:
|
||||
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||
if config.tools.webui_allow_local_service_access != allow_local:
|
||||
config.tools.webui_allow_local_service_access = allow_local
|
||||
changed = True
|
||||
|
||||
default_access_mode: str | None = None
|
||||
if raw_default_access_mode is not None:
|
||||
default_access_mode = raw_default_access_mode.strip().lower()
|
||||
if default_access_mode == "restricted":
|
||||
default_access_mode = "default"
|
||||
if default_access_mode not in {"default", "full"}:
|
||||
raise WebUISettingsError(
|
||||
"webui_default_access_mode must be default or full"
|
||||
)
|
||||
return changed, default_access_mode
|
||||
|
||||
|
||||
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||
provider_name = (query_first(query, "provider") or "").strip().lower()
|
||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||
if provider_option is None:
|
||||
raise WebUISettingsError("unknown web search provider")
|
||||
|
||||
search_config = config.tools.web.search
|
||||
web_config = config.tools.web
|
||||
previous_provider = search_config.provider
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
def set_search_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(search_config, attr) != value:
|
||||
setattr(search_config, attr, value)
|
||||
changed = True
|
||||
|
||||
def set_fetch_value(attr: str, value: object) -> None:
|
||||
nonlocal changed
|
||||
if getattr(web_config.fetch, attr) != value:
|
||||
setattr(web_config.fetch, attr, value)
|
||||
changed = True
|
||||
|
||||
if search_config.provider != provider_name:
|
||||
search_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
credential = provider_option["credential"]
|
||||
if credential == "none":
|
||||
set_search_value("api_key", "")
|
||||
set_search_value("base_url", "")
|
||||
elif credential == "base_url":
|
||||
base_url = query_first_alias(query, "base_url", "baseUrl")
|
||||
base_url = base_url.strip() if base_url is not None else None
|
||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||
base_url = search_config.base_url
|
||||
if not base_url:
|
||||
raise WebUISettingsError("base_url is required")
|
||||
set_search_value("base_url", base_url)
|
||||
set_search_value("api_key", "")
|
||||
elif credential in {"api_key", "optional_api_key"}:
|
||||
raw_api_key = query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||
api_key = search_config.api_key
|
||||
if credential == "api_key" and not api_key:
|
||||
raise WebUISettingsError("api_key is required")
|
||||
set_search_value("api_key", api_key or "")
|
||||
set_search_value("base_url", "")
|
||||
else:
|
||||
raise WebUISettingsError("unknown web search credential type")
|
||||
|
||||
max_results = query_first_alias(query, "max_results", "maxResults")
|
||||
if max_results is not None:
|
||||
try:
|
||||
parsed = int(max_results)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_results must be an integer") from None
|
||||
if parsed < 1 or parsed > 10:
|
||||
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||
set_search_value("max_results", parsed)
|
||||
|
||||
timeout = query_first(query, "timeout")
|
||||
if timeout is not None:
|
||||
try:
|
||||
parsed_timeout = int(timeout)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("timeout must be an integer") from None
|
||||
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||
set_search_value("timeout", parsed_timeout)
|
||||
|
||||
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||
if use_jina_reader is not None:
|
||||
previous_jina_reader = web_config.fetch.use_jina_reader
|
||||
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
|
||||
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
||||
restart_required = True
|
||||
return changed, restart_required
|
||||
|
||||
|
||||
def update_api_settings(config: Config, query: QueryParams) -> None:
|
||||
"""Update the managed OpenAI-compatible API configuration."""
|
||||
api = config.api
|
||||
host = query_first(query, "host")
|
||||
if host is not None:
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise WebUISettingsError("host is required")
|
||||
api.host = host
|
||||
|
||||
port = query_first(query, "port")
|
||||
if port is not None:
|
||||
try:
|
||||
parsed_port = int(port)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("port must be an integer") from None
|
||||
if parsed_port < 1 or parsed_port > 65535:
|
||||
raise WebUISettingsError("port must be between 1 and 65535")
|
||||
api.port = parsed_port
|
||||
|
||||
timeout = query_first(query, "timeout")
|
||||
if timeout is not None:
|
||||
try:
|
||||
parsed_timeout = float(timeout)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("timeout must be a number") from None
|
||||
if parsed_timeout < 1 or parsed_timeout > 3600:
|
||||
raise WebUISettingsError("timeout must be between 1 and 3600")
|
||||
api.timeout = parsed_timeout
|
||||
|
||||
api_key = query_first_alias(query, "api_key", "apiKey")
|
||||
if api_key is not None:
|
||||
api.api_key = api_key.strip()
|
||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||
raise WebUISettingsError(
|
||||
"an API key is required when the API is available on the network"
|
||||
)
|
||||
|
||||
|
||||
def update_image_generation_settings(
|
||||
config: Config,
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> bool:
|
||||
image_config = config.tools.image_generation
|
||||
changed = False
|
||||
|
||||
provider_name = query_first(query, "provider")
|
||||
if provider_name is not None:
|
||||
provider_name = provider_name.strip().lower()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("image generation provider is required")
|
||||
if get_image_gen_provider(provider_name) is None:
|
||||
raise WebUISettingsError("unknown image generation provider")
|
||||
if image_config.provider != provider_name:
|
||||
image_config.provider = provider_name
|
||||
changed = True
|
||||
|
||||
enabled = query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = parse_bool(enabled, "enabled")
|
||||
if image_config.enabled != parsed_enabled:
|
||||
image_config.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
model = query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
raise WebUISettingsError("image generation model is required")
|
||||
if len(model) > 200:
|
||||
raise WebUISettingsError("image generation model is too long")
|
||||
if image_config.model != model:
|
||||
image_config.model = model
|
||||
changed = True
|
||||
|
||||
default_aspect_ratio = query_first_alias(
|
||||
query,
|
||||
"default_aspect_ratio",
|
||||
"defaultAspectRatio",
|
||||
)
|
||||
if default_aspect_ratio is not None:
|
||||
default_aspect_ratio = default_aspect_ratio.strip()
|
||||
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
||||
raise WebUISettingsError("unsupported image generation aspect ratio")
|
||||
if image_config.default_aspect_ratio != default_aspect_ratio:
|
||||
image_config.default_aspect_ratio = default_aspect_ratio
|
||||
changed = True
|
||||
|
||||
default_image_size = query_first_alias(
|
||||
query,
|
||||
"default_image_size",
|
||||
"defaultImageSize",
|
||||
)
|
||||
if default_image_size is not None:
|
||||
default_image_size = default_image_size.strip()
|
||||
if not default_image_size:
|
||||
raise WebUISettingsError("default image size is required")
|
||||
if len(default_image_size) > 32 or not all(
|
||||
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
||||
for char in default_image_size
|
||||
):
|
||||
raise WebUISettingsError("unsupported image generation size")
|
||||
if image_config.default_image_size != default_image_size:
|
||||
image_config.default_image_size = default_image_size
|
||||
changed = True
|
||||
|
||||
max_images_per_turn = query_first_alias(
|
||||
query,
|
||||
"max_images_per_turn",
|
||||
"maxImagesPerTurn",
|
||||
)
|
||||
if max_images_per_turn is not None:
|
||||
try:
|
||||
parsed_max = int(max_images_per_turn)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
||||
if parsed_max < 1 or parsed_max > 8:
|
||||
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
||||
if image_config.max_images_per_turn != parsed_max:
|
||||
image_config.max_images_per_turn = parsed_max
|
||||
changed = True
|
||||
|
||||
if image_config.enabled:
|
||||
selected_provider = next(
|
||||
(
|
||||
provider
|
||||
for provider in _image_generation_provider_rows(
|
||||
config,
|
||||
oauth_status=oauth_status,
|
||||
)
|
||||
if provider["name"] == image_config.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not selected_provider or not selected_provider["configured"]:
|
||||
raise WebUISettingsError("image generation provider is not configured")
|
||||
return changed
|
||||
|
||||
|
||||
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
|
||||
transcription = config.transcription
|
||||
changed = False
|
||||
|
||||
enabled = query_first(query, "enabled")
|
||||
if enabled is not None:
|
||||
parsed_enabled = parse_bool(enabled, "enabled")
|
||||
if transcription.enabled != parsed_enabled:
|
||||
transcription.enabled = parsed_enabled
|
||||
changed = True
|
||||
|
||||
provider = query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip().lower()
|
||||
provider_spec = resolve_transcription_provider(provider)
|
||||
if provider_spec is None:
|
||||
raise WebUISettingsError("unknown transcription provider")
|
||||
provider = provider_spec.name
|
||||
if transcription.provider != provider:
|
||||
transcription.provider = provider
|
||||
changed = True
|
||||
|
||||
model = query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip() or None
|
||||
if model is not None and len(model) > 200:
|
||||
raise WebUISettingsError("transcription model is too long")
|
||||
if transcription.model != model:
|
||||
transcription.model = model
|
||||
changed = True
|
||||
|
||||
language = query_first(query, "language")
|
||||
if language is not None:
|
||||
language = language.strip().lower() or None
|
||||
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
||||
raise WebUISettingsError(
|
||||
"transcription language must be 2-3 lowercase letters"
|
||||
)
|
||||
if transcription.language != language:
|
||||
transcription.language = language
|
||||
changed = True
|
||||
|
||||
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
||||
if max_duration_sec is not None:
|
||||
try:
|
||||
parsed_duration = int(max_duration_sec)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
||||
if parsed_duration < 1 or parsed_duration > 600:
|
||||
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
||||
if transcription.max_duration_sec != parsed_duration:
|
||||
transcription.max_duration_sec = parsed_duration
|
||||
changed = True
|
||||
|
||||
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
||||
if max_upload_mb is not None:
|
||||
try:
|
||||
parsed_upload = int(max_upload_mb)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
||||
if parsed_upload < 1 or parsed_upload > 100:
|
||||
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
||||
if transcription.max_upload_mb != parsed_upload:
|
||||
transcription.max_upload_mb = parsed_upload
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def network_safety_payload(config: Config) -> dict[str, Any]:
|
||||
"""Return the network-related fields embedded in the advanced DTO."""
|
||||
return {
|
||||
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
|
||||
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
|
||||
"webui_default_access_mode": read_webui_default_access_mode(),
|
||||
"private_service_protection_enabled": True,
|
||||
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
||||
}
|
||||
|
||||
|
||||
def masked_api_secret(value: str) -> str | None:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
|
||||
|
||||
|
||||
def api_runtime_message(message: str) -> str:
|
||||
known = {
|
||||
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
|
||||
"api_stop_timeout": "API server did not stop in time.",
|
||||
"api_state_stale": "API server state was stale; try starting it again.",
|
||||
}
|
||||
if message in known:
|
||||
return known[message]
|
||||
if message.startswith("api_"):
|
||||
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
|
||||
return message.replace("_", " ")
|
||||
|
||||
|
||||
def api_service_payload(
|
||||
settings: WebUISettingsServices,
|
||||
runtime: ApiRuntime,
|
||||
*,
|
||||
last_action: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = settings.config.load()
|
||||
status = runtime.status()
|
||||
extras = optional_dependency_groups()
|
||||
connect_host = (
|
||||
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||
)
|
||||
payload = {
|
||||
"installed": extra_installed("api", extras.get("api")),
|
||||
"running": status.running,
|
||||
"managed": status.running,
|
||||
"host": config.api.host,
|
||||
"port": config.api.port,
|
||||
"timeout": config.api.timeout,
|
||||
"api_key_hint": masked_api_secret(config.api.api_key),
|
||||
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
|
||||
"command": "nanobot serve",
|
||||
"log_path": str(status.log_path),
|
||||
}
|
||||
if last_action:
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
|
||||
|
||||
class CapabilitySettingsHandler:
|
||||
"""Handle capability commands after transport authentication and decoding."""
|
||||
|
||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||
self.settings = settings
|
||||
self.logger = logger
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request: SettingsRequest,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
if action == "api-status":
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(self.settings, operations.api_runtime())
|
||||
)
|
||||
if action == "api-start":
|
||||
return await self._start_api(request, operations)
|
||||
if action == "api-stop":
|
||||
return await self._stop_api(operations)
|
||||
|
||||
mutation = {
|
||||
"web-search-update": (
|
||||
operations.update_web_search,
|
||||
"browser",
|
||||
False,
|
||||
),
|
||||
"transcription-update": (
|
||||
operations.update_transcription,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
"network-update": (
|
||||
operations.update_network,
|
||||
"runtime",
|
||||
False,
|
||||
),
|
||||
"image-update": (
|
||||
operations.update_image,
|
||||
"image",
|
||||
True,
|
||||
),
|
||||
}.get(action)
|
||||
if mutation is None:
|
||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||
|
||||
operation, section, apply_image_reload = mutation
|
||||
try:
|
||||
payload = self.settings.mutate(operation, request.query)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
if apply_image_reload:
|
||||
payload, image_restart_cleared = await self.apply_image_runtime_change(
|
||||
payload,
|
||||
operations.reload_image,
|
||||
)
|
||||
else:
|
||||
image_restart_cleared = False
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section=section,
|
||||
clear_restart_section=("image" if image_restart_cleared else None),
|
||||
)
|
||||
|
||||
async def apply_image_runtime_change(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
reload_image: Callable[[], Awaitable[dict[str, Any]]],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Hot-apply image settings, preserving restart fallback on failure."""
|
||||
if not payload.get("requires_restart"):
|
||||
return payload, False
|
||||
try:
|
||||
result = await reload_image()
|
||||
except Exception:
|
||||
self.logger.exception("failed to hot-reload image generation settings")
|
||||
return payload, False
|
||||
|
||||
applied = bool(result.get("ok")) and not result.get("requires_restart")
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = not applied
|
||||
if not applied:
|
||||
self.logger.warning(
|
||||
"image generation settings were saved but require restart: {}",
|
||||
result.get("message") or "hot reload failed",
|
||||
)
|
||||
return updated, applied
|
||||
|
||||
async def _start_api(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
api_key = (request.payload or {}).get("api_key")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return SettingsRouteResult.failure(
|
||||
400,
|
||||
"API service API key must be a string",
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self.settings.mutate,
|
||||
operations.nanobot_features_action,
|
||||
"enable",
|
||||
{"name": ["api"]},
|
||||
allow_install=self._allow_feature_package_install(request),
|
||||
)
|
||||
self.settings.mutate(operations.update_api, request.query)
|
||||
config = self.settings.config.load()
|
||||
runtime = operations.api_runtime()
|
||||
options = ApiStartOptions(
|
||||
host=config.api.host,
|
||||
port=config.api.port,
|
||||
workspace=str(config.workspace_path),
|
||||
config_path=str(self.settings.config.path),
|
||||
)
|
||||
current = runtime.status()
|
||||
result = await asyncio.to_thread(
|
||||
runtime.restart if current.running else runtime.start_background,
|
||||
options,
|
||||
)
|
||||
if not result.ok:
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
api_runtime_message(result.message),
|
||||
)
|
||||
except (WebUISettingsError, OptionalFeatureError) as exc:
|
||||
return SettingsRouteResult.failure(
|
||||
getattr(exc, "status", 400),
|
||||
getattr(exc, "message", str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to start managed API service")
|
||||
return SettingsRouteResult.failure(500, str(exc))
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(
|
||||
self.settings,
|
||||
operations.api_runtime(),
|
||||
last_action="started",
|
||||
)
|
||||
)
|
||||
|
||||
async def _stop_api(
|
||||
self,
|
||||
operations: CapabilitySettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
runtime = operations.api_runtime()
|
||||
try:
|
||||
result = await asyncio.to_thread(runtime.stop)
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to stop managed API service")
|
||||
return SettingsRouteResult.failure(500, str(exc))
|
||||
if not result.ok and result.message != "api_not_running":
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
api_runtime_message(result.message),
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
api_service_payload(
|
||||
self.settings,
|
||||
operations.api_runtime(),
|
||||
last_action="stopped",
|
||||
)
|
||||
)
|
||||
|
||||
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||
if request.local_browser:
|
||||
return True
|
||||
try:
|
||||
return bool(
|
||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load remote package install policy")
|
||||
return False
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Stable request and error contracts shared by WebUI settings domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsRequest:
|
||||
"""Transport-neutral input decoded by the settings route facade."""
|
||||
|
||||
query: QueryParams
|
||||
payload: dict[str, Any] | None = None
|
||||
local_browser: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsRouteResult:
|
||||
"""Transport-neutral result returned by a settings domain handler."""
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
status: int = 200
|
||||
error: str | None = None
|
||||
decorate_restart: bool = False
|
||||
restart_section: str | None = None
|
||||
clear_restart_section: str | None = None
|
||||
restart_payload_key: str | None = None
|
||||
|
||||
@classmethod
|
||||
def success(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
decorate_restart: bool = False,
|
||||
restart_section: str | None = None,
|
||||
clear_restart_section: str | None = None,
|
||||
restart_payload_key: str | None = None,
|
||||
) -> SettingsRouteResult:
|
||||
return cls(
|
||||
payload=payload,
|
||||
decorate_restart=decorate_restart,
|
||||
restart_section=restart_section,
|
||||
clear_restart_section=clear_restart_section,
|
||||
restart_payload_key=restart_payload_key,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, status: int, error: str) -> SettingsRouteResult:
|
||||
return cls(status=status, error=error)
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
"""User-facing settings validation failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
||||
value = query_first(query, snake)
|
||||
return query_first(query, camel) if value is None else value
|
||||
|
||||
|
||||
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
||||
return snake in query or camel in query
|
||||
|
||||
|
||||
def parse_bool(value: str, field: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||
raise WebUISettingsError(f"{field} must be boolean")
|
||||
return normalized in {"1", "true", "yes"}
|
||||
File diff suppressed because it is too large
Load Diff
+553
-1009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
"""Gateway-owned state for the WebUI settings surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||
|
||||
|
||||
class WebUISettingsConfig:
|
||||
"""Instance-scoped config access with serialized read-modify-write operations."""
|
||||
|
||||
def __init__(self, config_path: Path) -> None:
|
||||
self.path = config_path.expanduser().resolve(strict=False)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def load(self) -> Config:
|
||||
"""Load this gateway's config without consulting the process-global path."""
|
||||
with self._lock:
|
||||
return load_config(self.path)
|
||||
|
||||
def update(self, mutation: Callable[[Config], _T]) -> _T:
|
||||
"""Apply and atomically persist one in-process read-modify-write operation."""
|
||||
with self._lock:
|
||||
config = load_config(self.path)
|
||||
result = mutation(config)
|
||||
save_config(config, self.path)
|
||||
return result
|
||||
|
||||
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
|
||||
"""Run a path-aware read-modify-write operation under the instance lock."""
|
||||
with self._lock:
|
||||
return operation(self.path)
|
||||
|
||||
|
||||
class WebUIOAuthFlowRegistry:
|
||||
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
|
||||
|
||||
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
|
||||
if max_flows < 1:
|
||||
raise ValueError("max_flows must be at least one")
|
||||
self._max_flows = max_flows
|
||||
self._flows: dict[str, tuple[str, Any]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
|
||||
discarded: list[Any] = []
|
||||
with self._lock:
|
||||
for existing_id, (_provider_name, existing) in list(self._flows.items()):
|
||||
if existing.expired:
|
||||
discarded.append(self._flows.pop(existing_id)[1])
|
||||
while len(self._flows) >= self._max_flows:
|
||||
oldest_id = next(iter(self._flows))
|
||||
discarded.append(self._flows.pop(oldest_id)[1])
|
||||
self._flows[flow_id] = (provider_name, flow)
|
||||
for existing in discarded:
|
||||
existing.cancel()
|
||||
|
||||
def get(self, provider_name: str, flow_id: str) -> Any | None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if registered is None or registered[0] != provider_name:
|
||||
return None
|
||||
flow = registered[1]
|
||||
if not flow.expired:
|
||||
return flow
|
||||
self._flows.pop(flow_id, None)
|
||||
flow.cancel()
|
||||
return None
|
||||
|
||||
def remove(
|
||||
self,
|
||||
provider_name: str,
|
||||
flow_id: str,
|
||||
flow: Any,
|
||||
*,
|
||||
cancel: bool = True,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
registered = self._flows.get(flow_id)
|
||||
if (
|
||||
registered is not None
|
||||
and registered[0] == provider_name
|
||||
and registered[1] is flow
|
||||
):
|
||||
self._flows.pop(flow_id)
|
||||
if cancel:
|
||||
flow.cancel()
|
||||
|
||||
def clear(self, provider_name: str) -> None:
|
||||
with self._lock:
|
||||
flow_ids = [
|
||||
flow_id
|
||||
for flow_id, (registered_provider, _flow) in self._flows.items()
|
||||
if registered_provider == provider_name
|
||||
]
|
||||
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||
for flow in flows:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebUISettingsServices:
|
||||
"""Settings dependencies composed once for a gateway instance."""
|
||||
|
||||
config: WebUISettingsConfig
|
||||
oauth_flows: WebUIOAuthFlowRegistry
|
||||
|
||||
@classmethod
|
||||
def create(cls, config_path: Path) -> WebUISettingsServices:
|
||||
return cls(
|
||||
config=WebUISettingsConfig(config_path),
|
||||
oauth_flows=WebUIOAuthFlowRegistry(),
|
||||
)
|
||||
|
||||
def read(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Run a settings read against this gateway's explicit config path."""
|
||||
return operation(*args, config_path=self.config.path, **kwargs)
|
||||
|
||||
def mutate(
|
||||
self,
|
||||
operation: Callable[..., _T],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Serialize a path-aware settings read-modify-write operation."""
|
||||
return self.config.run_serialized(
|
||||
lambda config_path: operation(
|
||||
*args,
|
||||
config_path=config_path,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,957 @@
|
||||
"""System and channel settings domain logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
from nanobot.channels.connect import ChannelConnectError
|
||||
from nanobot.channels.contracts import (
|
||||
RouteFieldType,
|
||||
channel_instance_config,
|
||||
channel_update_instance_config,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
|
||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||
from nanobot.webui.settings_capabilities import network_safety_payload
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
SettingsRequest,
|
||||
SettingsRouteResult,
|
||||
WebUISettingsError,
|
||||
query_first,
|
||||
query_first_alias,
|
||||
)
|
||||
from nanobot.webui.token_usage import token_usage_payload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
LoadChannelPlugin = Callable[[str], Any]
|
||||
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
|
||||
SettingsOperation = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemSettingsOperations:
|
||||
cli_apps_payload: SettingsOperation
|
||||
cli_apps_action: SettingsOperation
|
||||
nanobot_features_payload: SettingsOperation
|
||||
nanobot_features_action: SettingsOperation
|
||||
nanobot_feature_instance_target: SettingsOperation
|
||||
validate_channel_config: SettingsOperation
|
||||
load_channel_plugin: LoadChannelPlugin
|
||||
list_pending: ListPendingPairings
|
||||
approve_code: SettingsOperation
|
||||
deny_code: SettingsOperation
|
||||
mcp_presets_action: SettingsOperation
|
||||
reload_mcp: SettingsOperation
|
||||
check_for_update: SettingsOperation
|
||||
channel_feature_action: SettingsOperation | None = None
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SystemSettingsPayload(TypedDict):
|
||||
runtime: dict[str, Any]
|
||||
usage: dict[str, Any]
|
||||
advanced: dict[str, Any]
|
||||
version: dict[str, Any]
|
||||
docs: dict[str, Any]
|
||||
|
||||
|
||||
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
|
||||
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
|
||||
_SKIP_FIELD = object()
|
||||
|
||||
|
||||
def docs_version(version: str) -> str:
|
||||
"""Map package versions to the matching public docs path."""
|
||||
normalized = version.strip()
|
||||
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
|
||||
return normalized
|
||||
return "latest"
|
||||
|
||||
|
||||
def docs_payload(version: str) -> dict[str, Any]:
|
||||
selected_version = docs_version(version)
|
||||
base_url = f"https://nanobot.wiki/docs/{selected_version}"
|
||||
return {
|
||||
"version": selected_version,
|
||||
"base_url": base_url,
|
||||
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
|
||||
"latest_url": _DOCS_LATEST_URL,
|
||||
}
|
||||
|
||||
|
||||
def system_settings_payload(
|
||||
config: Config,
|
||||
*,
|
||||
config_path: Path,
|
||||
version: str,
|
||||
) -> SystemSettingsPayload:
|
||||
defaults = config.agents.defaults
|
||||
exec_config = config.tools.exec
|
||||
sandbox_status = workspace_sandbox_status(
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
workspace=config.workspace_path,
|
||||
)
|
||||
return {
|
||||
"runtime": {
|
||||
"config_path": str(config_path.expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
"gateway_host": config.gateway.host,
|
||||
"gateway_port": config.gateway.port,
|
||||
"heartbeat": {
|
||||
"enabled": config.gateway.heartbeat.enabled,
|
||||
"interval_s": config.gateway.heartbeat.interval_s,
|
||||
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||
},
|
||||
"dream": {
|
||||
"schedule": defaults.dream.describe_schedule(),
|
||||
},
|
||||
"unified_session": defaults.unified_session,
|
||||
},
|
||||
"usage": token_usage_payload(timezone_name=defaults.timezone),
|
||||
"advanced": {
|
||||
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
||||
"workspace_sandbox": sandbox_status.as_dict(),
|
||||
**network_safety_payload(config),
|
||||
"mcp_server_count": len(config.tools.mcp_servers),
|
||||
"exec_enabled": exec_config.enable,
|
||||
"exec_sandbox": exec_config.sandbox or None,
|
||||
"exec_path_prepend_set": bool(exec_config.path_prepend),
|
||||
"exec_path_append_set": bool(exec_config.path_append),
|
||||
},
|
||||
"version": {"current": version},
|
||||
"docs": docs_payload(version),
|
||||
}
|
||||
|
||||
|
||||
def settings_usage_payload(config: Config) -> dict[str, Any]:
|
||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||
|
||||
|
||||
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
restart_required = False
|
||||
|
||||
timezone = query_first(query, "timezone")
|
||||
if timezone is not None:
|
||||
timezone = timezone.strip()
|
||||
if not timezone:
|
||||
raise WebUISettingsError("timezone is required")
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except Exception:
|
||||
raise WebUISettingsError("invalid timezone") from None
|
||||
timezone_changed = defaults.timezone != timezone
|
||||
if timezone_changed or defaults.timezone_mode != "manual":
|
||||
defaults.timezone = timezone
|
||||
defaults.timezone_mode = "manual"
|
||||
changed = True
|
||||
restart_required = timezone_changed
|
||||
|
||||
tool_hint_max_length = query_first_alias(
|
||||
query,
|
||||
"tool_hint_max_length",
|
||||
"toolHintMaxLength",
|
||||
)
|
||||
if tool_hint_max_length is not None:
|
||||
try:
|
||||
parsed = int(tool_hint_max_length)
|
||||
except ValueError:
|
||||
raise WebUISettingsError(
|
||||
"tool_hint_max_length must be an integer"
|
||||
) from None
|
||||
if parsed < 20 or parsed > 500:
|
||||
raise WebUISettingsError(
|
||||
"tool_hint_max_length must be between 20 and 500"
|
||||
)
|
||||
if defaults.tool_hint_max_length != parsed:
|
||||
defaults.tool_hint_max_length = parsed
|
||||
changed = True
|
||||
restart_required = True
|
||||
return changed, restart_required
|
||||
|
||||
|
||||
def save_channel_config_values(
|
||||
config: Config,
|
||||
name: str,
|
||||
raw_values: dict[str, Any],
|
||||
instance_id: str = "default",
|
||||
*,
|
||||
load_channel_plugin: LoadChannelPlugin,
|
||||
) -> list[str]:
|
||||
if not name:
|
||||
raise WebUISettingsError("missing channel name")
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
except ImportError:
|
||||
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
||||
setup_spec = channel_setup_spec(name, plugin=plugin)
|
||||
if setup_spec is None:
|
||||
raise WebUISettingsError(
|
||||
f"channel '{name}' cannot be configured from WebUI",
|
||||
status=404,
|
||||
)
|
||||
field_types = setup_spec.route_field_types
|
||||
if not raw_values:
|
||||
return []
|
||||
|
||||
section = getattr(config.channels, name, None)
|
||||
channel_config = channel_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
saved: list[str] = []
|
||||
prefix = f"channels.{name}."
|
||||
for raw_key, raw_value in raw_values.items():
|
||||
if not raw_key:
|
||||
raise WebUISettingsError(
|
||||
"channel settings payload contains an invalid key"
|
||||
)
|
||||
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
|
||||
value_type = field_types.get(field)
|
||||
if value_type is None:
|
||||
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
||||
value = coerce_channel_value(raw_key, raw_value, value_type)
|
||||
if value is _SKIP_FIELD:
|
||||
continue
|
||||
assign_channel_config_value(channel_config, field, value)
|
||||
saved.append(raw_key)
|
||||
|
||||
try:
|
||||
updated_section = channel_update_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
channel_config,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise WebUISettingsError(
|
||||
f"Invalid {name} configuration: {exc}",
|
||||
status=400,
|
||||
) from exc
|
||||
setattr(config.channels, name, updated_section)
|
||||
return saved
|
||||
|
||||
|
||||
def coerce_channel_value(
|
||||
raw_key: str,
|
||||
raw_value: Any,
|
||||
value_type: RouteFieldType,
|
||||
) -> Any:
|
||||
if isinstance(value_type, tuple):
|
||||
kind = value_type[0]
|
||||
allowed = value_type[1]
|
||||
else:
|
||||
kind = value_type
|
||||
allowed = None
|
||||
|
||||
if kind in {"string", "secret"}:
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||
if kind == "secret" and not value:
|
||||
return _SKIP_FIELD
|
||||
return value
|
||||
|
||||
if kind == "list":
|
||||
if raw_value is None:
|
||||
return []
|
||||
if isinstance(raw_value, str):
|
||||
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if isinstance(raw_value, list):
|
||||
return [
|
||||
str(item).strip()
|
||||
for item in cast(list[Any], raw_value)
|
||||
if str(item).strip()
|
||||
]
|
||||
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
|
||||
|
||||
if kind == "int":
|
||||
if raw_value in (None, ""):
|
||||
return _SKIP_FIELD
|
||||
try:
|
||||
return int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
|
||||
|
||||
if kind == "bool":
|
||||
if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
value = str(raw_value).strip().lower()
|
||||
if value in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if value in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
raise WebUISettingsError(f"'{raw_key}' must be true or false")
|
||||
|
||||
if kind == "enum":
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||
if not value:
|
||||
return _SKIP_FIELD
|
||||
if allowed is None or value not in allowed:
|
||||
options = ", ".join(sorted(allowed or ()))
|
||||
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
|
||||
return value
|
||||
|
||||
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
|
||||
|
||||
|
||||
def assign_channel_config_value(
|
||||
channel_config: dict[str, Any],
|
||||
field: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
target = channel_config
|
||||
parts = field.split(".")
|
||||
for part in parts[:-1]:
|
||||
current: object = target.get(part)
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
target[part] = current
|
||||
target = cast(dict[str, Any], current)
|
||||
target[parts[-1]] = value
|
||||
|
||||
|
||||
def pairing_payload(
|
||||
list_pending: ListPendingPairings,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current_time = time.time() if now is None else now
|
||||
requests: list[dict[str, Any]] = []
|
||||
for item in list_pending():
|
||||
expires_at = float(item.get("expires_at", 0) or 0)
|
||||
created_at = float(item.get("created_at", 0) or 0)
|
||||
requests.append(
|
||||
{
|
||||
"code": str(item.get("code", "")),
|
||||
"channel": str(item.get("channel", "")),
|
||||
"sender_id": str(item.get("sender_id", "")),
|
||||
"created_at_ms": int(created_at * 1000) if created_at else None,
|
||||
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
|
||||
"expires_in_seconds": (
|
||||
max(0, int(expires_at - current_time)) if expires_at else None
|
||||
),
|
||||
}
|
||||
)
|
||||
payload: dict[str, Any] = {"requests": requests}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
|
||||
|
||||
class SystemSettingsHandler:
|
||||
"""Handle channel and system commands behind a transport-neutral request DTO."""
|
||||
|
||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||
self.settings = settings
|
||||
self.logger = logger
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
*,
|
||||
channel_name: str | None = None,
|
||||
connect_action: str | None = None,
|
||||
) -> SettingsRouteResult:
|
||||
if action == "cli-list":
|
||||
return await self._cli_apps(request, operations)
|
||||
if action.startswith("cli-"):
|
||||
return await self._cli_apps_action(
|
||||
request,
|
||||
action.removeprefix("cli-"),
|
||||
operations,
|
||||
)
|
||||
if action == "features-list":
|
||||
return await self._features(operations)
|
||||
if action in {"features-enable", "features-disable"}:
|
||||
return await self._features_action(
|
||||
request,
|
||||
action.removeprefix("features-"),
|
||||
operations,
|
||||
)
|
||||
if action == "channel-validate":
|
||||
return await self._channel_validate(request, operations)
|
||||
if action == "channel-configure":
|
||||
return await self._channel_configure(request, operations)
|
||||
if action == "channel-connect" and channel_name and connect_action:
|
||||
return await self._channel_connect(
|
||||
request,
|
||||
channel_name,
|
||||
connect_action,
|
||||
operations,
|
||||
)
|
||||
if action == "pairing-list":
|
||||
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
|
||||
if action in {"pairing-approve", "pairing-deny"}:
|
||||
return self._pairing_action(
|
||||
request,
|
||||
action.removeprefix("pairing-"),
|
||||
operations,
|
||||
)
|
||||
if action == "mcp-list":
|
||||
return await self._mcp_presets(request, None, operations)
|
||||
if action.startswith("mcp-"):
|
||||
return await self._mcp_presets(
|
||||
request,
|
||||
action.removeprefix("mcp-"),
|
||||
operations,
|
||||
)
|
||||
if action == "version-check":
|
||||
return await self._version_check(operations)
|
||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||
|
||||
async def _cli_apps(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
payload = await operations.cli_apps_payload(
|
||||
installed_only=installed_only,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
async def _cli_apps_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.cli_apps_action,
|
||||
action,
|
||||
request.query,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
async def _features(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.nanobot_features_payload,
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load nanobot features")
|
||||
return SettingsRouteResult.failure(500, "failed to load nanobot features")
|
||||
return SettingsRouteResult.success(
|
||||
self._with_channel_runtime_status(payload, operations)
|
||||
)
|
||||
|
||||
def _nanobot_features_payload(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
return operations.nanobot_features_payload(config_path=self.settings.config.path)
|
||||
|
||||
def _nanobot_features_action(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
operations: SystemSettingsOperations,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return self.settings.mutate(
|
||||
operations.nanobot_features_action,
|
||||
action,
|
||||
query,
|
||||
allow_install=allow_install,
|
||||
)
|
||||
|
||||
async def _features_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
action,
|
||||
request.query,
|
||||
operations,
|
||||
allow_install=(
|
||||
action != "enable"
|
||||
or self.allow_feature_package_install(request)
|
||||
),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception(
|
||||
"nanobot feature action '{}' failed",
|
||||
action,
|
||||
)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
payload = await self._apply_feature_runtime_change(
|
||||
action,
|
||||
request.query,
|
||||
payload,
|
||||
operations,
|
||||
)
|
||||
payload = self._with_channel_runtime_status(payload, operations)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
)
|
||||
|
||||
def _with_channel_runtime_status(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
if operations.channel_runtime_status is None:
|
||||
return payload
|
||||
try:
|
||||
return with_channel_runtime_status(
|
||||
payload,
|
||||
operations.channel_runtime_status(),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load channel runtime status")
|
||||
return payload
|
||||
|
||||
async def _apply_feature_runtime_change(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
if operations.channel_feature_action is None:
|
||||
return payload
|
||||
name = (query_first(query, "name") or "").strip()
|
||||
if not name:
|
||||
return payload
|
||||
try:
|
||||
instance_id = operations.nanobot_feature_instance_target(query)
|
||||
result = operations.channel_feature_action(action, name, instance_id)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception as exc:
|
||||
self.logger.exception("failed to apply channel '{}' without restart", name)
|
||||
return self.feature_runtime_fallback(
|
||||
payload,
|
||||
message=(
|
||||
f"{name} channel config was saved, but hot reload failed: {exc}"
|
||||
),
|
||||
)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return payload
|
||||
result = cast(dict[str, Any], result)
|
||||
if not result.get("handled"):
|
||||
return payload
|
||||
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = bool(result.get("requires_restart"))
|
||||
message = result.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
last_action = dict(updated.get("last_action") or {})
|
||||
previous = last_action.get("message")
|
||||
last_action["message"] = (
|
||||
f"{previous}. {message}"
|
||||
if isinstance(previous, str) and previous
|
||||
else message
|
||||
)
|
||||
last_action["hot_reload"] = not updated["requires_restart"]
|
||||
if "ok" in result:
|
||||
last_action["ok"] = bool(result["ok"])
|
||||
updated["last_action"] = last_action
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def feature_runtime_fallback(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
message: str,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(payload)
|
||||
updated["requires_restart"] = True
|
||||
last_action = dict(updated.get("last_action") or {})
|
||||
previous = last_action.get("message")
|
||||
last_action["message"] = (
|
||||
f"{previous}. {message}"
|
||||
if isinstance(previous, str) and previous
|
||||
else message
|
||||
)
|
||||
last_action["hot_reload"] = False
|
||||
updated["last_action"] = last_action
|
||||
return updated
|
||||
|
||||
async def _channel_configure(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
name = (query_first(request.query, "name") or "").strip()
|
||||
instance_id = (
|
||||
query_first(request.query, "instance_id") or "default"
|
||||
).strip()
|
||||
enable = (query_first(request.query, "enable") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
saved = await asyncio.to_thread(
|
||||
self._save_channel_config_values,
|
||||
name,
|
||||
self.parse_channel_values(request),
|
||||
instance_id,
|
||||
operations,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception("failed to save channel '{}' settings", name)
|
||||
return SettingsRouteResult.failure(500, "failed to save channel settings")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"saved": True,
|
||||
"saved_keys": saved,
|
||||
}
|
||||
if not enable:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_payload,
|
||||
operations,
|
||||
)
|
||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
feature_query = {"name": [name]}
|
||||
if instance_id:
|
||||
feature_query["instance_id"] = [instance_id]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
feature_query,
|
||||
operations,
|
||||
allow_install=self.allow_feature_package_install(request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
return SettingsRouteResult.failure(
|
||||
exc.status,
|
||||
f"Settings saved, but {exc.message}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception(
|
||||
"failed to enable channel '{}' after settings save",
|
||||
name,
|
||||
)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
f"Settings saved, but enabling {name} failed: {exc}",
|
||||
)
|
||||
|
||||
features = await self._apply_feature_runtime_change(
|
||||
"enable",
|
||||
feature_query,
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
async def _channel_validate(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
name = (query_first(request.query, "name") or "").strip()
|
||||
instance_id = (
|
||||
query_first(request.query, "instance_id") or "default"
|
||||
).strip()
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
operations.validate_channel_config,
|
||||
name,
|
||||
self.parse_channel_values(request),
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except WebUISettingsError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception("failed to validate channel '{}' settings", name)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
"failed to validate channel settings",
|
||||
)
|
||||
return SettingsRouteResult.success(payload)
|
||||
|
||||
@staticmethod
|
||||
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
|
||||
if request.payload is None or "values" not in request.payload:
|
||||
return {}
|
||||
values = request.payload.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise WebUISettingsError(
|
||||
"channel settings payload must be a JSON object"
|
||||
)
|
||||
return cast(dict[str, Any], values)
|
||||
|
||||
def _save_channel_config_values(
|
||||
self,
|
||||
name: str,
|
||||
raw_values: dict[str, Any],
|
||||
instance_id: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> list[str]:
|
||||
return self.settings.config.update(
|
||||
lambda config: save_channel_config_values(
|
||||
config,
|
||||
name,
|
||||
raw_values,
|
||||
instance_id,
|
||||
load_channel_plugin=operations.load_channel_plugin,
|
||||
)
|
||||
)
|
||||
|
||||
async def _channel_connect(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
channel_name: str,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
connector = self._channel_connectors.get(channel_name)
|
||||
if connector is None:
|
||||
plugin = operations.load_channel_plugin(channel_name)
|
||||
connector = plugin.load_connector()
|
||||
self._channel_connectors[channel_name] = connector
|
||||
except ImportError:
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
f"channel '{channel_name}' does not support connect",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await connector.handle(action, request.query)
|
||||
except ChannelConnectError as exc:
|
||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"failed to run {} WebUI connect action for {}",
|
||||
action,
|
||||
channel_name,
|
||||
)
|
||||
return SettingsRouteResult.failure(
|
||||
500,
|
||||
f"failed to {action} {channel_name} connection",
|
||||
)
|
||||
|
||||
if payload.get("status") != "succeeded":
|
||||
return SettingsRouteResult.success(payload)
|
||||
payload = await self._with_channel_connect_success(
|
||||
request,
|
||||
channel_name,
|
||||
payload,
|
||||
operations,
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=True,
|
||||
restart_section="runtime",
|
||||
restart_payload_key="nanobot_features",
|
||||
)
|
||||
|
||||
async def _with_channel_connect_success(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
channel_name: str,
|
||||
payload: dict[str, Any],
|
||||
operations: SystemSettingsOperations,
|
||||
) -> dict[str, Any]:
|
||||
target = {"name": [channel_name]}
|
||||
if payload.get("instance_id"):
|
||||
target["instance_id"] = [str(payload["instance_id"])]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
target,
|
||||
operations,
|
||||
allow_install=self.allow_feature_package_install(request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
features = self.feature_runtime_fallback(
|
||||
self._nanobot_features_payload(operations),
|
||||
message=(
|
||||
f"{channel_name} connected, but enabling channel support failed: "
|
||||
f"{exc.message}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
features = await self._apply_feature_runtime_change(
|
||||
"enable",
|
||||
target,
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
updated = dict(payload)
|
||||
updated["nanobot_features"] = self._with_channel_runtime_status(
|
||||
features,
|
||||
operations,
|
||||
)
|
||||
return updated
|
||||
|
||||
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||
if request.local_browser:
|
||||
return True
|
||||
try:
|
||||
return bool(
|
||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load remote package install policy")
|
||||
return False
|
||||
|
||||
def _pairing_action(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
code = (query_first(request.query, "code") or "").strip()
|
||||
if not code:
|
||||
return SettingsRouteResult.failure(400, "Missing pairing code")
|
||||
if action == "approve":
|
||||
result = operations.approve_code(code)
|
||||
if result is None:
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
"Pairing code not found or expired",
|
||||
)
|
||||
channel, sender_id = result
|
||||
return SettingsRouteResult.success(
|
||||
pairing_payload(
|
||||
operations.list_pending,
|
||||
{
|
||||
"ok": True,
|
||||
"action": "approve",
|
||||
"message": f"Approved {sender_id} for {channel}",
|
||||
"channel": channel,
|
||||
"sender_id": sender_id,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if not operations.deny_code(code):
|
||||
return SettingsRouteResult.failure(
|
||||
404,
|
||||
"Pairing code not found or expired",
|
||||
)
|
||||
return SettingsRouteResult.success(
|
||||
pairing_payload(
|
||||
operations.list_pending,
|
||||
{
|
||||
"ok": True,
|
||||
"action": "deny",
|
||||
"message": f"Denied pairing code {code}",
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def _mcp_presets(
|
||||
self,
|
||||
request: SettingsRequest,
|
||||
action: str | None,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
payload = await operations.mcp_presets_action(
|
||||
action,
|
||||
request.query,
|
||||
reload_mcp=operations.reload_mcp,
|
||||
config=self.settings.config,
|
||||
)
|
||||
except Exception as exc:
|
||||
status = getattr(exc, "status", 500)
|
||||
message = getattr(exc, "message", str(exc))
|
||||
if status >= 500:
|
||||
self.logger.exception(
|
||||
"MCP preset action '{}' failed",
|
||||
action or "list",
|
||||
)
|
||||
return SettingsRouteResult.failure(status, message)
|
||||
return SettingsRouteResult.success(
|
||||
payload,
|
||||
decorate_restart=action is not None,
|
||||
restart_section="runtime" if action is not None else None,
|
||||
)
|
||||
|
||||
async def _version_check(
|
||||
self,
|
||||
operations: SystemSettingsOperations,
|
||||
) -> SettingsRouteResult:
|
||||
try:
|
||||
update_info = await asyncio.to_thread(operations.check_for_update)
|
||||
except Exception:
|
||||
self.logger.exception("version check failed")
|
||||
return SettingsRouteResult.failure(500, "version check failed")
|
||||
return SettingsRouteResult.success({"updateAvailable": update_info})
|
||||
+188
-25
@@ -17,9 +17,10 @@ import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||
|
||||
from loguru import logger
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
@@ -118,7 +119,64 @@ from nanobot.webui.transcript import build_webui_thread_response
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
|
||||
_WEBUI_MUTATION_PATHS = {
|
||||
"automation.enable": "/api/webui/automations/enable",
|
||||
"automation.disable": "/api/webui/automations/disable",
|
||||
"automation.delete": "/api/webui/automations/delete",
|
||||
"automation.run": "/api/webui/automations/run",
|
||||
"automation.update": "/api/webui/automations/update",
|
||||
"skill.install": "/api/webui/skills/install",
|
||||
"skill.update": "/api/webui/skills/update",
|
||||
"skill.delete": "/api/webui/skills/delete",
|
||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||
"settings.agent.update": "/api/settings/update",
|
||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
||||
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
||||
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
||||
"settings.provider.update": "/api/settings/provider/update",
|
||||
"settings.provider.create": "/api/settings/provider/create",
|
||||
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
||||
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
||||
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
||||
"settings.web_search.update": "/api/settings/web-search/update",
|
||||
"settings.api_service.start": "/api/settings/api-service/start",
|
||||
"settings.api_service.stop": "/api/settings/api-service/stop",
|
||||
"settings.image_generation.update": "/api/settings/image-generation/update",
|
||||
"settings.transcription.update": "/api/settings/transcription/update",
|
||||
"settings.network_safety.update": "/api/settings/network-safety/update",
|
||||
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
||||
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
||||
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
||||
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
||||
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
||||
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
||||
"settings.channel.validate": "/api/settings/channels/validate",
|
||||
"settings.channel.configure": "/api/settings/channels/configure",
|
||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
|
||||
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
|
||||
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
|
||||
}
|
||||
|
||||
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||
"settings.channel.connect.start": "start",
|
||||
"settings.channel.connect.poll": "poll",
|
||||
"settings.channel.connect.cancel": "cancel",
|
||||
}
|
||||
|
||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||
@@ -150,6 +208,7 @@ if TYPE_CHECKING:
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
def _decode_api_key(raw_key: str) -> str | None:
|
||||
key = unquote(raw_key)
|
||||
@@ -159,6 +218,33 @@ def _decode_api_key(raw_key: str) -> str | None:
|
||||
return key
|
||||
|
||||
|
||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], payload)
|
||||
|
||||
|
||||
def _request_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None:
|
||||
return _parse_query(request.path)
|
||||
query: dict[str, list[str]] = {}
|
||||
for key, value in payload.items():
|
||||
if not key:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
text = "true" if value else "false"
|
||||
elif value is None:
|
||||
text = ""
|
||||
elif isinstance(value, (dict, list)):
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
else:
|
||||
text = str(value)
|
||||
query[key] = [text]
|
||||
return query
|
||||
|
||||
|
||||
def _default_model_name_from_config() -> str | None:
|
||||
try:
|
||||
from nanobot.config.loader import load_config
|
||||
@@ -211,6 +297,7 @@ class GatewayHTTPHandler:
|
||||
media: WebUIMediaGateway,
|
||||
ingress: WebUIIngressPolicy,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
settings: WebUISettingsServices,
|
||||
skills_workspace_path: Path,
|
||||
disabled_skills: set[str] | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -231,6 +318,7 @@ class GatewayHTTPHandler:
|
||||
self.media = media
|
||||
self.ingress = ingress
|
||||
self.workspaces = workspaces
|
||||
self.settings = settings
|
||||
self.skills_workspace_path = skills_workspace_path
|
||||
self.disabled_skills: set[str] = (
|
||||
disabled_skills if disabled_skills is not None else set()
|
||||
@@ -249,6 +337,7 @@ class GatewayHTTPHandler:
|
||||
|
||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||
self.settings_routes = WebUISettingsRouter(
|
||||
settings=settings,
|
||||
bus=bus,
|
||||
logger=self._log,
|
||||
check_api_token=self.check_api_token,
|
||||
@@ -259,6 +348,7 @@ class GatewayHTTPHandler:
|
||||
runtime_capabilities=self._capabilities,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
def workspace_controls_available(self, connection: Any) -> bool:
|
||||
@@ -285,11 +375,86 @@ class GatewayHTTPHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if self._is_webui_mutation_path(got):
|
||||
return _http_error(
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
response = await self._dispatch_resolved(connection, request, got)
|
||||
return response
|
||||
finally:
|
||||
self._log_slow_http(got, response, started)
|
||||
|
||||
async def dispatch_webui_mutation(
|
||||
self,
|
||||
connection: Any,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
|
||||
path = self._webui_mutation_path(action, payload)
|
||||
if isinstance(path, Response):
|
||||
return path
|
||||
|
||||
source_request = getattr(connection, "request", None)
|
||||
source_headers = getattr(source_request, "headers", None)
|
||||
if source_headers is None:
|
||||
headers = Headers()
|
||||
else:
|
||||
try:
|
||||
headers = Headers(source_headers.raw_items())
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
headers = Headers(source_headers)
|
||||
except TypeError:
|
||||
headers = Headers()
|
||||
request = WsRequest(path, headers)
|
||||
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
|
||||
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
|
||||
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
|
||||
response = await self._dispatch_resolved(connection, request, path)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
return _http_error(404, "WebUI mutation action not found")
|
||||
|
||||
def _is_webui_mutation_path(self, path: str) -> bool:
|
||||
if self.settings_routes.is_mutation_path(path):
|
||||
return True
|
||||
if re.match(r"^/api/sessions/[^/]+/delete$", path):
|
||||
return True
|
||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||
return True
|
||||
return path in {
|
||||
"/api/webui/skills/install",
|
||||
"/api/webui/skills/update",
|
||||
"/api/webui/skills/delete",
|
||||
"/api/webui/sidebar-state/update",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _webui_mutation_path(
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
) -> str | Response:
|
||||
path = _WEBUI_MUTATION_PATHS.get(action)
|
||||
if path is not None:
|
||||
return path
|
||||
if action == "session.delete":
|
||||
key = payload.get("key")
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
return _http_error(400, "missing session key")
|
||||
return f"/api/sessions/{quote(key, safe='')}/delete"
|
||||
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
|
||||
if connect_action is not None:
|
||||
channel = payload.get("channel")
|
||||
if not isinstance(channel, str) or re.fullmatch(
|
||||
r"[A-Za-z0-9_-]{1,64}",
|
||||
channel,
|
||||
) is None:
|
||||
return _http_error(400, "invalid channel name")
|
||||
return f"/api/settings/channels/{channel}/connect/{connect_action}"
|
||||
return _http_error(404, "unknown WebUI mutation action")
|
||||
|
||||
async def _dispatch_resolved(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -457,6 +622,14 @@ class GatewayHTTPHandler:
|
||||
expected_path = _normalize_config_path(self.config.path)
|
||||
return f"{scheme}://{host}{expected_path}"
|
||||
|
||||
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
|
||||
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||
|
||||
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
|
||||
scheme = "https" if public_ws_url.scheme == "wss" else "http"
|
||||
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
|
||||
|
||||
# -- Session routes -----------------------------------------------------
|
||||
|
||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
@@ -646,7 +819,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(400, "invalid session key")
|
||||
if not _is_websocket_channel_session_key(decoded_key):
|
||||
return _http_error(404, "session not found")
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||
automation_jobs = session_automation_jobs(
|
||||
self.cron_service,
|
||||
@@ -742,7 +915,7 @@ class GatewayHTTPHandler:
|
||||
if self.cron_service is None and self.local_trigger_store is None:
|
||||
return _http_error(503, "automation service unavailable")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||
if not job_id:
|
||||
return _http_error(400, "missing automation id")
|
||||
@@ -974,7 +1147,7 @@ class GatewayHTTPHandler:
|
||||
if self._skill_install_lock.locked():
|
||||
return _http_error(409, "another skill installation is already in progress")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
provider = _query_first(query, "provider") or "skills_sh"
|
||||
source = _query_first(query, "source") or ""
|
||||
skill_id = _query_first(query, "skill") or ""
|
||||
@@ -1015,7 +1188,7 @@ class GatewayHTTPHandler:
|
||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
query = _request_query(request)
|
||||
name = _query_first(query, "name") or ""
|
||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||
if raw_enabled not in {"true", "false"}:
|
||||
@@ -1047,7 +1220,7 @@ class GatewayHTTPHandler:
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not _is_local_browser_request(connection, request.headers):
|
||||
return _http_error(403, "remote skill deletion is disabled")
|
||||
name = _query_first(_parse_query(request.path), "name") or ""
|
||||
name = _query_first(_request_query(request), "name") or ""
|
||||
try:
|
||||
action = delete_webui_skill(
|
||||
self.skills_workspace_path,
|
||||
@@ -1094,18 +1267,14 @@ class GatewayHTTPHandler:
|
||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
raw_state = _query_first(query, "state")
|
||||
if raw_state is None:
|
||||
payload = _mutation_payload(request)
|
||||
state_value = payload.get("state") if payload is not None else None
|
||||
if state_value is None:
|
||||
return _http_error(400, "missing state")
|
||||
try:
|
||||
decoded = json.loads(raw_state)
|
||||
except json.JSONDecodeError:
|
||||
return _http_error(400, "state must be JSON")
|
||||
if not isinstance(decoded, dict):
|
||||
if not isinstance(state_value, dict):
|
||||
return _http_error(400, "state must be an object")
|
||||
try:
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
||||
except ValueError as e:
|
||||
return _http_error(400, str(e))
|
||||
except OSError:
|
||||
@@ -1174,16 +1343,10 @@ class GatewayHTTPHandler:
|
||||
|
||||
|
||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||
if not raw:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None or "values" not in payload:
|
||||
return {}
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
values = json.loads(unquote(raw))
|
||||
except Exception:
|
||||
return None
|
||||
values = payload.get("values")
|
||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ dependencies = [
|
||||
"readability-lxml>=0.8.4,<1.0.0",
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
"rich>=14.0.0,<15.0.0",
|
||||
"qrcode[pil]>=8.0",
|
||||
"croniter>=6.0.0,<7.0.0",
|
||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||
"questionary>=2.0.0,<3.0.0",
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.plugins import (
|
||||
AGENT_PLUGIN_MCP_SCHEMA,
|
||||
AGENT_PLUGIN_SCHEMA,
|
||||
agent_plugin_mcp_servers,
|
||||
discover_agent_plugins,
|
||||
enabled_agent_plugin_skill_dirs,
|
||||
enabled_agent_plugin_skills,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.security.workspace_access import (
|
||||
bind_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
validate_workspace_scope_payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins, "get_config_path", lambda: tmp_path / "config" / "config.json"
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def _manifest(name: str, **fields: object) -> dict[str, object]:
|
||||
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
|
||||
|
||||
|
||||
def _plugin(workspace: Path, name: str = "demo", **fields: object) -> Path:
|
||||
root = workspace / "plugins" / name
|
||||
_write_json(root / "plugin.json", _manifest(name, **fields))
|
||||
return root
|
||||
|
||||
|
||||
def _skill(root: Path, name: str, frontmatter: str | None = None, body: str = "") -> Path:
|
||||
path = root / name
|
||||
path.mkdir(parents=True)
|
||||
metadata = frontmatter or f"name: {name}\ndescription: Plugin skill."
|
||||
(path / "SKILL.md").write_text(f"---\n{metadata}\n---\n\n{body}\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _loaded_skills(workspace: Path) -> list[str]:
|
||||
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||
|
||||
|
||||
def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
_skill(
|
||||
plugin / "skills",
|
||||
"shared",
|
||||
"name: shared\ndescription: Plugin version.\nalways: true",
|
||||
"Plugin body.",
|
||||
)
|
||||
_skill(tmp_path / "builtin", "shared", body="Built-in body.")
|
||||
workspace_skill = _skill(
|
||||
tmp_path / "skills", "shared", "name: shared\ndescription: Workspace version."
|
||||
)
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
|
||||
shutil.rmtree(workspace_skill)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
|
||||
assert loader.get_explicitly_invoked_skills("Use $shared") == ["shared"]
|
||||
assert loader.get_always_skills() == ["shared"]
|
||||
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||
assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary()
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||
assert "Built-in body" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_plugin_skills_are_direct_valid_and_contained(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
skills = plugin / "skills"
|
||||
_skill(skills, "direct")
|
||||
_skill(skills / "group", "nested")
|
||||
for name, frontmatter in (
|
||||
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||
("missing-description", "name: missing-description"),
|
||||
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||
):
|
||||
_skill(skills, name, frontmatter)
|
||||
outside = _skill(tmp_path / "outside", "escaped")
|
||||
try:
|
||||
(skills / "escaped").symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert _loaded_skills(tmp_path) == ["direct"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("manifest", "valid"),
|
||||
[
|
||||
({"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"}, False),
|
||||
(_manifest("Bad-Name"), False),
|
||||
(_manifest("demo", futureField=True, extensions="invalid but non-fatal"), True),
|
||||
],
|
||||
)
|
||||
def test_plugin_manifest_boundary(tmp_path: Path, manifest: object, valid: bool) -> None:
|
||||
_write_json(tmp_path / "plugins" / "candidate" / "plugin.json", manifest)
|
||||
assert bool(discover_agent_plugins(tmp_path)) is valid
|
||||
|
||||
|
||||
def test_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None:
|
||||
extension = {"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}}
|
||||
plugin = _plugin(tmp_path, "demo", **extension)
|
||||
icon = plugin / "assets" / "icon.png"
|
||||
icon.parent.mkdir()
|
||||
icon.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
escaped = _plugin(tmp_path, "escaped", **extension)
|
||||
(escaped / "assets").mkdir()
|
||||
try:
|
||||
(escaped / "assets" / "icon.png").symlink_to(icon)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert {plugin.name: plugin.logo for plugin in discover_agent_plugins(tmp_path)} == {
|
||||
"demo": "data:image/png;base64,iVBORw0KGgpsb2dv",
|
||||
"escaped": None,
|
||||
}
|
||||
|
||||
|
||||
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"desktop": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/server",
|
||||
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||
"cwd": "${PLUGIN_ROOT}",
|
||||
},
|
||||
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||
"escape": {"type": "stdio", "command": "../outside"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
server = agent_plugin_mcp_servers(tmp_path)["desktop"]
|
||||
assert (server.command, server.cwd, server.env["PLUGIN_ROOT"]) == (
|
||||
str(executable),
|
||||
str(plugin),
|
||||
str(plugin),
|
||||
)
|
||||
assert server.args[1].endswith("/state")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_mcp_namespaces_cannot_shadow_plugin_identities(tmp_path: Path) -> None:
|
||||
single = _plugin(tmp_path, "foo-bar")
|
||||
multi = _plugin(tmp_path, "foo")
|
||||
for root, servers in (
|
||||
(single, {"main": {"type": "stdio", "command": "echo", "args": ["single"]}}),
|
||||
(
|
||||
multi,
|
||||
{
|
||||
"bar": {"type": "stdio", "command": "echo", "args": ["multi"]},
|
||||
"other": {"type": "stdio", "command": "echo"},
|
||||
},
|
||||
),
|
||||
):
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "foo-bar", True)
|
||||
set_agent_plugin_enabled(tmp_path, "foo", True)
|
||||
|
||||
servers = agent_plugin_mcp_servers(tmp_path)
|
||||
|
||||
assert set(servers) == {"foo-bar", "foo--bar", "foo--other"}
|
||||
assert servers["foo-bar"].args == ["single"]
|
||||
assert servers["foo--bar"].args == ["multi"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_project_can_read_only_enabled_plugin_skill(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
plugin = _plugin(agent_workspace)
|
||||
skill = _skill(plugin / "skills", "demo-skill")
|
||||
resource = skill / "reference.md"
|
||||
resource.write_text("plugin reference", encoding="utf-8")
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(restrict_to_workspace=True),
|
||||
workspace=str(agent_workspace),
|
||||
)
|
||||
read_tool = ReadFileTool.create(ctx)
|
||||
write_tool = WriteFileTool.create(ctx)
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", True)
|
||||
activation_checks = 0
|
||||
activation_marker = agent_plugins._activation_marker
|
||||
|
||||
def count_activation_checks(plugin: agent_plugins.AgentPlugin) -> str | None:
|
||||
nonlocal activation_checks
|
||||
activation_checks += 1
|
||||
return activation_marker(plugin)
|
||||
|
||||
monkeypatch.setattr(agent_plugins, "_activation_marker", count_activation_checks)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=agent_workspace,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
read_result = await read_tool.execute(path=str(resource))
|
||||
repeated_read_result = await read_tool.execute(path=str(resource))
|
||||
write_result = await write_tool.execute(path=str(resource), content="changed")
|
||||
set_agent_plugin_enabled(agent_workspace, "demo", False)
|
||||
disabled_result = await read_tool.execute(path=str(resource))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "plugin reference" in read_result
|
||||
assert "File unchanged since last read" in repeated_read_result
|
||||
assert activation_checks == 1
|
||||
assert "outside allowed directory" in write_result
|
||||
assert "outside allowed directory" in disabled_result
|
||||
assert resource.read_text(encoding="utf-8") == "plugin reference"
|
||||
|
||||
|
||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = tmp_path / "config"
|
||||
config.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
try:
|
||||
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: config / "config.json")
|
||||
_plugin(tmp_path, "desktop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="escapes its parent"):
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
|
||||
def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path) -> None:
|
||||
roots = [tmp_path / "plugins" / directory for directory in ("first", "second")]
|
||||
for root, marker in zip(roots, ("trusted", "replacement"), strict=True):
|
||||
_write_json(root / "plugin.json", _manifest("duplicate"))
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert discover_agent_plugins(tmp_path) == []
|
||||
with pytest.raises(ValueError, match="unknown Agent Plugin"):
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
|
||||
shutil.rmtree(roots[1])
|
||||
set_agent_plugin_enabled(tmp_path, "duplicate", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
|
||||
moved = tmp_path / "plugins" / "moved"
|
||||
roots[0].rename(moved)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_legacy_path_activation_is_upgraded_to_package_fingerprint(tmp_path: Path) -> None:
|
||||
plugin = _plugin(tmp_path)
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
marker = next((tmp_path / "config" / "plugin-data").glob("*/demo/enabled"))
|
||||
marker.write_text(str(plugin), encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert marker.read_text(encoding="utf-8").startswith('{"fingerprint":')
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_contract_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
mcp = plugin / "mcp.json"
|
||||
|
||||
def write_server(marker: str) -> None:
|
||||
_write_json(
|
||||
mcp,
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
write_server("trusted")
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert agent_plugin_mcp_servers(tmp_path)["desktop"].args == ["trusted"]
|
||||
|
||||
write_server("replacement")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_activation_does_not_survive_in_place_code_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin = _plugin(tmp_path, "desktop")
|
||||
_skill(plugin / "skills", "demo")
|
||||
executable = plugin / "server.py"
|
||||
executable.write_text("print('trusted')\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"server": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["${PLUGIN_ROOT}/server.py"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == (plugin / "skills" / "demo",)
|
||||
|
||||
executable.write_text("print('replacement')\n", encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||
assert enabled_agent_plugin_skill_dirs(tmp_path) == ()
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
@@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
notion = MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.notion.test/mcp",
|
||||
)
|
||||
linear = MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.linear.test/mcp",
|
||||
)
|
||||
config.tools.mcp_servers.update({"notion": notion, "linear": linear})
|
||||
save_config(config)
|
||||
|
||||
attempted: list[str] = []
|
||||
|
||||
async def _fake_connect(servers, _registry):
|
||||
attempted.extend(servers)
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
return {"linear": stack}
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
||||
lambda name, _url: name == "linear",
|
||||
)
|
||||
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
|
||||
|
||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert attempted == ["linear"]
|
||||
assert result["ok"] is True
|
||||
assert result["failed"] == []
|
||||
assert result["retried"] == []
|
||||
assert result["connected"] == ["linear"]
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_reconnects_after_session_terminated(
|
||||
tmp_path,
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
@@ -34,6 +35,13 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.model_preset is None
|
||||
@@ -240,7 +248,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
output = tool._inspect_all()
|
||||
assert "model_preset: 'fast'" in output
|
||||
|
||||
@@ -250,7 +258,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model_preset", "fast")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset == "fast"
|
||||
@@ -263,7 +271,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "default")
|
||||
|
||||
@@ -280,7 +288,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "missing")
|
||||
|
||||
@@ -295,7 +303,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
||||
loop.set_session_model_preset = MagicMock(
|
||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||
)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
||||
value: object,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
session = loop.sessions.get_or_create("cli:one")
|
||||
|
||||
with request_context(RequestContext(
|
||||
@@ -366,7 +374,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset is None
|
||||
|
||||
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "beta"
|
||||
assert entries[0]["path"] == str(beta_path)
|
||||
assert loader.load_skill("alpha") is None
|
||||
|
||||
|
||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Contract and security regressions for the MyTool runtime boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
AgentRuntimeControl,
|
||||
RuntimeControl,
|
||||
)
|
||||
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
tools_config=tools_config,
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
tool = loop.tools.get("my")
|
||||
assert isinstance(tool, MyTool)
|
||||
return tool
|
||||
|
||||
|
||||
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert isinstance(tool._runtime_control, RuntimeControl)
|
||||
assert isinstance(tool._runtime_control, AgentRuntimeControl)
|
||||
assert tool._runtime_control is not loop
|
||||
assert not hasattr(tool, "_runtime_state")
|
||||
|
||||
|
||||
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.web_config.search.api_key = "search-secret"
|
||||
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
|
||||
loop.unlisted_secret = "loop-secret"
|
||||
|
||||
snapshot = _my_tool(loop)._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
|
||||
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
|
||||
assert RUNTIME_COMMAND_KEYS == frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
assert "provider" not in values
|
||||
assert "sessions" not in values
|
||||
assert "restrict_to_workspace" not in values
|
||||
assert "unlisted_secret" not in values
|
||||
rendered = repr(values)
|
||||
assert "search-secret" not in rendered
|
||||
assert "proxy-secret" not in rendered
|
||||
assert "loop-secret" not in rendered
|
||||
assert snapshot.web_config["proxy"] == "<configured>"
|
||||
|
||||
|
||||
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
control = _my_tool(loop)._runtime_control
|
||||
snapshot = control.snapshot()
|
||||
search = snapshot.web_config["search"]
|
||||
assert isinstance(search, dict)
|
||||
|
||||
search["provider"] = "mutated"
|
||||
snapshot.exec_config["allow_patterns"] = ["mutated"]
|
||||
snapshot.tool_names.append("mutated")
|
||||
|
||||
refreshed = control.snapshot()
|
||||
refreshed_search = refreshed.web_config["search"]
|
||||
assert isinstance(refreshed_search, dict)
|
||||
assert refreshed_search["provider"] == loop.web_config.search.provider
|
||||
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
|
||||
assert "mutated" not in refreshed.tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
loop.unlisted_control_plane = "internal-secret"
|
||||
original_workspace_root = loop.workspace_scopes.default_workspace
|
||||
tool = _my_tool(loop)
|
||||
|
||||
inspected = await tool.execute(action="check", key="unlisted_control_plane")
|
||||
modified = await tool.execute(
|
||||
action="set",
|
||||
key="unlisted_control_plane",
|
||||
value="scratch-value",
|
||||
)
|
||||
nested = await tool.execute(
|
||||
action="set",
|
||||
key="workspace_scopes.default_workspace",
|
||||
value="elsewhere",
|
||||
)
|
||||
|
||||
assert "internal-secret" not in inspected
|
||||
assert "not found" in inspected
|
||||
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
|
||||
assert loop.unlisted_control_plane == "internal-secret"
|
||||
assert "Error" in nested
|
||||
assert loop.workspace_scopes.default_workspace == original_workspace_root
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert ToolsConfig().my.allow_set is False
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["check", "set"],
|
||||
"description": "Action to perform",
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
|
||||
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
|
||||
"'request.sender_id' for current routing metadata. Use 'model_preset' "
|
||||
"to switch named model presets. For check without key, shows all "
|
||||
"config values."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"description": (
|
||||
"New value (for set). Type must match target (int for "
|
||||
"max_iterations/context_window_tokens, str for model/model_preset)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
}
|
||||
assert "READ-ONLY MODE" in tool.description
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert result == "Error: set is disabled (tools.my.allow_set is false)"
|
||||
assert loop.max_iterations != 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
max_iterations = await tool.execute(
|
||||
action="set",
|
||||
key="max_iterations",
|
||||
value=80,
|
||||
)
|
||||
retry_mode = await tool.execute(
|
||||
action="set",
|
||||
key="provider_retry_mode",
|
||||
value="persistent",
|
||||
)
|
||||
scratchpad = await tool.execute(
|
||||
action="set",
|
||||
key="preference",
|
||||
value={"concise": True},
|
||||
)
|
||||
|
||||
assert max_iterations == "Set max_iterations = 80 (was 200)"
|
||||
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
|
||||
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
|
||||
assert loop.max_iterations == 80
|
||||
assert loop.subagents.max_iterations == 80
|
||||
assert loop.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().scratchpad == {
|
||||
"preference": {"concise": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
|
||||
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
|
||||
changed = await loop.tools.execute(
|
||||
"my",
|
||||
{"action": "set", "key": "max_iterations", "value": 80},
|
||||
)
|
||||
|
||||
assert checked == "model: 'test-model'"
|
||||
assert changed == "Set max_iterations = 80 (was 200)"
|
||||
assert loop.max_iterations == 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = await tool.execute(action="set", key="workspace", value="elsewhere")
|
||||
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "elsewhere"
|
||||
assert loop.workspace == tmp_path
|
||||
assert loop.workspace_scopes.default_workspace == tmp_path
|
||||
@@ -8,10 +8,12 @@ from types import MappingProxyType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
|
||||
loop.workspace = Path("/tmp/workspace")
|
||||
loop.restrict_to_workspace = False
|
||||
loop._start_time = 1000.0
|
||||
loop.exec_config = MagicMock()
|
||||
loop.exec_config = ExecToolConfig()
|
||||
loop.channels_config = MagicMock()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
loop._runtime_vars = {}
|
||||
loop.last_usage = loop._last_usage
|
||||
loop._current_iteration = 0
|
||||
loop.current_iteration = loop._current_iteration
|
||||
loop.provider_retry_mode = "standard"
|
||||
loop.max_tool_result_chars = 16000
|
||||
loop.model_preset = None
|
||||
loop.model_presets = {}
|
||||
loop._concurrency_gate = None
|
||||
loop._unified_session = False
|
||||
loop._extra_hooks = []
|
||||
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
|
||||
)
|
||||
|
||||
# web_config mock — needed for check tests
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
loop.web_config.search = MagicMock()
|
||||
loop.web_config = WebToolsConfig()
|
||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||
|
||||
# Tools registry mock
|
||||
@@ -55,10 +58,13 @@ def _make_mock_loop(**overrides):
|
||||
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
||||
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||
loop.tools.get.return_value = None
|
||||
loop.tool_names = loop.tools.tool_names
|
||||
|
||||
# SubagentManager mock
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
||||
loop.subagents._task_statuses = {}
|
||||
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
|
||||
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||
|
||||
for k, v in overrides.items():
|
||||
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
|
||||
return loop
|
||||
|
||||
|
||||
def _make_tool(runtime_state=None):
|
||||
if runtime_state is None:
|
||||
runtime_state = _make_mock_loop()
|
||||
return MyTool(runtime_state=runtime_state)
|
||||
def _make_tool(loop=None):
|
||||
if loop is None:
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,10 +93,10 @@ class TestInspectSummary:
|
||||
assert "context_window_tokens: 65536" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_includes_runtime_vars(self):
|
||||
async def test_inspect_includes_scratchpad(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {"task": "review"}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
||||
result = await tool.execute(action="check")
|
||||
assert "task" in result
|
||||
|
||||
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_config_subfield(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="web_config.enable")
|
||||
assert "True" in result
|
||||
|
||||
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
|
||||
async def test_inspect_dict_key_via_dotpath(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||
assert "100" in result
|
||||
|
||||
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
||||
class SearchConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
api_key: str = "sk-test-secret"
|
||||
base_url: str = ""
|
||||
max_results: int = 5
|
||||
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.search = SearchConfig()
|
||||
loop.web_config.search = WebSearchConfig(
|
||||
provider="tavily",
|
||||
api_key="sk-test-secret",
|
||||
)
|
||||
tool = _make_tool(loop)
|
||||
|
||||
result = await tool.execute(action="check", key="web_config.search")
|
||||
|
||||
assert "provider='tavily'" in result
|
||||
assert "tavily" in result
|
||||
assert "sk-test-secret" not in result
|
||||
assert "api_key" not in result.lower()
|
||||
|
||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert "Set max_iterations = 80" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_out_of_range(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_iterations == 40
|
||||
assert tool._runtime_control.snapshot().max_iterations == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_max_exceeded(self):
|
||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||
assert "Set max_iterations" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
loop = _make_mock_loop()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert "Set context_window_tokens" in result
|
||||
assert loop.context_window_tokens == 131072
|
||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||
assert "Set provider_retry_mode" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
||||
async def test_modify_new_key_stores_in_scratchpad(self):
|
||||
"""Modifying an unknown key should store it in the scratchpad."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||
assert "my_custom_var" in result
|
||||
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
||||
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_rejects_callable(self):
|
||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||
assert result == "Set scratchpad.data = {'a': 1}"
|
||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_whitespace_key_rejected(self):
|
||||
@@ -396,7 +396,7 @@ class TestModifyFree:
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||
assert "Error" in result
|
||||
assert "str" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "standard"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -486,11 +486,12 @@ class TestModifyOpen:
|
||||
assert "protected" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_workspace_allowed(self):
|
||||
"""workspace was READONLY in v1, now freely modifiable."""
|
||||
async def test_modify_workspace_preserves_display_compatibility(self):
|
||||
"""The compatibility value is isolated from filesystem security boundaries."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_mcp_servers_blocked(self):
|
||||
@@ -584,28 +585,28 @@ class TestUnknownAction:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime_vars limits (from code review)
|
||||
# scratchpad limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsLimits:
|
||||
class TestScratchpadLimits:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_rejects_at_max_keys(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="overflow", value="data")
|
||||
assert "full" in result
|
||||
assert "overflow" not in loop._runtime_vars
|
||||
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||
assert "Error" not in result
|
||||
assert loop._runtime_vars["key_0"] == "updated"
|
||||
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -844,7 +845,7 @@ class TestInspectTaskStatuses:
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||
),
|
||||
}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||
assert "abc12345" in result
|
||||
assert "read logs" in result
|
||||
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
|
||||
stop_reason="completed",
|
||||
)
|
||||
loop.subagents._task_statuses = {"xyz": status}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||
assert "search code" in result
|
||||
assert "completed" in result
|
||||
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
|
||||
|
||||
def _make_readonly_tool(self):
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_allowed_in_readonly(self):
|
||||
@@ -904,13 +908,13 @@ class TestReadOnlyMode:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime vars check fallback (Fix #1: cross-turn memory)
|
||||
# scratchpad inspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsInspectFallback:
|
||||
class TestScratchpadInspection:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_after_modify(self):
|
||||
async def test_inspect_scratchpad_value_after_modify(self):
|
||||
"""Design doc scenario: set then check should return the value."""
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
|
||||
assert "True" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_string(self):
|
||||
async def test_inspect_scratchpad_string(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||
result = await tool.execute(action="check", key="current_project")
|
||||
assert "nanobot" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_dict(self):
|
||||
async def test_inspect_scratchpad_dict(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||
result = await tool.execute(action="check", key="task_meta")
|
||||
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
loop.some_config.password = "hunter2"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="some_config.password")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.vault = MagicMock()
|
||||
loop.vault.secret = "classified"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="vault.secret")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.auth_data = MagicMock()
|
||||
loop.auth_data.token = "jwt-payload"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="auth_data.token")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
async def test_modify_password_blocked(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_model_presets_dotpath_blocked(self):
|
||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||
presets = {"fast": {"model": "fast-model"}}
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
presets = {"fast": ModelPresetConfig(model="fast-model")}
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(
|
||||
action="set",
|
||||
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
|
||||
)
|
||||
|
||||
assert "read-only" in result
|
||||
assert presets == {"fast": {"model": "fast-model"}}
|
||||
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||
presets = MappingProxyType({
|
||||
"fast": ModelPresetConfig(model="fast-model"),
|
||||
})
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(action="check", key="model_presets.fast.model")
|
||||
|
||||
@@ -1150,7 +1154,8 @@ class TestLastUsageInSummary:
|
||||
async def test_last_usage_not_shown_when_empty(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
loop.last_usage = loop._last_usage
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check")
|
||||
assert "_last_usage" not in result
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||
loop = MagicMock()
|
||||
loop.max_iterations = 40
|
||||
loop._runtime_vars = {}
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
def _sync_subagent_runtime_limits() -> None:
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||
|
||||
tool = MyTool(runtime_state=loop)
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
max_iterations=40,
|
||||
)
|
||||
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
|
||||
|
||||
@@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -660,6 +661,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
||||
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||
)
|
||||
router = object.__new__(WebUISettingsRouter)
|
||||
router.settings = WebUISettingsServices.create(config_path)
|
||||
|
||||
saved = router._save_channel_config_values(
|
||||
"setupplugin",
|
||||
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.settings_api import WebUISettingsError
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
@@ -756,6 +759,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
||||
before = config_path.read_text(encoding="utf-8")
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
router = object.__new__(WebUISettingsRouter)
|
||||
router.settings = WebUISettingsServices.create(config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||
router._save_channel_config_values(
|
||||
|
||||
@@ -9,9 +9,16 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
@@ -391,6 +398,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("legacy", encoding="utf-8")
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
@@ -400,9 +410,15 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
assert "state_recorded" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8"))
|
||||
assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0")
|
||||
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
|
||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||
assert SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_run_argv_logs_command_exit_and_output(
|
||||
@@ -487,7 +503,7 @@ def test_install_records_available_cli_without_reinstalling(
|
||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
||||
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
@@ -704,7 +720,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
||||
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
@@ -717,7 +734,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert not skill_dir.exists()
|
||||
assert not plugin_dir.exists()
|
||||
|
||||
|
||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||
@@ -845,19 +862,62 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "public",
|
||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
{
|
||||
"name": "gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||
|
||||
manager.remove_skill("unimol_tools")
|
||||
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_migrated_cli_app_skill_keeps_legacy_identity_alias(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_dir = tmp_path / "data"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.apps.cli.service.get_runtime_subdir",
|
||||
lambda _name: data_dir,
|
||||
)
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
manager = CliAppManager(workspace=workspace)
|
||||
manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}})
|
||||
manager.install_skill({
|
||||
"name": "unimol_tools",
|
||||
"display_name": "Uni-Mol Tools",
|
||||
"entry_point": "unimol-tools",
|
||||
})
|
||||
agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True)
|
||||
|
||||
loader = SkillsLoader(workspace)
|
||||
assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [
|
||||
"cli-app-unimol-tools"
|
||||
]
|
||||
assert loader.load_skill("cli-app-unimol_tools") is not None
|
||||
|
||||
disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"})
|
||||
assert "cli-app-unimol-tools" not in {
|
||||
skill["name"] for skill in disabled.list_skills(filter_unavailable=False)
|
||||
}
|
||||
|
||||
|
||||
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
@@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
assert "CLI App Mention: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
|
||||
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||
lines = runtime_lines_for_request(
|
||||
"please use @zoom tonight",
|
||||
"please use @unimol_tools",
|
||||
{
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
}],
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
assert "CLI App Attachment: @zoom" in joined
|
||||
assert "CLI App Attachment: @unimol_tools" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
assert "entry_point=cli-anything-unimol-tools" in joined
|
||||
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
|
||||
|
||||
@@ -117,6 +117,30 @@ class TestBuildKwargsExtraBody:
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
}
|
||||
|
||||
def test_extra_body_appends_tools_without_clobbering_functions(self) -> None:
|
||||
function_tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "Write a local file",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
server_tool = {"type": "openrouter:web_search"}
|
||||
provider = _make_provider({
|
||||
"tools": [server_tool],
|
||||
"custom_param": "value",
|
||||
})
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=[function_tool], model=None, max_tokens=100,
|
||||
temperature=0.1, reasoning_effort=None, tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["tools"] == [function_tool, server_tool]
|
||||
assert kwargs["extra_body"] == {"custom_param": "value"}
|
||||
|
||||
def test_extra_body_merges_with_thinking(self) -> None:
|
||||
"""Config extra_body should merge with (and override) thinking params."""
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
@@ -133,6 +133,16 @@ class TestEditFileTool:
|
||||
assert "Successfully" in result
|
||||
assert f.read_text() == "hello earth"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identical_replacement_returns_clear_error(self, tool, tmp_path):
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
|
||||
result = await tool.execute(path=str(f), old_text="world", new_text="world")
|
||||
|
||||
assert result == "Error: new_text must be different from old_text."
|
||||
assert f.read_text(encoding="utf-8") == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crlf_normalisation(self, tool, tmp_path):
|
||||
f = tmp_path / "crlf.py"
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
MCPAuthorizationRequiredError,
|
||||
MCPOAuthHandlers,
|
||||
MCPOAuthStorage,
|
||||
create_mcp_oauth_auth,
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
)
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
|
||||
def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path)
|
||||
|
||||
|
||||
def test_mcp_server_config_accepts_explicit_oauth() -> None:
|
||||
config = MCPServerConfig.model_validate({
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
})
|
||||
|
||||
assert config.auth == "oauth"
|
||||
assert config.model_dump(by_alias=True)["auth"] == "oauth"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_storage_isolates_name_and_server_url(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp")
|
||||
tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret")
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
)
|
||||
|
||||
await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback")
|
||||
await storage.set_tokens(tokens)
|
||||
await storage.set_client_info(client_info)
|
||||
|
||||
assert await storage.get_tokens() == tokens
|
||||
assert await storage.get_client_info() == client_info
|
||||
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||
assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp")
|
||||
assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp")
|
||||
assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp")
|
||||
|
||||
payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8"))
|
||||
assert "https://mcp.example.com/mcp" not in str(payload)
|
||||
assert "access-secret" in str(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp")
|
||||
await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
await storage.set_tokens(OAuthToken(access_token="access-secret"))
|
||||
await storage.set_client_info(OAuthClientInformationFull(
|
||||
redirect_uris=["https://old.example/auth/mcp/callback"],
|
||||
client_id="old-client",
|
||||
))
|
||||
|
||||
await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
|
||||
assert await storage.get_tokens() is not None
|
||||
assert await storage.get_client_info() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_and_delete_credentials_are_scoped_to_one_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
first = MCPOAuthStorage("first", "https://mcp.example.com/mcp")
|
||||
second = MCPOAuthStorage("second", "https://mcp.example.com/mcp")
|
||||
await first.set_tokens(OAuthToken(access_token="first-token"))
|
||||
await second.set_tokens(OAuthToken(access_token="second-token"))
|
||||
|
||||
await first.prepare_redirect_uri(
|
||||
"https://agent.example/auth/mcp/callback",
|
||||
reset=True,
|
||||
)
|
||||
|
||||
assert await first.get_tokens() is None
|
||||
assert await second.get_tokens() is not None
|
||||
assert delete_mcp_oauth_credentials("first")
|
||||
assert not delete_mcp_oauth_credentials("first")
|
||||
assert await second.get_tokens() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_credentials_reject_late_writes_from_stale_oauth_flow(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.linear.example/mcp"
|
||||
stale = MCPOAuthStorage("linear", server_url)
|
||||
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
|
||||
assert delete_mcp_oauth_credentials("linear")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
replacement = MCPOAuthStorage("linear", server_url)
|
||||
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-replacement"))
|
||||
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
assert await replacement.get_tokens() is None
|
||||
|
||||
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||
stored = await replacement.get_tokens()
|
||||
assert stored is not None
|
||||
assert stored.access_token == "fresh-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_before_oauth_claim_rejects_late_credential_writes(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.linear.example/mcp"
|
||||
stale = MCPOAuthStorage("linear", server_url)
|
||||
|
||||
assert not delete_mcp_oauth_credentials("linear")
|
||||
with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"):
|
||||
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
replacement = MCPOAuthStorage("linear", server_url)
|
||||
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||
assert mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
|
||||
async def redirect(_url: str) -> None:
|
||||
return None
|
||||
|
||||
async def callback() -> tuple[str, str | None]:
|
||||
return "code", "state"
|
||||
|
||||
handlers = MCPOAuthHandlers(
|
||||
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||
redirect_handler=redirect,
|
||||
callback_handler=callback,
|
||||
)
|
||||
|
||||
auth = await create_mcp_oauth_auth(
|
||||
"xmind",
|
||||
"https://app.xmind.example/api/mcp",
|
||||
handlers,
|
||||
)
|
||||
|
||||
assert str(auth.context.client_metadata.redirect_uris[0]) == (
|
||||
"https://agent.example/auth/mcp/callback"
|
||||
)
|
||||
assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot"
|
||||
assert str(auth.context.client_metadata.logo_uri) == (
|
||||
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||
"webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
assert auth.context.redirect_handler is redirect
|
||||
assert auth.context.callback_handler is callback
|
||||
storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp")
|
||||
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_authorization_without_tokens_stops_locally(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(MCPAuthorizationRequiredError):
|
||||
await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp")
|
||||
|
||||
assert not (tmp_path / "auth" / "mcp.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_authorization_request_clears_rejected_token(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
storage = MCPOAuthStorage("notion", server_url)
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||
client_id="registered-client",
|
||||
)
|
||||
await storage.set_tokens(OAuthToken(access_token="rejected-token"))
|
||||
await storage.set_client_info(client_info)
|
||||
auth = await create_mcp_oauth_auth("notion", server_url)
|
||||
|
||||
redirect_handler = auth.context.redirect_handler
|
||||
assert redirect_handler is not None
|
||||
with pytest.raises(MCPAuthorizationRequiredError):
|
||||
await redirect_handler("https://accounts.example.com/authorize?state=state")
|
||||
|
||||
assert await storage.get_tokens() is None
|
||||
assert await storage.get_client_info() == client_info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
authorization_url = ""
|
||||
requests: list[tuple[str, str]] = []
|
||||
|
||||
async def redirect(url: str) -> None:
|
||||
nonlocal authorization_url
|
||||
authorization_url = url
|
||||
|
||||
async def callback() -> tuple[str, str | None]:
|
||||
state = parse_qs(urlsplit(authorization_url).query)["state"][0]
|
||||
return "authorization-code", state
|
||||
|
||||
auth = await create_mcp_oauth_auth(
|
||||
"company-mcp",
|
||||
server_url,
|
||||
MCPOAuthHandlers(
|
||||
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||
redirect_handler=redirect,
|
||||
callback_handler=callback,
|
||||
),
|
||||
)
|
||||
|
||||
async def respond(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.method, str(request.url)))
|
||||
if str(request.url) == server_url:
|
||||
if request.headers.get("Authorization") == "Bearer access-token":
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
return httpx.Response(
|
||||
401,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
'Bearer resource_metadata="https://mcp.example.com/'
|
||||
'.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
)
|
||||
if request.url.path == "/.well-known/oauth-protected-resource":
|
||||
return httpx.Response(200, json={
|
||||
"resource": server_url,
|
||||
"authorization_servers": ["https://auth.example.com"],
|
||||
})
|
||||
if request.url.path == "/.well-known/oauth-authorization-server":
|
||||
return httpx.Response(200, json={
|
||||
"issuer": "https://auth.example.com",
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
"registration_endpoint": "https://auth.example.com/register",
|
||||
"response_types_supported": ["code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
})
|
||||
if request.url.path == "/register":
|
||||
registration = json.loads(request.content)
|
||||
assert registration["client_uri"] == "https://github.com/HKUDS/nanobot"
|
||||
assert registration["logo_uri"].endswith(
|
||||
"/webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
return httpx.Response(201, json={
|
||||
"client_id": "nanobot-client",
|
||||
"redirect_uris": ["https://agent.example/auth/mcp/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
})
|
||||
if request.url.path == "/token":
|
||||
return httpx.Response(200, json={
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond),
|
||||
auth=auth,
|
||||
) as client:
|
||||
response = await client.get(server_url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert urlsplit(authorization_url)._replace(query="").geturl() == (
|
||||
"https://auth.example.com/authorize"
|
||||
)
|
||||
assert ("POST", "https://auth.example.com/register") in requests
|
||||
assert ("POST", "https://auth.example.com/token") in requests
|
||||
stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens()
|
||||
assert stored is not None
|
||||
assert stored.access_token == "access-token"
|
||||
assert stored.refresh_token == "refresh-token"
|
||||
@@ -5,10 +5,13 @@ import asyncio
|
||||
import socket
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools import mcp as mcp_mod
|
||||
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.security.network import configure_ssrf_whitelist
|
||||
|
||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||
@@ -171,6 +174,58 @@ async def test_connect_skips_unreachable_sse():
|
||||
assert len(registry._tools) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_isolates_streamable_http_status_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A reachable endpoint returning HTTP 530 must not poison the event loop."""
|
||||
async def _reachable(_url: str) -> bool:
|
||||
return True
|
||||
|
||||
def _return_http_530(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(530, text="cloudflare error 1033", request=request)
|
||||
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(_return_http_530),
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
previous_exception_handler = loop.get_exception_handler()
|
||||
unhandled: list[BaseException] = []
|
||||
|
||||
def _capture_unhandled(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
||||
if isinstance(context.get("exception"), BaseException):
|
||||
unhandled.append(context["exception"])
|
||||
|
||||
loop.set_exception_handler(_capture_unhandled)
|
||||
try:
|
||||
registry = ToolRegistry()
|
||||
stacks = await asyncio.wait_for(
|
||||
connect_mcp_servers(
|
||||
{
|
||||
"cloudflare": MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
url="https://mcp.example.com/mcp",
|
||||
)
|
||||
},
|
||||
registry,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert stacks == {}
|
||||
assert registry.tool_names == []
|
||||
assert unhandled == []
|
||||
assert not any(task.get_name() == "mcp:cloudflare" for task in asyncio.all_tasks())
|
||||
finally:
|
||||
loop.set_exception_handler(previous_exception_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_not_called_for_stdio():
|
||||
"""stdio transport should not be probed — it spawns a local process."""
|
||||
|
||||
@@ -826,19 +826,23 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
||||
) -> None:
|
||||
messages: list[str] = []
|
||||
|
||||
def _error(message: str, *args: object) -> None:
|
||||
messages.append(message.format(*args))
|
||||
|
||||
@asynccontextmanager
|
||||
async def _broken_stdio_client(_params: object):
|
||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
||||
yield # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
|
||||
sink = mcp_mod.logger.add(
|
||||
lambda message: messages.append(message.record["message"]), level="ERROR"
|
||||
)
|
||||
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
|
||||
try:
|
||||
stacks = await connect_mcp_servers(
|
||||
{"gh": MCPServerConfig(command="github-mcp")}, registry
|
||||
)
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
|
||||
assert stacks == {}
|
||||
assert messages
|
||||
@@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
||||
assert "stderr" in messages[-1]
|
||||
|
||||
|
||||
def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None:
|
||||
records: list[dict] = []
|
||||
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||
error = ExceptionGroup("transport failed", [httpx.ConnectError("")])
|
||||
try:
|
||||
mcp_mod._log_mcp_connection_failure("notion", error)
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
|
||||
warning = next(record for record in records if record["level"].name == "WARNING")
|
||||
debug = next(record for record in records if record["level"].name == "DEBUG")
|
||||
assert warning["exception"] is None
|
||||
assert "transient connection failure" in warning["message"]
|
||||
assert debug["exception"] is not None
|
||||
assert not any(record["level"].name == "ERROR" for record in records)
|
||||
|
||||
|
||||
def test_unexpected_connection_failure_keeps_error_trace() -> None:
|
||||
records: list[dict] = []
|
||||
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||
try:
|
||||
mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom"))
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
|
||||
error = next(record for record in records if record["level"].name == "ERROR")
|
||||
assert error["exception"] is not None
|
||||
assert not any(record["level"].name == "WARNING" for record in records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
@@ -1082,10 +1116,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
failure_mode: str,
|
||||
) -> None:
|
||||
sessions = {"good": _make_fake_session(["demo"])}
|
||||
bad_session = _make_fake_session([])
|
||||
|
||||
async def _cancel_initialize() -> None:
|
||||
raise asyncio.CancelledError("cancelled by SDK")
|
||||
|
||||
if failure_mode == "cancellation":
|
||||
bad_session.initialize = _cancel_initialize
|
||||
sessions = {
|
||||
"bad": bad_session,
|
||||
"good": _make_fake_session(["demo"]),
|
||||
}
|
||||
|
||||
class _SelectiveClientSession:
|
||||
def __init__(self, read: object, _write: object) -> None:
|
||||
@@ -1099,7 +1145,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||
|
||||
@asynccontextmanager
|
||||
async def _selective_stdio_client(params: object):
|
||||
if params.command == "bad":
|
||||
if params.command == "bad" and failure_mode == "exception":
|
||||
raise RuntimeError("boom")
|
||||
yield params.command, object()
|
||||
|
||||
@@ -1109,8 +1155,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{
|
||||
"good": MCPServerConfig(command="good"),
|
||||
"bad": MCPServerConfig(command="bad"),
|
||||
"good": MCPServerConfig(command="good"),
|
||||
},
|
||||
registry,
|
||||
)
|
||||
@@ -1121,6 +1167,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||
assert set(stacks) == {"good"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_propagates_external_cancellation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
started = asyncio.Event()
|
||||
closed = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _blocking_stdio_client(_params: object):
|
||||
try:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
yield object(), object()
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _blocking_stdio_client)
|
||||
|
||||
task = asyncio.create_task(
|
||||
connect_mcp_servers({"slow": MCPServerConfig(command="slow")}, ToolRegistry())
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
await asyncio.wait_for(closed.wait(), timeout=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
@@ -1168,6 +1244,129 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||
assert timeout.pool == 30.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transport", ["sse", "streamableHttp"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client(
|
||||
transport: str,
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
|
||||
oauth_auth = object()
|
||||
oauth_handlers = object()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _reachable(_url: str) -> bool:
|
||||
return True
|
||||
|
||||
def _validate(_url: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
async def _create_auth(name: str, url: str, handlers: object) -> object:
|
||||
captured.update(name=name, url=url, handlers=handlers)
|
||||
return oauth_auth
|
||||
|
||||
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||
oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined]
|
||||
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
captured["client_kwargs"] = kwargs
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
@asynccontextmanager
|
||||
async def _capturing_sse_client(
|
||||
_url: str,
|
||||
httpx_client_factory=None,
|
||||
auth=None,
|
||||
):
|
||||
captured["transport_auth"] = auth
|
||||
yield object(), object()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||
assert http_client is not None
|
||||
yield object(), object(), object()
|
||||
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
|
||||
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["mcp.client.streamable_http"],
|
||||
"streamable_http_client",
|
||||
_capturing_streamable_http_client,
|
||||
)
|
||||
|
||||
url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp"
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{"remote": MCPServerConfig(type=transport, url=url, auth="oauth")},
|
||||
registry,
|
||||
oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type]
|
||||
)
|
||||
for stack in stacks.values():
|
||||
await stack.aclose()
|
||||
|
||||
assert captured["name"] == "remote"
|
||||
assert captured["url"] == url
|
||||
assert captured["handlers"] is oauth_handlers
|
||||
if transport == "sse":
|
||||
assert captured["transport_auth"] is oauth_auth
|
||||
else:
|
||||
client_kwargs = captured["client_kwargs"]
|
||||
assert isinstance(client_kwargs, dict)
|
||||
assert client_kwargs["auth"] is oauth_auth
|
||||
assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_skips_background_oauth_without_credentials(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class AuthorizationRequiredError(RuntimeError):
|
||||
pass
|
||||
|
||||
async def _create_auth(*_args: object) -> object:
|
||||
raise AuthorizationRequiredError
|
||||
|
||||
probe_called = False
|
||||
|
||||
async def _probe(_url: str) -> bool:
|
||||
nonlocal probe_called
|
||||
probe_called = True
|
||||
return True
|
||||
|
||||
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
|
||||
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _probe)
|
||||
|
||||
stacks = await connect_mcp_servers(
|
||||
{
|
||||
"remote": MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
url="https://mcp.example.com/mcp",
|
||||
auth="oauth",
|
||||
)
|
||||
},
|
||||
ToolRegistry(),
|
||||
)
|
||||
|
||||
assert stacks == {}
|
||||
assert not probe_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Shared characterization cases for live and persisted WebUI projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.webui.transcript import replay_transcript_to_ui_messages
|
||||
|
||||
_FIXTURE_PATH = (
|
||||
Path(__file__).parents[2]
|
||||
/ "webui"
|
||||
/ "src"
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "live-replay-event-projection.json"
|
||||
)
|
||||
_SEMANTIC_MESSAGE_FIELDS = (
|
||||
"role",
|
||||
"content",
|
||||
"kind",
|
||||
"traces",
|
||||
"toolEvents",
|
||||
"fileEdits",
|
||||
"images",
|
||||
"media",
|
||||
"cliApps",
|
||||
"mcpPresets",
|
||||
"sessionMentions",
|
||||
"reasoning",
|
||||
"latencyMs",
|
||||
"source",
|
||||
"turnId",
|
||||
"turnPhase",
|
||||
"turnSeq",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_projection(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
segment_aliases: dict[str, str] = {}
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
row = {
|
||||
field: message[field]
|
||||
for field in _SEMANTIC_MESSAGE_FIELDS
|
||||
if field in message and message[field] is not None
|
||||
}
|
||||
segment_id = message.get("activitySegmentId")
|
||||
if isinstance(segment_id, str) and segment_id:
|
||||
row["activitySegmentId"] = segment_aliases.setdefault(
|
||||
segment_id,
|
||||
f"segment-{len(segment_aliases) + 1}",
|
||||
)
|
||||
normalized.append(row)
|
||||
return normalized
|
||||
|
||||
|
||||
def test_replay_matches_shared_live_projection_before_canonical_revision_migration() -> None:
|
||||
"""Lock the known-equivalent subset without defining the future snapshot protocol."""
|
||||
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
for case in fixture["cases"]:
|
||||
actual = replay_transcript_to_ui_messages(case["transcript"])
|
||||
assert _normalize_projection(actual) == case["expected"], case["name"]
|
||||
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.webui.mcp_oauth_api import (
|
||||
McpOAuthError,
|
||||
McpOAuthManager,
|
||||
prepare_mcp_oauth_redirect_uri,
|
||||
validate_mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _config() -> MCPServerConfig:
|
||||
return MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_retries_current_server_and_ignores_unrelated_reload_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
connection = _Connection()
|
||||
received: dict[str, object] = {}
|
||||
reload_calls = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(servers, _registry, *, oauth_handlers):
|
||||
assert set(servers) == {"xmind"}
|
||||
handlers = oauth_handlers["xmind"]
|
||||
await handlers.redirect_handler(
|
||||
"https://accounts.example.com/authorize?client_id=test&state=state-123"
|
||||
)
|
||||
received["callback"] = await handlers.callback_handler()
|
||||
return {"xmind": connection}
|
||||
|
||||
async def reload_mcp() -> dict[str, object]:
|
||||
nonlocal reload_calls
|
||||
reload_calls += 1
|
||||
if reload_calls == 1:
|
||||
return {
|
||||
"ok": False,
|
||||
"requires_restart": False,
|
||||
"failed": ["xmind"],
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"requires_restart": False,
|
||||
"connected": ["xmind"],
|
||||
"failed": ["notion"],
|
||||
"message": "MCP config reloaded, but some servers did not connect: notion",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
started = await manager.start(
|
||||
"xmind",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=reload_mcp,
|
||||
)
|
||||
|
||||
assert started["status"] == "authorization_required"
|
||||
assert started["authorization_url"].startswith("https://accounts.example.com/authorize?")
|
||||
manager.submit_callback(state="state-123", code="oauth-code", error=None)
|
||||
with pytest.raises(McpOAuthError, match="expired"):
|
||||
manager.submit_callback(state="state-123", code="replayed-code", error=None)
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if reload_calls == 2:
|
||||
break
|
||||
assert reload_calls == 2
|
||||
|
||||
for _ in range(10):
|
||||
first, second = await asyncio.gather(
|
||||
manager.status(started["flow_id"]),
|
||||
manager.status(started["flow_id"]),
|
||||
)
|
||||
if first["status"] == "connected":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert first["status"] == "connected"
|
||||
assert second["status"] == "connected"
|
||||
assert first["hot_reload"]["failed"] == ["notion"]
|
||||
assert received["callback"] == ("oauth-code", "state-123")
|
||||
assert reload_calls == 2
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_http_flow_accepts_a_pasted_loopback_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
connection = _Connection()
|
||||
received: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
handlers = oauth_handlers["linear"]
|
||||
received["redirect_uri"] = handlers.redirect_uri
|
||||
await handlers.redirect_handler(
|
||||
"https://accounts.example.com/authorize?client_id=test&state=manual-state"
|
||||
)
|
||||
received["callback"] = await handlers.callback_handler()
|
||||
return {"linear": connection}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
started = await manager.start(
|
||||
"linear",
|
||||
_config(),
|
||||
"http://192.0.2.10:8765/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(
|
||||
0,
|
||||
result={"ok": True, "requires_restart": False},
|
||||
),
|
||||
)
|
||||
|
||||
assert started["status"] == "authorization_required"
|
||||
assert started["completion_input"] == "callback_url"
|
||||
assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||
|
||||
with pytest.raises(McpOAuthError, match="complete callback URL"):
|
||||
manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state"
|
||||
),
|
||||
)
|
||||
with pytest.raises(McpOAuthError, match="different or expired"):
|
||||
manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
"?code=oauth-code&state=other-state"
|
||||
),
|
||||
)
|
||||
|
||||
submitted = manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
"?code=oauth-code&state=manual-state"
|
||||
),
|
||||
)
|
||||
assert submitted["status"] == "connecting"
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0)
|
||||
result = await manager.status(started["flow_id"])
|
||||
if result["status"] == "connected":
|
||||
break
|
||||
|
||||
assert result["status"] == "connected"
|
||||
assert result["completion_input"] == "callback_url"
|
||||
assert received["callback"] == ("oauth-code", "manual-state")
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_surfaces_provider_denial_without_callback_description(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
handlers = oauth_handlers["notion"]
|
||||
await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state")
|
||||
await handlers.callback_handler()
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
started = await manager.start(
|
||||
"notion",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||
)
|
||||
|
||||
with pytest.raises(McpOAuthError, match="access_denied"):
|
||||
manager.submit_callback(state="deny-state", code=None, error="access_denied")
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
result = await manager.status(started["flow_id"])
|
||||
if result["status"] == "failed":
|
||||
break
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "Authorization was not completed (access_denied)."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("authorization_url", "url_is_safe", "state"),
|
||||
[
|
||||
("https://127.0.0.1/authorize?state=private-state", False, "private-state"),
|
||||
("http://accounts.example.com/authorize?state=http-state", True, "http-state"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_blocks_unsafe_authorization_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
authorization_url: str,
|
||||
url_is_safe: bool,
|
||||
state: str,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (url_is_safe, "private address"),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
await oauth_handlers["linear"].redirect_handler(authorization_url)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
result = await manager.start(
|
||||
"linear",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "The MCP server returned an unsafe authorization URL."
|
||||
with pytest.raises(McpOAuthError, match="expired"):
|
||||
manager.submit_callback(state=state, code="code", error=None)
|
||||
|
||||
|
||||
def test_redirect_uri_requires_https_except_for_loopback() -> None:
|
||||
assert validate_mcp_oauth_redirect_uri(
|
||||
"https://agent.example.com/auth/mcp/callback"
|
||||
) == "https://agent.example.com/auth/mcp/callback"
|
||||
assert validate_mcp_oauth_redirect_uri(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
) == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||
|
||||
with pytest.raises(McpOAuthError, match="HTTPS or localhost"):
|
||||
validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback")
|
||||
with pytest.raises(McpOAuthError, match="Invalid"):
|
||||
validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong")
|
||||
|
||||
|
||||
def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None:
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"https://agent.example.com/auth/mcp/callback"
|
||||
) == ("https://agent.example.com/auth/mcp/callback", False)
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
) == ("http://127.0.0.1:8765/auth/mcp/callback", False)
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"http://agent.example.com:9443/auth/mcp/callback"
|
||||
) == ("http://127.0.0.1:9443/auth/mcp/callback", True)
|
||||
@@ -1,22 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthToken
|
||||
|
||||
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
McpPresetError,
|
||||
custom_mcp_action,
|
||||
mcp_presets_action,
|
||||
mcp_presets_payload,
|
||||
mcp_presets_settings_action,
|
||||
mcp_presets_test_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
|
||||
|
||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
|
||||
def _write_agent_plugin(workspace: Path) -> None:
|
||||
root = workspace / "plugins" / "desktop"
|
||||
root.mkdir(parents=True)
|
||||
for filename, payload in (
|
||||
(
|
||||
"plugin.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "desktop",
|
||||
"description": "Control the local desktop.",
|
||||
"extensions": {
|
||||
"dev.nanobot": {
|
||||
"displayName": "Desktop Control",
|
||||
"permissions": ["screen-recording"],
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp.json",
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {"desktop": {"type": "stdio", "command": "echo"}},
|
||||
},
|
||||
),
|
||||
):
|
||||
(root / filename).write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -38,6 +79,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
"aws-docs",
|
||||
"brave-search",
|
||||
"postman",
|
||||
"xmind",
|
||||
"notion",
|
||||
"linear",
|
||||
}.issubset(names)
|
||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||
assert browserbase["installed"] is False
|
||||
@@ -55,6 +99,83 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||
|
||||
|
||||
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
_write_agent_plugin(load_config().workspace_path)
|
||||
|
||||
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||
assert (row["name"], row["display_name"], row["requires"]) == (
|
||||
"plugin-desktop", "Desktop Control", "screen-recording"
|
||||
)
|
||||
assert row["installed"] and row["configured"] and not row["enabled"]
|
||||
|
||||
async def reload() -> dict[str, object]:
|
||||
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
||||
|
||||
plugin_action = partial(
|
||||
mcp_presets_settings_action,
|
||||
query={"name": ["plugin-desktop"]},
|
||||
)
|
||||
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
|
||||
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert (enabled_row["enabled"], enabled_row["status"], enabled["requires_restart"]) == (
|
||||
True, "enabled", False
|
||||
)
|
||||
|
||||
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
|
||||
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert (disabled_row["installed"], disabled_row["enabled"], disabled_row["status"]) == (
|
||||
True, False, "disabled"
|
||||
)
|
||||
|
||||
with pytest.raises(McpPresetError, match="enable and disable"):
|
||||
asyncio.run(plugin_action("remove"))
|
||||
|
||||
(load_config().workspace_path / "plugins" / "desktop" / "mcp.json").unlink()
|
||||
assert any(item["name"] == "plugin-desktop" for item in mcp_presets_payload()["presets"])
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["tools"] = {"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}}
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
rows = [item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"]
|
||||
assert len(rows) == 1 and rows[0]["source"] == "custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_action("enable", {"name": ["xmind"]})
|
||||
|
||||
row = next(item for item in payload["presets"] if item["name"] == "xmind")
|
||||
assert row["installed"] is True
|
||||
assert row["configured"] is False
|
||||
assert row["status"] == "authorization_required"
|
||||
assert row["transport"] == "streamableHttp"
|
||||
assert row["auth"] == "oauth"
|
||||
config = load_config()
|
||||
cfg = config.tools.mcp_servers["xmind"]
|
||||
assert cfg.type == "streamableHttp"
|
||||
assert cfg.auth == "oauth"
|
||||
assert cfg.url == "https://app.xmind.com/api/mcp"
|
||||
|
||||
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
|
||||
connected = mcp_presets_payload()
|
||||
row = next(item for item in connected["presets"] if item["name"] == "xmind")
|
||||
assert row["configured"] is True
|
||||
assert row["status"] == "configured"
|
||||
|
||||
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
||||
|
||||
|
||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -296,11 +417,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
|
||||
assert "<redacted>" in payload["last_action"]["error"]
|
||||
|
||||
|
||||
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(McpPresetError) as exc:
|
||||
mcp_presets_action("enable", {"name": ["linear"]})
|
||||
mcp_presets_action("enable", {"name": ["asana"]})
|
||||
|
||||
assert exc.value.status == 404
|
||||
|
||||
@@ -414,6 +535,72 @@ def test_import_mcp_config_and_tool_allowlist(
|
||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||
|
||||
|
||||
def test_import_recognizes_known_and_explicit_oauth_servers(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"import",
|
||||
{
|
||||
"config": [
|
||||
(
|
||||
'{"mcpServers":{'
|
||||
'"notion-work":{"url":"https://mcp.notion.com/mcp"},'
|
||||
'"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},'
|
||||
'"notion-pat":{"url":"https://mcp.notion.com/mcp",'
|
||||
'"headers":{"Authorization":"Bearer secret"}}'
|
||||
'}}'
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["notion-work"].auth == "oauth"
|
||||
assert config.tools.mcp_servers["company-mcp"].auth == "oauth"
|
||||
assert config.tools.mcp_servers["notion-pat"].auth is None
|
||||
rows = {row["name"]: row for row in payload["presets"]}
|
||||
assert rows["notion-work"]["status"] == "authorization_required"
|
||||
assert rows["company-mcp"]["status"] == "authorization_required"
|
||||
assert rows["notion-pat"]["status"] == "configured"
|
||||
assert "Bearer secret" not in str(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replacing_oauth_config_removes_its_stored_credentials(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["company-mcp"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": [server_url],
|
||||
"auth": ["oauth"],
|
||||
},
|
||||
)
|
||||
await MCPOAuthStorage("company-mcp", server_url).set_tokens(
|
||||
OAuthToken(access_token="secret")
|
||||
)
|
||||
assert mcp_oauth_has_credentials("company-mcp", server_url)
|
||||
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["company-mcp"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": [server_url],
|
||||
},
|
||||
)
|
||||
|
||||
assert not mcp_oauth_has_credentials("company-mcp", server_url)
|
||||
|
||||
|
||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -12,7 +12,6 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_clear_webui_oauth_flows,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
@@ -36,11 +35,17 @@ from nanobot.webui.settings_api import (
|
||||
update_transcription_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||
|
||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
||||
return WebUIOAuthFlowRegistry()
|
||||
|
||||
|
||||
def test_settings_payload_propagates_preset_resolution_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
|
||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config_path = tmp_path / "config.json"
|
||||
@@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
fake_start,
|
||||
)
|
||||
|
||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["openai-codex"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"proxy": proxy,
|
||||
@@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api.settings_payload",
|
||||
lambda: {"settings": "ready"},
|
||||
lambda **_kwargs: {"settings": "ready"},
|
||||
)
|
||||
|
||||
pending = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
completed = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert pending == {
|
||||
@@ -1574,6 +1585,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
|
||||
try:
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
||||
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
finally:
|
||||
_clear_webui_oauth_flows("openai_codex")
|
||||
oauth_flows.clear("openai_codex")
|
||||
|
||||
assert payload["completion_input"] == "callback_url"
|
||||
assert captured["open_browser"] is False
|
||||
@@ -1611,6 +1624,7 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
|
||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
@@ -1622,7 +1636,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["openai-codex"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["openai-codex"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
@@ -1632,6 +1649,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
|
||||
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
@@ -1643,7 +1661,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["github-copilot"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["github-copilot"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert str(exc.value) == (
|
||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||
@@ -1654,6 +1675,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config_path = tmp_path / "config.json"
|
||||
@@ -1675,7 +1697,10 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||
|
||||
payload = login_oauth_provider({"provider": ["xai-grok"]})
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert captured["proxy"] == proxy
|
||||
assert captured["timeout_s"] == 600
|
||||
@@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api.settings_payload",
|
||||
lambda: {"settings": "ready"},
|
||||
lambda **_kwargs: {"settings": "ready"},
|
||||
)
|
||||
|
||||
pending = complete_oauth_provider(
|
||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
completed = complete_oauth_provider(
|
||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||
"secret",
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert pending == {
|
||||
@@ -1722,6 +1749,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1734,7 +1762,10 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as exc:
|
||||
login_oauth_provider({"provider": ["xai-grok"]})
|
||||
login_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert exc.value.status == 502
|
||||
assert str(exc.value) == (
|
||||
@@ -1746,6 +1777,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||
lambda: token_path,
|
||||
)
|
||||
|
||||
logout_oauth_provider({"provider": ["xai-grok"]})
|
||||
logout_oauth_provider(
|
||||
{"provider": ["xai-grok"]},
|
||||
oauth_flows=oauth_flows,
|
||||
)
|
||||
|
||||
assert not token_path.exists()
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_capabilities import (
|
||||
capability_settings_payload,
|
||||
update_api_settings,
|
||||
update_image_generation_settings,
|
||||
update_network_safety_settings,
|
||||
update_transcription_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
|
||||
|
||||
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||
return {"configured": False}
|
||||
|
||||
|
||||
def test_capability_domain_updates_representative_settings() -> None:
|
||||
config = Config()
|
||||
config.providers.openrouter.api_key = "sk-test"
|
||||
|
||||
web_changed, web_restart = update_web_search_settings(
|
||||
config,
|
||||
{
|
||||
"provider": ["duckduckgo"],
|
||||
"max_results": ["7"],
|
||||
"use_jina_reader": ["false"],
|
||||
},
|
||||
)
|
||||
update_api_settings(
|
||||
config,
|
||||
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
|
||||
)
|
||||
image_changed = update_image_generation_settings(
|
||||
config,
|
||||
{"enabled": ["true"], "provider": ["openrouter"]},
|
||||
oauth_status=_oauth_status,
|
||||
)
|
||||
transcription_changed = update_transcription_settings(
|
||||
config,
|
||||
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
|
||||
)
|
||||
network_changed, access_mode = update_network_safety_settings(
|
||||
config,
|
||||
{
|
||||
"webui_allow_local_service_access": ["false"],
|
||||
"webui_default_access_mode": ["restricted"],
|
||||
},
|
||||
)
|
||||
payload = capability_settings_payload(config, oauth_status=_oauth_status)
|
||||
|
||||
assert (web_changed, web_restart) == (True, True)
|
||||
assert image_changed is True
|
||||
assert transcription_changed is True
|
||||
assert (network_changed, access_mode) == (True, "default")
|
||||
assert payload["web_search"]["max_results"] == 7
|
||||
assert payload["api"]["host"] == "127.0.0.2"
|
||||
assert payload["api"]["port"] == 8900
|
||||
assert payload["image_generation"]["enabled"] is True
|
||||
assert payload["transcription"]["provider"] == "openrouter"
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_models import (
|
||||
model_settings_payload,
|
||||
update_agent_model_settings,
|
||||
update_provider_settings,
|
||||
)
|
||||
|
||||
|
||||
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"configured": False,
|
||||
"account": None,
|
||||
"expires_at": None,
|
||||
"login_supported": True,
|
||||
}
|
||||
|
||||
|
||||
def test_model_domain_owns_dto_and_config_updates() -> None:
|
||||
config = Config()
|
||||
config.providers.openrouter.api_key = "sk-before"
|
||||
|
||||
agent_changed = update_agent_model_settings(
|
||||
config,
|
||||
{
|
||||
"model": ["openai/gpt-5.4"],
|
||||
"provider": ["openrouter"],
|
||||
"context_window_tokens": ["200000"],
|
||||
},
|
||||
oauth_status=_oauth_status,
|
||||
)
|
||||
provider_changed, restart_required = update_provider_settings(
|
||||
config,
|
||||
{
|
||||
"provider": ["openrouter"],
|
||||
"api_key": ["sk-after"],
|
||||
},
|
||||
)
|
||||
payload = model_settings_payload(config, oauth_status=_oauth_status)
|
||||
|
||||
assert agent_changed is True
|
||||
assert provider_changed is True
|
||||
assert restart_required is False
|
||||
assert config.agents.defaults.model == "openai/gpt-5.4"
|
||||
assert config.agents.defaults.provider == "openrouter"
|
||||
assert config.agents.defaults.context_window_tokens == 200_000
|
||||
assert config.providers.openrouter.api_key == "sk-after"
|
||||
assert set(payload) == {
|
||||
"agent",
|
||||
"model_presets",
|
||||
"model_call_order",
|
||||
"model_call_order_editable",
|
||||
"providers",
|
||||
}
|
||||
assert payload["agent"]["model"] == "openai/gpt-5.4"
|
||||
@@ -2,18 +2,21 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.webui.http_utils import http_json_response
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
|
||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
return WebUISettingsRouter(
|
||||
settings=WebUISettingsServices.create(get_config_path()),
|
||||
bus=SimpleNamespace(),
|
||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||
check_api_token=lambda _request: authorized,
|
||||
@@ -25,30 +28,158 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
),
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities={},
|
||||
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
||||
)
|
||||
|
||||
|
||||
def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
|
||||
request = SimpleNamespace(path=path, headers=Headers())
|
||||
request._nanobot_webui_mutation_request = True
|
||||
request._nanobot_webui_mutation_payload = payload
|
||||
request._nanobot_trusted_proxy_authenticated = True
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||
config = SimpleNamespace(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.ensure_mcp_oauth_server",
|
||||
lambda _query, *, config_path=None: ("xmind", config),
|
||||
)
|
||||
router = _router()
|
||||
start = AsyncMock(return_value={
|
||||
"status": "authorization_required",
|
||||
"flow_id": "flow-123",
|
||||
"name": "xmind",
|
||||
"authorization_url": "https://xmind.example/authorize?state=state-123",
|
||||
})
|
||||
router._mcp_oauth = SimpleNamespace(start=start)
|
||||
request = _mutation_request(
|
||||
"/api/settings/mcp-oauth/start",
|
||||
{"name": "xmind"},
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["flow_id"] == "flow-123"
|
||||
start.assert_awaited_once_with(
|
||||
"xmind",
|
||||
config,
|
||||
"https://gateway.example/auth/mcp/callback",
|
||||
reload_mcp=ANY,
|
||||
reset_credentials=False,
|
||||
)
|
||||
|
||||
denied = _router(authorized=False)
|
||||
denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
failed = _router()
|
||||
failed._mcp_oauth = SimpleNamespace(
|
||||
start=AsyncMock(side_effect=RuntimeError("upstream secret response"))
|
||||
)
|
||||
failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
assert failed_response is not None
|
||||
assert failed_response.status_code == 500
|
||||
assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"}
|
||||
assert b"upstream secret response" not in failed_response.body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None:
|
||||
router = _router(authorized=False)
|
||||
submit = MagicMock(return_value="xmind")
|
||||
router._mcp_oauth = SimpleNamespace(submit_callback=submit)
|
||||
request = SimpleNamespace(
|
||||
path="/auth/mcp/callback?code=oauth-code&state=state-123",
|
||||
headers=Headers(),
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/auth/mcp/callback")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
|
||||
assert b"window.close" in response.body
|
||||
assert b"Authorization received" in response.body
|
||||
assert b"oauth-code" not in response.body
|
||||
submit.assert_called_once_with(state="state-123", code="oauth-code", error=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None:
|
||||
callback_url = (
|
||||
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123"
|
||||
)
|
||||
router = _router()
|
||||
submit = MagicMock(
|
||||
return_value={
|
||||
"flow_id": "flow-123",
|
||||
"name": "linear",
|
||||
"status": "connecting",
|
||||
"expires_in": 299,
|
||||
"completion_input": "callback_url",
|
||||
}
|
||||
)
|
||||
router._mcp_oauth = SimpleNamespace(submit_callback_url=submit)
|
||||
request = _mutation_request(
|
||||
"/api/settings/mcp-oauth/complete",
|
||||
{"flow_id": "flow-123", "callback_url": callback_url},
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["status"] == "connecting"
|
||||
assert b"oauth-code" not in response.body
|
||||
submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url)
|
||||
|
||||
denied = _router(authorized=False)
|
||||
denied_response = await denied.dispatch(
|
||||
None,
|
||||
request,
|
||||
"/api/settings/mcp-oauth/complete",
|
||||
)
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "header_name", "authorization_response"),
|
||||
("provider", "authorization_response"),
|
||||
[
|
||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
||||
("xai_grok", "secret"),
|
||||
(
|
||||
"openai_codex",
|
||||
"X-Nanobot-OAuth-Callback",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_completion_reads_private_response_header(
|
||||
async def test_oauth_completion_reads_websocket_payload(
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
header_name: str,
|
||||
authorization_response: str,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def complete(query, authorization_response=None):
|
||||
def complete(
|
||||
query,
|
||||
authorization_response=None,
|
||||
*,
|
||||
oauth_flows=None,
|
||||
config_path=None,
|
||||
):
|
||||
captured.update(query=query, authorization_response=authorization_response)
|
||||
return {
|
||||
"status": "pending",
|
||||
@@ -58,19 +189,13 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
||||
router = _router()
|
||||
request = SimpleNamespace(
|
||||
path=(
|
||||
"/api/settings/provider/oauth-login/complete"
|
||||
f"?provider={provider}&flow_id=flow-123"
|
||||
),
|
||||
headers=Headers(
|
||||
[
|
||||
(
|
||||
header_name,
|
||||
authorization_response,
|
||||
)
|
||||
]
|
||||
),
|
||||
request = _mutation_request(
|
||||
"/api/settings/provider/oauth-login/complete",
|
||||
{
|
||||
"provider": provider,
|
||||
"flow_id": "flow-123",
|
||||
"authorization_response": authorization_response,
|
||||
},
|
||||
)
|
||||
|
||||
response = await router.dispatch(
|
||||
@@ -90,28 +215,29 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||
"authorization_response": authorization_response,
|
||||
}
|
||||
assert authorization_response not in request.path
|
||||
assert request.path == "/api/settings/provider/oauth-login/complete"
|
||||
assert not request.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_path", "route_path", "function_name", "expected_query"),
|
||||
("route_path", "function_name", "payload", "expected_query"),
|
||||
[
|
||||
(
|
||||
"/api/settings/model-configurations/delete?name=spare",
|
||||
"/api/settings/model-configurations/delete",
|
||||
"delete_model_configuration",
|
||||
{"name": "spare"},
|
||||
{"name": ["spare"]},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"migrate_model_configurations",
|
||||
{},
|
||||
{},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
|
||||
"/api/settings/model-call-order/update",
|
||||
"update_model_call_order",
|
||||
{"order": ["backup"]},
|
||||
{"order": ['["backup"]']},
|
||||
),
|
||||
],
|
||||
@@ -119,19 +245,19 @@ async def test_oauth_completion_reads_private_response_header(
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_preset_mutation_routes(
|
||||
monkeypatch,
|
||||
request_path: str,
|
||||
route_path: str,
|
||||
function_name: str,
|
||||
payload: dict[str, object],
|
||||
expected_query: dict[str, list[str]],
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def mutate(query):
|
||||
def mutate(query, *, config_path=None):
|
||||
captured["query"] = query
|
||||
return {"routed": function_name}
|
||||
|
||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
||||
request = SimpleNamespace(path=request_path, headers=Headers())
|
||||
request = _mutation_request(route_path, payload)
|
||||
|
||||
response = await _router().dispatch(None, request, route_path)
|
||||
|
||||
@@ -141,6 +267,23 @@ async def test_model_preset_mutation_routes(
|
||||
assert captured["query"] == expected_query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
|
||||
path = "/api/settings/provider/update"
|
||||
request = SimpleNamespace(
|
||||
path=f"{path}?provider=openrouter&api_key=must-not-run",
|
||||
headers=Headers(),
|
||||
)
|
||||
|
||||
response = await _router().dispatch(None, request, path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 405
|
||||
assert json.loads(response.body) == {
|
||||
"error": "WebUI mutations require an authenticated WebSocket"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("update_info", "expected"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings
|
||||
from nanobot.webui.settings_services import (
|
||||
WebUIOAuthFlowRegistry,
|
||||
WebUISettingsServices,
|
||||
)
|
||||
|
||||
|
||||
class _Flow:
|
||||
def __init__(self, *, expired: bool = False) -> None:
|
||||
self.expired = expired
|
||||
self.cancel_count = 0
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_count += 1
|
||||
|
||||
|
||||
def _gateway(config_path: Path, workspace: Path):
|
||||
return build_gateway_services(
|
||||
config=WebSocketConfig(),
|
||||
bus=MagicMock(),
|
||||
session_manager=None,
|
||||
static_dist_path=None,
|
||||
workspace_path=workspace,
|
||||
default_restrict_to_workspace=False,
|
||||
config_path=config_path,
|
||||
runtime_model_name=None,
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first_path = tmp_path / "first" / "config.json"
|
||||
second_path = tmp_path / "second" / "config.json"
|
||||
first_config = Config()
|
||||
first_config.api.host = "127.0.0.2"
|
||||
second_config = Config()
|
||||
second_config.api.host = "127.0.0.3"
|
||||
save_config(first_config, first_path)
|
||||
save_config(second_config, second_path)
|
||||
|
||||
first = _gateway(first_path, tmp_path / "first-workspace")
|
||||
second = _gateway(second_path, tmp_path / "second-workspace")
|
||||
|
||||
assert first.settings.config.path == first_path.resolve()
|
||||
assert second.settings.config.path == second_path.resolve()
|
||||
assert first.http.settings_routes.settings is first.settings
|
||||
assert second.http.settings_routes.settings is second.settings
|
||||
assert first.settings.config.load().api.host == "127.0.0.2"
|
||||
assert second.settings.config.load().api.host == "127.0.0.3"
|
||||
assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2"
|
||||
assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3"
|
||||
|
||||
first.settings.mutate(update_api_settings, {"port": ["19001"]})
|
||||
assert load_config(first_path).api.port == 19001
|
||||
assert load_config(second_path).api.port != 19001
|
||||
|
||||
first_flow = _Flow()
|
||||
second_flow = _Flow()
|
||||
first.settings.oauth_flows.register("openai_codex", "same-id", first_flow)
|
||||
second.settings.oauth_flows.register("openai_codex", "same-id", second_flow)
|
||||
|
||||
assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow
|
||||
assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow
|
||||
first.settings.oauth_flows.clear("openai_codex")
|
||||
assert first_flow.cancel_count == 1
|
||||
assert second_flow.cancel_count == 0
|
||||
|
||||
|
||||
def test_settings_mutations_serialize_read_modify_write(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
services = WebUISettingsServices.create(config_path)
|
||||
first_loaded = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_started = threading.Event()
|
||||
second_loaded = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
from nanobot.webui import settings_api
|
||||
|
||||
original_load = settings_api._load_settings_config
|
||||
|
||||
def controlled_load(path: Path | None) -> Config:
|
||||
config = original_load(path)
|
||||
if threading.current_thread().name == "settings-first":
|
||||
first_loaded.set()
|
||||
if not release_first.wait(timeout=2):
|
||||
raise TimeoutError("timed out waiting to release first settings mutation")
|
||||
elif threading.current_thread().name == "settings-second":
|
||||
second_loaded.set()
|
||||
return config
|
||||
|
||||
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
|
||||
|
||||
def run_first() -> None:
|
||||
try:
|
||||
services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]})
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||
errors.append(exc)
|
||||
|
||||
def run_second() -> None:
|
||||
try:
|
||||
second_started.set()
|
||||
services.mutate(update_api_settings, {"host": ["127.0.0.9"]})
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||
errors.append(exc)
|
||||
|
||||
first = threading.Thread(target=run_first, name="settings-first")
|
||||
second = threading.Thread(target=run_second, name="settings-second")
|
||||
first.start()
|
||||
assert first_loaded.wait(timeout=2)
|
||||
second.start()
|
||||
assert second_started.wait(timeout=2)
|
||||
assert not second_loaded.wait(timeout=0.1)
|
||||
release_first.set()
|
||||
first.join(timeout=2)
|
||||
second.join(timeout=2)
|
||||
|
||||
assert not first.is_alive()
|
||||
assert not second.is_alive()
|
||||
assert not errors
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.timezone == "Asia/Tokyo"
|
||||
assert saved.api.host == "127.0.0.9"
|
||||
|
||||
|
||||
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
|
||||
registry = WebUIOAuthFlowRegistry(max_flows=2)
|
||||
expired = _Flow(expired=True)
|
||||
oldest = _Flow()
|
||||
newest = _Flow()
|
||||
replacement = _Flow()
|
||||
|
||||
registry.register("openai_codex", "expired", expired)
|
||||
registry.register("openai_codex", "oldest", oldest)
|
||||
assert expired.cancel_count == 1
|
||||
assert registry.get("openai_codex", "expired") is None
|
||||
|
||||
registry.register("xai_grok", "newest", newest)
|
||||
registry.register("openai_codex", "replacement", replacement)
|
||||
assert oldest.cancel_count == 1
|
||||
assert registry.get("openai_codex", "oldest") is None
|
||||
assert registry.get("xai_grok", "newest") is newest
|
||||
assert registry.get("openai_codex", "newest") is None
|
||||
|
||||
registry.remove("xai_grok", "newest", newest, cancel=False)
|
||||
assert newest.cancel_count == 0
|
||||
assert registry.get("xai_grok", "newest") is None
|
||||
|
||||
registry.clear("openai_codex")
|
||||
assert replacement.cancel_count == 1
|
||||
assert registry.get("openai_codex", "replacement") is None
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_system import (
|
||||
coerce_channel_value,
|
||||
system_settings_payload,
|
||||
update_agent_system_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
|
||||
config = Config()
|
||||
|
||||
changed, restart_required = update_agent_system_settings(
|
||||
config,
|
||||
{
|
||||
"timezone": ["Asia/Shanghai"],
|
||||
"tool_hint_max_length": ["120"],
|
||||
},
|
||||
)
|
||||
payload = system_settings_payload(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
version="0.3.0",
|
||||
)
|
||||
|
||||
assert changed is True
|
||||
assert restart_required is True
|
||||
assert config.agents.defaults.timezone == "Asia/Shanghai"
|
||||
assert config.agents.defaults.timezone_mode == "manual"
|
||||
assert config.agents.defaults.tool_hint_max_length == 120
|
||||
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
|
||||
assert payload["version"] == {"current": "0.3.0"}
|
||||
assert payload["docs"]["version"] == "0.3.0"
|
||||
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
|
||||
|
||||
|
||||
def test_system_domain_validates_channel_field_values() -> None:
|
||||
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
|
||||
"alice",
|
||||
"bob",
|
||||
]
|
||||
assert coerce_channel_value("enabled", "yes", "bool") is True
|
||||
assert coerce_channel_value("port", "8765", "int") == 8765
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
|
||||
|
||||
def _handler(config: WebSocketConfig) -> GatewayHTTPHandler:
|
||||
handler = object.__new__(GatewayHTTPHandler)
|
||||
handler.config = config
|
||||
return handler
|
||||
|
||||
|
||||
def _request(**headers: str) -> WsRequest:
|
||||
return cast(WsRequest, SimpleNamespace(headers=Headers(headers)))
|
||||
|
||||
|
||||
def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None:
|
||||
handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws"))
|
||||
|
||||
redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example"))
|
||||
|
||||
assert redirect_uri == "https://agent.example/auth/mcp/callback"
|
||||
|
||||
|
||||
def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None:
|
||||
handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765))
|
||||
|
||||
redirect_uri = handler._mcp_oauth_redirect_uri(
|
||||
_request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"})
|
||||
)
|
||||
|
||||
assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback"
|
||||
+61
-21
@@ -8,7 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
@@ -316,13 +316,23 @@ function AuthForm({
|
||||
onSecret: (secret: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState("");
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
|
||||
failed ? "invalid" : null,
|
||||
);
|
||||
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const secret = value.trim();
|
||||
if (!secret) return;
|
||||
if (!secret) {
|
||||
setValidationError("required");
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
onSecret(secret);
|
||||
};
|
||||
@@ -333,27 +343,57 @@ function AuthForm({
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-sm flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-sm font-medium text-foreground">
|
||||
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="webui-access-password"
|
||||
name="webui-access-password"
|
||||
type={passwordVisible ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={submitting}
|
||||
aria-invalid={validationError ? true : undefined}
|
||||
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={submitting}
|
||||
aria-label={t(
|
||||
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
|
||||
)}
|
||||
aria-controls="webui-access-password"
|
||||
onClick={() => setPasswordVisible((visible) => !visible)}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{passwordVisible ? (
|
||||
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{failed && (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{t("app.auth.invalid")}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("app.auth.placeholder")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!value.trim() || submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("app.auth.submit")}
|
||||
</Button>
|
||||
@@ -2041,7 +2081,7 @@ function Shell({
|
||||
setPairingBusyCode(code);
|
||||
setPairingError(null);
|
||||
try {
|
||||
const payload = await runPairingAction(getToken(), action, code);
|
||||
const payload = await runPairingAction(client, action, code);
|
||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (!current.has(code)) return current;
|
||||
@@ -2056,7 +2096,7 @@ function Shell({
|
||||
setPairingBusyCode(null);
|
||||
}
|
||||
},
|
||||
[getToken, refreshPairingRequests],
|
||||
[client, refreshPairingRequests],
|
||||
);
|
||||
|
||||
const onDismissPairingRequest = useCallback((code: string) => {
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||
|
||||
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
||||
import { ImageGenerationSettings } from "@/components/settings/capabilities/ImageGenerationSettings";
|
||||
import { AdvancedSettings } from "@/components/settings/capabilities/SecuritySettings";
|
||||
import { TranscriptionSettings } from "@/components/settings/capabilities/TranscriptionSettings";
|
||||
import { WebSettings } from "@/components/settings/capabilities/WebSettings";
|
||||
import {
|
||||
ModelPresetDeleteDialog,
|
||||
ModelsSettings,
|
||||
} from "@/components/settings/models/ModelsSettings";
|
||||
import {
|
||||
ProviderOAuthLoginDialog,
|
||||
ProvidersSettings,
|
||||
providerFormFromRow,
|
||||
} from "@/components/settings/models/ProviderSettings";
|
||||
import { AppearanceSettings, OverviewSettings } from "@/components/settings/overview/OverviewSettings";
|
||||
import { SettingsSidebar, standaloneSectionTitle } from "@/components/settings/SettingsSidebar";
|
||||
import {
|
||||
NanobotFeatureInstallDialog,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { AppsCatalogSettings } from "@/components/settings/system/AppsSettings";
|
||||
import {
|
||||
AutomationDeleteDialog,
|
||||
AutomationEditDialog,
|
||||
AutomationsSettings,
|
||||
} from "@/components/settings/system/AutomationsSettings";
|
||||
import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings";
|
||||
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
|
||||
import type { SettingsController } from "@/components/settings/useSettingsController";
|
||||
import type { SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsPageProps {
|
||||
controller: SettingsController;
|
||||
theme: "light" | "dark";
|
||||
showSidebar: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onBackToChat: () => void;
|
||||
skills: SkillSummary[];
|
||||
onLogout?: () => void;
|
||||
isRestarting: boolean;
|
||||
hostChromeInset: boolean;
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
controller,
|
||||
theme,
|
||||
showSidebar,
|
||||
onToggleTheme,
|
||||
onBackToChat,
|
||||
skills,
|
||||
onLogout,
|
||||
isRestarting,
|
||||
hostChromeInset,
|
||||
}: SettingsPageProps) {
|
||||
const {
|
||||
activeSection,
|
||||
apiService,
|
||||
apiServiceAction,
|
||||
apiServiceError,
|
||||
apiServiceLoading,
|
||||
appsKindFilter,
|
||||
appsQuery,
|
||||
automationAction,
|
||||
automationPendingDelete,
|
||||
automationPendingEdit,
|
||||
automations,
|
||||
automationsError,
|
||||
automationsFilter,
|
||||
automationsLoading,
|
||||
automationsQuery,
|
||||
automationsSort,
|
||||
beginModelPresetCreation,
|
||||
cancelModelPresetCreation,
|
||||
changeModelCallOrder,
|
||||
channelsQuery,
|
||||
cliApps,
|
||||
cliAppsAction,
|
||||
cliAppsError,
|
||||
cliAppsFocusName,
|
||||
cliAppsLoading,
|
||||
cliAppsMessage,
|
||||
closeProviderOAuthFlow,
|
||||
completeProviderOAuthResponse,
|
||||
createCustomProvider,
|
||||
customMcpForm,
|
||||
editingProviderKeys,
|
||||
error,
|
||||
expandedProvider,
|
||||
featureCatalog,
|
||||
form,
|
||||
handleApiServiceAction,
|
||||
handleAutomationAction,
|
||||
handleAutomationEdit,
|
||||
handleCliAppAction,
|
||||
handleDeleteModelConfiguration,
|
||||
handleImportMcpConfig,
|
||||
handleMcpOAuthCancel,
|
||||
handleMcpOAuthComplete,
|
||||
handleMcpOAuthConnect,
|
||||
handleMcpOAuthOpen,
|
||||
handleMcpPresetAction,
|
||||
handleMcpToolsChange,
|
||||
handleMigrateModelConfigurations,
|
||||
handleNanobotFeatureAction,
|
||||
handleSaveCustomMcp,
|
||||
handleToggleProvider,
|
||||
handleWebSearchProviderChange,
|
||||
hasPendingRestart,
|
||||
hostEngineApplying,
|
||||
imageGenerationDirty,
|
||||
imageGenerationForm,
|
||||
imageGenerationSaving,
|
||||
installCapabilities,
|
||||
loading,
|
||||
localPrefs,
|
||||
mcpConfigImport,
|
||||
mcpError,
|
||||
mcpFieldValues,
|
||||
mcpMessage,
|
||||
mcpOAuthCallbackError,
|
||||
mcpOAuthCallbackUrl,
|
||||
mcpOAuthCompleting,
|
||||
mcpOAuthFlow,
|
||||
mcpOAuthPopupBlocked,
|
||||
mcpPresetAction,
|
||||
mcpPresets,
|
||||
mcpPresetsLoading,
|
||||
modelCallOrder,
|
||||
modelCallOrderSaving,
|
||||
modelConfigurationSaving,
|
||||
modelDirty,
|
||||
modelMigrationSaving,
|
||||
modelPresetBeforeCreateRef,
|
||||
modelPresetCreating,
|
||||
modelPresetPendingDelete,
|
||||
nanobotFeatureAction,
|
||||
nanobotFeatureConfirm,
|
||||
nanobotFeatures,
|
||||
nanobotFeaturesError,
|
||||
nanobotFeaturesLoading,
|
||||
networkSafetyDirty,
|
||||
networkSafetyForm,
|
||||
networkSafetySaving,
|
||||
pendingRestartSections,
|
||||
providerForms,
|
||||
providerOAuthCompleting,
|
||||
providerOAuthDialogError,
|
||||
providerOAuthFlow,
|
||||
providerOAuthResponse,
|
||||
providerSaving,
|
||||
remoteBrowserAccess,
|
||||
resetWebSearchDraft,
|
||||
restartViaSettingsSurface,
|
||||
runProviderOAuth,
|
||||
saveImageGenerationSettings,
|
||||
saveModelSettings,
|
||||
saveNetworkSafetySettings,
|
||||
saveProvider,
|
||||
saveTranscriptionSettings,
|
||||
saveWebSearch,
|
||||
saving,
|
||||
selectSection,
|
||||
setAppsKindFilter,
|
||||
setAppsQuery,
|
||||
setAutomationPendingDelete,
|
||||
setAutomationPendingEdit,
|
||||
setAutomationsFilter,
|
||||
setAutomationsQuery,
|
||||
setAutomationsSort,
|
||||
setChannelsQuery,
|
||||
setCliAppsError,
|
||||
setCliAppsMessage,
|
||||
setCustomMcpForm,
|
||||
setForm,
|
||||
setImageGenerationForm,
|
||||
setLocalPrefs,
|
||||
setMcpConfigImport,
|
||||
setMcpError,
|
||||
setMcpFieldValues,
|
||||
setMcpMessage,
|
||||
setMcpOAuthCallbackError,
|
||||
setMcpOAuthCallbackUrl,
|
||||
setModelPresetCreating,
|
||||
setModelPresetPendingDelete,
|
||||
setNanobotFeatureConfirm,
|
||||
setNanobotFeatures,
|
||||
setNanobotFeaturesError,
|
||||
setNetworkSafetyForm,
|
||||
setProviderForms,
|
||||
setProviderOAuthDialogError,
|
||||
setProviderOAuthResponse,
|
||||
setTranscriptionForm,
|
||||
setWebSearchForm,
|
||||
setWebSearchKeyEditing,
|
||||
setWebSearchKeyVisible,
|
||||
settings,
|
||||
t,
|
||||
toggleProviderKeyEditing,
|
||||
toggleProviderKeyVisibility,
|
||||
token,
|
||||
transcriptionDirty,
|
||||
transcriptionForm,
|
||||
transcriptionSaving,
|
||||
visibleProviderKeys,
|
||||
webSearchForm,
|
||||
webSearchKeyEditing,
|
||||
webSearchKeyVisible,
|
||||
webSearchSaving,
|
||||
} = controller;
|
||||
|
||||
const renderSection = () => {
|
||||
if (!settings) return null;
|
||||
switch (activeSection) {
|
||||
case "overview":
|
||||
return (
|
||||
<OverviewSettings
|
||||
settings={settings}
|
||||
requiresRestart={hasPendingRestart}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onSelectSection={selectSection}
|
||||
/>
|
||||
);
|
||||
case "appearance":
|
||||
return (
|
||||
<AppearanceSettings
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
localPrefs={localPrefs}
|
||||
onChangeLocalPrefs={setLocalPrefs}
|
||||
/>
|
||||
);
|
||||
case "models":
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ModelsSettings
|
||||
token={token}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
settings={settings}
|
||||
dirty={modelDirty}
|
||||
creating={modelPresetCreating}
|
||||
creatingSaving={modelConfigurationSaving}
|
||||
callOrder={modelCallOrder}
|
||||
saving={saving}
|
||||
orderSaving={modelCallOrderSaving || modelConfigurationSaving}
|
||||
migrationSaving={modelMigrationSaving}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
providerSaving={providerSaving}
|
||||
onChangeCallOrder={changeModelCallOrder}
|
||||
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
|
||||
onSave={saveModelSettings}
|
||||
onMigrate={handleMigrateModelConfigurations}
|
||||
onBeginCreate={beginModelPresetCreation}
|
||||
onCancelCreate={cancelModelPresetCreation}
|
||||
onSelectConfiguration={() => {
|
||||
setModelPresetCreating(false);
|
||||
modelPresetBeforeCreateRef.current = null;
|
||||
}}
|
||||
onDeleteConfiguration={setModelPresetPendingDelete}
|
||||
/>
|
||||
<ProvidersSettings
|
||||
settings={settings}
|
||||
nanobotFeatures={nanobotFeatures}
|
||||
featureAction={nanobotFeatureAction}
|
||||
capabilityError={nanobotFeaturesError}
|
||||
expandedProvider={expandedProvider}
|
||||
providerForms={providerForms}
|
||||
visibleProviderKeys={visibleProviderKeys}
|
||||
editingProviderKeys={editingProviderKeys}
|
||||
providerSaving={providerSaving}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
remoteBrowserAccess={remoteBrowserAccess}
|
||||
onToggleProvider={handleToggleProvider}
|
||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
||||
onChangeProviderForm={(provider, value) =>
|
||||
setProviderForms((prev) => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
...(prev[provider] ?? providerFormFromRow(
|
||||
settings.providers.find((row) => row.name === provider) ?? {
|
||||
name: provider,
|
||||
label: provider,
|
||||
configured: false,
|
||||
},
|
||||
)),
|
||||
...value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
onSaveProvider={saveProvider}
|
||||
onCreateCustomProvider={createCustomProvider}
|
||||
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
|
||||
onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")}
|
||||
imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<ImageGenerationSettings
|
||||
token={token}
|
||||
settings={settings}
|
||||
form={imageGenerationForm}
|
||||
dirty={imageGenerationDirty}
|
||||
saving={imageGenerationSaving}
|
||||
onChangeForm={setImageGenerationForm}
|
||||
onSave={saveImageGenerationSettings}
|
||||
onOpenProviders={() => selectSection("models")}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.image}
|
||||
/>
|
||||
);
|
||||
case "voice":
|
||||
return (
|
||||
<TranscriptionSettings
|
||||
settings={settings}
|
||||
form={transcriptionForm}
|
||||
dirty={transcriptionDirty}
|
||||
saving={transcriptionSaving}
|
||||
onChangeForm={setTranscriptionForm}
|
||||
onSave={saveTranscriptionSettings}
|
||||
onOpenProviders={() => selectSection("models")}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.browser}
|
||||
/>
|
||||
);
|
||||
case "browser":
|
||||
return (
|
||||
<WebSettings
|
||||
settings={settings}
|
||||
form={webSearchForm}
|
||||
keyVisible={webSearchKeyVisible}
|
||||
keyEditing={webSearchKeyEditing}
|
||||
saving={webSearchSaving}
|
||||
onChangeForm={setWebSearchForm}
|
||||
onChangeProvider={handleWebSearchProviderChange}
|
||||
onToggleKey={() => setWebSearchKeyVisible((visible) => !visible)}
|
||||
onToggleKeyEditing={() => {
|
||||
setWebSearchKeyEditing((editing) => !editing);
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchForm((prev) => ({ ...prev, apiKey: "" }));
|
||||
}}
|
||||
onReset={resetWebSearchDraft}
|
||||
onSave={saveWebSearch}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.browser}
|
||||
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
|
||||
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
|
||||
capabilityError={nanobotFeaturesError}
|
||||
/>
|
||||
);
|
||||
case "channels":
|
||||
return (
|
||||
<ChannelsSettings
|
||||
token={token}
|
||||
nanobotFeatures={nanobotFeatures}
|
||||
loading={nanobotFeaturesLoading}
|
||||
query={channelsQuery}
|
||||
actionKey={nanobotFeatureAction}
|
||||
chatAppsDocsUrl={settings.docs?.chat_apps_url}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
error={nanobotFeaturesError}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
onQueryChange={setChannelsQuery}
|
||||
onAction={handleNanobotFeatureAction}
|
||||
onFeaturesUpdate={setNanobotFeatures}
|
||||
onDismissStatus={() => {
|
||||
setNanobotFeaturesError(null);
|
||||
}}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
);
|
||||
case "apps":
|
||||
return (
|
||||
<AppsCatalogSettings
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
cliAppsLoading={cliAppsLoading}
|
||||
mcpPresetsLoading={mcpPresetsLoading}
|
||||
query={appsQuery}
|
||||
filter={appsKindFilter}
|
||||
cliActionKey={cliAppsAction}
|
||||
mcpActionKey={mcpPresetAction}
|
||||
mcpOAuthFlow={mcpOAuthFlow}
|
||||
mcpOAuthPopupBlocked={mcpOAuthPopupBlocked}
|
||||
mcpOAuthCallbackUrl={mcpOAuthCallbackUrl}
|
||||
mcpOAuthCompleting={mcpOAuthCompleting}
|
||||
mcpOAuthCallbackError={mcpOAuthCallbackError}
|
||||
cliMessage={cliAppsMessage}
|
||||
cliError={cliAppsError}
|
||||
cliFocusName={cliAppsFocusName}
|
||||
mcpMessage={mcpMessage}
|
||||
mcpError={mcpError}
|
||||
mcpFieldValues={mcpFieldValues}
|
||||
customMcpForm={customMcpForm}
|
||||
mcpConfigImport={mcpConfigImport}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
onQueryChange={setAppsQuery}
|
||||
onFilterChange={setAppsKindFilter}
|
||||
onCliAction={handleCliAppAction}
|
||||
onMcpAction={handleMcpPresetAction}
|
||||
onMcpOAuthConnect={handleMcpOAuthConnect}
|
||||
onMcpOAuthCancel={() => void handleMcpOAuthCancel()}
|
||||
onMcpOAuthOpen={handleMcpOAuthOpen}
|
||||
onMcpOAuthCallbackUrlChange={(value) => {
|
||||
setMcpOAuthCallbackUrl(value);
|
||||
setMcpOAuthCallbackError(null);
|
||||
}}
|
||||
onMcpOAuthComplete={() => void handleMcpOAuthComplete()}
|
||||
onDismissStatus={() => {
|
||||
setCliAppsMessage(null);
|
||||
setCliAppsError(null);
|
||||
setMcpMessage(null);
|
||||
setMcpError(null);
|
||||
}}
|
||||
onBackToChat={onBackToChat}
|
||||
onMcpFieldChange={(presetName, fieldName, value) => {
|
||||
setMcpFieldValues((prev) => ({
|
||||
...prev,
|
||||
[presetName]: {
|
||||
...(prev[presetName] ?? {}),
|
||||
[fieldName]: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
onCustomMcpFormChange={setCustomMcpForm}
|
||||
onMcpConfigImportChange={setMcpConfigImport}
|
||||
onSaveCustomMcp={handleSaveCustomMcp}
|
||||
onImportMcpConfig={handleImportMcpConfig}
|
||||
onMcpToolsChange={handleMcpToolsChange}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
);
|
||||
case "automations":
|
||||
return (
|
||||
<AutomationsSettings
|
||||
payload={automations}
|
||||
loading={automationsLoading}
|
||||
query={automationsQuery}
|
||||
filter={automationsFilter}
|
||||
sort={automationsSort}
|
||||
actionKey={automationAction}
|
||||
error={automationsError}
|
||||
onQueryChange={setAutomationsQuery}
|
||||
onFilterChange={setAutomationsFilter}
|
||||
onSortChange={setAutomationsSort}
|
||||
onAction={handleAutomationAction}
|
||||
onRequestEdit={setAutomationPendingEdit}
|
||||
onRequestDelete={setAutomationPendingDelete}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return <SkillsCatalogSettings skills={skills} />;
|
||||
case "runtime":
|
||||
return (
|
||||
<RuntimeSettings
|
||||
form={form}
|
||||
settings={settings}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
apiService={apiService}
|
||||
apiServiceLoading={apiServiceLoading}
|
||||
apiServiceAction={apiServiceAction}
|
||||
apiServiceError={apiServiceError}
|
||||
langfuseFeature={featureCatalog.find((feature) => feature.name === "langfuse")}
|
||||
capabilitiesLoading={nanobotFeaturesLoading}
|
||||
capabilityAction={nanobotFeatureAction}
|
||||
capabilityError={nanobotFeaturesError}
|
||||
onApiServiceAction={handleApiServiceAction}
|
||||
onInstallCapability={(name) => void installCapabilities([name])}
|
||||
/>
|
||||
);
|
||||
case "advanced":
|
||||
return (
|
||||
<AdvancedSettings
|
||||
form={networkSafetyForm}
|
||||
dirty={networkSafetyDirty}
|
||||
saving={networkSafetySaving}
|
||||
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
|
||||
onChangeForm={setNetworkSafetyForm}
|
||||
onSave={saveNetworkSafetySettings}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.runtime}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-settings-canvas lg:flex-row">
|
||||
{showSidebar ? (
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSelectSection={selectSection}
|
||||
onBackToChat={onBackToChat}
|
||||
onLogout={onLogout}
|
||||
hostChromeInset={hostChromeInset}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ModelPresetDeleteDialog
|
||||
preset={modelPresetPendingDelete}
|
||||
deleting={saving}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setModelPresetPendingDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteModelConfiguration}
|
||||
/>
|
||||
|
||||
<ProviderOAuthLoginDialog
|
||||
flow={providerOAuthFlow}
|
||||
providerLabel={
|
||||
providerOAuthFlow
|
||||
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
|
||||
?.label ?? providerOAuthFlow.provider
|
||||
: ""
|
||||
}
|
||||
authorizationResponse={providerOAuthResponse}
|
||||
completing={providerOAuthCompleting}
|
||||
error={providerOAuthDialogError}
|
||||
remoteBrowserAccess={remoteBrowserAccess}
|
||||
onAuthorizationResponseChange={(value) => {
|
||||
setProviderOAuthResponse(value);
|
||||
setProviderOAuthDialogError(null);
|
||||
}}
|
||||
onOpenAuthorization={() => {
|
||||
if (!providerOAuthFlow) return;
|
||||
const opened = window.open(
|
||||
providerOAuthFlow.authorization_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
if (opened) opened.opener = null;
|
||||
}}
|
||||
onComplete={() => void completeProviderOAuthResponse()}
|
||||
onClose={closeProviderOAuthFlow}
|
||||
/>
|
||||
|
||||
<NanobotFeatureInstallDialog
|
||||
feature={nanobotFeatureConfirm}
|
||||
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNanobotFeatureConfirm(null);
|
||||
}}
|
||||
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
|
||||
/>
|
||||
|
||||
<AutomationDeleteDialog
|
||||
job={automationPendingDelete}
|
||||
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAutomationPendingDelete(null);
|
||||
}}
|
||||
onConfirm={(job) => handleAutomationAction("delete", job)}
|
||||
/>
|
||||
|
||||
<AutomationEditDialog
|
||||
job={automationPendingEdit}
|
||||
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAutomationPendingEdit(null);
|
||||
}}
|
||||
onSave={handleAutomationEdit}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
||||
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={activeSection}
|
||||
data-testid="settings-section-transition"
|
||||
data-settings-section={activeSection}
|
||||
className={cn(
|
||||
"mx-auto w-full animate-in fade-in-0 slide-in-from-bottom-1 px-4 py-6 duration-200 ease-out",
|
||||
"motion-reduce:animate-none sm:px-8 sm:py-8 lg:py-12",
|
||||
activeSection === "channels" ? "max-w-[1240px] xl:px-10" : "max-w-[920px]",
|
||||
activeSection === "channels" && "flex min-h-full flex-col xl:h-full xl:min-h-0",
|
||||
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
|
||||
)}
|
||||
>
|
||||
{!showSidebar ? (
|
||||
<div className="mb-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
||||
{t(`settings.nav.${activeSection}`, {
|
||||
defaultValue: standaloneSectionTitle(activeSection),
|
||||
})}
|
||||
</h1>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("settings.status.loading")}
|
||||
</div>
|
||||
) : error && !settings ? (
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.status.loadError")}>
|
||||
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
) : settings ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-5",
|
||||
activeSection === "channels" &&
|
||||
"flex min-h-0 flex-1 flex-col xl:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{renderSection()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Globe2,
|
||||
ImageIcon,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Palette,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
SidebarSelectionHighlight,
|
||||
} from "@/components/SidebarSelectionHighlight";
|
||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
|
||||
{ key: "overview", icon: Activity, fallback: "Overview" },
|
||||
{ key: "appearance", icon: Palette, fallback: "Appearance" },
|
||||
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
|
||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||
{ key: "voice", icon: Mic, fallback: "Voice" },
|
||||
{ key: "browser", icon: Globe2, fallback: "Web" },
|
||||
{ key: "channels", icon: MessageCircle, fallback: "Channels" },
|
||||
{ key: "runtime", icon: Server, fallback: "System" },
|
||||
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
|
||||
];
|
||||
|
||||
export function standaloneSectionTitle(section: SettingsSectionKey): string {
|
||||
if (section === "apps") return "Apps";
|
||||
if (section === "automations") return "Automations";
|
||||
if (section === "skills") return "Skills";
|
||||
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
activeSection,
|
||||
onSelectSection,
|
||||
onBackToChat,
|
||||
onLogout,
|
||||
hostChromeInset,
|
||||
}: {
|
||||
activeSection: SettingsSectionKey;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
onBackToChat: () => void;
|
||||
onLogout?: () => void;
|
||||
hostChromeInset?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const activeNavItemRef = useRef<HTMLButtonElement>(null);
|
||||
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
||||
?? SETTINGS_NAV_ITEMS[0];
|
||||
const ActiveIcon = activeItem.icon;
|
||||
const activeLabel = t(`settings.nav.${activeItem.key}`, {
|
||||
defaultValue: activeItem.fallback,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-full shrink-0 flex-col bg-settings-surface px-3 pb-2 lg:w-[17rem] lg:px-3 lg:pb-4",
|
||||
hostChromeInset ? "pt-[4.25rem] lg:pt-[4.25rem]" : "pt-4 lg:pt-4",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
||||
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label={t("settings.sidebar.ariaLabel")}
|
||||
className="w-full"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
|
||||
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
|
||||
>
|
||||
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onSelect={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
|
||||
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(`settings.nav.${key}`, { defaultValue: fallback })}
|
||||
</span>
|
||||
{active ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeNavItemRef}
|
||||
activeId={activeSection}
|
||||
scope="settings"
|
||||
className="relative hidden space-y-1 lg:block"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
<button
|
||||
ref={active ? activeNavItemRef : undefined}
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="truncate">
|
||||
{t(`settings.nav.${key}`, { defaultValue: fallback })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</SidebarSelectionHighlight>
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:mt-auto lg:block lg:pt-4">
|
||||
{onLogout && !hostChromeInset ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onLogout}
|
||||
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
||||
>
|
||||
<LogOut className="h-4 w-4" aria-hidden />
|
||||
{t("app.account.logout")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { getToken } = useClient();
|
||||
const { client, getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -321,7 +321,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
|
||||
notifySkillsChanged(payload);
|
||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||
if (updated) {
|
||||
@@ -345,7 +345,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||
const payload = await deleteSkill(client, activeSkill.name);
|
||||
notifySkillsChanged(payload);
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
|
||||
installing: string;
|
||||
onInstallingChange: (skillId: string) => void;
|
||||
}) {
|
||||
const { getToken } = useClient();
|
||||
const { client, getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
|
||||
setError("");
|
||||
try {
|
||||
const payload = await installMarketplaceSkill(
|
||||
getToken(),
|
||||
client,
|
||||
skill.provider,
|
||||
skill.source,
|
||||
skill.skill_id,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ModelIdPicker, ProviderPicker, optionRowsWithCurrent } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
NumberInput,
|
||||
ReadOnlyRow,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ImageGenerationSettingsUpdate, SettingsPayload } from "@/lib/types";
|
||||
|
||||
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
|
||||
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
|
||||
|
||||
export const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
defaultAspectRatio: "1:1",
|
||||
defaultImageSize: "1K",
|
||||
maxImagesPerTurn: 4,
|
||||
};
|
||||
|
||||
export function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
|
||||
return {
|
||||
enabled: payload.image_generation.enabled,
|
||||
provider: payload.image_generation.provider,
|
||||
model: payload.image_generation.model,
|
||||
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
|
||||
defaultImageSize: payload.image_generation.default_image_size,
|
||||
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageGenerationSettings({
|
||||
token,
|
||||
settings,
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onOpenProviders,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
}: {
|
||||
token: string;
|
||||
settings: SettingsPayload;
|
||||
form: ImageGenerationSettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onOpenProviders: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const selectedProvider =
|
||||
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
|
||||
settings.image_generation.providers[0];
|
||||
const providerConfigured = !!selectedProvider?.configured;
|
||||
const missingCredential = form.enabled && !providerConfigured;
|
||||
const aspectOptions = optionRowsWithCurrent(
|
||||
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
|
||||
form.defaultAspectRatio,
|
||||
);
|
||||
const sizeOptions = optionRowsWithCurrent(
|
||||
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
|
||||
form.defaultImageSize,
|
||||
);
|
||||
const selectProvider = (provider: string) => {
|
||||
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
|
||||
onChangeForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
|
||||
<ToggleButton
|
||||
checked={form.enabled}
|
||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
||||
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
|
||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
|
||||
<ProviderPicker
|
||||
providers={settings.image_generation.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={selectProvider}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.imageProviderStatus", "Provider status")}
|
||||
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
||||
{providerConfigured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")}
|
||||
</StatusPill>
|
||||
{!providerConfigured ? (
|
||||
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
||||
{tx("settings.image.configureProvider", "Configure provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
|
||||
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
|
||||
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
models={selectedProvider?.models ?? []}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
emptyLabel={tx("settings.image.selectModel", "Select image model")}
|
||||
searchPlaceholder={tx(
|
||||
"settings.image.searchOrTypeModel",
|
||||
"Search or type model ID",
|
||||
)}
|
||||
emptyMessage={tx(
|
||||
"settings.image.typeModelId",
|
||||
"Type the model ID supported by this provider.",
|
||||
)}
|
||||
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
|
||||
<ProviderPicker
|
||||
providers={aspectOptions}
|
||||
value={form.defaultAspectRatio}
|
||||
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
|
||||
onChange={(defaultAspectRatio) =>
|
||||
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
|
||||
<ProviderPicker
|
||||
providers={sizeOptions}
|
||||
value={form.defaultImageSize}
|
||||
emptyLabel={tx("settings.image.selectSize", "Select size")}
|
||||
onChange={(defaultImageSize) =>
|
||||
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
|
||||
<NumberInput
|
||||
value={form.maxImagesPerTurn}
|
||||
min={1}
|
||||
max={8}
|
||||
onChange={(maxImagesPerTurn) =>
|
||||
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
disabled={missingCredential}
|
||||
message={
|
||||
missingCredential
|
||||
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
|
||||
: undefined
|
||||
}
|
||||
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
||||
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type {
|
||||
NetworkSafetySettingsUpdate,
|
||||
SettingsPayload,
|
||||
WebuiDefaultAccessMode,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
};
|
||||
|
||||
export function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
||||
return {
|
||||
webuiAllowLocalServiceAccess:
|
||||
payload.advanced.webui_allow_local_service_access ??
|
||||
payload.advanced.allow_local_preview_access ??
|
||||
true,
|
||||
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
|
||||
payload.advanced.webui_default_access_mode,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
|
||||
return mode === "full" ? "full" : "default";
|
||||
}
|
||||
|
||||
export function AdvancedSettings({
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
requiresRestartPending,
|
||||
isNativeHostSurface,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
}: {
|
||||
form: NetworkSafetySettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
isNativeHostSurface: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>
|
||||
{isNativeHostSurface
|
||||
? tx("settings.sections.hostSafety", "App safety")
|
||||
: tx("settings.sections.webuiSafety", "Web safety")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
||||
description={tx(
|
||||
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
|
||||
isNativeHostSurface
|
||||
? "Allow Full Access shell commands to reach services on this Mac."
|
||||
: "Allow Full Access shell commands to reach localhost services.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={form.webuiAllowLocalServiceAccess}
|
||||
onChange={(webuiAllowLocalServiceAccess) =>
|
||||
onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
|
||||
}
|
||||
ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
||||
label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
|
||||
description={tx(
|
||||
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
|
||||
isNativeHostSurface
|
||||
? "Used by native chats without a project-specific permission."
|
||||
: "Used by web chats without a project-specific permission.",
|
||||
)}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={form.webuiDefaultAccessMode}
|
||||
options={[
|
||||
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
|
||||
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
|
||||
]}
|
||||
onChange={(webuiDefaultAccessMode) =>
|
||||
onChangeForm((prev) => ({
|
||||
...prev,
|
||||
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.help.securityManagedControls",
|
||||
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
NumberInput,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { SettingsPayload, TranscriptionSettingsUpdate } from "@/lib/types";
|
||||
|
||||
export const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
model: "",
|
||||
language: "",
|
||||
maxDurationSec: 120,
|
||||
maxUploadMb: 25,
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
provider_configured: false,
|
||||
model: "whisper-large-v3",
|
||||
language: null,
|
||||
max_duration_sec: 120,
|
||||
max_upload_mb: 25,
|
||||
providers: [],
|
||||
};
|
||||
|
||||
export function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
|
||||
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
return {
|
||||
enabled: transcription.enabled,
|
||||
provider: transcription.provider,
|
||||
model: transcription.model,
|
||||
language: transcription.language ?? "",
|
||||
maxDurationSec: transcription.max_duration_sec,
|
||||
maxUploadMb: transcription.max_upload_mb,
|
||||
};
|
||||
}
|
||||
|
||||
export function TranscriptionSettings({
|
||||
settings,
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onOpenProviders,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: TranscriptionSettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onOpenProviders: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const selectedProvider =
|
||||
transcription.providers.find((provider) => provider.name === form.provider) ??
|
||||
transcription.providers[0];
|
||||
const providerConfigured = !!selectedProvider?.configured;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcription", "Transcription")}
|
||||
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={form.enabled}
|
||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
||||
ariaLabel={tx("settings.rows.transcription", "Transcription")}
|
||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
|
||||
<ProviderPicker
|
||||
providers={transcription.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
|
||||
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
||||
{providerConfigured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")}
|
||||
</StatusPill>
|
||||
{!providerConfigured ? (
|
||||
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
||||
{tx("settings.voice.configureProvider", "Configure provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionModel", "Model")}
|
||||
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
|
||||
>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionLanguage", "Language")}
|
||||
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
|
||||
>
|
||||
<Input
|
||||
value={form.language}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
|
||||
placeholder={tx("settings.voice.languageAuto", "Auto")}
|
||||
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<NumberInput
|
||||
value={form.maxDurationSec}
|
||||
min={1}
|
||||
max={600}
|
||||
suffix="s"
|
||||
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
|
||||
/>
|
||||
<NumberInput
|
||||
value={form.maxUploadMb}
|
||||
min={1}
|
||||
max={100}
|
||||
suffix="MB"
|
||||
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
||||
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Eye, EyeOff, Pencil } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
CapabilityInstallNotice,
|
||||
NumberInput,
|
||||
RestartSettingsFooter,
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type {
|
||||
NanobotFeatureInfo,
|
||||
SettingsPayload,
|
||||
WebSearchSettingsUpdate,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
|
||||
provider: "duckduckgo",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
maxResults: 5,
|
||||
timeout: 30,
|
||||
useJinaReader: true,
|
||||
};
|
||||
|
||||
export function webSearchFormFromPayload(
|
||||
payload: SettingsPayload,
|
||||
previous?: WebSearchSettingsUpdate,
|
||||
): WebSearchSettingsUpdate {
|
||||
return {
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
|
||||
baseUrl: payload.web_search.base_url ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
};
|
||||
}
|
||||
|
||||
type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number];
|
||||
|
||||
export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
|
||||
return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
|
||||
}
|
||||
|
||||
export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
|
||||
return provider?.credential === "api_key";
|
||||
}
|
||||
|
||||
export function WebSettings({
|
||||
settings,
|
||||
form,
|
||||
keyVisible,
|
||||
keyEditing,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onChangeProvider,
|
||||
onToggleKey,
|
||||
onToggleKeyEditing,
|
||||
onReset,
|
||||
onSave,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
olostepFeature,
|
||||
olostepInstalling,
|
||||
capabilityError,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: WebSearchSettingsUpdate;
|
||||
keyVisible: boolean;
|
||||
keyEditing: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
|
||||
onChangeProvider: (provider: string) => void;
|
||||
onToggleKey: () => void;
|
||||
onToggleKeyEditing: () => void;
|
||||
onReset: () => void;
|
||||
onSave: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
olostepFeature?: NanobotFeatureInfo;
|
||||
olostepInstalling: boolean;
|
||||
capabilityError: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const selectedProvider =
|
||||
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
|
||||
settings.web_search.providers[0];
|
||||
const hasExistingSecret =
|
||||
webSearchProviderAcceptsApiKey(selectedProvider) &&
|
||||
form.provider === settings.web_search.provider &&
|
||||
!!settings.web_search.api_key_hint;
|
||||
const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
|
||||
const apiKey = form.apiKey?.trim() ?? "";
|
||||
const baseUrl = form.baseUrl?.trim() ?? "";
|
||||
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
|
||||
const dirty =
|
||||
form.provider !== settings.web_search.provider ||
|
||||
apiKey.length > 0 ||
|
||||
baseUrl !== (settings.web_search.base_url ?? "") ||
|
||||
form.maxResults !== settings.web_search.max_results ||
|
||||
form.timeout !== settings.web_search.timeout ||
|
||||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||
const missingCredential =
|
||||
webSearchProviderRequiresApiKey(selectedProvider)
|
||||
? !apiKey && !hasExistingSecret
|
||||
: selectedProvider?.credential === "base_url"
|
||||
? !baseUrl
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
||||
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
|
||||
<div className="mb-3">
|
||||
<CapabilityInstallNotice
|
||||
title={tx("settings.capabilities.searchSupport", "Search provider support")}
|
||||
description={tx(
|
||||
"settings.capabilities.searchInstallOnSave",
|
||||
"Olostep support will be installed automatically when you save.",
|
||||
)}
|
||||
installing={olostepInstalling}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{capabilityError ? (
|
||||
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
|
||||
) : null}
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.byok.webSearch.provider")}>
|
||||
<ProviderPicker
|
||||
providers={settings.web_search.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={t("settings.byok.webSearch.selectProvider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={onChangeProvider}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{selectedProvider?.credential === "none" ? (
|
||||
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
|
||||
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
|
||||
<SettingsRow
|
||||
title={t("settings.byok.apiKey")}
|
||||
description={t("settings.byok.webSearch.apiKeyHelp")}
|
||||
>
|
||||
<div className="relative w-[280px] max-w-full">
|
||||
{showKeyInput ? (
|
||||
<>
|
||||
<Input
|
||||
type={keyVisible ? "text" : "password"}
|
||||
value={form.apiKey ?? ""}
|
||||
onChange={(event) =>
|
||||
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
hasExistingSecret
|
||||
? t("settings.byok.apiKeyConfiguredPlaceholder")
|
||||
: t("settings.byok.apiKeyPlaceholder")
|
||||
}
|
||||
className="h-9 rounded-full pr-11 text-[13px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleKey}
|
||||
aria-label={
|
||||
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
|
||||
}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{keyVisible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
|
||||
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleKeyEditing}
|
||||
aria-label={t("settings.actions.edit")}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
{selectedProvider?.credential === "base_url" ? (
|
||||
<SettingsRow
|
||||
title={t("settings.byok.webSearch.baseUrl")}
|
||||
description={t("settings.byok.webSearch.baseUrlHelp")}
|
||||
>
|
||||
<Input
|
||||
value={form.baseUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
|
||||
}
|
||||
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
|
||||
className="h-9 w-[280px] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
|
||||
<NumberInput
|
||||
value={form.maxResults ?? settings.web_search.max_results}
|
||||
min={1}
|
||||
max={10}
|
||||
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
|
||||
<NumberInput
|
||||
value={form.timeout ?? settings.web_search.timeout}
|
||||
min={1}
|
||||
max={120}
|
||||
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
|
||||
suffix="s"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.jinaReader", "Jina reader")}
|
||||
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={effectiveJinaReader}
|
||||
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
|
||||
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
|
||||
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
disabled={missingCredential}
|
||||
message={
|
||||
missingCredential
|
||||
? t("settings.byok.webSearch.missingCredential")
|
||||
: requiresRestartPending && !dirty
|
||||
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||
: jinaReaderDirty
|
||||
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
|
||||
: dirty
|
||||
? t("settings.byok.webSearch.saveHint")
|
||||
: undefined
|
||||
}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
onReset={onReset}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import {
|
||||
webSearchProviderAcceptsApiKey,
|
||||
webSearchProviderRequiresApiKey,
|
||||
} from "@/components/settings/capabilities/WebSettings";
|
||||
import type { CapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
|
||||
import type {
|
||||
ApplySettingsPayload,
|
||||
MaybeRestartHostEngine,
|
||||
PendingRestartSections,
|
||||
} from "@/components/settings/contracts";
|
||||
import {
|
||||
updateImageGenerationSettings,
|
||||
updateNetworkSafetySettings,
|
||||
updateTranscriptionSettings,
|
||||
updateWebSearchSettings,
|
||||
} from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
|
||||
|
||||
interface CapabilitySettingsActionsOptions {
|
||||
state: CapabilitySettingsState;
|
||||
settings: SettingsPayload | null;
|
||||
client: NanobotClient;
|
||||
t: TFunction;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
maybeRestartHostEngine: MaybeRestartHostEngine;
|
||||
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
installCapabilities: (names: string[]) => Promise<boolean>;
|
||||
imageGenerationDirty: boolean;
|
||||
transcriptionDirty: boolean;
|
||||
networkSafetyDirty: boolean;
|
||||
}
|
||||
|
||||
export function useCapabilitySettingsActions({
|
||||
state,
|
||||
settings,
|
||||
client,
|
||||
t,
|
||||
applyPayload,
|
||||
maybeRestartHostEngine,
|
||||
setPendingRestartSections,
|
||||
setError,
|
||||
installCapabilities,
|
||||
imageGenerationDirty,
|
||||
transcriptionDirty,
|
||||
networkSafetyDirty,
|
||||
}: CapabilitySettingsActionsOptions) {
|
||||
const {
|
||||
imageGenerationForm,
|
||||
imageGenerationSaving,
|
||||
networkSafetyForm,
|
||||
networkSafetySaving,
|
||||
setImageGenerationSaving,
|
||||
setNetworkSafetySaving,
|
||||
setTranscriptionSaving,
|
||||
setWebSearchForm,
|
||||
setWebSearchKeyEditing,
|
||||
setWebSearchKeyVisible,
|
||||
setWebSearchSaving,
|
||||
transcriptionForm,
|
||||
transcriptionSaving,
|
||||
webSearchForm,
|
||||
webSearchKeyEditing,
|
||||
webSearchSaving,
|
||||
} = state;
|
||||
|
||||
const saveImageGenerationSettings = async () => {
|
||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||
setImageGenerationSaving(true);
|
||||
try {
|
||||
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setImageGenerationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTranscriptionSettings = async () => {
|
||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||
setTranscriptionSaving(true);
|
||||
try {
|
||||
const payload = await updateTranscriptionSettings(client, transcriptionForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setTranscriptionSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNetworkSafetySettings = async () => {
|
||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||
setNetworkSafetySaving(true);
|
||||
try {
|
||||
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setNetworkSafetySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveWebSearch = async () => {
|
||||
if (!settings || webSearchSaving) return;
|
||||
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
|
||||
if (!provider) return;
|
||||
const apiKey = webSearchForm.apiKey?.trim() ?? "";
|
||||
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
|
||||
const hasExistingSecret =
|
||||
webSearchProviderAcceptsApiKey(provider) &&
|
||||
webSearchForm.provider === settings.web_search.provider &&
|
||||
!!settings.web_search.api_key_hint;
|
||||
|
||||
if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) {
|
||||
setError(t("settings.byok.webSearch.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (provider.credential === "base_url" && !baseUrl) {
|
||||
setError(t("settings.byok.webSearch.baseUrlRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setWebSearchSaving(true);
|
||||
try {
|
||||
if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return;
|
||||
const webFetchRestartRequired =
|
||||
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
|
||||
settings.web.fetch.use_jina_reader;
|
||||
const update: WebSearchSettingsUpdate = {
|
||||
provider: webSearchForm.provider,
|
||||
maxResults: webSearchForm.maxResults,
|
||||
timeout: webSearchForm.timeout,
|
||||
useJinaReader: webSearchForm.useJinaReader,
|
||||
};
|
||||
if (
|
||||
webSearchProviderAcceptsApiKey(provider) &&
|
||||
(apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing))
|
||||
) {
|
||||
update.apiKey = apiKey;
|
||||
}
|
||||
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||
const payload = await updateWebSearchSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart || webFetchRestartRequired) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setWebSearchForm((prev) => ({
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: "",
|
||||
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
}));
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setWebSearchSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetWebSearchDraft = useCallback(() => {
|
||||
if (!settings) return;
|
||||
setWebSearchForm({
|
||||
provider: settings.web_search.provider,
|
||||
apiKey: "",
|
||||
baseUrl: settings.web_search.base_url ?? "",
|
||||
maxResults: settings.web_search.max_results,
|
||||
timeout: settings.web_search.timeout,
|
||||
useJinaReader: settings.web.fetch.use_jina_reader,
|
||||
});
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
}, [settings]);
|
||||
|
||||
const handleWebSearchProviderChange = useCallback((provider: string) => {
|
||||
if (!settings) return;
|
||||
setWebSearchForm((prev) => ({
|
||||
provider,
|
||||
apiKey: "",
|
||||
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
|
||||
maxResults: prev.maxResults ?? settings.web_search.max_results,
|
||||
timeout: prev.timeout ?? settings.web_search.timeout,
|
||||
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
|
||||
}));
|
||||
setWebSearchKeyVisible(false);
|
||||
setWebSearchKeyEditing(false);
|
||||
}, [settings]);
|
||||
|
||||
return {
|
||||
handleWebSearchProviderChange,
|
||||
resetWebSearchDraft,
|
||||
saveImageGenerationSettings,
|
||||
saveNetworkSafetySettings,
|
||||
saveTranscriptionSettings,
|
||||
saveWebSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_IMAGE_GENERATION_FORM,
|
||||
imageGenerationFormFromPayload,
|
||||
} from "@/components/settings/capabilities/ImageGenerationSettings";
|
||||
import {
|
||||
DEFAULT_NETWORK_SAFETY_FORM,
|
||||
networkSafetyFormFromPayload,
|
||||
} from "@/components/settings/capabilities/SecuritySettings";
|
||||
import {
|
||||
DEFAULT_TRANSCRIPTION_FORM,
|
||||
transcriptionFormFromPayload,
|
||||
} from "@/components/settings/capabilities/TranscriptionSettings";
|
||||
import {
|
||||
DEFAULT_WEB_SEARCH_FORM,
|
||||
webSearchFormFromPayload,
|
||||
} from "@/components/settings/capabilities/WebSettings";
|
||||
import type {
|
||||
ImageGenerationSettingsUpdate,
|
||||
NetworkSafetySettingsUpdate,
|
||||
SettingsPayload,
|
||||
TranscriptionSettingsUpdate,
|
||||
WebSearchSettingsUpdate,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function useCapabilitySettingsState(initialSettings: SettingsPayload | null) {
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
|
||||
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
|
||||
);
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
|
||||
() => initialSettings
|
||||
? imageGenerationFormFromPayload(initialSettings)
|
||||
: DEFAULT_IMAGE_GENERATION_FORM,
|
||||
);
|
||||
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
|
||||
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
|
||||
);
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
||||
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
||||
);
|
||||
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
||||
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
||||
|
||||
return {
|
||||
imageGenerationForm,
|
||||
imageGenerationSaving,
|
||||
networkSafetyForm,
|
||||
networkSafetySaving,
|
||||
setImageGenerationForm,
|
||||
setImageGenerationSaving,
|
||||
setNetworkSafetyForm,
|
||||
setNetworkSafetySaving,
|
||||
setTranscriptionForm,
|
||||
setTranscriptionSaving,
|
||||
setWebSearchForm,
|
||||
setWebSearchKeyEditing,
|
||||
setWebSearchKeyVisible,
|
||||
setWebSearchSaving,
|
||||
transcriptionForm,
|
||||
transcriptionSaving,
|
||||
webSearchForm,
|
||||
webSearchKeyEditing,
|
||||
webSearchKeyVisible,
|
||||
webSearchSaving,
|
||||
};
|
||||
}
|
||||
|
||||
export type CapabilitySettingsState = ReturnType<typeof useCapabilitySettingsState>;
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export type ChannelInstancesPanelCustomization = {
|
||||
countLabel?: (runningCount: number) => string;
|
||||
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
|
||||
};
|
||||
|
||||
export function ChannelInstancesPanel({
|
||||
token,
|
||||
feature,
|
||||
showBrandLogos,
|
||||
chatAppsDocsUrl,
|
||||
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
|
||||
onFeaturesUpdate,
|
||||
customization = {},
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
chatAppsDocsUrl?: string;
|
||||
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
customization?: ChannelInstancesPanelCustomization;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const displayName = localizedChannelDisplayName(feature, t);
|
||||
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = checked
|
||||
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
||||
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
||||
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
||||
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
channelValuesForSave(instanceFields, fieldValues),
|
||||
{ enable: selected.enabled, instanceId: selected.id },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ChannelConnectPayload,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export type ChannelQrConnectLabels = {
|
||||
qrAlt: string;
|
||||
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
|
||||
};
|
||||
|
||||
export function ChannelQrConnectFlow({
|
||||
token,
|
||||
channelName,
|
||||
startOptions = {},
|
||||
idleLabel,
|
||||
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
|
||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||
suppressSucceeded?: boolean;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const pageVisible = usePageVisibility();
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||
const pollInFlight = useRef(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const startDomain = startOptions.domain;
|
||||
const startInstanceId = startOptions.instanceId;
|
||||
const startMode = startOptions.mode;
|
||||
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
sessionId,
|
||||
);
|
||||
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
|
||||
};
|
||||
}, [
|
||||
channelName,
|
||||
client,
|
||||
connect?.interval_ms,
|
||||
connect?.session_id,
|
||||
connect?.status,
|
||||
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||
const payload = await startChannelConnect(client, channelName, {
|
||||
domain: startDomain,
|
||||
instanceId: startInstanceId,
|
||||
mode: startMode,
|
||||
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await cancelChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
);
|
||||
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
client,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
"",
|
||||
params,
|
||||
);
|
||||
setConnect((current) => ({
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function ChannelCatalogRow({
|
||||
feature,
|
||||
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
|
||||
if (feature.instances !== undefined) {
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
|
||||
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
|
||||
setNotice(null);
|
||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||
try {
|
||||
const validationPayload = await validateChannel(token, feature.name, values);
|
||||
const validationPayload = await validateChannel(client, feature.name, values);
|
||||
setValidation(validationPayload);
|
||||
if (!validationPayload.can_enable) {
|
||||
setNotice(
|
||||
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
|
||||
return;
|
||||
}
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
values,
|
||||
{ enable: true },
|
||||
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await validateChannel(
|
||||
token,
|
||||
client,
|
||||
feature.name,
|
||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
export type SettingsSectionKey =
|
||||
| "overview"
|
||||
| "appearance"
|
||||
| "models"
|
||||
| "image"
|
||||
| "voice"
|
||||
| "browser"
|
||||
| "channels"
|
||||
| "apps"
|
||||
| "automations"
|
||||
| "skills"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
export type PendingRestartSection = "runtime" | "browser" | "image";
|
||||
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
||||
|
||||
export type RestartAwarePayload = {
|
||||
requires_restart?: boolean;
|
||||
surface?: SettingsPayload["surface"];
|
||||
runtime_surface?: SettingsPayload["runtime_surface"];
|
||||
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
|
||||
};
|
||||
|
||||
export type ApplySettingsPayload = (
|
||||
payload: SettingsPayload,
|
||||
options?: { preserveAgentForm?: boolean },
|
||||
) => void;
|
||||
|
||||
export type MaybeRestartHostEngine = (payload: RestartAwarePayload) => Promise<void>;
|
||||
@@ -0,0 +1,923 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
ModelIdPicker,
|
||||
ProviderPicker,
|
||||
ProviderPickerIcon,
|
||||
formatContextWindow,
|
||||
formatModelContextWindow,
|
||||
normalizeContextWindowTokens,
|
||||
settingsProviderConfigured,
|
||||
} from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
SettingsStatusMessage,
|
||||
StatusPill,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
export interface AgentSettingsDraft {
|
||||
model: string;
|
||||
provider: string;
|
||||
modelPreset: string;
|
||||
presetLabel: string;
|
||||
maxTokens: number;
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
timezone: string;
|
||||
toolHintMaxLength: number;
|
||||
}
|
||||
|
||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
|
||||
|
||||
function modelPresetValue(payload: SettingsPayload): string {
|
||||
return (
|
||||
payload.model_call_order?.[0] ??
|
||||
payload.model_presets.find((preset) => !preset.is_default)?.name ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
||||
model: "",
|
||||
provider: "",
|
||||
modelPreset: "",
|
||||
presetLabel: "",
|
||||
maxTokens: 8192,
|
||||
contextWindowTokens: 200_000,
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "",
|
||||
timezone: "UTC",
|
||||
toolHintMaxLength: 40,
|
||||
};
|
||||
|
||||
export function agentDraftFromPayload(
|
||||
payload: SettingsPayload,
|
||||
preferredPresetName?: string,
|
||||
): AgentSettingsDraft {
|
||||
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
||||
const activePreset =
|
||||
payload.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === activePresetName,
|
||||
) ?? null;
|
||||
return {
|
||||
model: activePreset?.model ?? payload.agent.model,
|
||||
provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "",
|
||||
modelPreset: activePresetName,
|
||||
presetLabel: activePreset?.label ?? activePresetName,
|
||||
maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
|
||||
),
|
||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||
timezone: payload.agent.timezone,
|
||||
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
||||
};
|
||||
}
|
||||
|
||||
export function ModelPresetDeleteDialog({
|
||||
preset,
|
||||
deleting,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
preset: SettingsPayload["model_presets"][number] | null;
|
||||
deleting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
return (
|
||||
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[440px] rounded-[24px]">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>
|
||||
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="leading-5">
|
||||
{tx(
|
||||
"settings.models.deletePresetHelp",
|
||||
"This removes the preset “{{name}}”. Provider credentials are not affected.",
|
||||
{ name: preset?.label ?? "" },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
disabled={deleting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-full"
|
||||
disabled={deleting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{deleting
|
||||
? tx("settings.actions.deleting", "Deleting...")
|
||||
: tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelsSettings({
|
||||
token,
|
||||
form,
|
||||
setForm,
|
||||
settings,
|
||||
dirty,
|
||||
creating,
|
||||
creatingSaving,
|
||||
callOrder,
|
||||
saving,
|
||||
orderSaving,
|
||||
migrationSaving,
|
||||
showBrandLogos,
|
||||
providerSaving,
|
||||
onChangeCallOrder,
|
||||
onProviderOAuthLogin,
|
||||
onSave,
|
||||
onMigrate,
|
||||
onBeginCreate,
|
||||
onCancelCreate,
|
||||
onSelectConfiguration,
|
||||
onDeleteConfiguration,
|
||||
}: {
|
||||
token: string;
|
||||
form: AgentSettingsDraft;
|
||||
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
||||
settings: SettingsPayload;
|
||||
dirty: boolean;
|
||||
creating: boolean;
|
||||
creatingSaving: boolean;
|
||||
callOrder: string[];
|
||||
saving: boolean;
|
||||
orderSaving: boolean;
|
||||
migrationSaving: boolean;
|
||||
showBrandLogos: boolean;
|
||||
providerSaving: string | null;
|
||||
onChangeCallOrder: (order: string[]) => void;
|
||||
onProviderOAuthLogin: (provider: string) => void;
|
||||
onSave: () => void;
|
||||
onMigrate: () => void;
|
||||
onBeginCreate: () => void;
|
||||
onCancelCreate: () => void;
|
||||
onSelectConfiguration: () => void;
|
||||
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
||||
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
|
||||
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||
const callOrderOccurrences = new Map<string, number>();
|
||||
const presetRows = [
|
||||
...callOrder.map((name, orderIndex) => {
|
||||
const occurrence = callOrderOccurrences.get(name) ?? 0;
|
||||
callOrderOccurrences.set(name, occurrence + 1);
|
||||
return {
|
||||
key: `ordered:${name}:${occurrence}`,
|
||||
name,
|
||||
orderIndex,
|
||||
preset: namedPresetsByName.get(name),
|
||||
};
|
||||
}),
|
||||
...unorderedPresets.map((preset) => ({
|
||||
key: `disabled:${preset.name}`,
|
||||
name: preset.name,
|
||||
orderIndex: -1,
|
||||
preset,
|
||||
})),
|
||||
];
|
||||
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
|
||||
const activeEditorRowKey =
|
||||
editorRowKey ??
|
||||
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
|
||||
null;
|
||||
useEffect(() => {
|
||||
setAdvancedOpen(false);
|
||||
}, [editorOpen, selectedPreset?.name]);
|
||||
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
|
||||
const selectableProviders = uniqueProviders([
|
||||
...configuredProviders,
|
||||
...(selectedProvider ? [selectedProvider] : []),
|
||||
]);
|
||||
const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
|
||||
const providerOptions = showAutoProvider
|
||||
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
|
||||
: selectableProviders;
|
||||
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
|
||||
? form.provider
|
||||
: "";
|
||||
const selectedProviderNeedsSignIn =
|
||||
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
|
||||
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
|
||||
const selectedProviderConfigured = settingsProviderConfigured(
|
||||
settings,
|
||||
form.provider,
|
||||
selectedPreset?.resolved_provider,
|
||||
);
|
||||
const modelFieldsMissing =
|
||||
!form.model.trim() ||
|
||||
!form.provider.trim() ||
|
||||
!form.presetLabel.trim() ||
|
||||
form.maxTokens <= 0 ||
|
||||
form.temperature < 0 ||
|
||||
form.temperature > 2;
|
||||
const selectedPresetReferenced = Boolean(
|
||||
selectedPreset && callOrder.includes(selectedPreset.name),
|
||||
);
|
||||
const callOrderBusy = orderSaving || saving;
|
||||
const selectPreset = (
|
||||
preset: SettingsPayload["model_presets"][number],
|
||||
rowKey: string,
|
||||
) => {
|
||||
const toggleCurrentPreset =
|
||||
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
|
||||
onSelectConfiguration();
|
||||
if (toggleCurrentPreset) {
|
||||
setEditorOpen((open) => !open);
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelPreset: preset.name,
|
||||
model: preset.model,
|
||||
provider: preset.provider,
|
||||
presetLabel: preset.label,
|
||||
maxTokens: preset.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
||||
temperature: preset.temperature,
|
||||
reasoningEffort: preset.reasoning_effort ?? "",
|
||||
}));
|
||||
setEditorRowKey(rowKey);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const moveCallOrderItem = (index: number, offset: -1 | 1) => {
|
||||
if (callOrderBusy) return;
|
||||
const nextIndex = index + offset;
|
||||
if (nextIndex < 0 || nextIndex >= callOrder.length) return;
|
||||
const next = [...callOrder];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
onChangeCallOrder(next);
|
||||
};
|
||||
|
||||
const removeCallOrderItem = (index: number) => {
|
||||
if (callOrderBusy || callOrder.length <= 1) return;
|
||||
onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const dropCallOrderItem = (targetIndex: number) => {
|
||||
if (
|
||||
callOrderBusy ||
|
||||
draggedCallOrderIndex === null ||
|
||||
draggedCallOrderIndex === targetIndex
|
||||
) {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
return;
|
||||
}
|
||||
const next = [...callOrder];
|
||||
const moved = next.splice(draggedCallOrderIndex, 1)[0];
|
||||
if (!moved) {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
return;
|
||||
}
|
||||
next.splice(targetIndex, 0, moved);
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
onChangeCallOrder(next);
|
||||
};
|
||||
|
||||
const renderPresetEditor = () => (
|
||||
<div
|
||||
id="model-preset-editor"
|
||||
data-testid="model-preset-editor"
|
||||
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
||||
>
|
||||
{creating ? (
|
||||
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
||||
<span className="text-[13px] font-semibold text-foreground/85">
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
|
||||
<Input
|
||||
autoFocus={creating}
|
||||
value={form.presetLabel}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
|
||||
}
|
||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={t("settings.rows.provider")}>
|
||||
<ProviderPicker
|
||||
providers={providerOptions}
|
||||
value={providerValue}
|
||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: provider === prev.provider ? prev.model : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{selectedProviderNeedsSignIn ? (
|
||||
<SettingsRow
|
||||
title={tx("settings.oauth.signInRequired", "Sign in required")}
|
||||
description={tx(
|
||||
"settings.oauth.signInBeforeSaving",
|
||||
"Sign in before saving this provider in the preset.",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
|
||||
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
|
||||
className="rounded-full"
|
||||
>
|
||||
{selectedProviderSigningIn ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{selectedProviderSigningIn
|
||||
? tx("settings.oauth.signingIn", "Signing in...")
|
||||
: tx("settings.oauth.signIn", "Sign in")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
<SettingsRow title={t("settings.rows.model")}>
|
||||
<ModelIdPicker
|
||||
token={token}
|
||||
settings={settings}
|
||||
provider={form.provider}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.advancedOptions", "Advanced options")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.advancedSummary",
|
||||
"Context {{context}} · Max {{max}} tokens",
|
||||
{
|
||||
context: formatModelContextWindow(form.contextWindowTokens),
|
||||
max: formatContextWindow(form.maxTokens),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="bg-muted/12 px-4 py-4 sm:px-5">
|
||||
<ModelAdvancedFields
|
||||
maxTokens={form.maxTokens}
|
||||
contextWindowTokens={form.contextWindowTokens}
|
||||
temperature={form.temperature}
|
||||
reasoningEffort={form.reasoningEffort}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="self-start rounded-full text-muted-foreground"
|
||||
disabled={creatingSaving}
|
||||
onClick={() => {
|
||||
setEditorOpen(false);
|
||||
onCancelCreate();
|
||||
}}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
) : selectedPreset ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full text-muted-foreground hover:text-destructive"
|
||||
disabled={selectedPresetReferenced || saving || orderSaving}
|
||||
aria-describedby={
|
||||
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
|
||||
}
|
||||
onClick={() => onDeleteConfiguration(selectedPreset)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.actions.delete", "Delete")}
|
||||
</Button>
|
||||
{selectedPresetReferenced ? (
|
||||
<span
|
||||
id="model-preset-delete-hint"
|
||||
className="text-[11px] leading-4 text-muted-foreground"
|
||||
>
|
||||
{tx(
|
||||
"settings.models.removeBeforeDelete",
|
||||
"Remove this preset from the call order before deleting it.",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
disabled={
|
||||
(!creating && !dirty) ||
|
||||
!selectedProviderConfigured ||
|
||||
modelFieldsMissing ||
|
||||
saving ||
|
||||
orderSaving
|
||||
}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving || creatingSaving
|
||||
? tx("settings.actions.saving", "Saving...")
|
||||
: tx("settings.actions.savePreset", "Save preset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>
|
||||
{tx("settings.models.presets", "Model presets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
{!settings.model_call_order_editable ? (
|
||||
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
|
||||
<ListOrdered className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-medium text-foreground">
|
||||
{tx("settings.models.convertTitle", "Convert the current model setup")}
|
||||
</p>
|
||||
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.models.convertHelp",
|
||||
"Turn the existing primary and fallback models into presets so their order can be managed here.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={migrationSaving}
|
||||
onClick={onMigrate}
|
||||
>
|
||||
{migrationSaving ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{migrationSaving
|
||||
? tx("settings.models.converting", "Converting...")
|
||||
: tx("settings.models.convertAction", "Convert to presets")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div role="list" className="divide-y divide-border/45">
|
||||
{presetRows.map(({ key, name, orderIndex, preset }) => {
|
||||
const ordered = orderIndex >= 0;
|
||||
const provider = preset
|
||||
? modelPresetProviderKey(preset, settings)
|
||||
: settings.agent.resolved_provider ?? settings.agent.provider;
|
||||
const presetConfigured = preset
|
||||
? settingsProviderConfigured(
|
||||
settings,
|
||||
preset.provider,
|
||||
preset.resolved_provider,
|
||||
)
|
||||
: true;
|
||||
const isDropTarget =
|
||||
ordered &&
|
||||
dragOverCallOrderIndex === orderIndex &&
|
||||
draggedCallOrderIndex !== orderIndex;
|
||||
const dropAfterTarget =
|
||||
isDropTarget &&
|
||||
draggedCallOrderIndex !== null &&
|
||||
draggedCallOrderIndex < orderIndex;
|
||||
const isSelected =
|
||||
editorOpen &&
|
||||
!creating &&
|
||||
activeEditorRowKey === key &&
|
||||
selectedPreset?.name === name;
|
||||
const presetRow = (
|
||||
<div
|
||||
tabIndex={ordered ? 0 : -1}
|
||||
draggable={ordered && !callOrderBusy}
|
||||
aria-label={
|
||||
ordered
|
||||
? `${preset?.label ?? name}. ${tx(
|
||||
"settings.models.dragToReorder",
|
||||
"Drag to reorder",
|
||||
)}`
|
||||
: preset?.label ?? name
|
||||
}
|
||||
data-testid={`model-call-order-row-${name}`}
|
||||
onDragStart={(event) => {
|
||||
if (!ordered || callOrderBusy) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", name);
|
||||
setDraggedCallOrderIndex(orderIndex);
|
||||
setDragOverCallOrderIndex(orderIndex);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedCallOrderIndex(null);
|
||||
setDragOverCallOrderIndex(null);
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
if (ordered && draggedCallOrderIndex !== null) {
|
||||
event.preventDefault();
|
||||
setDragOverCallOrderIndex(orderIndex);
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!ordered || draggedCallOrderIndex === null) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!ordered) return;
|
||||
event.preventDefault();
|
||||
dropCallOrderItem(orderIndex);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (ordered && event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveCallOrderItem(orderIndex, -1);
|
||||
} else if (ordered && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveCallOrderItem(orderIndex, 1);
|
||||
} else if ((event.key === "Enter" || event.key === " ") && preset) {
|
||||
event.preventDefault();
|
||||
selectPreset(preset, key);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
|
||||
ordered &&
|
||||
(callOrderBusy
|
||||
? "cursor-wait"
|
||||
: "cursor-grab active:cursor-grabbing"),
|
||||
"hover:bg-muted/25",
|
||||
isDropTarget &&
|
||||
!dropAfterTarget &&
|
||||
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
|
||||
isDropTarget &&
|
||||
dropAfterTarget &&
|
||||
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
|
||||
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
|
||||
isSelected && "bg-muted/45 hover:bg-muted/45",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{ordered ? (
|
||||
<GripVertical
|
||||
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedPreset?.name === name}
|
||||
aria-expanded={isSelected}
|
||||
aria-controls={isSelected ? "model-preset-editor" : undefined}
|
||||
disabled={!preset}
|
||||
onClick={() => preset && selectPreset(preset, key)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{ordered ? (
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||
{orderIndex + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-7 w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<ProviderPickerIcon
|
||||
provider={provider}
|
||||
showBrandLogos={showBrandLogos}
|
||||
unconfigured={!presetConfigured}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[14px] font-medium text-foreground">
|
||||
{preset?.label ?? name}
|
||||
</span>
|
||||
{orderIndex === 0 ? (
|
||||
<StatusPill tone="success">
|
||||
{tx("settings.models.primary", "Primary")}
|
||||
</StatusPill>
|
||||
) : !ordered ? (
|
||||
<StatusPill tone="neutral">
|
||||
{tx("settings.models.disabled", "Disabled")}
|
||||
</StatusPill>
|
||||
) : null}
|
||||
{!presetConfigured ? (
|
||||
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
|
||||
{tx(
|
||||
"settings.models.providerSetupRequired",
|
||||
"Provider setup required",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{preset?.model ?? name}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isSelected && "rotate-90",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={ordered}
|
||||
aria-label={
|
||||
ordered
|
||||
? tx("settings.models.removeFromOrder", "Disable preset")
|
||||
: tx("settings.models.addToOrder", "Enable preset")
|
||||
}
|
||||
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
|
||||
onClick={() => {
|
||||
if (ordered) {
|
||||
removeCallOrderItem(orderIndex);
|
||||
} else if (preset) {
|
||||
onChangeCallOrder([...callOrder, preset.name]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
ordered ? "bg-foreground" : "bg-muted-foreground/25",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
ordered ? "translate-x-[18px]" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key} role="listitem">
|
||||
{presetRow}
|
||||
{isSelected ? renderPresetEditor() : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{!creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
disabled={callOrderBusy}
|
||||
onClick={() => {
|
||||
setEditorRowKey(null);
|
||||
setEditorOpen(true);
|
||||
onBeginCreate();
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{orderSaving ? (
|
||||
<SettingsStatusMessage>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{tx("settings.actions.saving", "Saving...")}
|
||||
</span>
|
||||
</SettingsStatusMessage>
|
||||
) : null}
|
||||
</div>
|
||||
{creating && editorOpen ? renderPresetEditor() : null}
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelAdvancedFields({
|
||||
maxTokens,
|
||||
contextWindowTokens,
|
||||
temperature,
|
||||
reasoningEffort,
|
||||
onChange,
|
||||
}: {
|
||||
maxTokens: number;
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
onChange: (
|
||||
value: Partial<
|
||||
Pick<
|
||||
AgentSettingsDraft,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
>
|
||||
>,
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const contextWindowOptions = Array.from(
|
||||
new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
|
||||
).sort((left, right) => left - right);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.maxTokens", "Max output tokens")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={maxTokens}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value)) onChange({ maxTokens: value });
|
||||
}}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.temperature", "Temperature")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value)) onChange({ temperature: value });
|
||||
}}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.rows.contextWindow", "Context window")}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={String(contextWindowTokens)}
|
||||
options={contextWindowOptions.map((tokens) => ({
|
||||
value: String(tokens),
|
||||
label: formatModelContextWindow(tokens),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
onChange({ contextWindowTokens: normalizeContextWindowTokens(Number(value)) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.reasoningEffort", "Reasoning effort")}
|
||||
</span>
|
||||
<Input
|
||||
value={reasoningEffort}
|
||||
onChange={(event) => onChange({ reasoningEffort: event.target.value })}
|
||||
placeholder={tx("settings.values.default", "Default")}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
const seen = new Set<string>();
|
||||
return providers.filter((provider) => {
|
||||
if (seen.has(provider.name)) return false;
|
||||
seen.add(provider.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function modelPresetProviderKey(
|
||||
preset: SettingsPayload["model_presets"][number],
|
||||
settings: SettingsPayload,
|
||||
options: { draftProvider?: string } = {},
|
||||
): string {
|
||||
const provider = options.draftProvider ?? preset.provider;
|
||||
if (provider === "auto") {
|
||||
return (
|
||||
preset.resolved_provider ||
|
||||
settings.agent.resolved_provider ||
|
||||
settings.agent.provider ||
|
||||
preset.provider
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type {
|
||||
ApplySettingsPayload,
|
||||
MaybeRestartHostEngine,
|
||||
PendingRestartSections,
|
||||
} from "@/components/settings/contracts";
|
||||
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
|
||||
import {
|
||||
CUSTOM_PROVIDER_CREATION_KEY,
|
||||
providerFormFromRow,
|
||||
type CustomProviderDraft,
|
||||
} from "@/components/settings/models/ProviderSettings";
|
||||
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
|
||||
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteModelConfiguration,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
migrateModelConfigurations,
|
||||
updateModelCallOrder,
|
||||
updateModelConfiguration,
|
||||
updateProviderSettings,
|
||||
} from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
ProviderOAuthAuthorizationRequired,
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthLoginResult,
|
||||
ProviderOAuthPending,
|
||||
ProviderSettingsUpdate,
|
||||
SettingsPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
function isProviderOAuthAuthorizationRequired(
|
||||
payload: ProviderOAuthLoginResult,
|
||||
): payload is ProviderOAuthAuthorizationRequired {
|
||||
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
|
||||
}
|
||||
|
||||
function isProviderOAuthPending(
|
||||
payload: ProviderOAuthCompletionResult,
|
||||
): payload is ProviderOAuthPending {
|
||||
return (payload as ProviderOAuthPending).status === "pending";
|
||||
}
|
||||
|
||||
interface ModelSettingsActionsOptions {
|
||||
state: ModelSettingsState;
|
||||
settings: SettingsPayload | null;
|
||||
client: NanobotClient;
|
||||
t: TFunction;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
maybeRestartHostEngine: MaybeRestartHostEngine;
|
||||
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
remoteBrowserAccess: boolean;
|
||||
closeProviderOAuthFlow: () => void;
|
||||
installCapabilities: (names: string[]) => Promise<boolean>;
|
||||
modelDirty: boolean;
|
||||
configuredModelProviderOptions: Array<{ name: string; label: string }>;
|
||||
}
|
||||
|
||||
export function useModelSettingsActions({
|
||||
state,
|
||||
settings,
|
||||
client,
|
||||
t,
|
||||
applyPayload,
|
||||
maybeRestartHostEngine,
|
||||
setPendingRestartSections,
|
||||
setError,
|
||||
onModelNameChange,
|
||||
remoteBrowserAccess,
|
||||
closeProviderOAuthFlow,
|
||||
installCapabilities,
|
||||
modelDirty,
|
||||
configuredModelProviderOptions,
|
||||
}: ModelSettingsActionsOptions) {
|
||||
const {
|
||||
expandedProvider,
|
||||
form,
|
||||
modelCallOrder,
|
||||
modelCallOrderSaving,
|
||||
modelConfigurationSaving,
|
||||
modelMigrationSaving,
|
||||
modelPresetBeforeCreateRef,
|
||||
modelPresetCreating,
|
||||
modelPresetPendingDelete,
|
||||
providerForms,
|
||||
providerOAuthCompleting,
|
||||
providerOAuthFlowRef,
|
||||
providerOAuthResponse,
|
||||
providerSaving,
|
||||
saving,
|
||||
setEditingProviderKeys,
|
||||
setExpandedProvider,
|
||||
setForm,
|
||||
setModelCallOrder,
|
||||
setModelCallOrderSaving,
|
||||
setModelConfigurationSaving,
|
||||
setModelMigrationSaving,
|
||||
setModelPresetCreating,
|
||||
setModelPresetPendingDelete,
|
||||
setProviderForms,
|
||||
setProviderOAuthCompleting,
|
||||
setProviderOAuthDialogError,
|
||||
setProviderOAuthFlow,
|
||||
setProviderOAuthResponse,
|
||||
setProviderSaving,
|
||||
setSaving,
|
||||
setVisibleProviderKeys,
|
||||
visibleProviderKeys,
|
||||
} = state;
|
||||
|
||||
const saveModelSettings = async () => {
|
||||
if (
|
||||
!settings ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelPresetCreating) {
|
||||
const label = form.presetLabel.trim();
|
||||
const provider = form.provider.trim();
|
||||
const model = form.model.trim();
|
||||
if (
|
||||
!label ||
|
||||
!provider ||
|
||||
!model ||
|
||||
form.maxTokens <= 0 ||
|
||||
form.contextWindowTokens <= 0 ||
|
||||
form.temperature < 0 ||
|
||||
form.temperature > 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setModelConfigurationSaving(true);
|
||||
try {
|
||||
const payload = await createModelConfiguration(client, {
|
||||
label,
|
||||
provider,
|
||||
model,
|
||||
maxTokens: form.maxTokens,
|
||||
contextWindowTokens: form.contextWindowTokens,
|
||||
temperature: form.temperature,
|
||||
reasoningEffort: form.reasoningEffort || null,
|
||||
});
|
||||
const createdPreset = payload.created_model_preset;
|
||||
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
||||
applyPayload(payload);
|
||||
if (createdPreset) {
|
||||
setForm(agentDraftFromPayload(payload, createdPreset));
|
||||
}
|
||||
|
||||
let finalPayload = payload;
|
||||
if (nextOrder) {
|
||||
const orderedPayload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(orderedPayload);
|
||||
finalPayload = orderedPayload;
|
||||
}
|
||||
if (createdPreset) {
|
||||
setForm(agentDraftFromPayload(finalPayload, createdPreset));
|
||||
}
|
||||
modelPresetBeforeCreateRef.current = null;
|
||||
onModelNameChange(finalPayload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelConfigurationSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modelDirty) return;
|
||||
const selectedPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
||||
);
|
||||
if (!selectedPreset) return;
|
||||
const reasoningEffort = form.reasoningEffort || null;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await updateModelConfiguration(client, {
|
||||
name: selectedPreset.name,
|
||||
label:
|
||||
form.presetLabel.trim() !== selectedPreset.label
|
||||
? form.presetLabel.trim()
|
||||
: undefined,
|
||||
model: form.model !== selectedPreset.model ? form.model : undefined,
|
||||
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
|
||||
maxTokens:
|
||||
form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined,
|
||||
contextWindowTokens:
|
||||
form.contextWindowTokens !==
|
||||
normalizeContextWindowTokens(selectedPreset.context_window_tokens)
|
||||
? form.contextWindowTokens
|
||||
: undefined,
|
||||
temperature:
|
||||
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
||||
reasoningEffort:
|
||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||
});
|
||||
applyPayload(payload);
|
||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginModelPresetCreation = () => {
|
||||
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
||||
const primaryPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
|
||||
);
|
||||
const currentProvider = primaryPreset?.provider === "auto"
|
||||
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
||||
: primaryPreset?.provider ?? settings.agent.provider;
|
||||
const provider =
|
||||
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
|
||||
configuredModelProviderOptions[0]?.name ??
|
||||
"";
|
||||
modelPresetBeforeCreateRef.current = form.modelPreset;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelPreset: "",
|
||||
presetLabel: "",
|
||||
provider,
|
||||
model: "",
|
||||
maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens,
|
||||
),
|
||||
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
||||
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
||||
}));
|
||||
setModelPresetCreating(true);
|
||||
};
|
||||
|
||||
const cancelModelPresetCreation = () => {
|
||||
if (!settings || modelConfigurationSaving) return;
|
||||
const previousPreset = modelPresetBeforeCreateRef.current;
|
||||
setModelPresetCreating(false);
|
||||
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
|
||||
modelPresetBeforeCreateRef.current = null;
|
||||
};
|
||||
|
||||
const changeModelCallOrder = async (nextOrder: string[]) => {
|
||||
const unchanged =
|
||||
nextOrder.length === modelCallOrder.length &&
|
||||
nextOrder.every((name, index) => name === modelCallOrder[index]);
|
||||
if (
|
||||
!settings ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving ||
|
||||
nextOrder.length === 0 ||
|
||||
unchanged
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previousOrder = [...modelCallOrder];
|
||||
setModelCallOrder(nextOrder);
|
||||
setModelCallOrderSaving(true);
|
||||
try {
|
||||
const payload = await updateModelCallOrder(client, nextOrder);
|
||||
applyPayload(payload, { preserveAgentForm: true });
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setModelCallOrder(previousOrder);
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelCallOrderSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrateModelConfigurations = async () => {
|
||||
if (modelMigrationSaving) return;
|
||||
setModelMigrationSaving(true);
|
||||
try {
|
||||
const payload = await migrateModelConfigurations(client);
|
||||
applyPayload(payload);
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setModelMigrationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModelConfiguration = async () => {
|
||||
if (
|
||||
!modelPresetPendingDelete ||
|
||||
saving ||
|
||||
modelCallOrderSaving ||
|
||||
modelConfigurationSaving
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
|
||||
applyPayload(payload);
|
||||
setModelPresetPendingDelete(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProvider = async (providerName: string) => {
|
||||
if (providerSaving) return;
|
||||
const provider = settings?.providers.find((item) => item.name === providerName);
|
||||
if (!provider) return;
|
||||
const isOauthProvider = provider.auth_type === "oauth";
|
||||
const providerForm = providerForms[providerName] ?? providerFormFromRow(provider);
|
||||
const apiKey = providerForm.apiKey.trim();
|
||||
const apiKeyRequired = provider.api_key_required ?? true;
|
||||
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
|
||||
setError(t("settings.byok.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
setProviderSaving(providerName);
|
||||
try {
|
||||
const supportName = providerName === "bedrock"
|
||||
? "bedrock"
|
||||
: providerName === "azure_openai"
|
||||
? "azure"
|
||||
: null;
|
||||
if (supportName && !(await installCapabilities([supportName]))) return;
|
||||
const update: ProviderSettingsUpdate = { provider: providerName };
|
||||
if (!isOauthProvider) {
|
||||
update.apiKey = apiKey || undefined;
|
||||
update.apiBase = providerForm.apiBase.trim();
|
||||
if (provider.is_custom) update.displayName = providerForm.displayName.trim();
|
||||
}
|
||||
for (const field of provider.advanced_fields ?? []) {
|
||||
if (field === "api_type") update.apiType = providerForm.apiType;
|
||||
if (field === "proxy") update.proxy = providerForm.proxy.trim();
|
||||
if (field === "extra_headers") {
|
||||
update.extraHeaders = providerForm.extraHeaders.trim();
|
||||
}
|
||||
if (field === "extra_body") update.extraBody = providerForm.extraBody.trim();
|
||||
if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim();
|
||||
if (field === "thinking_style") {
|
||||
update.thinkingStyle = providerForm.thinkingStyle.trim();
|
||||
}
|
||||
if (field === "region") update.region = providerForm.region.trim();
|
||||
if (field === "profile") update.profile = providerForm.profile.trim();
|
||||
}
|
||||
const payload = await updateProviderSettings(client, update);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setProviderForms((prev) => ({
|
||||
...prev,
|
||||
[providerName]: {
|
||||
...providerForm,
|
||||
displayName: providerForm.displayName.trim(),
|
||||
apiKey: "",
|
||||
apiBase: providerForm.apiBase.trim(),
|
||||
proxy: providerForm.proxy.trim(),
|
||||
thinkingStyle: providerForm.thinkingStyle.trim(),
|
||||
region: providerForm.region.trim(),
|
||||
profile: providerForm.profile.trim(),
|
||||
},
|
||||
}));
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
if (!isOauthProvider) setExpandedProvider(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const createCustomProvider = async (draft: CustomProviderDraft): Promise<boolean> => {
|
||||
if (providerSaving) return false;
|
||||
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
||||
try {
|
||||
const payload = await createProviderSettings(client, {
|
||||
name: draft.name.trim(),
|
||||
apiKey: draft.apiKey.trim() || undefined,
|
||||
apiBase: draft.apiBase.trim(),
|
||||
proxy: draft.proxy.trim(),
|
||||
extraHeaders: draft.extraHeaders.trim(),
|
||||
extraBody: draft.extraBody.trim(),
|
||||
extraQuery: draft.extraQuery.trim(),
|
||||
thinkingStyle: draft.thinkingStyle.trim(),
|
||||
});
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(null);
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
||||
if (providerSaving) return;
|
||||
let popup: Window | null = null;
|
||||
if (
|
||||
action === "login"
|
||||
&& providerName === "xai_grok"
|
||||
&& !remoteBrowserAccess
|
||||
) {
|
||||
try {
|
||||
popup = window.open("about:blank", "_blank");
|
||||
if (popup) popup.opener = null;
|
||||
} catch {
|
||||
popup = null;
|
||||
}
|
||||
}
|
||||
setProviderSaving(providerName);
|
||||
try {
|
||||
const payload =
|
||||
action === "login"
|
||||
? await loginProviderOAuth(
|
||||
client,
|
||||
providerName,
|
||||
providerName === "openai_codex" && remoteBrowserAccess,
|
||||
)
|
||||
: await logoutProviderOAuth(client, providerName);
|
||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||
try {
|
||||
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
||||
} catch {
|
||||
// The dialog keeps the authorization link available when the popup was closed.
|
||||
}
|
||||
providerOAuthFlowRef.current = payload;
|
||||
setProviderOAuthFlow(payload);
|
||||
setProviderOAuthResponse("");
|
||||
setProviderOAuthDialogError(null);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
popup?.close();
|
||||
closeProviderOAuthFlow();
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
popup?.close();
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setProviderSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const completeProviderOAuthResponse = async () => {
|
||||
const flow = providerOAuthFlowRef.current;
|
||||
const authorizationResponse = providerOAuthResponse.trim();
|
||||
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
|
||||
setProviderOAuthCompleting(true);
|
||||
setProviderOAuthDialogError(null);
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
client,
|
||||
flow.provider,
|
||||
flow.flow_id,
|
||||
authorizationResponse,
|
||||
);
|
||||
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||
if (isProviderOAuthPending(payload)) return;
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(flow.provider);
|
||||
setError(null);
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
||||
setProviderOAuthDialogError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setProviderOAuthCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetProviderDraft = useCallback((providerName: string) => {
|
||||
const provider = settings?.providers.find((item) => item.name === providerName);
|
||||
if (!provider) return;
|
||||
setProviderForms((prev) => ({
|
||||
...prev,
|
||||
[providerName]: providerFormFromRow(provider),
|
||||
}));
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
||||
}, [settings]);
|
||||
|
||||
const handleToggleProvider = useCallback((providerName: string) => {
|
||||
if (expandedProvider) resetProviderDraft(expandedProvider);
|
||||
setExpandedProvider(expandedProvider === providerName ? null : providerName);
|
||||
}, [expandedProvider, resetProviderDraft]);
|
||||
|
||||
const toggleProviderKeyVisibility = (providerName: string) => {
|
||||
const isVisible = visibleProviderKeys[providerName];
|
||||
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
|
||||
};
|
||||
|
||||
const toggleProviderKeyEditing = (providerName: string) => {
|
||||
setEditingProviderKeys((prev) => {
|
||||
const nextEditing = !prev[providerName];
|
||||
if (!nextEditing) {
|
||||
setProviderForms((forms) => ({
|
||||
...forms,
|
||||
[providerName]: {
|
||||
...(forms[providerName] ?? providerFormFromRow(
|
||||
settings?.providers.find((provider) => provider.name === providerName) ?? {
|
||||
name: providerName,
|
||||
label: providerName,
|
||||
configured: false,
|
||||
},
|
||||
)),
|
||||
apiKey: "",
|
||||
},
|
||||
}));
|
||||
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
|
||||
}
|
||||
return { ...prev, [providerName]: nextEditing };
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginModelPresetCreation,
|
||||
cancelModelPresetCreation,
|
||||
changeModelCallOrder,
|
||||
completeProviderOAuthResponse,
|
||||
createCustomProvider,
|
||||
handleDeleteModelConfiguration,
|
||||
handleMigrateModelConfigurations,
|
||||
handleToggleProvider,
|
||||
resetProviderDraft,
|
||||
runProviderOAuth,
|
||||
saveModelSettings,
|
||||
saveProvider,
|
||||
toggleProviderKeyEditing,
|
||||
toggleProviderKeyVisibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { ApplySettingsPayload } from "@/components/settings/contracts";
|
||||
import { providerFormFromRow } from "@/components/settings/models/ProviderSettings";
|
||||
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
|
||||
import { completeProviderOAuth } from "@/lib/api";
|
||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthPending,
|
||||
SettingsPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
function isProviderOAuthPending(
|
||||
payload: ProviderOAuthCompletionResult,
|
||||
): payload is ProviderOAuthPending {
|
||||
return (payload as ProviderOAuthPending).status === "pending";
|
||||
}
|
||||
|
||||
interface ProviderOAuthPollingOptions {
|
||||
state: ModelSettingsState;
|
||||
client: NanobotClient;
|
||||
applyPayload: ApplySettingsPayload;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
closeProviderOAuthFlow: () => void;
|
||||
}
|
||||
|
||||
export function useProviderOAuthPolling({
|
||||
state,
|
||||
client,
|
||||
applyPayload,
|
||||
setError,
|
||||
closeProviderOAuthFlow,
|
||||
}: ProviderOAuthPollingOptions) {
|
||||
const {
|
||||
providerOAuthFlow,
|
||||
providerOAuthFlowRef,
|
||||
setExpandedProvider,
|
||||
} = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerOAuthFlow) return;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
client,
|
||||
providerOAuthFlow.provider,
|
||||
providerOAuthFlow.flow_id,
|
||||
);
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
if (isProviderOAuthPending(payload)) {
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
return;
|
||||
}
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(providerOAuthFlow.provider);
|
||||
setError(null);
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
setError((err as Error).message);
|
||||
closeProviderOAuthFlow();
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
|
||||
}
|
||||
|
||||
export function useProviderFormsSync(
|
||||
state: ModelSettingsState,
|
||||
settings: SettingsPayload | null,
|
||||
) {
|
||||
const { setProviderForms } = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setProviderForms((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const provider of settings.providers) {
|
||||
next[provider.name] = next[provider.name] ?? providerFormFromRow(provider);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [settings]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
agentDraftFromPayload,
|
||||
type AgentSettingsDraft,
|
||||
} from "@/components/settings/models/ModelsSettings";
|
||||
import type { ProviderForm } from "@/components/settings/models/ProviderSettings";
|
||||
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
|
||||
|
||||
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modelPresetCreating, setModelPresetCreating] = useState(false);
|
||||
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
||||
const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false);
|
||||
const [modelMigrationSaving, setModelMigrationSaving] = useState(false);
|
||||
const [modelPresetPendingDelete, setModelPresetPendingDelete] =
|
||||
useState<SettingsPayload["model_presets"][number] | null>(null);
|
||||
const modelPresetBeforeCreateRef = useRef<string | null>(null);
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [providerOAuthFlow, setProviderOAuthFlow] =
|
||||
useState<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
|
||||
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
|
||||
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
|
||||
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
||||
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
);
|
||||
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
|
||||
() => initialSettings?.model_call_order ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
editingProviderKeys,
|
||||
expandedProvider,
|
||||
form,
|
||||
modelCallOrder,
|
||||
modelCallOrderSaving,
|
||||
modelConfigurationSaving,
|
||||
modelMigrationSaving,
|
||||
modelPresetBeforeCreateRef,
|
||||
modelPresetCreating,
|
||||
modelPresetPendingDelete,
|
||||
providerForms,
|
||||
providerOAuthCompleting,
|
||||
providerOAuthDialogError,
|
||||
providerOAuthFlow,
|
||||
providerOAuthFlowRef,
|
||||
providerOAuthResponse,
|
||||
providerSaving,
|
||||
saving,
|
||||
setEditingProviderKeys,
|
||||
setExpandedProvider,
|
||||
setForm,
|
||||
setModelCallOrder,
|
||||
setModelCallOrderSaving,
|
||||
setModelConfigurationSaving,
|
||||
setModelMigrationSaving,
|
||||
setModelPresetCreating,
|
||||
setModelPresetPendingDelete,
|
||||
setProviderForms,
|
||||
setProviderOAuthCompleting,
|
||||
setProviderOAuthDialogError,
|
||||
setProviderOAuthFlow,
|
||||
setProviderOAuthResponse,
|
||||
setProviderSaving,
|
||||
setSaving,
|
||||
setVisibleProviderKeys,
|
||||
visibleProviderKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModelSettingsState = ReturnType<typeof useModelSettingsState>;
|
||||
@@ -0,0 +1,526 @@
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Mic,
|
||||
Server,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { DEFAULT_TRANSCRIPTION_SETTINGS } from "@/components/settings/capabilities/TranscriptionSettings";
|
||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
import { settingsProviderConfigured } from "@/components/settings/shared/ModelControls";
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsSectionTitle,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { checkVersion } from "@/lib/api";
|
||||
import type {
|
||||
FileEditDisplayMode,
|
||||
LocalActivityMode,
|
||||
LocalDensity,
|
||||
LocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
import { providerBrand, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shortWorkspacePath } from "@/lib/workspace";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function OverviewSettings({
|
||||
settings,
|
||||
requiresRestart,
|
||||
onSelectSection,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
requiresRestart: boolean;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const activePresetName = settings.agent.model_preset;
|
||||
const activePreset =
|
||||
activePresetName && activePresetName !== "default"
|
||||
? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ??
|
||||
activePresetName
|
||||
: null;
|
||||
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
|
||||
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
|
||||
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
|
||||
const activeModelValue = activeProviderConfigured
|
||||
? settings.agent.model
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const activeModelCaption = activeProviderConfigured
|
||||
? [activeProvider, activePreset].filter(Boolean).join(" · ")
|
||||
: activeProviderLabel || settings.agent.model
|
||||
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
|
||||
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
||||
const webStatus = settings.web.enable
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const webSearchProvider =
|
||||
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
|
||||
settings.web_search.providers[0];
|
||||
const webSearchProviderLabel = providerDisplayLabel(
|
||||
settings.web_search.providers,
|
||||
settings.web_search.provider,
|
||||
);
|
||||
const webSearchCredentialStatus =
|
||||
webSearchProvider?.credential === "none"
|
||||
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
||||
: webSearchProvider?.credential === "optional_api_key"
|
||||
? settings.web_search.api_key_hint
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
||||
: webSearchProvider?.credential === "base_url"
|
||||
? settings.web_search.base_url
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
: settings.web_search.api_key_hint
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
|
||||
const imageStatus = settings.image_generation.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
|
||||
settings.image_generation.provider_configured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const voiceStatus = transcription.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
|
||||
transcription.provider_configured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
||||
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
||||
const runtimeTitle = isNativeHost
|
||||
? tx("settings.rows.engine", "Engine")
|
||||
: tx("settings.rows.gateway", "Gateway");
|
||||
const runtimeValue = isNativeHost
|
||||
? tx("settings.values.privateEngine", "Private engine")
|
||||
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
|
||||
const runtimeCaption = isNativeHost
|
||||
? tx("settings.values.unixSocket", "Unix socket")
|
||||
: requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready");
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
|
||||
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Bot}
|
||||
valueLogoProvider={activeProvider}
|
||||
title={tx("settings.overview.model", "Current model")}
|
||||
value={activeModelValue}
|
||||
caption={activeModelCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("models")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Globe2}
|
||||
valueLogoProvider={settings.web_search.provider}
|
||||
title={tx("settings.overview.webSearch", "Web search")}
|
||||
value={webStatus}
|
||||
caption={webCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("browser")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={ImageIcon}
|
||||
valueLogoProvider={settings.image_generation.provider}
|
||||
title={tx("settings.overview.imageGeneration", "Image generation")}
|
||||
value={imageStatus}
|
||||
caption={imageCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("image")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={Mic}
|
||||
valueLogoProvider={transcription.provider}
|
||||
title={tx("settings.overview.voiceInput", "Voice input")}
|
||||
value={voiceStatus}
|
||||
caption={voiceCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("voice")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Server}
|
||||
title={runtimeTitle}
|
||||
value={runtimeValue}
|
||||
caption={runtimeCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={HardDrive}
|
||||
title={tx("settings.overview.workspace", "Workspace")}
|
||||
value={tx("settings.values.defaultWorkspace", "Default workspace")}
|
||||
caption={workspaceCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<VersionCheckRow currentVersion={settings.version?.current} />
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const { token } = useClient();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { type: "up-to-date" }
|
||||
| { type: "update"; latestVersion: string; pypiUrl?: string }
|
||||
| { type: "error"; message: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await checkVersion(token);
|
||||
if (res.updateAvailable) {
|
||||
setResult({
|
||||
type: "update",
|
||||
latestVersion: res.updateAvailable.latestVersion,
|
||||
pypiUrl: res.updateAvailable.pypiUrl,
|
||||
});
|
||||
} else {
|
||||
setResult({ type: "up-to-date" });
|
||||
}
|
||||
} catch (err) {
|
||||
setResult({ type: "error", message: (err as Error).message });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium leading-5 text-foreground">
|
||||
{tx("settings.about.version", "Version")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{currentVersion ? `v${currentVersion}` : "nanobot"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleCheck()}
|
||||
disabled={checking}
|
||||
className="rounded-full"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{checking
|
||||
? tx("settings.about.checking", "Checking...")
|
||||
: tx("settings.about.checkForUpdates", "Check for updates")}
|
||||
</Button>
|
||||
{result?.type === "up-to-date" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
{tx("settings.about.upToDate", "You're up to date")}
|
||||
</span>
|
||||
) : null}
|
||||
{result?.type === "update" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
|
||||
<ArrowUpCircle className="h-3 w-3" aria-hidden />
|
||||
{t("settings.about.updateAvailable", {
|
||||
defaultValue: "Update available v{{version}}",
|
||||
version: result.latestVersion,
|
||||
})}
|
||||
{result.pypiUrl ? (
|
||||
<a
|
||||
href={result.pypiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
|
||||
>
|
||||
PyPI
|
||||
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{result?.type === "error" ? (
|
||||
<span className="text-[12px] text-destructive">{result.message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSettings({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
localPrefs,
|
||||
onChangeLocalPrefs,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
localPrefs: LocalPreferences;
|
||||
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("settings.rows.theme")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTheme}
|
||||
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
theme === "light" &&
|
||||
"bg-background text-foreground ring-1 ring-inset ring-border/45",
|
||||
)}
|
||||
>
|
||||
{t("settings.values.light")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
theme === "dark" &&
|
||||
"bg-background text-foreground ring-1 ring-inset ring-border/45",
|
||||
)}
|
||||
>
|
||||
{t("settings.values.dark")}
|
||||
</span>
|
||||
</button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title={t("settings.rows.language")}>
|
||||
<LanguageSwitcher />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={tx("settings.rows.density", "Density")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.density}
|
||||
options={[
|
||||
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
|
||||
{ value: "compact", label: tx("settings.values.compact", "Compact") },
|
||||
]}
|
||||
onChange={(density) =>
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.activityMode}
|
||||
options={[
|
||||
{ value: "auto", label: tx("settings.values.auto", "Auto") },
|
||||
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
|
||||
]}
|
||||
onChange={(activityMode) =>
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
|
||||
<SegmentedControl
|
||||
value={localPrefs.fileEditDisplayMode}
|
||||
options={[
|
||||
{ value: "summary", label: tx("settings.values.summary", "Summary") },
|
||||
{ value: "diff", label: tx("settings.values.diff", "Diff") },
|
||||
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
|
||||
]}
|
||||
onChange={(fileEditDisplayMode) =>
|
||||
onChangeLocalPrefs((prev) => ({
|
||||
...prev,
|
||||
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
|
||||
<ToggleButton
|
||||
checked={localPrefs.codeWrap}
|
||||
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
|
||||
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx(
|
||||
"settings.legal.thirdPartyBrands",
|
||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.brandLogos}
|
||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewRowIcon({
|
||||
icon: Icon,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewValueLogo({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
provider: string | null | undefined;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const brand = provider ? providerBrand(provider) : null;
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
|
||||
if (!provider || !showBrandLogos || !brand) return null;
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`overview-logo-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`overview-logo-fallback-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: brand.color }}
|
||||
aria-hidden
|
||||
>
|
||||
{brand.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewListRow({
|
||||
icon: Icon,
|
||||
valueLogoProvider,
|
||||
title,
|
||||
value,
|
||||
caption,
|
||||
showBrandLogos = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
valueLogoProvider?: string | null;
|
||||
title: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
showBrandLogos?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
||||
>
|
||||
<OverviewRowIcon icon={Icon} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
|
||||
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
|
||||
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
Cloud,
|
||||
Cpu,
|
||||
Database,
|
||||
Gem,
|
||||
Grid3X3,
|
||||
Hexagon,
|
||||
Layers,
|
||||
Loader2,
|
||||
Moon,
|
||||
Orbit,
|
||||
Pencil,
|
||||
Search,
|
||||
Sparkles,
|
||||
Triangle,
|
||||
Waves,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { fetchProviderModels } from "@/lib/api";
|
||||
import { providerBrand } from "@/lib/provider-brand";
|
||||
import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||
"aihubmix",
|
||||
"atomic_chat",
|
||||
"byteplus",
|
||||
"byteplus_coding_plan",
|
||||
"huggingface",
|
||||
"lm_studio",
|
||||
"modelscope",
|
||||
"novita",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"ovms",
|
||||
"siliconflow",
|
||||
"vllm",
|
||||
"volcengine",
|
||||
"volcengine_coding_plan",
|
||||
]);
|
||||
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
|
||||
|
||||
export function normalizeContextWindowTokens(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
||||
}
|
||||
|
||||
function settingsProviderRow(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
): SettingsPayload["providers"][number] | null {
|
||||
if (!provider) return null;
|
||||
return payload.providers.find((row) => row.name === provider) ?? null;
|
||||
}
|
||||
|
||||
export function settingsProviderConfigured(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
resolvedProvider?: string | null,
|
||||
): boolean {
|
||||
const row = settingsProviderRow(payload, provider);
|
||||
if (row) return row.configured;
|
||||
if (provider === "auto") {
|
||||
const resolvedRow = settingsProviderRow(
|
||||
payload,
|
||||
resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider,
|
||||
);
|
||||
if (resolvedRow) return resolvedRow.configured;
|
||||
}
|
||||
return payload.agent.has_api_key;
|
||||
}
|
||||
|
||||
export function ProviderPicker({
|
||||
providers,
|
||||
value,
|
||||
emptyLabel,
|
||||
showProviderLogos = false,
|
||||
onChange,
|
||||
}: {
|
||||
providers: Array<{ name: string; label: string }>;
|
||||
value: string;
|
||||
emptyLabel: string;
|
||||
showProviderLogos?: boolean;
|
||||
onChange: (provider: string) => void;
|
||||
}) {
|
||||
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
|
||||
const disabled = providers.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild disabled={disabled}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
||||
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
disabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selectedProvider && showProviderLogos ? (
|
||||
<ProviderPickerIcon
|
||||
provider={selectedProvider.name}
|
||||
showBrandLogos={showProviderLogos}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{providers.map((provider) => {
|
||||
const selected = provider.name === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.name}
|
||||
onSelect={() => onChange(provider.name)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 text-[13px]",
|
||||
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{showProviderLogos ? (
|
||||
<ProviderPickerIcon
|
||||
provider={provider.name}
|
||||
showBrandLogos={showProviderLogos}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{provider.label}</span>
|
||||
</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelIdPicker({
|
||||
token,
|
||||
settings,
|
||||
provider,
|
||||
models,
|
||||
value,
|
||||
showProviderLogos,
|
||||
emptyLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
onChange,
|
||||
}: {
|
||||
token: string;
|
||||
settings: SettingsPayload;
|
||||
provider: string;
|
||||
models?: string[];
|
||||
value: string;
|
||||
showProviderLogos: boolean;
|
||||
emptyLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const effectiveProvider =
|
||||
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
||||
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||
const hasStaticModels = models !== undefined;
|
||||
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||
const providerRequiresConfiguration =
|
||||
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
||||
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
||||
const providerUsesManualModelIds =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider &&
|
||||
providerConfigured &&
|
||||
providerRow?.auth_type === "oauth" &&
|
||||
!providerHasBuiltinModels;
|
||||
const canFetchModels =
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const providerModels: ProviderModelsPayload["models"] = useMemo(
|
||||
() => hasStaticModels
|
||||
? (models?.map((id) => ({ id })) ?? [])
|
||||
: (payload?.models ?? []),
|
||||
[hasStaticModels, models, payload?.models],
|
||||
);
|
||||
const visibleModels = useMemo(
|
||||
() => providerModels
|
||||
.filter((model) => {
|
||||
if (!normalizedQuery) return true;
|
||||
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
|
||||
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||
})
|
||||
.slice(0, 80),
|
||||
[normalizedQuery, providerModels],
|
||||
);
|
||||
const isCatalog = payload?.catalog_kind === "catalog";
|
||||
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
|
||||
const hasDeferredSearchQuery =
|
||||
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
|
||||
const shouldFetchModels =
|
||||
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
||||
const waitingForModelSearch =
|
||||
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
||||
const hasModelList = hasStaticModels || payload?.status === "available";
|
||||
const showModels = Boolean(
|
||||
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
|
||||
);
|
||||
const customCandidate = query.trim();
|
||||
const allowCustomModel = !providerRequiresConfiguration;
|
||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||
const showCustomModel = Boolean(
|
||||
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
|
||||
);
|
||||
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||
const modelUnconfigured = !value.trim() || !providerConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
|
||||
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !shouldFetchModels) {
|
||||
setPayload(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPayload(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
fetchProviderModels(tokenRef.current, effectiveProvider)
|
||||
.then((nextPayload) => {
|
||||
if (!cancelled) setPayload(nextPayload);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvider, open, shouldFetchModels]);
|
||||
|
||||
const selectModel = (model: string) => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
};
|
||||
const navigationValues = useMemo(
|
||||
() => [
|
||||
...(showModels ? visibleModels.map((model) => model.id) : []),
|
||||
...(showCustomModel ? [customCandidate] : []),
|
||||
],
|
||||
[customCandidate, showCustomModel, showModels, visibleModels],
|
||||
);
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: navigationValues,
|
||||
selectedValue: value,
|
||||
onSelect: selectModel,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
const renderModelRow = (
|
||||
model: ProviderModelsPayload["models"][number],
|
||||
options: { selected?: boolean } = {},
|
||||
) => (
|
||||
<ComboboxOption
|
||||
key={model.id}
|
||||
{...navigation.getOptionProps(model.id)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
||||
options.selected && "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={!providerConfigured}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{model.label ?? model.id}
|
||||
</span>
|
||||
{model.description || (model.label && model.label !== model.id) ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] text-muted-foreground">
|
||||
{[model.label && model.label !== model.id ? model.id : null, model.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
|
||||
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
|
||||
</span>
|
||||
</ComboboxOption>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
|
||||
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={modelUnconfigured}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-medium",
|
||||
value ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
|
||||
>
|
||||
<div className="p-1 pb-1.5">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
{...navigation.inputProps}
|
||||
placeholder={
|
||||
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
|
||||
}
|
||||
aria-label={
|
||||
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
|
||||
}
|
||||
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerRequiresConfiguration ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||
</div>
|
||||
) : hasStaticModels && !providerModels.length ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : providerUsesManualModelIds ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : !canFetchModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
||||
</div>
|
||||
) : waitingForModelSearch ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{tx("settings.models.loadingModels", "Loading models...")}
|
||||
</div>
|
||||
) : error || payload?.status === "error" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
|
||||
</div>
|
||||
) : payload?.status === "not_configured" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||
</div>
|
||||
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : isCatalog && !normalizedQuery ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
||||
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{navigationValues.length ? (
|
||||
<div
|
||||
{...navigation.listProps}
|
||||
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
|
||||
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{showModels
|
||||
? visibleModels.map((model) =>
|
||||
renderModelRow(model, { selected: model.id === value }),
|
||||
)
|
||||
: null}
|
||||
{showCustomModel ? (
|
||||
<>
|
||||
{showModels && visibleModels.length ? (
|
||||
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
|
||||
) : null}
|
||||
<ComboboxOption
|
||||
{...navigation.getOptionProps(customCandidate)}
|
||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{tx("settings.models.useCustomModel", "Use")}{" "}
|
||||
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
||||
</span>
|
||||
</ComboboxOption>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : showModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
{tx("settings.models.noModelResults", "No matching models.")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1_000_000) {
|
||||
const value = tokens / 1_000_000;
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1_000) {
|
||||
const value = tokens / 1_000;
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
|
||||
}
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
export function formatModelContextWindow(tokens: number): string {
|
||||
if (tokens === 65_536) return "64K";
|
||||
if (tokens === 262_144) return "256K";
|
||||
if (tokens === 1_048_576) return "1M";
|
||||
return formatContextWindow(tokens);
|
||||
}
|
||||
|
||||
export function ProviderPickerIcon({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
unconfigured = false,
|
||||
}: {
|
||||
provider: string;
|
||||
showBrandLogos: boolean;
|
||||
unconfigured?: boolean;
|
||||
}) {
|
||||
const brand = providerBrand(provider);
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
|
||||
if (unconfigured) {
|
||||
return (
|
||||
<span
|
||||
data-testid="provider-picker-unconfigured-icon"
|
||||
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
|
||||
aria-hidden
|
||||
>
|
||||
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-picker-logo-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBrandLogos && brand) {
|
||||
return (
|
||||
<span
|
||||
data-testid={`provider-picker-logo-fallback-${provider}`}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: brand.color }}
|
||||
aria-hidden
|
||||
>
|
||||
{brand.initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-3 w-3" strokeWidth={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function optionRowsWithCurrent(
|
||||
options: Array<{ name: string; label: string }>,
|
||||
value: string,
|
||||
): Array<{ name: string; label: string }> {
|
||||
if (!value || options.some((option) => option.name === value)) return options;
|
||||
return [{ name: value, label: value }, ...options];
|
||||
}
|
||||
|
||||
export const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
||||
custom: Hexagon,
|
||||
openrouter: Sparkles,
|
||||
skywork: Sparkles,
|
||||
aihubmix: Triangle,
|
||||
anthropic: Brain,
|
||||
openai: Bot,
|
||||
deepseek: Waves,
|
||||
zhipu: Grid3X3,
|
||||
dashscope: Cloud,
|
||||
modelscope: Layers,
|
||||
moonshot: Moon,
|
||||
minimax: Zap,
|
||||
minimax_anthropic: Brain,
|
||||
groq: Cpu,
|
||||
huggingface: Layers,
|
||||
gemini: Gem,
|
||||
mistral: Orbit,
|
||||
siliconflow: Layers,
|
||||
volcengine: Cloud,
|
||||
volcengine_coding_plan: Cloud,
|
||||
byteplus: Cloud,
|
||||
byteplus_coding_plan: Cloud,
|
||||
qianfan: Database,
|
||||
ant_ling: Sparkles,
|
||||
azure_openai: Cloud,
|
||||
bedrock: Database,
|
||||
bocha: Search,
|
||||
brave: Search,
|
||||
duckduckgo: Search,
|
||||
exa: Search,
|
||||
jina: Search,
|
||||
kagi: Search,
|
||||
olostep: Search,
|
||||
searxng: Search,
|
||||
tavily: Search,
|
||||
vllm: Cpu,
|
||||
ollama: Cpu,
|
||||
lm_studio: Cpu,
|
||||
atomic_chat: Cpu,
|
||||
ovms: Cpu,
|
||||
nvidia: Zap,
|
||||
};
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CircleAlert, Loader2, RotateCcw, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { isNativeRuntime } from "@/lib/runtime";
|
||||
import type { NanobotFeatureInfo } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SETTINGS_SEARCH_INPUT_CLASS = cn(
|
||||
"border-border/45 bg-settings-surface transition-colors hover:border-border/70",
|
||||
"focus-visible:border-border/70 focus-visible:bg-background",
|
||||
);
|
||||
|
||||
export function CapabilityInstallNotice({
|
||||
title,
|
||||
description,
|
||||
installing = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
installing?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
|
||||
{installing ? (
|
||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12.5px] font-medium text-foreground">{title}</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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 p-5 text-center"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DismissibleStatusMessage({
|
||||
message,
|
||||
isError,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
|
||||
isError
|
||||
? "border-destructive/20 bg-destructive/5 text-destructive"
|
||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tx("settings.actions.dismiss", "Dismiss")}
|
||||
title={tx("settings.actions.dismiss", "Dismiss")}
|
||||
onClick={onDismiss}
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
isError
|
||||
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
|
||||
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestartRequiredNotice({
|
||||
message,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
}: {
|
||||
message: string;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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>{message}</span>
|
||||
{onRestart ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="divide-y divide-border/45">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
|
||||
{description ? (
|
||||
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyRow({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow title={title} description={description}>
|
||||
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
|
||||
{value}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestartSettingsFooter({
|
||||
dirty,
|
||||
saving,
|
||||
pendingRestart,
|
||||
disabled = false,
|
||||
message,
|
||||
dirtyMessage,
|
||||
pendingMessage,
|
||||
onSave,
|
||||
onRestart,
|
||||
onReset,
|
||||
isRestarting,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
pendingRestart: boolean;
|
||||
disabled?: boolean;
|
||||
message?: string;
|
||||
dirtyMessage?: string;
|
||||
pendingMessage?: string;
|
||||
onSave: () => void;
|
||||
onRestart?: () => void;
|
||||
onReset?: () => void;
|
||||
isRestarting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const isNativeHost = isNativeRuntime();
|
||||
const restartLabel = isNativeHost
|
||||
? tx("app.system.restartEngine", "Restart engine")
|
||||
: t("app.system.restart");
|
||||
const restartingLabel = isNativeHost
|
||||
? tx("app.system.restartingEngine", "Restarting engine...")
|
||||
: t("app.system.restarting");
|
||||
const statusMessage =
|
||||
message ??
|
||||
(pendingRestart && !dirty
|
||||
? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||
: dirty
|
||||
? dirtyMessage ?? t("settings.status.unsaved")
|
||||
: undefined);
|
||||
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
|
||||
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
|
||||
{pendingRestart && !dirty && onRestart ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="rounded-full"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? restartingLabel : restartLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onReset ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onReset}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded-full"
|
||||
>
|
||||
{t("settings.actions.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || disabled || saving}
|
||||
className="rounded-full"
|
||||
>
|
||||
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatusMessage({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
tone?: "accent" | "danger";
|
||||
}) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
|
||||
tone === "danger" && "font-medium text-destructive",
|
||||
)}
|
||||
>
|
||||
{tone ? (
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
tone === "accent" &&
|
||||
"bg-blue-500 dark:bg-blue-400",
|
||||
tone === "danger" && "bg-destructive/70",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "success" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
|
||||
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
tone === "neutral" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
suffix,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const parsed = Number(event.target.value);
|
||||
if (Number.isFinite(parsed)) onChange(parsed);
|
||||
}}
|
||||
className="h-8 w-24 max-w-full rounded-full text-[13px]"
|
||||
/>
|
||||
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user