mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 14:58:39 +03:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0240a9074 | ||
|
|
8071ac3b27 | ||
|
|
495a06f21a | ||
|
|
32c5a10947 | ||
|
|
40ad6cb44c | ||
|
|
52e0a6a1e3 | ||
|
|
8e77f3f8a4 | ||
|
|
b3b0517611 | ||
|
|
c281e090d0 | ||
|
|
85a452e5c7 | ||
|
|
05d73803e7 | ||
|
|
5d733b1c7c | ||
|
|
71a99b0780 | ||
|
|
43511decc9 | ||
|
|
8dd2059be3 | ||
|
|
7b1646f58c | ||
|
|
e620944150 | ||
|
|
66316f21da | ||
|
|
55ecda275d | ||
|
|
411d6061ae |
@@ -173,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test WebUI
|
- name: Test WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run test
|
run: bun run test:coverage
|
||||||
|
|
||||||
- name: Build WebUI
|
- name: Build WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 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">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<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:
|
Use it to:
|
||||||
|
|
||||||
- keep separate topics for different tasks and projects;
|
- 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;
|
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||||
- switch models and workspaces without leaving the conversation;
|
- 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).
|
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).
|
||||||
|
|
||||||
|
|||||||
+41
-4
@@ -1971,15 +1971,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` |
|
| **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]
|
> [!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:
|
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,15 @@ remote HTTP endpoint.
|
|||||||
For local interactive setup:
|
For local interactive setup:
|
||||||
|
|
||||||
1. Run `nanobot webui` and open **Apps**.
|
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.
|
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||||
4. Save and restart when prompted.
|
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`:
|
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.
|
- Prefer `enabledTools` over exposing every tool by default.
|
||||||
- Use `toolTimeout` for slow MCP operations.
|
- Use `toolTimeout` for slow MCP operations.
|
||||||
- Use HTTP MCP only for endpoints you trust.
|
- 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.
|
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- Stdio MCP starts a local process; review the command before enabling it.
|
- 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.
|
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||||
- Do not place secrets in command arguments when environment variables or
|
- Do not place secrets in command arguments when environment variables or
|
||||||
headers can be used.
|
headers can be used.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
## Chat App Problems
|
||||||
|
|||||||
+77
-23
@@ -1,10 +1,10 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# 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
|
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
||||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
||||||
one place.
|
settings, and Automations in one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
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
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
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
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
outside the workspace when that access mode is allowed by the gateway.
|
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
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
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
|
The composer supports plain messages, image attachments, voice input when
|
||||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
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
|
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
|
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
|
is relevant and can link it in the response. The model badge shows the current
|
||||||
@@ -177,8 +204,13 @@ turn. The default **Ready** view shows only tools that can be used immediately:
|
|||||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
- **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
|
Installing an adapter does not modify the native desktop or web app it
|
||||||
connects to.
|
connects to.
|
||||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
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
|
Apps intentionally does not list nanobot runtime support packages such as
|
||||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||||
@@ -199,15 +231,25 @@ 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
|
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.
|
provider; mention `@parallel-search` when a turn should use it.
|
||||||
|
|
||||||
After an App or integration is available, mention it from the composer with
|
After an App or MCP server is available, mention it from the composer with `@`
|
||||||
`@` to attach that tool to the next message.
|
to attach that tool to the next message.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
The Skills view shows the skill instructions available to the agent, including
|
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
||||||
built-in skills and workspace-provided skills. Check this view when you want to
|
You can search and filter them, inspect their instructions and setup
|
||||||
know whether nanobot already has a focused workflow for a task before you ask it
|
requirements, enable or disable them, and delete workspace skills you no longer
|
||||||
to perform that task.
|
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
|
## Automations
|
||||||
|
|
||||||
@@ -288,10 +330,17 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
|||||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||||
form.
|
form.
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||||
install missing nanobot support packages, such as adding a channel dependency,
|
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||||
are blocked by default. To let trusted remote administrators change the Python
|
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||||
environment through the WebUI, opt in explicitly:
|
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 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -302,12 +351,13 @@ environment through the WebUI, opt in explicitly:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use this only for a private deployment where every authenticated WebUI user is
|
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
|
trusted to change nanobot's Python environment and install workspace skill
|
||||||
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
||||||
as remote access and leave package installs disabled unless that is intentional.
|
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
|
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
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
private, trusted network.
|
private, trusted network.
|
||||||
@@ -322,6 +372,10 @@ If the page does not open, check these in order:
|
|||||||
4. You are opening port `8765`, not the gateway health port.
|
4. You are opening port `8765`, not the gateway health port.
|
||||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||||
|
|
||||||
|
If voice input asks for a secure connection, use HTTPS with a certificate the
|
||||||
|
device trusts. Browsers do not expose microphone capture to
|
||||||
|
`http://<your-ip>` origins.
|
||||||
|
|
||||||
For detailed diagnostics, see
|
For detailed diagnostics, see
|
||||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||||
|
|||||||
+11
-3
@@ -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.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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.tools.self import MyTool
|
||||||
from nanobot.agent.turn_delivery import (
|
from nanobot.agent.turn_delivery import (
|
||||||
TurnDelivery,
|
TurnDelivery,
|
||||||
@@ -197,6 +198,11 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
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
|
@property
|
||||||
def provider(self) -> LLMProvider:
|
def provider(self) -> LLMProvider:
|
||||||
"""Provider selected for future turn admissions."""
|
"""Provider selected for future turn admissions."""
|
||||||
@@ -448,7 +454,6 @@ class AgentLoop:
|
|||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||||
self._runtime_vars: dict[str, Any] = {}
|
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
register_builtin_commands(self.commands)
|
||||||
@@ -623,10 +628,13 @@ class AgentLoop:
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
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:
|
if self.tools_config.my.enable:
|
||||||
self.tools.register(
|
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")
|
registered.append("my")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypedDict
|
from typing import Any, Callable, TypedDict
|
||||||
@@ -157,6 +158,10 @@ class SubagentManager:
|
|||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
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:
|
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|||||||
@@ -827,7 +827,8 @@ class EditFileTool(_FsTool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Perform a small, exact replacement in one file by replacing "
|
"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, "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
"multiple times, provide more context or set occurrence, line_hint, "
|
"multiple times, provide more context or set occurrence, line_hint, "
|
||||||
@@ -862,9 +863,12 @@ class EditFileTool(_FsTool):
|
|||||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||||
|
|
||||||
fp = self._resolve_write(path)
|
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
|
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||||
if not fp.exists():
|
if not file_exists:
|
||||||
if old_text == "":
|
if old_text == "":
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
"base", "schema", "registry", "context", "loader", "config",
|
"base", "schema", "registry", "context", "loader", "config",
|
||||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+95
-40
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
|
|||||||
from mcp.types import Prompt, Resource
|
from mcp.types import Prompt, Resource
|
||||||
from mcp.types import Tool as MCPToolDefinition
|
from mcp.types import Tool as MCPToolDefinition
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# 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
|
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:
|
def _is_session_terminated(exc: BaseException) -> bool:
|
||||||
"""Return True when the MCP SDK reports a dead client session."""
|
"""Return True when the MCP SDK reports a dead client session."""
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
@@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
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]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""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
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||||
) -> tuple[str, AsyncExitStack | None]:
|
) -> bool:
|
||||||
server_stack = AsyncExitStack()
|
|
||||||
await server_stack.__aenter__()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -991,8 +1011,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -1003,8 +1022,30 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
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":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1022,8 +1063,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1044,31 +1084,37 @@ async def connect_mcp_servers(
|
|||||||
**_pinned_transport_kwargs(),
|
**_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(
|
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":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client_kwargs: dict[str, Any] = {
|
||||||
httpx.AsyncClient(
|
"headers": cfg.headers or None,
|
||||||
headers=cfg.headers or None,
|
"event_hooks": {"request": [_validate_mcp_request_url]},
|
||||||
event_hooks={"request": [_validate_mcp_request_url]},
|
"follow_redirects": True,
|
||||||
follow_redirects=True,
|
"timeout": httpx.Timeout(30.0, connect=10.0),
|
||||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
||||||
**_pinned_transport_kwargs(),
|
**_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(**http_client_kwargs)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write, _ = await server_stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1171,7 +1217,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return name, server_stack
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1190,10 +1236,8 @@ async def connect_mcp_servers(
|
|||||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
" 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."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
_log_mcp_connection_failure(name, e, hint)
|
||||||
with suppress(Exception):
|
return False
|
||||||
await server_stack.aclose()
|
|
||||||
return name, None
|
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1203,30 +1247,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
stack: AsyncExitStack | None = None
|
|
||||||
try:
|
try:
|
||||||
_, stack = await open_single_server(name, cfg)
|
async with AsyncExitStack() as stack:
|
||||||
|
connected = await open_single_server(name, cfg, stack)
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_result(stack is not None)
|
ready.set_result(connected)
|
||||||
if stack is not None:
|
if connected:
|
||||||
await close_requested.wait()
|
await close_requested.wait()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if stack is not None:
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException:
|
except BaseException as exc:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
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
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
@@ -1239,7 +1283,7 @@ async def connect_mcp_servers(
|
|||||||
try:
|
try:
|
||||||
result = await connect_single_server(name, cfg)
|
result = await connect_single_server(name, cfg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
_log_mcp_connection_failure(name, e)
|
||||||
continue
|
continue
|
||||||
if result[1] is not None:
|
if result[1] is not None:
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
@@ -1312,6 +1356,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
current_servers = dict(state._mcp_servers)
|
current_servers = dict(state._mcp_servers)
|
||||||
current_names = set(current_servers)
|
current_names = set(current_servers)
|
||||||
next_names = set(next_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)
|
removed = sorted(current_names - next_names)
|
||||||
added = sorted(next_names - current_names)
|
added = sorted(next_names - current_names)
|
||||||
changed = sorted(
|
changed = sorted(
|
||||||
@@ -1329,9 +1380,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
retry_missing = sorted(
|
retry_missing = sorted(
|
||||||
name
|
name
|
||||||
for name in next_names
|
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}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, MCPConnection] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
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: ...
|
|
||||||
+196
-165
@@ -1,8 +1,7 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
# Tool.execute accepts heterogeneous schemas.
|
||||||
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,7 +13,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
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.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
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
|
|||||||
allow_set: bool = False
|
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]:
|
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
return isinstance(value, 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):
|
class MyTool(Tool):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
@@ -79,7 +87,10 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
READ_ONLY = frozenset({
|
READ_ONLY = frozenset({
|
||||||
"subagents", # observable but replacing it would break the system
|
"subagents", # observable but replacing it would break the system
|
||||||
|
"tool_names",
|
||||||
|
"current_iteration",
|
||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
|
"_last_usage",
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
"model_presets", # config-derived catalog; changes require config reload
|
"model_presets", # config-derived catalog; changes require config reload
|
||||||
@@ -103,13 +114,6 @@ class MyTool(Tool):
|
|||||||
"private_key", "access_token", "refresh_token", "auth",
|
"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]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||||
@@ -123,15 +127,15 @@ class MyTool(Tool):
|
|||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_state = runtime_state
|
self._runtime_control = runtime_control
|
||||||
self._modify_allowed = modify_allowed
|
self._modify_allowed = modify_allowed
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
result = cls.__new__(cls)
|
result = cls.__new__(cls)
|
||||||
memo[id(self)] = result
|
memo[id(self)] = result
|
||||||
result._runtime_state = self._runtime_state
|
result._runtime_control = self._runtime_control
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -208,9 +212,12 @@ class MyTool(Tool):
|
|||||||
# Path resolution
|
# 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(".")
|
parts = path.split(".")
|
||||||
obj: Any = self._runtime_state
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
@@ -218,17 +225,13 @@ class MyTool(Tool):
|
|||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
if part.lower() in self._SENSITIVE_NAMES:
|
if part.lower() in self._SENSITIVE_NAMES:
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
try:
|
obj: object = snapshot.as_mapping()
|
||||||
if isinstance(obj, Mapping):
|
for part in parts:
|
||||||
mapping = cast(Mapping[str, Any], obj)
|
if not _is_string_mapping(obj):
|
||||||
if part in mapping:
|
return None, f"'{part}' not found"
|
||||||
obj = mapping[part]
|
if part not in obj:
|
||||||
else:
|
|
||||||
return None, f"'{part}' not found in mapping"
|
return None, f"'{part}' not found in mapping"
|
||||||
else:
|
obj = obj[part]
|
||||||
obj = getattr(obj, part)
|
|
||||||
except (KeyError, AttributeError) as e:
|
|
||||||
return None, f"'{part}' not found: {e}"
|
|
||||||
return obj, None
|
return obj, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -242,20 +245,48 @@ class MyTool(Tool):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
def _format_status(
|
||||||
elapsed = time.monotonic() - st.started_at
|
st: "SubagentStatus | Mapping[str, object]",
|
||||||
tool_summary = ", ".join(
|
indent: str = " ",
|
||||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
) -> str:
|
||||||
) or "none"
|
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 = [
|
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}tools: {tool_summary}",
|
||||||
f"{indent}usage: {st.usage or 'n/a'}",
|
f"{indent}usage: {usage or 'n/a'}",
|
||||||
]
|
]
|
||||||
if st.error:
|
if error:
|
||||||
lines.append(f"{indent}error: {st.error}")
|
lines.append(f"{indent}error: {error}")
|
||||||
if st.stop_reason:
|
if stop_reason:
|
||||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
lines.append(f"{indent}stop_reason: {stop_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -264,29 +295,38 @@ class MyTool(Tool):
|
|||||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
# SubagentManager: delegate to its _task_statuses dict
|
if _is_subagent_status_snapshot(val):
|
||||||
task_statuses = getattr(val, "_task_statuses", None)
|
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
||||||
if isinstance(task_statuses, dict):
|
detail = MyTool._format_status(val, " ")
|
||||||
return MyTool._format_value(task_statuses, key)
|
return f"{header}\n task: {val['task_description']}\n{detail}"
|
||||||
if isinstance(val, Mapping):
|
if isinstance(val, Mapping):
|
||||||
mapping = cast(Mapping[object, object], val)
|
mapping = cast(Mapping[object, object], val)
|
||||||
else:
|
else:
|
||||||
mapping = None
|
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 (
|
if (
|
||||||
mapping
|
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 ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||||
for tid, st in status_mapping.items():
|
for tid, st in mapping.items():
|
||||||
|
if _is_subagent_status(st):
|
||||||
detail = MyTool._format_status(st, " ")
|
detail = MyTool._format_status(st, " ")
|
||||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
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)
|
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
|
# Scalar types — repr is fine
|
||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
@@ -311,32 +351,6 @@ class MyTool(Tool):
|
|||||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||||
r = repr(sequence)
|
r = repr(sequence)
|
||||||
return f"{key}: {r}" if key else r
|
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)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
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
|
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||||
return False, None
|
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:
|
def _inspect(self, key: str | None) -> str:
|
||||||
if not key:
|
if not key:
|
||||||
@@ -375,62 +394,64 @@ class MyTool(Tool):
|
|||||||
request_ctx = current_request_context()
|
request_ctx = current_request_context()
|
||||||
if request_ctx is None:
|
if request_ctx is None:
|
||||||
return ToolResult.error("Error: current request context is unavailable")
|
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":
|
if key == "request":
|
||||||
return self._format_value(
|
return self._format_value(request_values, key)
|
||||||
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
field = key.removeprefix("request.")
|
field = key.removeprefix("request.")
|
||||||
if field not in self._REQUEST_FIELDS:
|
if field not in self._REQUEST_FIELDS:
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
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:
|
if "." not in key:
|
||||||
found, value = self._current_runtime_value(key)
|
found, value = self._current_runtime_value(key)
|
||||||
if found:
|
if found:
|
||||||
return self._format_value(value, key)
|
return self._format_value(value, key)
|
||||||
|
snapshot = self._runtime_control.snapshot()
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||||
obj, err = self._resolve_path(key)
|
obj, err = self._resolve_path(snapshot, key)
|
||||||
if err:
|
if err:
|
||||||
# "scratchpad" alias for _runtime_vars
|
|
||||||
if key == "scratchpad":
|
if key == "scratchpad":
|
||||||
rv = self._runtime_state._runtime_vars
|
return (
|
||||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
self._format_value(snapshot.scratchpad, "scratchpad")
|
||||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
if snapshot.scratchpad
|
||||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
else "scratchpad is empty"
|
||||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
)
|
||||||
|
if "." not in key and key in snapshot.scratchpad:
|
||||||
|
return self._format_value(snapshot.scratchpad[key], key)
|
||||||
return ToolResult.error(f"Error: {err}")
|
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)
|
return self._format_value(obj, key)
|
||||||
|
|
||||||
def _inspect_all(self) -> str:
|
def _inspect_all(self) -> str:
|
||||||
state = self._runtime_state
|
snapshot = self._runtime_control.snapshot()
|
||||||
|
values = snapshot.as_mapping()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
# RESTRICTED keys
|
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
found, value = self._current_runtime_value(k)
|
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")
|
found, value = self._current_runtime_value("model_preset")
|
||||||
parts.append(self._format_value(
|
parts.append(self._format_value(
|
||||||
value if found else state.model_preset,
|
value if found else snapshot.model_preset,
|
||||||
"model_preset",
|
"model_preset",
|
||||||
))
|
))
|
||||||
# Other useful top-level keys shown in description
|
for k in (
|
||||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
"workspace",
|
||||||
if _has_real_attr(state, k):
|
"provider_retry_mode",
|
||||||
parts.append(self._format_value(getattr(state, k, None), k))
|
"max_tool_result_chars",
|
||||||
# Token usage
|
"_current_iteration",
|
||||||
usage = state._last_usage
|
"web_config",
|
||||||
if usage:
|
"exec_config",
|
||||||
parts.append(self._format_value(usage, "_last_usage"))
|
"subagents",
|
||||||
rv = state._runtime_vars
|
):
|
||||||
if rv:
|
parts.append(self._format_value(values[k], k))
|
||||||
parts.append(self._format_value(rv, "scratchpad"))
|
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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
# -- modify --
|
# -- modify --
|
||||||
@@ -454,48 +475,49 @@ class MyTool(Tool):
|
|||||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
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:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
if isinstance(parent, dict):
|
self._audit("modify", f"READ_ONLY {key}")
|
||||||
parent[leaf] = value
|
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||||
else:
|
|
||||||
setattr(parent, leaf, value)
|
|
||||||
self._audit("modify", f"{key} = {value!r}")
|
|
||||||
return f"Set {key} = {value!r}"
|
|
||||||
if key == "model_preset":
|
if key == "model_preset":
|
||||||
return self._modify_model_preset(value)
|
return self._modify_model_preset(value)
|
||||||
if key in self.RESTRICTED:
|
if key in self.RESTRICTED:
|
||||||
return self._modify_restricted(key, value)
|
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:
|
def _modify_model_preset(self, value: Any) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
session_key = current_request_session_key()
|
session_key = current_request_session_key()
|
||||||
if session_key:
|
old = self._runtime_control.snapshot().model_preset
|
||||||
try:
|
try:
|
||||||
runtime = self._runtime_state.set_session_model_preset(
|
runtime = self._runtime_control.set_model_preset(
|
||||||
session_key,
|
|
||||||
name,
|
name,
|
||||||
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
message = str(exc.args[0]) if exc.args else str(exc)
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
|
if session_key:
|
||||||
self._audit("modify", f"model_preset = {name!r}")
|
self._audit("modify", f"model_preset = {name!r}")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} for the next turn; "
|
f"Set model_preset = {name!r} for the next turn; "
|
||||||
f"model will be {runtime.model!r}; "
|
f"model will be {runtime.model!r}; "
|
||||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
result = self._modify_free("model_preset", name)
|
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
||||||
if isinstance(result, ToolResult) and result.is_error:
|
|
||||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
|
||||||
return (
|
return (
|
||||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
||||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
@@ -508,7 +530,7 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
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"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||||
if "max" in spec and value > spec["max"]:
|
if "max" in spec and value > spec["max"]:
|
||||||
@@ -521,41 +543,46 @@ class MyTool(Tool):
|
|||||||
"during an active session; use a configured model_preset"
|
"during an active session; use a configured model_preset"
|
||||||
)
|
)
|
||||||
if key == "model":
|
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":
|
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:
|
else:
|
||||||
setattr(self._runtime_state, key, value)
|
self._runtime_control.set_max_iterations(cast(int, value))
|
||||||
if key == "max_iterations" and hasattr(
|
|
||||||
self._runtime_state,
|
|
||||||
"_sync_subagent_runtime_limits",
|
|
||||||
):
|
|
||||||
self._runtime_state._sync_subagent_runtime_limits()
|
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
def _modify_free(self, key: str, value: Any) -> str:
|
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
||||||
if _has_real_attr(self._runtime_state, key):
|
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||||
old = getattr(self._runtime_state, key)
|
if key == "workspace":
|
||||||
if isinstance(old, (str, int, float, bool)):
|
if not isinstance(value, str):
|
||||||
old_t: type[Any] = type(old)
|
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))
|
new_t = cast(type[Any], type(value))
|
||||||
if old_t is float and new_t is int:
|
if old_t is float and new_t is int:
|
||||||
pass # int → float coercion allowed
|
pass
|
||||||
elif old_t is not new_t:
|
elif old_t is not new_t:
|
||||||
self._audit(
|
self._audit(
|
||||||
"modify",
|
"modify",
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
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__}")
|
return ToolResult.error(
|
||||||
try:
|
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||||
setattr(self._runtime_state, key, value)
|
)
|
||||||
except (ValueError, KeyError) as e:
|
if key == "provider_retry_mode":
|
||||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
||||||
self._audit("modify", f"REJECTED {key}: {message}")
|
elif key == "max_tool_result_chars":
|
||||||
return ToolResult.error(f"Error: {message}")
|
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}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
|
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
||||||
if callable(value):
|
if callable(value):
|
||||||
self._audit("modify", f"REJECTED callable {key}")
|
self._audit("modify", f"REJECTED callable {key}")
|
||||||
return ToolResult.error("Error: cannot store callable values")
|
return ToolResult.error("Error: cannot store callable values")
|
||||||
@@ -563,12 +590,16 @@ class MyTool(Tool):
|
|||||||
if err:
|
if err:
|
||||||
self._audit("modify", f"REJECTED {key}: {err}")
|
self._audit("modify", f"REJECTED {key}: {err}")
|
||||||
return ToolResult.error(f"Error: {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")
|
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.")
|
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
||||||
old = self._runtime_state._runtime_vars.get(key)
|
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
||||||
self._runtime_state._runtime_vars[key] = value
|
|
||||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
|
||||||
return f"Set scratchpad.{key} = {value!r}"
|
return f"Set scratchpad.{key} = {value!r}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import ( # pyright: ignore[reportMissingImports]
|
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
||||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
|
|||||||
@@ -238,20 +238,6 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_removal_when_both_ids_missing(self):
|
|
||||||
ch = _make_channel()
|
|
||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
|
||||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
|
||||||
)
|
|
||||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
|
||||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
|
||||||
ch._remove_reaction = AsyncMock()
|
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_removal_when_not_stream_end(self):
|
async def test_no_removal_when_not_stream_end(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||||
|
|
||||||
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
token={token}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
|
|||||||
instance: NanobotChannelInstanceInfo;
|
instance: NanobotChannelInstanceInfo;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = channelTranslator(t, "feishu");
|
const tx = channelTranslator(t, "feishu");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ class ChannelManager:
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | 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 = config
|
||||||
|
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
@@ -170,6 +176,7 @@ class ChannelManager:
|
|||||||
static_dist_path=static_path,
|
static_dist_path=static_path,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
default_restrict_to_workspace=self.config.tools.restrict_to_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),
|
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||||
runtime_model_name=self._webui_runtime_model_name,
|
runtime_model_name=self._webui_runtime_model_name,
|
||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default: dict[ServerConnection, str] = {}
|
self._conn_default: dict[ServerConnection, str] = {}
|
||||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
self._webui_connections: set[ServerConnection] = set()
|
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._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -523,6 +530,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
|
async def _broadcast_webui_event(self, event: str, **fields: Any) -> None:
|
||||||
|
for connection in tuple(self._webui_connections):
|
||||||
|
await self._send_event(connection, event, **fields)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
return WebSocketConfig().model_dump(by_alias=True)
|
return WebSocketConfig().model_dump(by_alias=True)
|
||||||
@@ -758,6 +769,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||||
t = envelope.get("type")
|
t = envelope.get("type")
|
||||||
|
if t == "webui_request":
|
||||||
|
await self._start_webui_request(connection, envelope)
|
||||||
|
return
|
||||||
if t == "new_chat":
|
if t == "new_chat":
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
@@ -838,7 +852,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
saved_state = await asyncio.to_thread(
|
||||||
write_webui_sidebar_state,
|
write_webui_sidebar_state,
|
||||||
cast(dict[str, Any], state),
|
cast(dict[str, Any], state),
|
||||||
)
|
)
|
||||||
@@ -849,6 +863,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
detail="invalid_sidebar_state",
|
detail="invalid_sidebar_state",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
await self._broadcast_webui_event(
|
||||||
|
"sidebar_state_updated",
|
||||||
|
state=saved_state,
|
||||||
|
)
|
||||||
|
return
|
||||||
if t == "set_workspace_scope":
|
if t == "set_workspace_scope":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
@@ -1105,6 +1124,157 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
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
|
||||||
|
if action == "sidebar.update" and isinstance(result, dict):
|
||||||
|
await self._broadcast_webui_event(
|
||||||
|
"sidebar_state_updated",
|
||||||
|
state=result,
|
||||||
|
)
|
||||||
|
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(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -1145,6 +1315,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
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._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
|
from websockets.datastructures import Headers
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.frames import Close
|
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 import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
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 (
|
from nanobot.webui.http_utils import (
|
||||||
issue_route_secret_matches as _issue_route_secret_matches,
|
issue_route_secret_matches as _issue_route_secret_matches,
|
||||||
)
|
)
|
||||||
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||||
channel = _ch(MessageBus())
|
channel = _ch(MessageBus())
|
||||||
@@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
|||||||
assert client_connection not in channel._webui_connections
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
@@ -866,23 +1000,91 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
conn = AsyncMock()
|
conn = AsyncMock()
|
||||||
|
conn.request = SimpleNamespace(headers=Headers())
|
||||||
channel._webui_connections.add(conn)
|
channel._webui_connections.add(conn)
|
||||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||||
|
request_id = "sidebar-large-state"
|
||||||
envelope = {
|
envelope = {
|
||||||
"type": "set_sidebar_state",
|
"type": "webui_request",
|
||||||
"state": {
|
"request_id": request_id,
|
||||||
|
"action": "sidebar.update",
|
||||||
|
"payload": {"state": {
|
||||||
"session_order": session_order,
|
"session_order": session_order,
|
||||||
"view": {"sort": "manual"},
|
"view": {"sort": "manual"},
|
||||||
},
|
}},
|
||||||
}
|
}
|
||||||
assert len(json.dumps(envelope).encode()) > 8_192
|
assert len(json.dumps(envelope).encode()) > 8_192
|
||||||
|
|
||||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
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"))
|
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||||
assert saved["session_order"] == session_order
|
assert saved["session_order"] == session_order
|
||||||
assert saved["view"]["sort"] == "manual"
|
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
|
||||||
|
async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
channel = _ch(bus)
|
||||||
|
source = AsyncMock()
|
||||||
|
source.request = SimpleNamespace(headers=Headers())
|
||||||
|
other_device = AsyncMock()
|
||||||
|
channel._webui_connections.update({source, other_device})
|
||||||
|
request_id = "sidebar-workbench-state"
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
source,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"action": "sidebar.update",
|
||||||
|
"payload": {
|
||||||
|
"state": {
|
||||||
|
"workbench": {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": "Research",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
||||||
|
"activePaneKey": "websocket:a",
|
||||||
|
"layout": "columns",
|
||||||
|
"splitRatios": [0.35],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||||
|
|
||||||
|
event = json.loads(other_device.send.await_args.args[0])
|
||||||
|
assert event["event"] == "sidebar_state_updated"
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["paneKeys"] == [
|
||||||
|
"websocket:a",
|
||||||
|
"websocket:b",
|
||||||
|
]
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["layoutPaneKeys"] == [
|
||||||
|
"websocket:b",
|
||||||
|
"websocket:a",
|
||||||
|
]
|
||||||
|
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
|
||||||
|
0.35
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2887,7 +3089,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
webui_client = None
|
||||||
try:
|
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(
|
settings = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/settings",
|
f"http://127.0.0.1:{port}/api/settings",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
@@ -2971,11 +3181,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert unknown_api.status_code == 404
|
assert unknown_api.status_code == 404
|
||||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||||
|
|
||||||
provider_updated = await _http_get(
|
provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"apiKey": "sk-or-test",
|
||||||
|
"apiBase": "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert provider_updated.status_code == 200
|
assert provider_updated.status_code == 200
|
||||||
provider_body = provider_updated.json()
|
provider_body = provider_updated.json()
|
||||||
@@ -2985,11 +3198,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert provider_body["image_generation"]["provider_configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
custom_provider_created = await _http_get(
|
custom_provider_created = await _webui_mutate(
|
||||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
webui_client,
|
||||||
headers={
|
"settings.provider.create",
|
||||||
"Authorization": "Bearer tok",
|
|
||||||
"X-Nanobot-Provider-Values": json.dumps(
|
|
||||||
{
|
{
|
||||||
"name": "Company Gateway",
|
"name": "Company Gateway",
|
||||||
"apiBase": "https://gateway.example/v1",
|
"apiBase": "https://gateway.example/v1",
|
||||||
@@ -2999,8 +3210,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"proxy": "http://127.0.0.1:7890",
|
||||||
"thinkingStyle": "enable_thinking",
|
"thinkingStyle": "enable_thinking",
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert custom_provider_created.status_code == 200
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3015,11 +3224,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert "sk-company" not in custom_provider_created.text
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _http_get(
|
local_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
"settings.provider.update",
|
||||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||||
headers={"Authorization": "Bearer tok"},
|
|
||||||
)
|
)
|
||||||
assert local_provider_updated.status_code == 200
|
assert local_provider_updated.status_code == 200
|
||||||
local_provider_body = local_provider_updated.json()
|
local_provider_body = local_provider_updated.json()
|
||||||
@@ -3029,38 +3237,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||||
assert "localhost:1337" in local_provider_updated.text
|
assert "localhost:1337" in local_provider_updated.text
|
||||||
|
|
||||||
updated = await _http_get(
|
updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
"settings.agent.update",
|
||||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
{
|
||||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
"model": "atomic_chat/test",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "atomic_chat",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"tool_hint_max_length": 120,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
updated_body = updated.json()
|
updated_body = updated.json()
|
||||||
assert updated_body["requires_restart"] is True
|
assert updated_body["requires_restart"] is True
|
||||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||||
|
|
||||||
preset_updated = await _http_get(
|
preset_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=deep",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "deep"},
|
||||||
)
|
)
|
||||||
assert preset_updated.status_code == 200
|
assert preset_updated.status_code == 200
|
||||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
bad_preset = await _http_get(
|
bad_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=missing",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _http_get(
|
created_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-4.1-mini",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert created_preset.status_code == 200
|
assert created_preset.status_code == 200
|
||||||
created_body = created_preset.json()
|
created_body = created_preset.json()
|
||||||
@@ -3074,11 +3288,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"]["label"] == "Fast writing"
|
||||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||||
|
|
||||||
updated_preset = await _http_get(
|
updated_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/update"
|
"settings.model_configuration.update",
|
||||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"name": "fast-writing",
|
||||||
|
"label": "Codex",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-5.5",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert updated_preset.status_code == 200
|
assert updated_preset.status_code == 200
|
||||||
updated_preset_body = updated_preset.json()
|
updated_preset_body = updated_preset.json()
|
||||||
@@ -3089,11 +3307,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||||
|
|
||||||
call_order_updated = await _http_get(
|
call_order_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-call-order/update"
|
"settings.model_call_order.update",
|
||||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
{"order": ["fast-writing", "deep"]},
|
||||||
headers={"Authorization": "Bearer tok"},
|
|
||||||
)
|
)
|
||||||
assert call_order_updated.status_code == 200
|
assert call_order_updated.status_code == 200
|
||||||
call_order_body = call_order_updated.json()
|
call_order_body = call_order_updated.json()
|
||||||
@@ -3101,20 +3318,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["agent"]["model"] == "openai/gpt-5.5"
|
||||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _http_get(
|
duplicate_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-4.1-mini",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _http_get(
|
search_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
"settings.web_search.update",
|
||||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
{
|
||||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
"provider": "searxng",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"base_url": "https://search.example.com",
|
||||||
|
"max_results": 8,
|
||||||
|
"timeout": 45,
|
||||||
|
"use_jina_reader": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert search_updated.status_code == 200
|
assert search_updated.status_code == 200
|
||||||
search_body = search_updated.json()
|
search_body = search_updated.json()
|
||||||
@@ -3126,10 +3350,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert search_body["web_search"]["max_results"] == 8
|
assert search_body["web_search"]["max_results"] == 8
|
||||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||||
|
|
||||||
network_safety_updated = await _http_get(
|
network_safety_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
"settings.network_safety.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{
|
||||||
|
"webui_allow_local_service_access": False,
|
||||||
|
"webui_default_access_mode": "full",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert network_safety_updated.status_code == 200
|
assert network_safety_updated.status_code == 200
|
||||||
network_safety_body = network_safety_updated.json()
|
network_safety_body = network_safety_updated.json()
|
||||||
@@ -3139,13 +3366,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"]["webui_default_access_mode"] == "full"
|
||||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||||
|
|
||||||
image_updated = await _http_get(
|
image_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
"settings.image_generation.update",
|
||||||
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
{
|
||||||
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
"enabled": True,
|
||||||
"&max_images_per_turn=3",
|
"provider": "openrouter",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"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
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
@@ -3157,11 +3388,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"]["default_image_size"] == "2K"
|
||||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||||
|
|
||||||
image_provider_updated = await _http_get(
|
image_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"apiKey": "sk-or-next",
|
||||||
|
"apiBase": "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert image_provider_updated.status_code == 200
|
assert image_provider_updated.status_code == 200
|
||||||
assert image_provider_updated.json()["requires_restart"] is True
|
assert image_provider_updated.json()["requires_restart"] is True
|
||||||
@@ -3169,17 +3403,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
assert image_reload.await_count == 2
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _http_get(
|
bad_web = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
"settings.web_search.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "duckduckgo", "max_results": 99},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _http_get(
|
bad_image = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?provider=missing",
|
"settings.image_generation.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3216,6 +3450,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.default_image_size == "2K"
|
||||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3248,11 +3484,17 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
webui_client = None
|
||||||
try:
|
try:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||||
headers={"Authorization": "Bearer tok"},
|
)
|
||||||
|
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.status_code == 200
|
||||||
@@ -3260,6 +3502,8 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
assert response.json()["restart_required_sections"] == []
|
assert response.json()["restart_required_sections"] == []
|
||||||
image_reload.assert_awaited_once_with(bus)
|
image_reload.assert_awaited_once_with(bus)
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3291,17 +3535,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
webui_client = None
|
||||||
try:
|
try:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||||
headers={"Authorization": "Bearer tok"},
|
)
|
||||||
|
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.status_code == 200
|
||||||
assert response.json()["requires_restart"] is True
|
assert response.json()["requires_restart"] is True
|
||||||
assert response.json()["restart_required_sections"] == ["image"]
|
assert response.json()["restart_required_sections"] == ["image"]
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class WeixinConnectSession:
|
|||||||
channel: WeixinChannel
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
|
force: bool
|
||||||
created_wall: float
|
created_wall: float
|
||||||
deadline: float
|
deadline: float
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -72,7 +73,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -89,6 +90,7 @@ class WeixinConnectStore:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.connect_base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
|
force=force,
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
)
|
)
|
||||||
@@ -187,7 +189,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
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:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -204,6 +206,17 @@ class WeixinConnectStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status == "binded_redirect":
|
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():
|
if not session.channel.connect_load_state():
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@@ -234,7 +247,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
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:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
break
|
break
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
async def _fetch_qr_code(self) -> tuple[str, str]:
|
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
"""Fetch a QR code without existing credentials when forced."""
|
||||||
local_tokens = self._local_token_list()
|
local_tokens = [] if force else self._local_token_list()
|
||||||
data = await self._api_post(
|
data = await self._api_post(
|
||||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
{"local_token_list": local_tokens},
|
{"local_token_list": local_tokens},
|
||||||
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||||
|
|
||||||
async def _qr_login(self) -> bool:
|
async def _qr_login(self, *, force: bool = False) -> bool:
|
||||||
"""Perform QR code login flow. Returns True on success."""
|
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
||||||
try:
|
try:
|
||||||
refresh_count = 0
|
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)
|
self._print_qr_code(scan_url)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
@@ -825,11 +825,16 @@ class WeixinChannel(BaseChannel):
|
|||||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
self.logger.warning("WeChat verification failed too many times")
|
self.logger.warning("WeChat verification failed too many times")
|
||||||
return False
|
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
|
current_poll_base_url = self.config.base_url
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
continue
|
continue
|
||||||
elif status == "binded_redirect":
|
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():
|
if self._token or self._load_state():
|
||||||
self.logger.info("WeChat account is already connected")
|
self.logger.info("WeChat account is already connected")
|
||||||
return True
|
return True
|
||||||
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
MAX_QR_REFRESH_COUNT,
|
MAX_QR_REFRESH_COUNT,
|
||||||
)
|
)
|
||||||
return False
|
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
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
self._print_qr_code(scan_url)
|
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._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
return await self._fetch_qr_code()
|
return await self._fetch_qr_code(force=force)
|
||||||
|
|
||||||
async def connect_poll_qr_code(
|
async def connect_poll_qr_code(
|
||||||
self,
|
self,
|
||||||
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if force:
|
if force:
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._get_updates_buf = ""
|
self._get_updates_buf = ""
|
||||||
if self._token or self._load_state():
|
if self._token or (not force and self._load_state()):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Initialize HTTP client for the login flow
|
# Initialize HTTP client for the login flow
|
||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True # Enable polling loop in _qr_login()
|
self._running = True # Enable polling loop in _qr_login()
|
||||||
try:
|
try:
|
||||||
return await self._qr_login()
|
return await self._qr_login(force=force)
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
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)
|
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"
|
return "qr-1", "https://qr.example/1"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
observed_force: list[bool] = []
|
||||||
return "qr-reconnect", "https://qr.example/reconnect"
|
|
||||||
|
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, "_fetch_qr_code", fake_fetch_qr_code)
|
||||||
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
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
|
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||||
cancelled = await store.cancel(started["session_id"])
|
cancelled = await store.cancel(started["session_id"])
|
||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
@@ -116,7 +135,9 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
poll_started = asyncio.Event()
|
poll_started = asyncio.Event()
|
||||||
release_poll = 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"
|
return "qr-cancel", "https://qr.example/cancel"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
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"
|
return "qr-verify", "https://qr.example/verify"
|
||||||
|
|
||||||
responses = [
|
responses = [
|
||||||
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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)
|
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"
|
return "qr-existing", "https://qr.example/existing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "succeeded"
|
assert completed["status"] == "failed"
|
||||||
assert "already connected" in completed["message"]
|
assert "new WeChat login" in completed["message"]
|
||||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
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)
|
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"
|
return "qr-missing", "https://qr.example/missing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
started = await store.start(force=False)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||||
@@ -64,6 +65,7 @@ export function WeixinPanel({
|
|||||||
onAction,
|
onAction,
|
||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
}: ChannelPluginPanelProps) {
|
}: ChannelPluginPanelProps) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const channelTx = channelTranslator(t, "weixin");
|
const channelTx = channelTranslator(t, "weixin");
|
||||||
@@ -150,7 +152,7 @@ export function WeixinPanel({
|
|||||||
setSaveState("idle");
|
setSaveState("idle");
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
context.token,
|
client,
|
||||||
"weixin",
|
"weixin",
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
channelValuesForSave(editableFieldsRef.current, values),
|
||||||
{ enable: context.enabled },
|
{ enable: context.enabled },
|
||||||
@@ -168,7 +170,7 @@ export function WeixinPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ def _run_gateway(
|
|||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ class MCPServerConfig(Base):
|
|||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
|
|
||||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
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")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
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
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
def _manager() -> CliAppManager:
|
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
async def cli_apps_payload(
|
||||||
manager = _manager()
|
*,
|
||||||
|
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:
|
if installed_only:
|
||||||
return manager.installed_payload()
|
return manager.installed_payload()
|
||||||
payload = manager.payload(cache_only=True)
|
payload = manager.payload(cache_only=True)
|
||||||
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
|||||||
return payload
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise CliAppError("missing CLI app name")
|
raise CliAppError("missing CLI app name")
|
||||||
manager = _manager()
|
manager = _manager(config_path) if config_path is not None else _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||||||
|
|
||||||
from loguru import logger as default_logger
|
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.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
@@ -29,6 +31,7 @@ class GatewayServices:
|
|||||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||||
|
|
||||||
http: GatewayHTTPHandler
|
http: GatewayHTTPHandler
|
||||||
|
settings: WebUISettingsServices
|
||||||
tokens: GatewayTokenStore
|
tokens: GatewayTokenStore
|
||||||
media: WebUIMediaGateway
|
media: WebUIMediaGateway
|
||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
@@ -50,6 +53,7 @@ def build_gateway_services(
|
|||||||
static_dist_path: Path | None,
|
static_dist_path: Path | None,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
|
config_path: Path | None = None,
|
||||||
runtime_model_name: Callable[[], str | None] | None,
|
runtime_model_name: Callable[[], str | None] | None,
|
||||||
runtime_surface: str,
|
runtime_surface: str,
|
||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
@@ -63,6 +67,7 @@ def build_gateway_services(
|
|||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
|
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||||
@@ -102,6 +107,7 @@ def build_gateway_services(
|
|||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
|
settings=settings,
|
||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
@@ -115,6 +121,7 @@ def build_gateway_services(
|
|||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
http=http,
|
http=http,
|
||||||
|
settings=settings,
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
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,12 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Mapping, cast
|
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
|
delete_mcp_oauth_credentials,
|
||||||
|
mcp_oauth_has_credentials,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||||
@@ -25,6 +29,9 @@ from nanobot.utils.helpers import ensure_dir
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
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)
|
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
_SECRET_QUERY_RE = re.compile(
|
_SECRET_QUERY_RE = re.compile(
|
||||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||||
@@ -334,6 +341,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
|||||||
),
|
),
|
||||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
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(
|
McpPreset(
|
||||||
name="github",
|
name="github",
|
||||||
display_name="GitHub",
|
display_name="GitHub",
|
||||||
@@ -654,6 +718,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
|||||||
return "not_installed" if preset.install_supported else "coming_soon"
|
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):
|
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||||
return "missing_credentials"
|
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):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
return "missing_dependency"
|
return "missing_dependency"
|
||||||
return "configured"
|
return "configured"
|
||||||
@@ -699,6 +765,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": server.auth if server and server.auth else None,
|
||||||
"command": server.command if server and server.command else None,
|
"command": server.command if server and server.command else None,
|
||||||
"args": list(server.args) 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,
|
"url": _connection_summary(server) if server and server.url else None,
|
||||||
@@ -749,6 +816,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"command": cfg.command or None,
|
"command": cfg.command or None,
|
||||||
"url": _connection_summary(cfg) if cfg.url else None,
|
"url": _connection_summary(cfg) if cfg.url else None,
|
||||||
})
|
})
|
||||||
@@ -776,7 +844,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||||
cfg = configured_servers.get(preset.name)
|
cfg = configured_servers.get(preset.name)
|
||||||
status = _status_for(preset, cfg)
|
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)
|
logo_url = _favicon_url(preset.brand_domain)
|
||||||
return {
|
return {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
@@ -785,6 +853,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
|||||||
"description": preset.description,
|
"description": preset.description,
|
||||||
"docs_url": preset.docs_url,
|
"docs_url": preset.docs_url,
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||||
"requires": preset.requires,
|
"requires": preset.requires,
|
||||||
"note": preset.note,
|
"note": preset.note,
|
||||||
"install_supported": preset.install_supported,
|
"install_supported": preset.install_supported,
|
||||||
@@ -811,7 +880,11 @@ def _custom_payload(
|
|||||||
transport = cfg.type
|
transport = cfg.type
|
||||||
if not transport:
|
if not transport:
|
||||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
||||||
|
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"
|
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||||
|
configured = status != "authorization_required"
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"display_name": name,
|
"display_name": name,
|
||||||
@@ -819,12 +892,13 @@ def _custom_payload(
|
|||||||
"description": "Custom MCP server from nanobot config.",
|
"description": "Custom MCP server from nanobot config.",
|
||||||
"docs_url": "",
|
"docs_url": "",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"requires": "",
|
"requires": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"install_supported": True,
|
"install_supported": True,
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"configured": True,
|
"configured": configured,
|
||||||
"available": _config_available(cfg),
|
"available": configured and _config_available(cfg),
|
||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": None,
|
"logo_url": None,
|
||||||
"brand_color": "#64748B",
|
"brand_color": "#64748B",
|
||||||
@@ -841,8 +915,9 @@ def mcp_presets_payload(
|
|||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -928,7 +1003,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
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."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
@@ -941,16 +1020,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(last_action={
|
return mcp_presets_payload(
|
||||||
|
last_action={
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": _scrub_test_error(str(exc)),
|
"message": _scrub_test_error(str(exc)),
|
||||||
"error": _scrub_test_error(str(exc)),
|
"error": _scrub_test_error(str(exc)),
|
||||||
"tool_count": 0,
|
"tool_count": 0,
|
||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
})
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
@@ -968,7 +1053,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"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):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -979,7 +1064,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"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()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
@@ -1040,7 +1125,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
tool_names = last_action.get("tool_names", [])
|
tool_names = last_action.get("tool_names", [])
|
||||||
preview = {name: tool_names} if tool_names else None
|
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:
|
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||||
@@ -1109,6 +1198,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
|||||||
return normalized # type: ignore[return-value]
|
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:
|
def _validated_server_name(name: str) -> str:
|
||||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||||
raise McpPresetError("invalid MCP server name")
|
raise McpPresetError("invalid MCP server name")
|
||||||
@@ -1124,6 +1239,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("stdio MCP servers require a command")
|
raise McpPresetError("stdio MCP servers require a command")
|
||||||
if transport in {"sse", "streamableHttp"} and not url:
|
if transport in {"sse", "streamableHttp"} and not url:
|
||||||
raise McpPresetError("remote MCP servers require a 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()
|
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||||
if raw_timeout:
|
if raw_timeout:
|
||||||
@@ -1133,12 +1255,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||||
cfg = MCPServerConfig(
|
cfg = MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=_parse_string_list(_query_first(query, "args")),
|
args=_parse_string_list(_query_first(query, "args")),
|
||||||
env=_parse_string_map(_query_first(query, "env")),
|
env=_parse_string_map(_query_first(query, "env")),
|
||||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=_parse_string_map(_query_first(query, "headers")),
|
headers=headers,
|
||||||
tool_timeout=tool_timeout,
|
tool_timeout=tool_timeout,
|
||||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||||
)
|
)
|
||||||
@@ -1183,6 +1306,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
headers = cast(dict[object, object], headers_value)
|
headers = cast(dict[object, object], headers_value)
|
||||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
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")
|
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):
|
if not isinstance(enabled_tools_value, list):
|
||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
else:
|
else:
|
||||||
@@ -1191,12 +1321,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
return server_name, MCPServerConfig(
|
return server_name, MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=cast(list[str], args),
|
args=cast(list[str], args),
|
||||||
env=cast(dict[str, str], env),
|
env=cast(dict[str, str], env),
|
||||||
cwd=cwd if transport == "stdio" else "",
|
cwd=cwd if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=cast(dict[str, str], headers),
|
headers=typed_headers,
|
||||||
tool_timeout=timeout_int,
|
tool_timeout=timeout_int,
|
||||||
enabled_tools=cast(list[str], enabled_tools_value),
|
enabled_tools=cast(list[str], enabled_tools_value),
|
||||||
)
|
)
|
||||||
@@ -1221,24 +1352,54 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def _oauth_credentials_replaced(
|
||||||
config = load_config()
|
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":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
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
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
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
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
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)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action={
|
for name in delete_credentials:
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
|
payload = mcp_presets_payload(
|
||||||
|
last_action={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
})
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1249,29 +1410,61 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
raise McpPresetError("unknown MCP server", status=404)
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_server_action_message(action, name),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(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)
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_action_message(action, preset),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1287,7 +1480,8 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
@@ -1303,7 +1497,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||||
)
|
)
|
||||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
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
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1339,13 +1536,21 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
|
config: WebUISettingsConfig | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""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:
|
if action is None:
|
||||||
return mcp_presets_payload()
|
return mcp_presets_payload(config_path=config_path)
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query)
|
return await mcp_presets_test_action(query, config_path=config_path)
|
||||||
if action in _CUSTOM_ACTIONS:
|
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)
|
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
@@ -15,9 +16,14 @@ from nanobot.webui.http_utils import query_first
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload() -> dict[str, Any]:
|
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
if config_path is None:
|
||||||
return optional_features_payload()
|
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:
|
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||||
"""Preserve the difference between a global action and an explicit instance."""
|
"""Preserve the difference between a global action and an explicit instance."""
|
||||||
@@ -32,13 +38,19 @@ def nanobot_features_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (query_first(query, "name") or "").strip()
|
name = (query_first(query, "name") or "").strip()
|
||||||
instance_id = nanobot_feature_instance_target(query)
|
instance_id = nanobot_feature_instance_target(query)
|
||||||
if not name:
|
if not name:
|
||||||
raise OptionalFeatureError("missing feature name")
|
raise OptionalFeatureError("missing feature name")
|
||||||
if action == "enable":
|
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":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
plugin = load_channel_plugin(name)
|
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.",
|
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||||
status=400,
|
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)
|
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||||
|
|||||||
+241
-2150
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
+541
-998
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})
|
||||||
@@ -8,6 +8,7 @@ does not modify agent sessions.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,8 +25,10 @@ _MAX_MAP_ITEMS = 2_000
|
|||||||
_MAX_KEY_LEN = 512
|
_MAX_KEY_LEN = 512
|
||||||
_MAX_TITLE_LEN = 160
|
_MAX_TITLE_LEN = 160
|
||||||
_MAX_TAG_LEN = 40
|
_MAX_TAG_LEN = 40
|
||||||
|
_MAX_WORKBENCH_PANES = 4
|
||||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
||||||
|
_ALLOWED_WORKBENCH_LAYOUTS = {"columns", "rows", "grid", "bsp", "main-stack"}
|
||||||
|
|
||||||
|
|
||||||
def webui_sidebar_state_path() -> Path:
|
def webui_sidebar_state_path() -> Path:
|
||||||
@@ -42,6 +45,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
"project_name_overrides": {},
|
"project_name_overrides": {},
|
||||||
"tags_by_key": {},
|
"tags_by_key": {},
|
||||||
"collapsed_groups": {},
|
"collapsed_groups": {},
|
||||||
|
"workbench": {"version": 1, "tabs": {}},
|
||||||
"view": {
|
"view": {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -76,6 +80,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_split_ratios(value: Any) -> list[float]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
ratios: list[float] = []
|
||||||
|
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
|
||||||
|
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
|
||||||
|
continue
|
||||||
|
ratio = float(raw_ratio)
|
||||||
|
if not math.isfinite(ratio):
|
||||||
|
continue
|
||||||
|
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
|
||||||
|
return ratios
|
||||||
|
|
||||||
|
|
||||||
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -131,8 +149,54 @@ def _clean_view(value: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_workbench(value: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {"version": 1, "tabs": {}}
|
||||||
|
raw_tabs = cast(dict[str, Any], value).get("tabs")
|
||||||
|
if not isinstance(raw_tabs, dict):
|
||||||
|
return {"version": 1, "tabs": {}}
|
||||||
|
|
||||||
|
tabs: dict[str, dict[str, Any]] = {}
|
||||||
|
claimed_panes: set[str] = set()
|
||||||
|
for raw_tab_key, raw_tab in list(cast(dict[Any, Any], raw_tabs).items())[:_MAX_MAP_ITEMS]:
|
||||||
|
tab_key = _clean_string(raw_tab_key)
|
||||||
|
if tab_key is None or not isinstance(raw_tab, dict):
|
||||||
|
continue
|
||||||
|
tab = cast(dict[str, Any], raw_tab)
|
||||||
|
pane_keys = [
|
||||||
|
key
|
||||||
|
for key in _clean_string_list(tab.get("paneKeys"))
|
||||||
|
if key not in claimed_panes
|
||||||
|
][:_MAX_WORKBENCH_PANES]
|
||||||
|
if not pane_keys:
|
||||||
|
continue
|
||||||
|
requested_layout_pane_keys = [
|
||||||
|
key for key in _clean_string_list(tab.get("layoutPaneKeys")) if key in pane_keys
|
||||||
|
]
|
||||||
|
layout_pane_keys = requested_layout_pane_keys + [
|
||||||
|
key for key in pane_keys if key not in requested_layout_pane_keys
|
||||||
|
]
|
||||||
|
claimed_panes.update(pane_keys)
|
||||||
|
raw_layout = tab.get("layout")
|
||||||
|
layout = raw_layout if raw_layout in _ALLOWED_WORKBENCH_LAYOUTS else "columns"
|
||||||
|
title = _clean_string(tab.get("title"), max_len=_MAX_TITLE_LEN)
|
||||||
|
active_pane_key = _clean_string(tab.get("activePaneKey"))
|
||||||
|
tabs[tab_key] = {
|
||||||
|
"explicit": tab.get("explicit") is True,
|
||||||
|
"title": title,
|
||||||
|
"paneKeys": pane_keys,
|
||||||
|
"layoutPaneKeys": layout_pane_keys,
|
||||||
|
"activePaneKey": (
|
||||||
|
active_pane_key if active_pane_key in pane_keys else pane_keys[0]
|
||||||
|
),
|
||||||
|
"layout": layout,
|
||||||
|
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
|
||||||
|
}
|
||||||
|
return {"version": 1, "tabs": tabs}
|
||||||
|
|
||||||
|
|
||||||
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
||||||
"""Return a schema-v1 sidebar state from any older/partial input."""
|
"""Return a validated canonical sidebar state."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raw = {}
|
raw = {}
|
||||||
raw = cast(dict[str, Any], raw)
|
raw = cast(dict[str, Any], raw)
|
||||||
@@ -146,6 +210,7 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||||
|
state["workbench"] = _clean_workbench(raw.get("workbench"))
|
||||||
state["view"] = _clean_view(raw.get("view"))
|
state["view"] = _clean_view(raw.get("view"))
|
||||||
updated_at = raw.get("updated_at")
|
updated_at = raw.get("updated_at")
|
||||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
|
|
||||||
@@ -42,7 +43,13 @@ def check_for_update() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_cache = (now, latest)
|
_cache = (now, latest)
|
||||||
|
|
||||||
if not latest or latest == __version__:
|
if not isinstance(latest, str) or not latest:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if Version(latest) <= Version(__version__):
|
||||||
|
return None
|
||||||
|
except InvalidVersion:
|
||||||
|
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"currentVersion": __version__,
|
"currentVersion": __version__,
|
||||||
|
|||||||
+187
-25
@@ -17,9 +17,10 @@ import time
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
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 loguru import logger
|
||||||
|
from websockets.datastructures import Headers
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
@@ -118,7 +119,63 @@ from nanobot.webui.transcript import build_webui_thread_response
|
|||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
|
|
||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_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.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
|
# 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'
|
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||||
@@ -150,6 +207,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
@@ -159,6 +217,33 @@ def _decode_api_key(raw_key: str) -> str | None:
|
|||||||
return key
|
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:
|
def _default_model_name_from_config() -> str | None:
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
@@ -211,6 +296,7 @@ class GatewayHTTPHandler:
|
|||||||
media: WebUIMediaGateway,
|
media: WebUIMediaGateway,
|
||||||
ingress: WebUIIngressPolicy,
|
ingress: WebUIIngressPolicy,
|
||||||
workspaces: WebUIWorkspaceController,
|
workspaces: WebUIWorkspaceController,
|
||||||
|
settings: WebUISettingsServices,
|
||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -231,6 +317,7 @@ class GatewayHTTPHandler:
|
|||||||
self.media = media
|
self.media = media
|
||||||
self.ingress = ingress
|
self.ingress = ingress
|
||||||
self.workspaces = workspaces
|
self.workspaces = workspaces
|
||||||
|
self.settings = settings
|
||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills: set[str] = (
|
self.disabled_skills: set[str] = (
|
||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
@@ -249,6 +336,7 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||||
self.settings_routes = WebUISettingsRouter(
|
self.settings_routes = WebUISettingsRouter(
|
||||||
|
settings=settings,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
check_api_token=self.check_api_token,
|
check_api_token=self.check_api_token,
|
||||||
@@ -259,6 +347,7 @@ class GatewayHTTPHandler:
|
|||||||
runtime_capabilities=self._capabilities,
|
runtime_capabilities=self._capabilities,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
@@ -285,11 +374,86 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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)
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
self._log_slow_http(got, response, started)
|
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(
|
async def _dispatch_resolved(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -457,6 +621,14 @@ class GatewayHTTPHandler:
|
|||||||
expected_path = _normalize_config_path(self.config.path)
|
expected_path = _normalize_config_path(self.config.path)
|
||||||
return f"{scheme}://{host}{expected_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 -----------------------------------------------------
|
# -- Session routes -----------------------------------------------------
|
||||||
|
|
||||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
@@ -646,7 +818,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
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()
|
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||||
automation_jobs = session_automation_jobs(
|
automation_jobs = session_automation_jobs(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
@@ -742,7 +914,7 @@ class GatewayHTTPHandler:
|
|||||||
if self.cron_service is None and self.local_trigger_store is None:
|
if self.cron_service is None and self.local_trigger_store is None:
|
||||||
return _http_error(503, "automation service unavailable")
|
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()
|
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return _http_error(400, "missing automation id")
|
return _http_error(400, "missing automation id")
|
||||||
@@ -974,7 +1146,7 @@ class GatewayHTTPHandler:
|
|||||||
if self._skill_install_lock.locked():
|
if self._skill_install_lock.locked():
|
||||||
return _http_error(409, "another skill installation is already in progress")
|
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"
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
@@ -1015,7 +1187,7 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
name = _query_first(query, "name") or ""
|
name = _query_first(query, "name") or ""
|
||||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||||
if raw_enabled not in {"true", "false"}:
|
if raw_enabled not in {"true", "false"}:
|
||||||
@@ -1047,7 +1219,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not _is_local_browser_request(connection, request.headers):
|
if not _is_local_browser_request(connection, request.headers):
|
||||||
return _http_error(403, "remote skill deletion is disabled")
|
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:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1094,18 +1266,14 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _parse_query(request.path)
|
payload = _mutation_payload(request)
|
||||||
raw_state = _query_first(query, "state")
|
state_value = payload.get("state") if payload is not None else None
|
||||||
if raw_state is None:
|
if state_value is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
try:
|
if not isinstance(state_value, dict):
|
||||||
decoded = json.loads(raw_state)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return _http_error(400, "state must be JSON")
|
|
||||||
if not isinstance(decoded, dict):
|
|
||||||
return _http_error(400, "state must be an object")
|
return _http_error(400, "state must be an object")
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1174,16 +1342,10 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
payload = _mutation_payload(request)
|
||||||
if not raw:
|
if payload is None or "values" not in payload:
|
||||||
return {}
|
return {}
|
||||||
try:
|
values = payload.get("values")
|
||||||
values = json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
values = json.loads(unquote(raw))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
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",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
|
"qrcode[pil]>=8.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
|
|||||||
@@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
|||||||
await loop.close_mcp()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_tool_reconnects_after_session_terminated(
|
async def test_mcp_tool_reconnects_after_session_terminated(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
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.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
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:
|
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
assert loop.model_preset is None
|
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"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
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()
|
output = tool._inspect_all()
|
||||||
assert "model_preset: 'fast'" in output
|
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"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
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")
|
result = tool._modify("model_preset", "fast")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset == "fast"
|
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),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
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")
|
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"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
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")
|
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"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
|||||||
loop.set_session_model_preset = MagicMock(
|
loop.set_session_model_preset = MagicMock(
|
||||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
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(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
|||||||
value: object,
|
value: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
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")
|
session = loop.sessions.get_or_create("cli:one")
|
||||||
|
|
||||||
with request_context(RequestContext(
|
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"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
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")
|
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
|
|||||||
@@ -84,10 +84,6 @@ class TestToolHintKnownTools:
|
|||||||
assert '"C:/Program Files/Git/project"' not in result
|
assert '"C:/Program Files/Git/project"' not in result
|
||||||
assert '"' in result
|
assert '"' in result
|
||||||
|
|
||||||
def test_exec_short_command_unchanged(self):
|
|
||||||
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
|
||||||
assert result == "$ npm install typescript"
|
|
||||||
|
|
||||||
def test_exec_chained_commands_truncated_not_mid_path(self):
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||||
|
|||||||
@@ -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
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
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.self import MyTool
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
|
|||||||
loop.workspace = Path("/tmp/workspace")
|
loop.workspace = Path("/tmp/workspace")
|
||||||
loop.restrict_to_workspace = False
|
loop.restrict_to_workspace = False
|
||||||
loop._start_time = 1000.0
|
loop._start_time = 1000.0
|
||||||
loop.exec_config = MagicMock()
|
loop.exec_config = ExecToolConfig()
|
||||||
loop.channels_config = MagicMock()
|
loop.channels_config = MagicMock()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
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 = 0
|
||||||
|
loop.current_iteration = loop._current_iteration
|
||||||
loop.provider_retry_mode = "standard"
|
loop.provider_retry_mode = "standard"
|
||||||
loop.max_tool_result_chars = 16000
|
loop.max_tool_result_chars = 16000
|
||||||
|
loop.model_preset = None
|
||||||
|
loop.model_presets = {}
|
||||||
loop._concurrency_gate = None
|
loop._concurrency_gate = None
|
||||||
loop._unified_session = False
|
loop._unified_session = False
|
||||||
loop._extra_hooks = []
|
loop._extra_hooks = []
|
||||||
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = MagicMock()
|
loop.web_config = WebToolsConfig()
|
||||||
loop.web_config.enable = True
|
|
||||||
loop.web_config.search = MagicMock()
|
|
||||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||||
|
|
||||||
# Tools registry mock
|
# 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.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.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||||
loop.tools.get.return_value = None
|
loop.tools.get.return_value = None
|
||||||
|
loop.tool_names = loop.tools.tool_names
|
||||||
|
|
||||||
# SubagentManager mock
|
# SubagentManager mock
|
||||||
loop.subagents = MagicMock()
|
loop.subagents = MagicMock()
|
||||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
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)
|
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||||
|
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _make_tool(runtime_state=None):
|
def _make_tool(loop=None):
|
||||||
if runtime_state is None:
|
if loop is None:
|
||||||
runtime_state = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(runtime_state=runtime_state)
|
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,10 +93,10 @@ class TestInspectSummary:
|
|||||||
assert "context_window_tokens: 65536" in result
|
assert "context_window_tokens: 65536" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_includes_runtime_vars(self):
|
async def test_inspect_includes_scratchpad(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._runtime_vars = {"task": "review"}
|
tool = _make_tool(loop=loop)
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "task" in result
|
assert "task" in result
|
||||||
|
|
||||||
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_config_subfield(self):
|
async def test_inspect_config_subfield(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.web_config = MagicMock()
|
tool = _make_tool(loop=loop)
|
||||||
loop.web_config.enable = True
|
|
||||||
tool = _make_tool(runtime_state=loop)
|
|
||||||
result = await tool.execute(action="check", key="web_config.enable")
|
result = await tool.execute(action="check", key="web_config.enable")
|
||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
|
|||||||
async def test_inspect_dict_key_via_dotpath(self):
|
async def test_inspect_dict_key_via_dotpath(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
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")
|
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||||
assert "100" in result
|
assert "100" in result
|
||||||
|
|
||||||
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
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 = _make_mock_loop()
|
||||||
loop.web_config = MagicMock()
|
loop.web_config.search = WebSearchConfig(
|
||||||
loop.web_config.search = SearchConfig()
|
provider="tavily",
|
||||||
|
api_key="sk-test-secret",
|
||||||
|
)
|
||||||
tool = _make_tool(loop)
|
tool = _make_tool(loop)
|
||||||
|
|
||||||
result = await tool.execute(action="check", key="web_config.search")
|
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 "sk-test-secret" not in result
|
||||||
assert "api_key" not in result.lower()
|
assert "api_key" not in result.lower()
|
||||||
|
|
||||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
assert "Set max_iterations = 80" in result
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_out_of_range(self):
|
async def test_modify_restricted_out_of_range(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._runtime_state.max_iterations == 40
|
assert tool._runtime_control.snapshot().max_iterations == 40
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_max_exceeded(self):
|
async def test_modify_restricted_max_exceeded(self):
|
||||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||||
assert "Set max_iterations" in result
|
assert "Set max_iterations" in result
|
||||||
assert tool._runtime_state.max_iterations == 80
|
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
loop = _make_mock_loop()
|
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)
|
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert "Set context_window_tokens" in result
|
assert "Set context_window_tokens" in result
|
||||||
assert loop.context_window_tokens == 131072
|
assert loop.context_window_tokens == 131072
|
||||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||||
assert "Set provider_retry_mode" in result
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
async def test_modify_new_key_stores_in_scratchpad(self):
|
||||||
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
"""Modifying an unknown key should store it in the scratchpad."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||||
assert "my_custom_var" in result
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_rejects_callable(self):
|
async def test_modify_rejects_callable(self):
|
||||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||||
assert result == "Set scratchpad.items = [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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_dict(self):
|
async def test_modify_allows_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||||
assert result == "Set scratchpad.data = {'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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_whitespace_key_rejected(self):
|
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)
|
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert "str" 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
|
@pytest.mark.asyncio
|
||||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||||
assert "Error" in result
|
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
|
assert "protected" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_workspace_allowed(self):
|
async def test_modify_workspace_preserves_display_compatibility(self):
|
||||||
"""workspace was READONLY in v1, now freely modifiable."""
|
"""The compatibility value is isolated from filesystem security boundaries."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||||
assert "Set workspace" in result
|
assert "Set workspace" in result
|
||||||
|
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_mcp_servers_blocked(self):
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
async def test_scratchpad_rejects_at_max_keys(self):
|
||||||
loop = _make_mock_loop()
|
tool = _make_tool()
|
||||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
for i in range(64):
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||||
result = await tool.execute(action="set", key="overflow", value="data")
|
result = await tool.execute(action="set", key="overflow", value="data")
|
||||||
assert "full" in result
|
assert "full" in result
|
||||||
assert "overflow" not in loop._runtime_vars
|
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
||||||
loop = _make_mock_loop()
|
tool = _make_tool()
|
||||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
for i in range(64):
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||||
assert "Error" not in result
|
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},
|
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")
|
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||||
assert "abc12345" in result
|
assert "abc12345" in result
|
||||||
assert "read logs" in result
|
assert "read logs" in result
|
||||||
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
|
|||||||
stop_reason="completed",
|
stop_reason="completed",
|
||||||
)
|
)
|
||||||
loop.subagents._task_statuses = {"xyz": status}
|
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")
|
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||||
assert "search code" in result
|
assert "search code" in result
|
||||||
assert "completed" in result
|
assert "completed" in result
|
||||||
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
def _make_readonly_tool(self):
|
def _make_readonly_tool(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
return MyTool(
|
||||||
|
runtime_control=AgentRuntimeControl(loop),
|
||||||
|
modify_allowed=False,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_allowed_in_readonly(self):
|
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
|
@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."""
|
"""Design doc scenario: set then check should return the value."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||||
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
|
|||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_runtime_var_string(self):
|
async def test_inspect_scratchpad_string(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||||
result = await tool.execute(action="check", key="current_project")
|
result = await tool.execute(action="check", key="current_project")
|
||||||
assert "nanobot" in result
|
assert "nanobot" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_runtime_var_dict(self):
|
async def test_inspect_scratchpad_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||||
result = await tool.execute(action="check", key="task_meta")
|
result = await tool.execute(action="check", key="task_meta")
|
||||||
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
loop.some_config = MagicMock()
|
||||||
loop.some_config.password = "hunter2"
|
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")
|
result = await tool.execute(action="check", key="some_config.password")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.vault = MagicMock()
|
loop.vault = MagicMock()
|
||||||
loop.vault.secret = "classified"
|
loop.vault.secret = "classified"
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="vault.secret")
|
result = await tool.execute(action="check", key="vault.secret")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.auth_data = MagicMock()
|
loop.auth_data = MagicMock()
|
||||||
loop.auth_data.token = "jwt-payload"
|
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")
|
result = await tool.execute(action="check", key="auth_data.token")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
async def test_modify_password_blocked(self):
|
async def test_modify_password_blocked(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
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")
|
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_model_presets_dotpath_blocked(self):
|
async def test_modify_model_presets_dotpath_blocked(self):
|
||||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||||
presets = {"fast": {"model": "fast-model"}}
|
presets = {"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(
|
result = await tool.execute(
|
||||||
action="set",
|
action="set",
|
||||||
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "read-only" in result
|
assert "read-only" in result
|
||||||
assert presets == {"fast": {"model": "fast-model"}}
|
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||||
presets = MappingProxyType({
|
presets = MappingProxyType({
|
||||||
"fast": ModelPresetConfig(model="fast-model"),
|
"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")
|
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):
|
async def test_last_usage_not_shown_when_empty(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {}
|
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")
|
result = await tool.execute(action="check")
|
||||||
assert "_last_usage" not in result
|
assert "_last_usage" not in result
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
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.agent.tools.self import MyTool
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
||||||
loop = MagicMock()
|
provider = MagicMock()
|
||||||
loop.max_iterations = 40
|
provider.get_default_model.return_value = "test-model"
|
||||||
loop._runtime_vars = {}
|
loop = AgentLoop(
|
||||||
loop.subagents = MagicMock()
|
bus=MessageBus(),
|
||||||
loop.subagents.max_iterations = loop.max_iterations
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
def _sync_subagent_runtime_limits() -> None:
|
max_iterations=40,
|
||||||
loop.subagents.max_iterations = loop.max_iterations
|
)
|
||||||
|
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
|
||||||
|
|
||||||
tool = MyTool(runtime_state=loop)
|
|
||||||
|
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
|
|
||||||
|
|||||||
@@ -241,24 +241,6 @@ async def test_file_edit_events_route_to_channel_capability(manager):
|
|||||||
channel._send_mock.assert_not_awaited()
|
channel._send_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
|
|
||||||
channel = manager.channels["mock"]
|
|
||||||
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id="c1",
|
|
||||||
event=ProgressEvent(file_edit_events=edits),
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager._send_once(channel, msg)
|
|
||||||
|
|
||||||
channel._file_edit_mock.assert_awaited_once_with(
|
|
||||||
"c1", edits, msg.metadata
|
|
||||||
)
|
|
||||||
channel._send_mock.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_channel_file_edit_events_are_noop_safe():
|
async def test_base_channel_file_edit_events_are_noop_safe():
|
||||||
class _Plain(BaseChannel):
|
class _Plain(BaseChannel):
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
|||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
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),
|
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||||
)
|
)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
saved = router._save_channel_config_values(
|
saved = router._save_channel_config_values(
|
||||||
"setupplugin",
|
"setupplugin",
|
||||||
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_api import WebUISettingsError
|
from nanobot.webui.settings_api import WebUISettingsError
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
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")
|
before = config_path.read_text(encoding="utf-8")
|
||||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||||
router._save_channel_config_values(
|
router._save_channel_config_values(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
StreamDeltaEvent,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
StreamEndEvent,
|
||||||
|
outbound_message_for_event,
|
||||||
|
)
|
||||||
|
from nanobot.cli.commands import app
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("streamed", [False, True])
|
||||||
|
def test_interactive_agent_routes_a_complete_user_turn(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path,
|
||||||
|
streamed: bool,
|
||||||
|
) -> None:
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||||
|
seen: dict[str, object] = {}
|
||||||
|
renderers: list[object] = []
|
||||||
|
|
||||||
|
class _Renderer:
|
||||||
|
def __init__(self, **_kwargs: object) -> None:
|
||||||
|
self.streamed = False
|
||||||
|
self.header_printed = False
|
||||||
|
self.deltas: list[str] = []
|
||||||
|
self.ends: list[bool] = []
|
||||||
|
self.closed = 0
|
||||||
|
renderers.append(self)
|
||||||
|
|
||||||
|
async def on_delta(self, content: str) -> None:
|
||||||
|
self.streamed = True
|
||||||
|
self.deltas.append(content)
|
||||||
|
|
||||||
|
async def on_end(self, *, resuming: bool = False) -> None:
|
||||||
|
self.ends.append(resuming)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
def stop_for_input(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _AgentLoop:
|
||||||
|
channels_config = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, _config, bus, **_kwargs):
|
||||||
|
instance = cls(bus)
|
||||||
|
seen["loop"] = instance
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, bus) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.stopped = asyncio.Event()
|
||||||
|
self.close_mcp_calls = 0
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
message = await self.bus.consume_inbound()
|
||||||
|
seen["inbound"] = message
|
||||||
|
if streamed:
|
||||||
|
for event in (
|
||||||
|
StreamDeltaEvent(content="hello "),
|
||||||
|
StreamDeltaEvent(content="world"),
|
||||||
|
StreamEndEvent(),
|
||||||
|
StreamedResponseEvent(),
|
||||||
|
):
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=message.channel,
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
event=event,
|
||||||
|
content="hello world" if isinstance(event, StreamedResponseEvent) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=message.channel,
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
content="hello world",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.stopped.wait()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stopped.set()
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
|
self.close_mcp_calls += 1
|
||||||
|
|
||||||
|
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
|
||||||
|
print_response = MagicMock()
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.is_default_workspace", lambda *_args: False)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._set_nanobot_logs", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._model_display", lambda *_args: ("test-model", ""))
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.consume_restart_notice_from_env", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _AgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.StreamRenderer", _Renderer)
|
||||||
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda *_args: object())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.image_generation.image_gen_provider_configs",
|
||||||
|
lambda *_args: [],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda *_args: object())
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.signal.signal", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._init_prompt_session", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._flush_pending_tty_input", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._restore_terminal", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._read_interactive_input_async", read_input)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", print_response)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["agent", "--session", "cli:journey"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
inbound = seen["inbound"]
|
||||||
|
assert isinstance(inbound, InboundMessage)
|
||||||
|
assert (inbound.channel, inbound.chat_id, inbound.content) == (
|
||||||
|
"cli",
|
||||||
|
"journey",
|
||||||
|
"hello nanobot",
|
||||||
|
)
|
||||||
|
assert inbound.metadata == {"_wants_stream": True}
|
||||||
|
loop = seen["loop"]
|
||||||
|
assert isinstance(loop, _AgentLoop)
|
||||||
|
assert loop.close_mcp_calls == 1
|
||||||
|
assert len(renderers) == 1
|
||||||
|
renderer = renderers[0]
|
||||||
|
assert isinstance(renderer, _Renderer)
|
||||||
|
if streamed:
|
||||||
|
assert renderer.deltas == ["hello ", "world"]
|
||||||
|
assert renderer.ends == [False]
|
||||||
|
assert renderer.closed == 0
|
||||||
|
print_response.assert_not_called()
|
||||||
|
else:
|
||||||
|
assert renderer.deltas == []
|
||||||
|
assert renderer.closed == 1
|
||||||
|
print_response.assert_called_once_with(
|
||||||
|
"hello world",
|
||||||
|
render_markdown=True,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
@@ -1231,27 +1231,6 @@ def test_openai_compat_provider_passes_model_through():
|
|||||||
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
||||||
|
|
||||||
|
|
||||||
def test_make_provider_uses_github_copilot_backend():
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "github-copilot",
|
|
||||||
"model": "github-copilot/gpt-4.1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = make_provider(config)
|
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
||||||
def config_with_proxy(proxy: str) -> Config:
|
def config_with_proxy(proxy: str) -> Config:
|
||||||
return Config.model_validate(
|
return Config.model_validate(
|
||||||
|
|||||||
@@ -243,27 +243,6 @@ def test_workspace_override(tmp_path):
|
|||||||
assert bot._loop.workspace == custom_ws
|
assert bot._loop.workspace == custom_ws
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_make_provider_uses_github_copilot_backend():
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "github-copilot",
|
|
||||||
"model": "github-copilot/gpt-4.1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = make_provider(config)
|
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_custom_session_key(tmp_path):
|
async def test_run_custom_session_key(tmp_path):
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ class TestEditFileTool:
|
|||||||
assert "Successfully" in result
|
assert "Successfully" in result
|
||||||
assert f.read_text() == "hello earth"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_crlf_normalisation(self, tool, tmp_path):
|
async def test_crlf_normalisation(self, tool, tmp_path):
|
||||||
f = tmp_path / "crlf.py"
|
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
|
import socket
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
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.mcp import _probe_http_url, connect_mcp_servers
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
_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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_probe_not_called_for_stdio():
|
async def test_probe_not_called_for_stdio():
|
||||||
"""stdio transport should not be probed — it spawns a local process."""
|
"""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:
|
) -> None:
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
def _error(message: str, *args: object) -> None:
|
|
||||||
messages.append(message.format(*args))
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _broken_stdio_client(_params: object):
|
async def _broken_stdio_client(_params: object):
|
||||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
||||||
yield # pragma: no cover
|
yield # pragma: no cover
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
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()
|
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 stacks == {}
|
||||||
assert messages
|
assert messages
|
||||||
@@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|||||||
assert "stderr" in messages[-1]
|
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.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"config",
|
"config",
|
||||||
@@ -1082,10 +1116,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
failure_mode: str,
|
||||||
) -> None:
|
) -> 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:
|
class _SelectiveClientSession:
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
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
|
@asynccontextmanager
|
||||||
async def _selective_stdio_client(params: object):
|
async def _selective_stdio_client(params: object):
|
||||||
if params.command == "bad":
|
if params.command == "bad" and failure_mode == "exception":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1109,8 +1155,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stacks = await connect_mcp_servers(
|
||||||
{
|
{
|
||||||
"good": MCPServerConfig(command="good"),
|
|
||||||
"bad": MCPServerConfig(command="bad"),
|
"bad": MCPServerConfig(command="bad"),
|
||||||
|
"good": MCPServerConfig(command="good"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1121,6 +1167,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
assert set(stacks) == {"good"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
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"]
|
||||||
@@ -17,7 +17,7 @@ def test_sidebar_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None
|
|||||||
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
assert webui_sidebar_state_path() == tmp_path / "webui" / "sidebar-state.json"
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch) -> None:
|
def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
path = webui_sidebar_state_path()
|
path = webui_sidebar_state_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -31,6 +31,25 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||||
"collapsed_groups": {"Earlier": 1},
|
"collapsed_groups": {"Earlier": 1},
|
||||||
|
"workbench": {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": " Research ",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b", "websocket:a"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
|
||||||
|
"activePaneKey": "missing",
|
||||||
|
"layout": "invalid-layout",
|
||||||
|
"splitRatios": [0.4, 2, "bad", float("nan")],
|
||||||
|
},
|
||||||
|
"tab:websocket:b": {
|
||||||
|
"paneKeys": ["websocket:b", "websocket:c"],
|
||||||
|
"activePaneKey": "websocket:c",
|
||||||
|
"layout": "bsp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -47,6 +66,29 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||||
assert state["collapsed_groups"] == {"Earlier": True}
|
assert state["collapsed_groups"] == {"Earlier": True}
|
||||||
|
assert state["workbench"] == {
|
||||||
|
"version": 1,
|
||||||
|
"tabs": {
|
||||||
|
"tab:websocket:a": {
|
||||||
|
"explicit": True,
|
||||||
|
"title": "Research",
|
||||||
|
"paneKeys": ["websocket:a", "websocket:b"],
|
||||||
|
"layoutPaneKeys": ["websocket:b", "websocket:a"],
|
||||||
|
"activePaneKey": "websocket:a",
|
||||||
|
"layout": "columns",
|
||||||
|
"splitRatios": [0.4, 0.95],
|
||||||
|
},
|
||||||
|
"tab:websocket:b": {
|
||||||
|
"explicit": False,
|
||||||
|
"title": None,
|
||||||
|
"paneKeys": ["websocket:c"],
|
||||||
|
"layoutPaneKeys": ["websocket:c"],
|
||||||
|
"activePaneKey": "websocket:c",
|
||||||
|
"layout": "bsp",
|
||||||
|
"splitRatios": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
assert state["view"] == {
|
assert state["view"] == {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.webui.forking as forking
|
||||||
|
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_rebuilds_missing_transcript_and_saves_clean_title(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
forked = SimpleNamespace(messages=[{"role": "user", "content": "hello"}], metadata={})
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = forked
|
||||||
|
rebuild = MagicMock()
|
||||||
|
marker = MagicMock()
|
||||||
|
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "fork-id")
|
||||||
|
monkeypatch.setattr(forking, "fork_transcript_before_user_index", lambda *_args: False)
|
||||||
|
monkeypatch.setattr(forking, "write_session_messages_as_transcript", rebuild)
|
||||||
|
monkeypatch.setattr(forking, "append_fork_marker", marker)
|
||||||
|
|
||||||
|
result = forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="source",
|
||||||
|
before_user_index=2,
|
||||||
|
title=" Useful fork ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == ("fork-id", "websocket:fork-id")
|
||||||
|
manager.fork_session_before_user_index.assert_called_once_with(
|
||||||
|
"websocket:source",
|
||||||
|
"websocket:fork-id",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
rebuild.assert_called_once_with("websocket:fork-id", forked.messages)
|
||||||
|
marker.assert_called_once_with("websocket:fork-id")
|
||||||
|
assert forked.metadata[WEBUI_TITLE_METADATA_KEY] == "Useful fork"
|
||||||
|
manager.save.assert_called_once_with(forked, fsync=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_rolls_back_session_and_transcript_together(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = SimpleNamespace(
|
||||||
|
messages=[],
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
delete_transcript = MagicMock()
|
||||||
|
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "failed-fork")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
forking,
|
||||||
|
"fork_transcript_before_user_index",
|
||||||
|
MagicMock(side_effect=OSError("disk full")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(forking, "delete_webui_transcript", delete_transcript)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="source",
|
||||||
|
before_user_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_transcript.assert_called_once_with("websocket:failed-fork")
|
||||||
|
manager.delete_session.assert_called_once_with("websocket:failed-fork")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_stops_before_transcript_work_when_source_is_invalid(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = None
|
||||||
|
fork_transcript = MagicMock()
|
||||||
|
monkeypatch.setattr(forking, "fork_transcript_before_user_index", fork_transcript)
|
||||||
|
|
||||||
|
result = forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="missing",
|
||||||
|
before_user_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
fork_transcript.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("envelope", "detail"),
|
||||||
|
[
|
||||||
|
({"source_chat_id": "bad/id", "before_user_index": 0}, "invalid source_chat_id"),
|
||||||
|
({"source_chat_id": "source", "before_user_index": True}, "invalid before_user_index"),
|
||||||
|
({"source_chat_id": "source", "before_user_index": -1}, "invalid before_user_index"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_rejects_invalid_protocol_input(
|
||||||
|
envelope: dict[str, object],
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||||
|
)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(connection, detail)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_reports_unavailable_session_manager() -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(
|
||||||
|
channel,
|
||||||
|
connection,
|
||||||
|
{"source_chat_id": "source", "before_user_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||||
|
connection,
|
||||||
|
"session_manager_unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_maps_invalid_source_and_internal_failure_to_stable_errors(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||||
|
logger=SimpleNamespace(warning=MagicMock()),
|
||||||
|
)
|
||||||
|
envelope = {"source_chat_id": "source", "before_user_index": 0}
|
||||||
|
monkeypatch.setattr(forking, "create_webui_chat_fork", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||||
|
connection,
|
||||||
|
"invalid fork source or index",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.reset_mock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
forking,
|
||||||
|
"create_webui_chat_fork",
|
||||||
|
MagicMock(side_effect=RuntimeError("broken transcript")),
|
||||||
|
)
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
|
||||||
|
channel.logger.warning.assert_called_once_with("fork_chat failed: {}", ANY)
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(connection, "fork_chat_failed")
|
||||||
@@ -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)
|
||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.shared.auth import OAuthToken
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
@@ -38,6 +40,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
"aws-docs",
|
"aws-docs",
|
||||||
"brave-search",
|
"brave-search",
|
||||||
"postman",
|
"postman",
|
||||||
|
"xmind",
|
||||||
|
"notion",
|
||||||
|
"linear",
|
||||||
}.issubset(names)
|
}.issubset(names)
|
||||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||||
assert browserbase["installed"] is False
|
assert browserbase["installed"] is False
|
||||||
@@ -55,6 +60,37 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -296,11 +332,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
|
|||||||
assert "<redacted>" in payload["last_action"]["error"]
|
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)
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
with pytest.raises(McpPresetError) as exc:
|
with pytest.raises(McpPresetError) as exc:
|
||||||
mcp_presets_action("enable", {"name": ["linear"]})
|
mcp_presets_action("enable", {"name": ["asana"]})
|
||||||
|
|
||||||
assert exc.value.status == 404
|
assert exc.value.status == 404
|
||||||
|
|
||||||
@@ -414,6 +450,72 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
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(
|
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
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.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
_clear_webui_oauth_flows,
|
|
||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
@@ -36,11 +35,17 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
||||||
|
return WebUIOAuthFlowRegistry()
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_propagates_preset_resolution_failure(
|
def test_settings_payload_propagates_preset_resolution_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
|
|||||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
fake_start,
|
fake_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
payload = login_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
@@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
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(
|
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_clear_webui_oauth_flows("openai_codex")
|
oauth_flows.clear("openai_codex")
|
||||||
|
|
||||||
assert payload["completion_input"] == "callback_url"
|
assert payload["completion_input"] == "callback_url"
|
||||||
assert captured["open_browser"] is False
|
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(
|
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
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)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"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(
|
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
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)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"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(
|
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
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)
|
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["proxy"] == proxy
|
||||||
assert captured["timeout_s"] == 600
|
assert captured["timeout_s"] == 600
|
||||||
@@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
"secret",
|
"secret",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
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(
|
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
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)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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 exc.value.status == 502
|
||||||
assert str(exc.value) == (
|
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(
|
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
|||||||
lambda: token_path,
|
lambda: token_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logout_oauth_provider({"provider": ["xai-grok"]})
|
logout_oauth_provider(
|
||||||
|
{"provider": ["xai-grok"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert not token_path.exists()
|
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,17 +2,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from websockets.datastructures import Headers
|
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.http_utils import http_json_response
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
|
||||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||||
return WebUISettingsRouter(
|
return WebUISettingsRouter(
|
||||||
|
settings=WebUISettingsServices.create(get_config_path()),
|
||||||
bus=SimpleNamespace(),
|
bus=SimpleNamespace(),
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
@@ -24,30 +28,158 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
),
|
),
|
||||||
runtime_surface="browser",
|
runtime_surface="browser",
|
||||||
runtime_capabilities={},
|
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(
|
@pytest.mark.parametrize(
|
||||||
("provider", "header_name", "authorization_response"),
|
("provider", "authorization_response"),
|
||||||
[
|
[
|
||||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
("xai_grok", "secret"),
|
||||||
(
|
(
|
||||||
"openai_codex",
|
"openai_codex",
|
||||||
"X-Nanobot-OAuth-Callback",
|
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_oauth_completion_reads_private_response_header(
|
async def test_oauth_completion_reads_websocket_payload(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
provider: str,
|
provider: str,
|
||||||
header_name: str,
|
|
||||||
authorization_response: str,
|
authorization_response: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
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)
|
captured.update(query=query, authorization_response=authorization_response)
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -57,19 +189,13 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
||||||
router = _router()
|
router = _router()
|
||||||
request = SimpleNamespace(
|
request = _mutation_request(
|
||||||
path=(
|
"/api/settings/provider/oauth-login/complete",
|
||||||
"/api/settings/provider/oauth-login/complete"
|
{
|
||||||
f"?provider={provider}&flow_id=flow-123"
|
"provider": provider,
|
||||||
),
|
"flow_id": "flow-123",
|
||||||
headers=Headers(
|
"authorization_response": authorization_response,
|
||||||
[
|
},
|
||||||
(
|
|
||||||
header_name,
|
|
||||||
authorization_response,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await router.dispatch(
|
response = await router.dispatch(
|
||||||
@@ -89,28 +215,29 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_response": authorization_response,
|
"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(
|
@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",
|
"/api/settings/model-configurations/delete",
|
||||||
"delete_model_configuration",
|
"delete_model_configuration",
|
||||||
|
{"name": "spare"},
|
||||||
{"name": ["spare"]},
|
{"name": ["spare"]},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"/api/settings/model-configurations/migrate",
|
|
||||||
"/api/settings/model-configurations/migrate",
|
"/api/settings/model-configurations/migrate",
|
||||||
"migrate_model_configurations",
|
"migrate_model_configurations",
|
||||||
{},
|
{},
|
||||||
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
|
|
||||||
"/api/settings/model-call-order/update",
|
"/api/settings/model-call-order/update",
|
||||||
"update_model_call_order",
|
"update_model_call_order",
|
||||||
|
{"order": ["backup"]},
|
||||||
{"order": ['["backup"]']},
|
{"order": ['["backup"]']},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -118,19 +245,19 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_model_preset_mutation_routes(
|
async def test_model_preset_mutation_routes(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
request_path: str,
|
|
||||||
route_path: str,
|
route_path: str,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
|
payload: dict[str, object],
|
||||||
expected_query: dict[str, list[str]],
|
expected_query: dict[str, list[str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def mutate(query):
|
def mutate(query, *, config_path=None):
|
||||||
captured["query"] = query
|
captured["query"] = query
|
||||||
return {"routed": function_name}
|
return {"routed": function_name}
|
||||||
|
|
||||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
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)
|
response = await _router().dispatch(None, request, route_path)
|
||||||
|
|
||||||
@@ -138,3 +265,79 @@ async def test_model_preset_mutation_routes(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body)["routed"] == function_name
|
assert json.loads(response.body)["routed"] == function_name
|
||||||
assert captured["query"] == expected_query
|
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"),
|
||||||
|
[
|
||||||
|
(None, {"updateAvailable": None}),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updateAvailable": {
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_check_route_returns_stable_payload(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
update_info: dict[str, str] | None,
|
||||||
|
expected: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.check_for_update",
|
||||||
|
lambda: update_info,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||||
|
|
||||||
|
response = await _router().dispatch(None, request, request.path)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_check_route_enforces_auth_and_bounds_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
|
||||||
|
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||||
|
|
||||||
|
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
|
||||||
|
assert unauthorized is not None
|
||||||
|
assert unauthorized.status_code == 401
|
||||||
|
check.assert_not_called()
|
||||||
|
|
||||||
|
failed = await _router().dispatch(None, request, request.path)
|
||||||
|
assert failed is not None
|
||||||
|
assert failed.status_code == 500
|
||||||
|
assert json.loads(failed.body) == {"error": "version check failed"}
|
||||||
|
assert "upstream secret body" not in failed.body.decode()
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.webui.version_check as version_check
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_version_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(version_check, "_cache", (0.0, None))
|
||||||
|
monkeypatch.setattr(version_check.time, "monotonic", lambda: 1_000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _pypi_response(latest: object) -> MagicMock:
|
||||||
|
response = MagicMock()
|
||||||
|
response.json.return_value = {"info": {"version": latest}}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_check_reports_only_a_newer_release_and_caches_it(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
get = MagicMock(return_value=_pypi_response("1.3.0"))
|
||||||
|
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
}
|
||||||
|
assert version_check.check_for_update() == expected
|
||||||
|
assert version_check.check_for_update() == expected
|
||||||
|
get.assert_called_once_with(
|
||||||
|
"https://pypi.org/pypi/nanobot-ai/json",
|
||||||
|
timeout=5.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("latest", ["1.2.0", "1.1.9", "not-a-version", 42, None])
|
||||||
|
def test_version_check_ignores_non_newer_or_invalid_releases(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
latest: object,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", lambda *_args, **_kwargs: _pypi_response(latest))
|
||||||
|
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_check_treats_network_failure_as_best_effort(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
get = MagicMock(side_effect=TimeoutError("offline"))
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||||
|
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
|
||||||
|
# Failures are not cached, so a later explicit check can recover.
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
assert get.call_count == 2
|
||||||
@@ -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"
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -61,6 +62,8 @@
|
|||||||
|
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||||
|
|
||||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
@@ -103,6 +106,8 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||||
|
|
||||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||||
|
|
||||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||||
@@ -191,6 +196,10 @@
|
|||||||
|
|
||||||
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
|
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
|
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -209,6 +218,8 @@
|
|||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||||
@@ -467,6 +478,8 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
|
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
|
||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||||
@@ -673,6 +686,8 @@
|
|||||||
|
|
||||||
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
|
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
|
||||||
|
|
||||||
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -741,6 +756,8 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|
||||||
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
@@ -755,6 +772,8 @@
|
|||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
||||||
@@ -763,6 +782,8 @@
|
|||||||
|
|
||||||
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
|
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||||
|
|
||||||
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
||||||
@@ -799,6 +820,8 @@
|
|||||||
|
|
||||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||||
|
|
||||||
|
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||||
|
|
||||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||||
|
|
||||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
@@ -845,6 +868,16 @@
|
|||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||||
|
|
||||||
|
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||||
|
|
||||||
|
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
|
||||||
|
|
||||||
|
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||||
|
|
||||||
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
@@ -893,6 +926,10 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||||
|
|
||||||
|
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||||
@@ -999,6 +1036,8 @@
|
|||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
@@ -1023,6 +1062,8 @@
|
|||||||
|
|
||||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
|
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
|
||||||
|
|
||||||
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
|
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
|
||||||
@@ -1037,6 +1078,8 @@
|
|||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
@@ -1163,6 +1206,8 @@
|
|||||||
|
|
||||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||||
@@ -1175,10 +1220,14 @@
|
|||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||||
|
|
||||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||||
@@ -1189,6 +1238,8 @@
|
|||||||
|
|
||||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
||||||
@@ -1197,6 +1248,8 @@
|
|||||||
|
|
||||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||||
|
|
||||||
|
"test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
|
||||||
|
|
||||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
@@ -1295,6 +1348,8 @@
|
|||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
@@ -1317,6 +1372,12 @@
|
|||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -1353,6 +1414,8 @@
|
|||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
|
|
||||||
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
@@ -1365,12 +1428,16 @@
|
|||||||
|
|
||||||
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
||||||
|
|
||||||
|
"make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||||
|
|
||||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
|
|
||||||
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||||
@@ -1387,14 +1454,24 @@
|
|||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||||
|
|
||||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||||
|
|
||||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||||
|
|
||||||
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||||
|
|
||||||
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||||
@@ -1415,6 +1492,8 @@
|
|||||||
|
|
||||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||||
|
|
||||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|||||||
Generated
+532
@@ -44,6 +44,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -75,6 +76,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ampproject/remapping": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@antfu/install-pkg": {
|
"node_modules/@antfu/install-pkg": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
@@ -348,6 +363,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@braintree/sanitize-url": {
|
"node_modules/@braintree/sanitize-url": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||||
@@ -990,6 +1012,119 @@
|
|||||||
"import-meta-resolve": "^4.2.0"
|
"import-meta-resolve": "^4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@istanbuljs/schema": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1071,6 +1206,17 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -3003,6 +3149,39 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ampproject/remapping": "^2.3.0",
|
||||||
|
"@bcoe/v8-coverage": "^0.2.3",
|
||||||
|
"debug": "^4.3.7",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-lib-source-maps": "^5.0.6",
|
||||||
|
"istanbul-reports": "^3.1.7",
|
||||||
|
"magic-string": "^0.30.12",
|
||||||
|
"magicast": "^0.3.5",
|
||||||
|
"std-env": "^3.8.0",
|
||||||
|
"test-exclude": "^7.0.1",
|
||||||
|
"tinyrainbow": "^1.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "2.1.9",
|
||||||
|
"vitest": "2.1.9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4247,6 +4426,13 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.340",
|
"version": "1.5.340",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4703,6 +4889,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/format": {
|
"node_modules/format": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4768,6 +4971,27 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
|
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4779,6 +5003,39 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.6.0",
|
"version": "17.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
||||||
@@ -4810,6 +5067,16 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5143,6 +5410,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -5378,6 +5652,76 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.23",
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5587,6 +5931,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.25.4",
|
||||||
|
"@babel/types": "^7.25.4",
|
||||||
|
"source-map-js": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-table": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6573,6 +6958,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -6699,6 +7094,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
||||||
@@ -6761,6 +7163,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7619,6 +8045,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7697,6 +8136,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stringify-entities": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -7729,6 +8184,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7789,6 +8258,19 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7864,6 +8346,21 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/test-exclude": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"glob": "^10.4.1",
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8521,6 +9018,41 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "tsc -p tsconfig.build.json && vite build",
|
"build": "tsc -p tsconfig.build.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
|
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
|
||||||
},
|
},
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|||||||
+616
-71
@@ -8,12 +8,34 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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 { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
|
import type { SidebarDeleteItem } from "@/components/ChatList";
|
||||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
|
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||||
|
import {
|
||||||
|
EMPTY_WORKBENCH_STATE,
|
||||||
|
MAX_WORKBENCH_PANES,
|
||||||
|
addWorkbenchPane,
|
||||||
|
attachWorkbenchPane,
|
||||||
|
createWorkbenchTab,
|
||||||
|
detachWorkbenchPane,
|
||||||
|
dissolveWorkbenchTab,
|
||||||
|
ensureWorkbenchPaneTab,
|
||||||
|
focusWorkbenchPane,
|
||||||
|
orderWorkbenchTabs,
|
||||||
|
reconcileWorkbench,
|
||||||
|
renameWorkbenchTab,
|
||||||
|
setWorkbenchLayout,
|
||||||
|
setWorkbenchPaneLayoutOrder,
|
||||||
|
setWorkbenchSplitRatios,
|
||||||
|
workbenchTab,
|
||||||
|
workbenchTabForPane,
|
||||||
|
type WorkbenchState,
|
||||||
|
} from "@/components/workbench/workbench-model";
|
||||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
@@ -35,7 +57,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
import { displayTitle } from "@/lib/chat-groups";
|
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
@@ -316,13 +338,23 @@ function AuthForm({
|
|||||||
onSecret: (secret: string) => void;
|
onSecret: (secret: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [submitting, setSubmitting] = 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const secret = value.trim();
|
const secret = value.trim();
|
||||||
if (!secret) return;
|
if (!secret) {
|
||||||
|
setValidationError("required");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
onSecret(secret);
|
onSecret(secret);
|
||||||
};
|
};
|
||||||
@@ -333,27 +365,57 @@ function AuthForm({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex w-full max-w-sm flex-col gap-4"
|
className="flex w-full max-w-sm flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="space-y-2">
|
||||||
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
<h1 className="text-sm font-medium text-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
||||||
</div>
|
</h1>
|
||||||
{failed && (
|
<div className="relative">
|
||||||
<p className="text-center text-sm text-destructive">
|
|
||||||
{t("app.auth.invalid")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
ref={inputRef}
|
||||||
placeholder={t("app.auth.placeholder")}
|
id="webui-access-password"
|
||||||
|
name="webui-access-password"
|
||||||
|
type={passwordVisible ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
aria-invalid={validationError ? true : undefined}
|
||||||
|
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
||||||
|
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
||||||
autoFocus
|
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>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!value.trim() || submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{t("app.auth.submit")}
|
{t("app.auth.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -977,7 +1039,11 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const { state: sidebarState, update: updateSidebarState } =
|
const {
|
||||||
|
state: sidebarState,
|
||||||
|
loading: sidebarStateLoading,
|
||||||
|
update: updateSidebarState,
|
||||||
|
} =
|
||||||
useSidebarState(sessions, !loading);
|
useSidebarState(sessions, !loading);
|
||||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||||
@@ -994,15 +1060,26 @@ function Shell({
|
|||||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||||
|
const [workbenchState, setWorkbenchState] = useState<WorkbenchState>(
|
||||||
|
EMPTY_WORKBENCH_STATE,
|
||||||
|
);
|
||||||
|
const workbenchServerHydratedRef = useRef(false);
|
||||||
|
const lastServerWorkbenchRef = useRef("");
|
||||||
|
const skipWorkbenchPersistenceRef = useRef(false);
|
||||||
|
const [creatingPane, setCreatingPane] = useState(false);
|
||||||
|
const topicSessions = sessions;
|
||||||
const [pendingDelete, setPendingDelete] = useState<{
|
const [pendingDelete, setPendingDelete] = useState<{
|
||||||
key: string;
|
items: SidebarDeleteItem[];
|
||||||
label: string;
|
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRename, setPendingRename] = useState<{
|
const [pendingRename, setPendingRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pendingTabRename, setPendingTabRename] = useState<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
} | null>(null);
|
||||||
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
const [pendingProjectRename, setPendingProjectRename] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1106,6 +1183,19 @@ function Shell({
|
|||||||
};
|
};
|
||||||
}, [getToken]);
|
}, [getToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarStateLoading) return;
|
||||||
|
const serialized = JSON.stringify(sidebarState.workbench);
|
||||||
|
if (
|
||||||
|
workbenchServerHydratedRef.current
|
||||||
|
&& lastServerWorkbenchRef.current === serialized
|
||||||
|
) return;
|
||||||
|
workbenchServerHydratedRef.current = true;
|
||||||
|
lastServerWorkbenchRef.current = serialized;
|
||||||
|
skipWorkbenchPersistenceRef.current = true;
|
||||||
|
setWorkbenchState(sidebarState.workbench);
|
||||||
|
}, [sidebarState.workbench, sidebarStateLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
@@ -1117,6 +1207,21 @@ function Shell({
|
|||||||
}
|
}
|
||||||
}, [hostSidebarOpen]);
|
}, [hostSidebarOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workbenchServerHydratedRef.current || sidebarStateLoading) return;
|
||||||
|
if (skipWorkbenchPersistenceRef.current) {
|
||||||
|
skipWorkbenchPersistenceRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(workbenchState);
|
||||||
|
if (serialized === JSON.stringify(sidebarState.workbench)) return;
|
||||||
|
lastServerWorkbenchRef.current = serialized;
|
||||||
|
void updateSidebarState((current) => ({
|
||||||
|
...current,
|
||||||
|
workbench: workbenchState,
|
||||||
|
}));
|
||||||
|
}, [sidebarState.workbench, sidebarStateLoading, updateSidebarState, workbenchState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeSessionUpdateChatIds(updatedChatIds);
|
writeSessionUpdateChatIds(updatedChatIds);
|
||||||
}, [updatedChatIds]);
|
}, [updatedChatIds]);
|
||||||
@@ -1180,9 +1285,21 @@ function Shell({
|
|||||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey, temporarySessions]);
|
||||||
|
const activeTabMatch = useMemo(() => (
|
||||||
|
activeKey && !temporarySessions[activeKey]
|
||||||
|
? workbenchTabForPane(workbenchState, activeKey)
|
||||||
|
: null
|
||||||
|
), [activeKey, temporarySessions, workbenchState]);
|
||||||
|
const activeTabKey = activeTabMatch?.tabKey ?? null;
|
||||||
|
const activeTabState = activeTabMatch?.tab ?? null;
|
||||||
|
const activePaneSession = useMemo<ChatSummary | null>(() => {
|
||||||
|
if (!activeTabState) return activeSession;
|
||||||
|
return sessions.find((session) => session.key === activeTabState.activePaneKey)
|
||||||
|
?? activeSession;
|
||||||
|
}, [activeSession, activeTabState, sessions]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activePaneSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1202,13 +1319,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activeSession?.workspaceScope) {
|
if (activePaneSession?.workspaceScope) {
|
||||||
return activeSession.workspaceScope;
|
return activePaneSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activePaneSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1244,6 +1361,25 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [loading, sessions]);
|
}, [loading, sessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || sidebarStateLoading) return;
|
||||||
|
const validKeys = new Set(sessions.map((session) => session.key));
|
||||||
|
setWorkbenchState((current) => {
|
||||||
|
const reconciled = reconcileWorkbench(current, validKeys);
|
||||||
|
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
|
||||||
|
return reconciled;
|
||||||
|
}
|
||||||
|
const match = workbenchTabForPane(reconciled, activeKey);
|
||||||
|
return focusWorkbenchPane(reconciled, match.tabKey, activeKey);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
loading,
|
||||||
|
sidebarStateLoading,
|
||||||
|
sessions,
|
||||||
|
temporarySessions,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||||
@@ -1675,6 +1811,18 @@ function Shell({
|
|||||||
[pendingRename, updateSidebarState],
|
[pendingRename, updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onRequestRenameTab = useCallback((key: string, label: string) => {
|
||||||
|
setPendingTabRename({ key, label });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onConfirmTabRename = useCallback((title: string) => {
|
||||||
|
if (!pendingTabRename) return;
|
||||||
|
setWorkbenchState((current) => (
|
||||||
|
renameWorkbenchTab(current, pendingTabRename.key, title)
|
||||||
|
));
|
||||||
|
setPendingTabRename(null);
|
||||||
|
}, [pendingTabRename]);
|
||||||
|
|
||||||
const onToggleGroup = useCallback(
|
const onToggleGroup = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
void updateSidebarState((current) => {
|
void updateSidebarState((current) => {
|
||||||
@@ -1748,7 +1896,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
||||||
const archived = new Set([...sidebarState.archived_keys, key]);
|
const archived = new Set([...sidebarState.archived_keys, key]);
|
||||||
const next = sessions.find((session) => !archived.has(session.key));
|
const next = topicSessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1756,18 +1904,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
||||||
);
|
|
||||||
|
|
||||||
const onReorderSessions = useCallback(
|
|
||||||
(sessionOrder: string[]) => {
|
|
||||||
void updateSidebarState((current) => ({
|
|
||||||
...current,
|
|
||||||
session_order: sessionOrder,
|
|
||||||
view: { ...current.view, sort: "manual" },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[updateSidebarState],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onToggleArchived = useCallback(() => {
|
const onToggleArchived = useCallback(() => {
|
||||||
@@ -1785,6 +1922,60 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onAddPane = useCallback(async () => {
|
||||||
|
const tabKey = activeTabKey;
|
||||||
|
if (
|
||||||
|
!tabKey
|
||||||
|
|| !activeKey
|
||||||
|
|| !activeSession
|
||||||
|
|| creatingPane
|
||||||
|
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
||||||
|
|| temporarySessionsRef.current[activeKey]
|
||||||
|
) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
setSessionSearchOpen(false);
|
||||||
|
setCreatingPane(true);
|
||||||
|
try {
|
||||||
|
const scope = activeWorkspaceScope;
|
||||||
|
const chatId = await createChat(scope);
|
||||||
|
const paneKey = `websocket:${chatId}`;
|
||||||
|
pendingCreatedSessionKeyRef.current = paneKey;
|
||||||
|
setWorkbenchState((current) => {
|
||||||
|
const withTab = ensureWorkbenchPaneTab(current, activeKey);
|
||||||
|
const target = workbenchTabForPane(withTab, activeKey);
|
||||||
|
return addWorkbenchPane(withTab, target.tabKey, paneKey);
|
||||||
|
});
|
||||||
|
navigate({
|
||||||
|
view: "chat",
|
||||||
|
activeKey: paneKey,
|
||||||
|
settingsSection: "overview",
|
||||||
|
});
|
||||||
|
if (scope) {
|
||||||
|
setWorkspaceOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[chatId]: normalizeWorkspaceScope(scope),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create pane", error);
|
||||||
|
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
||||||
|
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCreatingPane(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
activeSession,
|
||||||
|
activeTabKey,
|
||||||
|
activeTabState,
|
||||||
|
activeWorkspaceScope,
|
||||||
|
createChat,
|
||||||
|
creatingPane,
|
||||||
|
navigate,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
@@ -1862,15 +2053,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return sessions[0]?.key ?? null;
|
return topicSessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, sessions]);
|
}, [activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -1977,31 +2168,43 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activeSession,
|
temporaryChatActive ? null : activePaneSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const key = pendingDelete.key;
|
const items = pendingDelete.items;
|
||||||
|
const deletingKeys = new Set(items.map((item) => item.key));
|
||||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||||
const deletingActive = activeKey === key;
|
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||||
const fallbackKey = deletingActive
|
const fallbackKey = deletingActive
|
||||||
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
? (
|
||||||
|
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||||
|
!deletingKeys.has(session.key)
|
||||||
|
))?.key
|
||||||
|
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||||
|
!deletingKeys.has(session.key)
|
||||||
|
))?.key
|
||||||
|
?? null
|
||||||
|
)
|
||||||
: activeKey;
|
: activeKey;
|
||||||
try {
|
try {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
const result = await deleteChat(
|
const result = await deleteChat(
|
||||||
key,
|
item.key,
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
);
|
);
|
||||||
if (result.blocked_by_automations) {
|
if (result.blocked_by_automations) {
|
||||||
setPendingDelete({
|
setPendingDelete({
|
||||||
...pendingDelete,
|
items: items.slice(index),
|
||||||
automations: result.automations ?? [],
|
automations: result.automations ?? [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -2013,18 +2216,24 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||||
let automations: SessionAutomationJob[] = [];
|
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||||
try {
|
if (uniqueItems.length === 0) return;
|
||||||
automations = await getSessionAutomations(key);
|
const automationResults = await Promise.allSettled(
|
||||||
} catch {
|
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
||||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
);
|
||||||
}
|
const automations = automationResults.flatMap((result) => (
|
||||||
setPendingDelete({ key, label, automations });
|
result.status === "fulfilled" ? result.value : []
|
||||||
|
));
|
||||||
|
setPendingDelete({ items: uniqueItems, automations });
|
||||||
}, [getSessionAutomations]);
|
}, [getSessionAutomations]);
|
||||||
|
|
||||||
|
const onRequestDelete = useCallback((key: string, label: string) => {
|
||||||
|
void onRequestDeleteMany([{ key, label }]);
|
||||||
|
}, [onRequestDeleteMany]);
|
||||||
|
|
||||||
const visiblePairingRequests = useMemo(
|
const visiblePairingRequests = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2041,7 +2250,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(getToken(), action, code);
|
const payload = await runPairingAction(client, action, code);
|
||||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setSnoozedPairingCodes((current) => {
|
setSnoozedPairingCodes((current) => {
|
||||||
if (!current.has(code)) return current;
|
if (!current.has(code)) return current;
|
||||||
@@ -2056,7 +2265,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getToken, refreshPairingRequests],
|
[client, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
@@ -2069,13 +2278,210 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const titleForSession = useCallback((session: ChatSummary) => (
|
||||||
|
sidebarState.title_overrides[session.key]
|
||||||
|
|| session.title
|
||||||
|
|| deriveTitle(session.preview, t("chat.newChat"))
|
||||||
|
), [sidebarState.title_overrides, t]);
|
||||||
|
|
||||||
|
const automaticSidebarSort = sidebarState.view.sort === "manual"
|
||||||
|
? "updated_desc"
|
||||||
|
: sidebarState.view.sort;
|
||||||
|
const orderedWorkbenchTabs = useMemo(() => {
|
||||||
|
const orderedSessions = sortSessions(
|
||||||
|
sessions,
|
||||||
|
automaticSidebarSort,
|
||||||
|
sidebarState.title_overrides,
|
||||||
|
sidebarState.session_order,
|
||||||
|
);
|
||||||
|
const updatedAtByKey = new Map(sessions.map((session) => [
|
||||||
|
session.key,
|
||||||
|
session.updatedAt ?? session.createdAt,
|
||||||
|
]));
|
||||||
|
return orderWorkbenchTabs(
|
||||||
|
workbenchState,
|
||||||
|
orderedSessions.map((session) => session.key),
|
||||||
|
updatedAtByKey,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
automaticSidebarSort,
|
||||||
|
sessions,
|
||||||
|
sidebarState.session_order,
|
||||||
|
sidebarState.title_overrides,
|
||||||
|
workbenchState,
|
||||||
|
]);
|
||||||
|
const orderedWorkbenchTabsByKey = useMemo(
|
||||||
|
() => new Map(orderedWorkbenchTabs.map((tab) => [tab.tabKey, tab])),
|
||||||
|
[orderedWorkbenchTabs],
|
||||||
|
);
|
||||||
|
const sidebarTabPresentations = useMemo(() => {
|
||||||
|
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
return orderedWorkbenchTabs.flatMap((tab) => {
|
||||||
|
const anchorKey = tab.tab.paneKeys.find((key) => sessionsByKey.has(key))
|
||||||
|
?? tab.paneKeys[0];
|
||||||
|
const anchor = sessionsByKey.get(anchorKey);
|
||||||
|
if (!anchor) return [];
|
||||||
|
const title = tab.tab.title ?? titleForSession(anchor);
|
||||||
|
const visible = tab.tab.explicit || tab.paneKeys.length > 1;
|
||||||
|
const rowKey = visible ? tab.tabKey : tab.paneKeys[0];
|
||||||
|
return [{
|
||||||
|
orderedTab: tab,
|
||||||
|
rowKey,
|
||||||
|
title,
|
||||||
|
session: visible
|
||||||
|
? {
|
||||||
|
...anchor,
|
||||||
|
key: tab.tabKey,
|
||||||
|
chatId: `workbench-tab:${tab.tabKey}`,
|
||||||
|
title,
|
||||||
|
preview: "",
|
||||||
|
updatedAt: tab.updatedAt,
|
||||||
|
}
|
||||||
|
: anchor,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}, [orderedWorkbenchTabs, sessions, titleForSession]);
|
||||||
|
const sidebarTopicSessions = useMemo(
|
||||||
|
() => sidebarTabPresentations.map((presentation) => presentation.session),
|
||||||
|
[sidebarTabPresentations],
|
||||||
|
);
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = temporaryChatActive
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||||
: activeSession
|
: activeSession
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? titleForSession(activeSession)
|
||||||
activeSession.title ||
|
|
||||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
const workbenchPaneSessions = useMemo(() => {
|
||||||
|
if (!activeTabState) return [];
|
||||||
|
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
const sortedPaneKeys = activeTabKey
|
||||||
|
? orderedWorkbenchTabsByKey.get(activeTabKey)?.paneKeys ?? activeTabState.paneKeys
|
||||||
|
: activeTabState.paneKeys;
|
||||||
|
const paneKeys = [
|
||||||
|
...activeTabState.layoutPaneKeys.filter((key) => byKey.has(key)),
|
||||||
|
...sortedPaneKeys.filter((key) => !activeTabState.layoutPaneKeys.includes(key)),
|
||||||
|
];
|
||||||
|
return paneKeys
|
||||||
|
.map((key) => byKey.get(key))
|
||||||
|
.filter((session): session is ChatSummary => session !== undefined);
|
||||||
|
}, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
|
||||||
|
const paneChromeEnabled = Boolean(
|
||||||
|
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
||||||
|
);
|
||||||
|
const activeTabVisible = Boolean(
|
||||||
|
activeTabState
|
||||||
|
&& (activeTabState.explicit || activeTabState.paneKeys.length > 1),
|
||||||
|
);
|
||||||
|
const renderedWorkbenchPanes = useMemo(() => {
|
||||||
|
if (paneChromeEnabled) {
|
||||||
|
return workbenchPaneSessions.map((session) => ({
|
||||||
|
key: session.key,
|
||||||
|
reactKey: session.key === activeTabState?.paneKeys[0]
|
||||||
|
? "tab-root"
|
||||||
|
: `pane:${session.key}`,
|
||||||
|
title: titleForSession(session),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
key: activeKey ?? "new-topic",
|
||||||
|
reactKey: "tab-root",
|
||||||
|
title: headerTitle,
|
||||||
|
}];
|
||||||
|
}, [
|
||||||
|
activeKey,
|
||||||
|
activeTabState?.paneKeys,
|
||||||
|
headerTitle,
|
||||||
|
paneChromeEnabled,
|
||||||
|
titleForSession,
|
||||||
|
workbenchPaneSessions,
|
||||||
|
]);
|
||||||
|
const renderedActivePaneKey = paneChromeEnabled && activeTabState
|
||||||
|
? activeTabState.activePaneKey
|
||||||
|
: renderedWorkbenchPanes[0].key;
|
||||||
|
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
||||||
|
? activeTabState.layout
|
||||||
|
: "columns";
|
||||||
|
const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState
|
||||||
|
? activeTabState.splitRatios
|
||||||
|
: [];
|
||||||
|
const sidebarPaneGroups = useMemo(() => {
|
||||||
|
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||||
|
return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
|
||||||
|
const orderedTab = presentation.orderedTab;
|
||||||
|
const panes = orderedTab.paneKeys
|
||||||
|
.map((key) => sessionsByKey.get(key))
|
||||||
|
.filter((session): session is ChatSummary => session !== undefined)
|
||||||
|
.map((session) => ({
|
||||||
|
key: session.key,
|
||||||
|
chatId: session.chatId,
|
||||||
|
title: titleForSession(session),
|
||||||
|
}));
|
||||||
|
return [presentation.rowKey, {
|
||||||
|
tabKey: orderedTab.tabKey,
|
||||||
|
title: presentation.title,
|
||||||
|
activePaneKey: orderedTab.tab.activePaneKey,
|
||||||
|
visible: orderedTab.tab.explicit || orderedTab.paneKeys.length > 1,
|
||||||
|
panes,
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
sessions,
|
||||||
|
sidebarTabPresentations,
|
||||||
|
titleForSession,
|
||||||
|
]);
|
||||||
|
const activePaneLimitReached = Boolean(
|
||||||
|
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
setWorkbenchState((current) => focusWorkbenchPane(current, activeTabKey, paneKey));
|
||||||
|
onSelectChat(paneKey);
|
||||||
|
}, [activeTabKey, onSelectChat]);
|
||||||
|
|
||||||
|
const onSelectSidebarTab = useCallback((tabKey: string) => {
|
||||||
|
const tab = workbenchTab(workbenchState, tabKey);
|
||||||
|
if (tab) onSelectChat(tab.activePaneKey);
|
||||||
|
}, [onSelectChat, workbenchState]);
|
||||||
|
|
||||||
|
const onSelectSidebarItem = useCallback((key: string) => {
|
||||||
|
if (
|
||||||
|
temporarySessionsRef.current[key]
|
||||||
|
|| sessions.some((session) => session.key === key)
|
||||||
|
) {
|
||||||
|
onSelectChat(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectSidebarTab(key);
|
||||||
|
}, [onSelectChat, onSelectSidebarTab, sessions]);
|
||||||
|
|
||||||
|
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
|
||||||
|
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
|
||||||
|
onSelectChat(paneKey);
|
||||||
|
}, [onSelectChat]);
|
||||||
|
|
||||||
|
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||||
|
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCreateWorkbenchTab = useCallback((tabKey: string) => {
|
||||||
|
setWorkbenchState((current) => createWorkbenchTab(current, tabKey));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDissolveWorkbenchTab = useCallback((tabKey: string) => {
|
||||||
|
setWorkbenchState((current) => dissolveWorkbenchTab(current, tabKey));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onAttachWorkbenchPane = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
tabKey: string,
|
||||||
|
) => {
|
||||||
|
setWorkbenchState((current) => {
|
||||||
|
const target = workbenchTab(current, tabKey);
|
||||||
|
if (!target || (!target.explicit && target.paneKeys.length < 2)) return current;
|
||||||
|
return attachWorkbenchPane(current, tabKey, paneKey);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2107,20 +2513,49 @@ function Shell({
|
|||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
|
const pinnedPaneKeys = useMemo(
|
||||||
|
() => new Set(sidebarState.pinned_keys),
|
||||||
|
[sidebarState.pinned_keys],
|
||||||
|
);
|
||||||
|
const archivedPaneKeys = useMemo(
|
||||||
|
() => new Set(sidebarState.archived_keys),
|
||||||
|
[sidebarState.archived_keys],
|
||||||
|
);
|
||||||
|
const sidebarPinnedTabKeys = useMemo(() => sidebarTabPresentations
|
||||||
|
.filter(({ orderedTab }) => orderedTab.paneKeys.some((key) => pinnedPaneKeys.has(key)))
|
||||||
|
.map(({ rowKey }) => rowKey), [pinnedPaneKeys, sidebarTabPresentations]);
|
||||||
|
const sidebarArchivedTabKeys = useMemo(() => sidebarTabPresentations
|
||||||
|
.filter(({ orderedTab }) => orderedTab.paneKeys.every((key) => archivedPaneKeys.has(key)))
|
||||||
|
.map(({ rowKey }) => rowKey), [archivedPaneKeys, sidebarTabPresentations]);
|
||||||
|
const activeSidebarKey = activeTabKey
|
||||||
|
? sidebarTabPresentations.find(({ orderedTab }) => (
|
||||||
|
orderedTab.tabKey === activeTabKey
|
||||||
|
))?.rowKey ?? activeKey
|
||||||
|
: activeKey;
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions,
|
sessions: sidebarTopicSessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat"
|
||||||
|
? (temporaryChatActive ? activeKey : activeSidebarKey)
|
||||||
|
: null,
|
||||||
loading,
|
loading,
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
newChatActive: view === "chat" && activeKey === null,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectSidebarItem,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
|
onRequestDeleteMany,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onReorderSessions,
|
onRequestRenameTab,
|
||||||
|
paneGroups: sidebarPaneGroups,
|
||||||
|
onSelectPane: onSelectSidebarPane,
|
||||||
|
onCreateTab: onCreateWorkbenchTab,
|
||||||
|
onDetachPane: onDetachWorkbenchPane,
|
||||||
|
onDissolveTab: onDissolveWorkbenchTab,
|
||||||
|
onAttachPane: onAttachWorkbenchPane,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
onNewChatInProject,
|
onNewChatInProject,
|
||||||
@@ -2132,17 +2567,19 @@ function Shell({
|
|||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
pinnedKeys: sidebarState.pinned_keys,
|
pinnedKeys: sidebarPinnedTabKeys,
|
||||||
archivedKeys: sidebarState.archived_keys,
|
archivedKeys: sidebarArchivedTabKeys,
|
||||||
|
pinnedPaneKeys: sidebarState.pinned_keys,
|
||||||
|
archivedPaneKeys: sidebarState.archived_keys,
|
||||||
sessionOrder: sidebarState.session_order,
|
sessionOrder: sidebarState.session_order,
|
||||||
titleOverrides: sidebarState.title_overrides,
|
titleOverrides: sidebarState.title_overrides,
|
||||||
projectNameOverrides: sidebarState.project_name_overrides,
|
projectNameOverrides: sidebarState.project_name_overrides,
|
||||||
collapsedGroups: sidebarState.collapsed_groups,
|
collapsedGroups: sidebarState.collapsed_groups,
|
||||||
runningChatIds: runningChatIdList,
|
runningChatIds: runningChatIdList,
|
||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
viewState: sidebarState.view,
|
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarState.archived_keys.length,
|
archivedCount: sidebarArchivedTabKeys.length,
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2278,7 +2715,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={sessions}
|
sessions={topicSessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2297,6 +2734,37 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<PaneWorkbench
|
||||||
|
panes={renderedWorkbenchPanes}
|
||||||
|
activePaneKey={renderedActivePaneKey}
|
||||||
|
layout={renderedWorkbenchLayout}
|
||||||
|
splitRatios={renderedWorkbenchSplitRatios}
|
||||||
|
chrome={paneChromeEnabled}
|
||||||
|
showLayoutControl={activeTabVisible}
|
||||||
|
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||||
|
onActivatePane={onActivateWorkbenchPane}
|
||||||
|
onAddPane={onAddPane}
|
||||||
|
onLayoutChange={(layout) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
setWorkbenchState((current) => (
|
||||||
|
setWorkbenchLayout(current, activeTabKey, layout)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onPaneOrderChange={(paneKeys) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
setWorkbenchState((current) => (
|
||||||
|
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onSplitRatiosChange={(splitRatios) => {
|
||||||
|
if (!activeTabKey) return;
|
||||||
|
setWorkbenchState((current) => (
|
||||||
|
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
renderPane={(pane, context) => {
|
||||||
|
if (!paneChromeEnabled) {
|
||||||
|
return (
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
session={activeSession}
|
session={activeSession}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -2309,7 +2777,9 @@ function Shell({
|
|||||||
}
|
}
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
onCreateChat={
|
||||||
|
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
||||||
|
}
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -2327,6 +2797,67 @@ function Shell({
|
|||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneSession = workbenchPaneSessions.find(
|
||||||
|
(session) => session.key === pane.key,
|
||||||
|
);
|
||||||
|
if (!paneSession) return null;
|
||||||
|
const paneScope = workspaceOverrides[paneSession.chatId]
|
||||||
|
?? paneSession.workspaceScope
|
||||||
|
?? workspaces?.default_scope
|
||||||
|
?? null;
|
||||||
|
const paneRunning = runningChatIds.has(paneSession.chatId);
|
||||||
|
return (
|
||||||
|
<ThreadShell
|
||||||
|
session={paneSession}
|
||||||
|
sessions={sessions}
|
||||||
|
title={pane.title}
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onCreateChat={onCreateChat}
|
||||||
|
onForkChat={onForkChat}
|
||||||
|
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggle}
|
||||||
|
hideSidebarToggle={!context.active}
|
||||||
|
hideSidebarToggleForHostChrome={context.active}
|
||||||
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
|
hideThemeButton={!context.active}
|
||||||
|
hideHeaderTitle
|
||||||
|
headerActions={context.headerActions}
|
||||||
|
headerPortalTarget={context.headerPortalTarget}
|
||||||
|
headerActive={context.active}
|
||||||
|
composerPortalTarget={context.composerPortalTarget}
|
||||||
|
composerActive={context.active}
|
||||||
|
composerInputAriaLabel={t("workbench.composerAria", {
|
||||||
|
defaultValue: "Message {{title}}",
|
||||||
|
title: pane.title,
|
||||||
|
})}
|
||||||
|
emptyComposerVariant="thread"
|
||||||
|
workspaceScope={paneScope}
|
||||||
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
|
workspaceScopeDisabled={paneRunning}
|
||||||
|
workspaceError={context.active ? workspaceError : null}
|
||||||
|
onWorkspaceScopeChange={(scope) => {
|
||||||
|
if (paneRunning) return;
|
||||||
|
const next = normalizeWorkspaceScope(scope);
|
||||||
|
setWorkspaceError(null);
|
||||||
|
setWorkspaceOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[paneSession.chatId]: next,
|
||||||
|
}));
|
||||||
|
client.setWorkspaceScope(paneSession.chatId, next);
|
||||||
|
}}
|
||||||
|
settingsSnapshot={settingsSnapshot}
|
||||||
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
skills={skills}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
@@ -2358,7 +2889,8 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.label}
|
title={pendingDelete.items[0]?.label ?? ""}
|
||||||
|
count={pendingDelete.items.length}
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
@@ -2375,6 +2907,19 @@ function Shell({
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pendingTabRename ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RenameChatDialog
|
||||||
|
open
|
||||||
|
title={pendingTabRename.label}
|
||||||
|
dialogTitle={t("workbench.renameTabTitle")}
|
||||||
|
description={t("workbench.renameTabDescription")}
|
||||||
|
placeholder={t("workbench.renameTabPlaceholder")}
|
||||||
|
onCancel={() => setPendingTabRename(null)}
|
||||||
|
onConfirm={onConfirmTabRename}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
{pendingProjectRename ? (
|
{pendingProjectRename ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RenameChatDialog
|
<RenameChatDialog
|
||||||
|
|||||||
+800
-123
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
|||||||
interface DeleteConfirmProps {
|
interface DeleteConfirmProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
count?: number;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
|||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
count = 1,
|
||||||
automations = [],
|
automations = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = currentLocale();
|
const locale = currentLocale();
|
||||||
const hasAutomations = automations.length > 0;
|
const hasAutomations = automations.length > 0;
|
||||||
|
const multiple = count > 1;
|
||||||
const visibleAutomations = automations.slice(0, 4);
|
const visibleAutomations = automations.slice(0, 4);
|
||||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||||
return (
|
return (
|
||||||
@@ -47,11 +50,24 @@ export function DeleteConfirm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
{t("deleteConfirm.title", { title })}
|
{multiple
|
||||||
|
? t("deleteConfirm.titleMany", {
|
||||||
|
defaultValue: "Delete {{count}} topics and panes?",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.title", { title })}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||||
{hasAutomations
|
{hasAutomations
|
||||||
? t("deleteConfirm.automationsDescription")
|
? multiple
|
||||||
|
? t("deleteConfirm.automationsDescriptionMany", {
|
||||||
|
defaultValue: "Linked automations will also be deleted.",
|
||||||
|
})
|
||||||
|
: t("deleteConfirm.automationsDescription")
|
||||||
|
: multiple
|
||||||
|
? t("deleteConfirm.descriptionMany", {
|
||||||
|
defaultValue: "This action cannot be undone.",
|
||||||
|
})
|
||||||
: t("deleteConfirm.description")}
|
: t("deleteConfirm.description")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{hasAutomations ? (
|
{hasAutomations ? (
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import {
|
||||||
|
ChatList,
|
||||||
|
type SidebarDeleteItem,
|
||||||
|
type SidebarPaneGroup,
|
||||||
|
} from "@/components/ChatList";
|
||||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
import {
|
import {
|
||||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||||
@@ -39,10 +43,20 @@ interface SidebarProps {
|
|||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
onCloseTemporaryChat?: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
|
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
|
onRequestRenameTab?: (key: string, label: string) => void;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
onReorderSessions: (keys: string[]) => void;
|
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||||
|
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||||
|
onCreateTab?: (tabKey: string) => void;
|
||||||
|
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||||
|
onDissolveTab?: (tabKey: string) => void;
|
||||||
|
onAttachPane?: (
|
||||||
|
paneKey: string,
|
||||||
|
tabKey: string,
|
||||||
|
) => void;
|
||||||
onToggleGroup: (groupId: string) => void;
|
onToggleGroup: (groupId: string) => void;
|
||||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||||
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
||||||
@@ -60,6 +74,8 @@ interface SidebarProps {
|
|||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
pinnedKeys?: string[];
|
pinnedKeys?: string[];
|
||||||
archivedKeys?: string[];
|
archivedKeys?: string[];
|
||||||
|
pinnedPaneKeys?: string[];
|
||||||
|
archivedPaneKeys?: string[];
|
||||||
sessionOrder?: string[];
|
sessionOrder?: string[];
|
||||||
titleOverrides?: Record<string, string>;
|
titleOverrides?: Record<string, string>;
|
||||||
projectNameOverrides?: Record<string, string>;
|
projectNameOverrides?: Record<string, string>;
|
||||||
@@ -230,15 +246,24 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onSelect={props.onSelect}
|
onSelect={props.onSelect}
|
||||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||||
onRequestDelete={props.onRequestDelete}
|
onRequestDelete={props.onRequestDelete}
|
||||||
|
onRequestDeleteMany={props.onRequestDeleteMany}
|
||||||
onTogglePin={props.onTogglePin}
|
onTogglePin={props.onTogglePin}
|
||||||
onRequestRename={props.onRequestRename}
|
onRequestRename={props.onRequestRename}
|
||||||
|
onRequestRenameTab={props.onRequestRenameTab}
|
||||||
onToggleArchive={props.onToggleArchive}
|
onToggleArchive={props.onToggleArchive}
|
||||||
onReorderSessions={props.onReorderSessions}
|
paneGroups={props.paneGroups}
|
||||||
|
onSelectPane={props.onSelectPane}
|
||||||
|
onCreateTab={props.onCreateTab}
|
||||||
|
onDetachPane={props.onDetachPane}
|
||||||
|
onDissolveTab={props.onDissolveTab}
|
||||||
|
onAttachPane={props.onAttachPane}
|
||||||
onToggleGroup={props.onToggleGroup}
|
onToggleGroup={props.onToggleGroup}
|
||||||
onRequestRenameProject={props.onRequestRenameProject}
|
onRequestRenameProject={props.onRequestRenameProject}
|
||||||
onNewChatInProject={props.onNewChatInProject}
|
onNewChatInProject={props.onNewChatInProject}
|
||||||
pinnedKeys={props.pinnedKeys}
|
pinnedKeys={props.pinnedKeys}
|
||||||
archivedKeys={props.archivedKeys}
|
archivedKeys={props.archivedKeys}
|
||||||
|
pinnedPaneKeys={props.pinnedPaneKeys}
|
||||||
|
archivedPaneKeys={props.archivedPaneKeys}
|
||||||
sessionOrder={props.sessionOrder}
|
sessionOrder={props.sessionOrder}
|
||||||
titleOverrides={props.titleOverrides}
|
titleOverrides={props.titleOverrides}
|
||||||
projectNameOverrides={props.projectNameOverrides}
|
projectNameOverrides={props.projectNameOverrides}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { getToken } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -321,7 +321,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -345,7 +345,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await deleteSkill(getToken(), activeSkill.name);
|
const payload = await deleteSkill(client, activeSkill.name);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
|
|||||||
installing: string;
|
installing: string;
|
||||||
onInstallingChange: (skillId: string) => void;
|
onInstallingChange: (skillId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { getToken } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||||
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
getToken(),
|
client,
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export type ChannelInstancesPanelCustomization = {
|
export type ChannelInstancesPanelCustomization = {
|
||||||
countLabel?: (runningCount: number) => string;
|
countLabel?: (runningCount: number) => string;
|
||||||
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelInstancesPanel({
|
export function ChannelInstancesPanel({
|
||||||
token,
|
|
||||||
feature,
|
feature,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
chatAppsDocsUrl,
|
chatAppsDocsUrl,
|
||||||
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
customization = {},
|
customization = {},
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
|
||||||
feature: NanobotFeatureInfo;
|
feature: NanobotFeatureInfo;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
chatAppsDocsUrl?: string;
|
chatAppsDocsUrl?: string;
|
||||||
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
customization?: ChannelInstancesPanelCustomization;
|
customization?: ChannelInstancesPanelCustomization;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const displayName = localizedChannelDisplayName(feature, t);
|
const displayName = localizedChannelDisplayName(feature, t);
|
||||||
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = checked
|
const payload = checked
|
||||||
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
||||||
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
||||||
onFeaturesUpdate(payload);
|
onFeaturesUpdate(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotice((err as Error).message);
|
setNotice((err as Error).message);
|
||||||
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSave(instanceFields, fieldValues),
|
channelValuesForSave(instanceFields, fieldValues),
|
||||||
{ enable: selected.enabled, instanceId: selected.id },
|
{ enable: selected.enabled, instanceId: selected.id },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ChannelConnectPayload,
|
ChannelConnectPayload,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export type ChannelQrConnectLabels = {
|
export type ChannelQrConnectLabels = {
|
||||||
qrAlt: string;
|
qrAlt: string;
|
||||||
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelQrConnectFlow({
|
export function ChannelQrConnectFlow({
|
||||||
token,
|
|
||||||
channelName,
|
channelName,
|
||||||
startOptions = {},
|
startOptions = {},
|
||||||
idleLabel,
|
idleLabel,
|
||||||
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
|
|||||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||||
suppressSucceeded?: boolean;
|
suppressSucceeded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||||
const pollInFlight = useRef(false);
|
const pollInFlight = useRef(false);
|
||||||
const tokenRef = useRef(token);
|
|
||||||
tokenRef.current = token;
|
|
||||||
const startDomain = startOptions.domain;
|
const startDomain = startOptions.domain;
|
||||||
const startInstanceId = startOptions.instanceId;
|
const startInstanceId = startOptions.instanceId;
|
||||||
const startMode = startOptions.mode;
|
const startMode = startOptions.mode;
|
||||||
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
|
|||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
channelName,
|
channelName,
|
||||||
|
client,
|
||||||
connect?.interval_ms,
|
connect?.interval_ms,
|
||||||
connect?.session_id,
|
connect?.session_id,
|
||||||
connect?.status,
|
connect?.status,
|
||||||
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
const payload = await startChannelConnect(client, channelName, {
|
||||||
domain: startDomain,
|
domain: startDomain,
|
||||||
instanceId: startInstanceId,
|
instanceId: startInstanceId,
|
||||||
mode: startMode,
|
mode: startMode,
|
||||||
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||||
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = await cancelChannelConnect(
|
const payload = await cancelChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
);
|
);
|
||||||
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
"",
|
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export function ChannelCatalogRow({
|
export function ChannelCatalogRow({
|
||||||
feature,
|
feature,
|
||||||
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
|
|||||||
if (feature.instances !== undefined) {
|
if (feature.instances !== undefined) {
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
token={token}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
|
|||||||
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||||
try {
|
try {
|
||||||
const validationPayload = await validateChannel(token, feature.name, values);
|
const validationPayload = await validateChannel(client, feature.name, values);
|
||||||
setValidation(validationPayload);
|
setValidation(validationPayload);
|
||||||
if (!validationPayload.can_enable) {
|
if (!validationPayload.can_enable) {
|
||||||
setNotice(
|
setNotice(
|
||||||
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
values,
|
values,
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await validateChannel(
|
const payload = await validateChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,9 +179,14 @@ function getVoiceShortcutLabel(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadComposerProps {
|
interface ThreadComposerProps {
|
||||||
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
onSend: (
|
||||||
|
content: string,
|
||||||
|
images?: SendAttachment[],
|
||||||
|
options?: SendOptions,
|
||||||
|
) => boolean | void | Promise<boolean | void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
inputAriaLabel?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
modelDetail?: string | null;
|
modelDetail?: string | null;
|
||||||
@@ -936,6 +941,7 @@ export function ThreadComposer({
|
|||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
inputAriaLabel,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
modelDetail = null,
|
modelDetail = null,
|
||||||
@@ -981,6 +987,8 @@ export function ThreadComposer({
|
|||||||
end: number;
|
end: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||||
|
const [sendPending, setSendPending] = useState(false);
|
||||||
|
const interactionDisabled = !!disabled || sendPending;
|
||||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||||
@@ -1071,7 +1079,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (interactionDisabled || files.length === 0) return;
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
const { rejected } = enqueue(files);
|
const { rejected } = enqueue(files);
|
||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
@@ -1080,7 +1088,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueue, formatRejection],
|
[enqueue, formatRejection, interactionDisabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1093,18 +1101,20 @@ export function ThreadComposer({
|
|||||||
} = useClipboardAndDrop(addFiles);
|
} = useClipboardAndDrop(addFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled || hasTouchPrimaryPointer) return;
|
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const id = requestAnimationFrame(() => el.focus());
|
const id = requestAnimationFrame(() => el.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [disabled, hasTouchPrimaryPointer]);
|
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusRequest || disabled) return;
|
if (!focusRequest || interactionDisabled) return;
|
||||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [disabled, focusRequest]);
|
}, [focusRequest, interactionDisabled]);
|
||||||
|
|
||||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||||
|
|
||||||
@@ -1118,15 +1128,17 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||||
const canSend =
|
const canSend =
|
||||||
!disabled
|
!interactionDisabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
&& hasComposerContent;
|
&& hasComposerContent;
|
||||||
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
const canOpenModelSettings = Boolean(
|
||||||
|
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
|
||||||
|
);
|
||||||
const canQueueGuidance =
|
const canQueueGuidance =
|
||||||
isStreaming
|
isStreaming
|
||||||
&& !disabled
|
&& !interactionDisabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
@@ -1134,14 +1146,14 @@ export function ThreadComposer({
|
|||||||
&& !value.trimStart().startsWith("/");
|
&& !value.trimStart().startsWith("/");
|
||||||
|
|
||||||
const slashQuery = useMemo(() => {
|
const slashQuery = useMemo(() => {
|
||||||
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||||
const commandToken = value.slice(1);
|
const commandToken = value.slice(1);
|
||||||
if (/\s/.test(commandToken)) return null;
|
if (/\s/.test(commandToken)) return null;
|
||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [interactionDisabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const skillQuery = useMemo(() => {
|
const skillQuery = useMemo(() => {
|
||||||
if (disabled || slashMenuDismissed) return null;
|
if (interactionDisabled || slashMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
||||||
@@ -1151,7 +1163,7 @@ export function ThreadComposer({
|
|||||||
start: match.index,
|
start: match.index,
|
||||||
text: match[1].toLowerCase(),
|
text: match[1].toLowerCase(),
|
||||||
};
|
};
|
||||||
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
if (!(isStreaming && onStop)) return slashCommands;
|
if (!(isStreaming && onStop)) return slashCommands;
|
||||||
@@ -1279,7 +1291,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
if (disabled || cliAppMenuDismissed) return null;
|
if (interactionDisabled || cliAppMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
||||||
@@ -1290,7 +1302,7 @@ export function ThreadComposer({
|
|||||||
start: caret - query.length - 1,
|
start: caret - query.length - 1,
|
||||||
end: caret,
|
end: caret,
|
||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
|
||||||
|
|
||||||
const availableSessionMentions = useMemo(
|
const availableSessionMentions = useMemo(
|
||||||
() => sessionMentionOptions(
|
() => sessionMentionOptions(
|
||||||
@@ -1580,7 +1592,7 @@ export function ThreadComposer({
|
|||||||
}, VOICE_ERROR_VISIBLE_MS);
|
}, VOICE_ERROR_VISIBLE_MS);
|
||||||
}, [clearVoiceErrorTimers, t]);
|
}, [clearVoiceErrorTimers, t]);
|
||||||
const voiceRecorder = useVoiceRecorder({
|
const voiceRecorder = useVoiceRecorder({
|
||||||
disabled,
|
disabled: interactionDisabled,
|
||||||
onClearError: clearInlineError,
|
onClearError: clearInlineError,
|
||||||
onError: setVoiceError,
|
onError: setVoiceError,
|
||||||
onTranscript: appendTranscription,
|
onTranscript: appendTranscription,
|
||||||
@@ -1714,7 +1726,7 @@ export function ThreadComposer({
|
|||||||
clearDraggedSession();
|
clearDraggedSession();
|
||||||
const preview = sessionDragPreview;
|
const preview = sessionDragPreview;
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
if (disabled) return true;
|
if (interactionDisabled) return true;
|
||||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||||
const mention = availableSessionMentions.find(
|
const mention = availableSessionMentions.find(
|
||||||
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||||
@@ -1732,11 +1744,17 @@ export function ThreadComposer({
|
|||||||
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
}, [
|
||||||
|
availableSessionMentions,
|
||||||
|
insertMentionCandidate,
|
||||||
|
interactionDisabled,
|
||||||
|
sessionDragPreview,
|
||||||
|
value.length,
|
||||||
|
]);
|
||||||
|
|
||||||
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||||
if (disabled) {
|
if (interactionDisabled) {
|
||||||
event.dataTransfer.dropEffect = "none";
|
event.dataTransfer.dropEffect = "none";
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -1765,7 +1783,7 @@ export function ThreadComposer({
|
|||||||
: { mention, start, end }
|
: { mention, start, end }
|
||||||
));
|
));
|
||||||
return true;
|
return true;
|
||||||
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDragPreview) return;
|
if (!sessionDragPreview) return;
|
||||||
@@ -2008,7 +2026,16 @@ export function ThreadComposer({
|
|||||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||||
const finalizeActiveTurn =
|
const finalizeActiveTurn =
|
||||||
slashLifecycle === "finalize_active_turn";
|
slashLifecycle === "finalize_active_turn";
|
||||||
onSend(
|
const finishSend = () => {
|
||||||
|
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||||
|
setQueuedPrompts([]);
|
||||||
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
|
// preview here without affecting the rendered message.
|
||||||
|
clear();
|
||||||
|
clearComposerText(!hasTouchPrimaryPointer);
|
||||||
|
onQuotedContextChange?.(null);
|
||||||
|
};
|
||||||
|
const result = onSend(
|
||||||
content,
|
content,
|
||||||
payload,
|
payload,
|
||||||
isSlashSideChannel
|
isSlashSideChannel
|
||||||
@@ -2019,13 +2046,19 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: options,
|
: options,
|
||||||
);
|
);
|
||||||
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
if (result instanceof Promise) {
|
||||||
setQueuedPrompts([]);
|
setSendPending(true);
|
||||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
void result
|
||||||
// preview here without affecting the rendered message.
|
.then((accepted) => {
|
||||||
clear();
|
if (accepted !== false) finishSend();
|
||||||
clearComposerText(!hasTouchPrimaryPointer);
|
})
|
||||||
onQuotedContextChange?.(null);
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to send message", error);
|
||||||
|
})
|
||||||
|
.finally(() => setSendPending(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result !== false) finishSend();
|
||||||
}, [
|
}, [
|
||||||
activeCliMentionApps,
|
activeCliMentionApps,
|
||||||
activeMcpPresetMentions,
|
activeMcpPresetMentions,
|
||||||
@@ -2168,7 +2201,7 @@ export function ThreadComposer({
|
|||||||
[removeChip],
|
[removeChip],
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachButtonDisabled = disabled || full;
|
const attachButtonDisabled = interactionDisabled || full;
|
||||||
const showVoiceButton = Boolean(onTranscribeAudio);
|
const showVoiceButton = Boolean(onTranscribeAudio);
|
||||||
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
||||||
time: voiceRecorder.elapsedLabel,
|
time: voiceRecorder.elapsedLabel,
|
||||||
@@ -2253,7 +2286,7 @@ export function ThreadComposer({
|
|||||||
isHero
|
isHero
|
||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
disabled && "opacity-60",
|
interactionDisabled && "opacity-60",
|
||||||
sessionDragPreview && "ring-1 ring-primary/25",
|
sessionDragPreview && "ring-1 ring-primary/25",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
@@ -2368,8 +2401,8 @@ export function ThreadComposer({
|
|||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={disabled}
|
disabled={interactionDisabled}
|
||||||
aria-label={t("thread.composer.inputAria")}
|
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputTextClasses,
|
inputTextClasses,
|
||||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||||
@@ -2383,7 +2416,7 @@ export function ThreadComposer({
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||||
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
||||||
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -2443,7 +2476,7 @@ export function ThreadComposer({
|
|||||||
) : workspaceScope && !workspaceControlsHidden ? (
|
) : workspaceScope && !workspaceControlsHidden ? (
|
||||||
<WorkspaceAccessMenu
|
<WorkspaceAccessMenu
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
disabled={disabled || workspaceScopeDisabled}
|
disabled={interactionDisabled || workspaceScopeDisabled}
|
||||||
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
@@ -2521,7 +2554,7 @@ export function ThreadComposer({
|
|||||||
<Button
|
<Button
|
||||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
|
||||||
aria-label={
|
aria-label={
|
||||||
showStopButton
|
showStopButton
|
||||||
? t("thread.composer.stop")
|
? t("thread.composer.stop")
|
||||||
@@ -2562,7 +2595,7 @@ export function ThreadComposer({
|
|||||||
<div className="composer-workspace-drawer-content">
|
<div className="composer-workspace-drawer-content">
|
||||||
<WorkspaceProjectPicker
|
<WorkspaceProjectPicker
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
|||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
|
hideSidebarToggle?: boolean;
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
actions?: ReactNode;
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
|||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
|
hideSidebarToggle = false,
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
|
hideTitle = false,
|
||||||
|
actions,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
@@ -54,6 +60,7 @@ export function ThreadHeader({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex min-w-0 items-center gap-2">
|
<div className="relative flex min-w-0 items-center gap-2">
|
||||||
|
{!hideSidebarToggle ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -66,7 +73,8 @@ export function ThreadHeader({
|
|||||||
>
|
>
|
||||||
<Menu className="h-3.5 w-3.5" />
|
<Menu className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{!minimal ? (
|
) : null}
|
||||||
|
{!minimal && !hideTitle ? (
|
||||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{promptNavigatorAction}
|
||||||
|
{actions}
|
||||||
{onTemporaryChatEnabledChange ? (
|
{onTemporaryChatEnabledChange ? (
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||||
@@ -311,9 +312,18 @@ interface ThreadShellProps {
|
|||||||
theme?: "light" | "dark";
|
theme?: "light" | "dark";
|
||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
|
hideSidebarToggle?: boolean;
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
|
hideHeaderTitle?: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
headerPortalTarget?: HTMLElement | null;
|
||||||
|
headerActive?: boolean;
|
||||||
|
composerPortalTarget?: HTMLElement | null;
|
||||||
|
composerActive?: boolean;
|
||||||
|
composerInputAriaLabel?: string;
|
||||||
|
emptyComposerVariant?: "hero" | "thread";
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
@@ -598,9 +608,18 @@ export function ThreadShell({
|
|||||||
theme = "light",
|
theme = "light",
|
||||||
onToggleTheme = () => {},
|
onToggleTheme = () => {},
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
|
hideSidebarToggle = false,
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
|
hideHeaderTitle = false,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
headerActions,
|
||||||
|
headerPortalTarget,
|
||||||
|
headerActive = true,
|
||||||
|
composerPortalTarget,
|
||||||
|
composerActive = true,
|
||||||
|
composerInputAriaLabel,
|
||||||
|
emptyComposerVariant = "hero",
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
@@ -843,6 +862,7 @@ export function ThreadShell({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||||
|
const composerVariant = showHeroComposer ? emptyComposerVariant : "thread";
|
||||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||||
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
||||||
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
||||||
@@ -1261,7 +1281,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleWelcomeSend = useCallback(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
if (booting) return;
|
if (booting) return false;
|
||||||
setBooting(true);
|
setBooting(true);
|
||||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
@@ -1270,12 +1290,13 @@ export function ThreadShell({
|
|||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (localModelPreset) {
|
if (localModelPreset) {
|
||||||
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
||||||
}
|
}
|
||||||
setPendingFirstTargetChatId(newId);
|
setPendingFirstTargetChatId(newId);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
||||||
);
|
);
|
||||||
@@ -1404,9 +1425,10 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
|
inputAriaLabel={composerInputAriaLabel}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
composerVariant === "hero"
|
||||||
? t("thread.composer.placeholderHero")
|
? t("thread.composer.placeholderHero")
|
||||||
: t("thread.composer.placeholderThread")
|
: t("thread.composer.placeholderThread")
|
||||||
}
|
}
|
||||||
@@ -1420,7 +1442,7 @@ export function ThreadShell({
|
|||||||
modelNeedsSetup={modelBadge.needsSetup}
|
modelNeedsSetup={modelBadge.needsSetup}
|
||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={composerVariant}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={availableSlashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
@@ -1448,6 +1470,7 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
disabled={booting}
|
disabled={booting}
|
||||||
|
inputAriaLabel={composerInputAriaLabel}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
booting
|
booting
|
||||||
@@ -1507,18 +1530,18 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
const threadHeader = !hideHeader ? (
|
||||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{!hideHeader ? (
|
|
||||||
<ThreadHeader
|
<ThreadHeader
|
||||||
title={title}
|
title={title}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||||
|
hideSidebarToggle={hideSidebarToggle}
|
||||||
hostChromeTitleInset={hostChromeTitleInset}
|
hostChromeTitleInset={hostChromeTitleInset}
|
||||||
hideThemeButton={hideThemeButton}
|
hideThemeButton={hideThemeButton}
|
||||||
|
hideTitle={hideHeaderTitle}
|
||||||
|
actions={headerActions}
|
||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
sessionInfoAction={sessionInfoAction}
|
sessionInfoAction={sessionInfoAction}
|
||||||
@@ -1528,7 +1551,12 @@ export function ThreadShell({
|
|||||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{headerPortalTarget === undefined ? threadHeader : null}
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||||
>
|
>
|
||||||
@@ -1538,7 +1566,7 @@ export function ThreadShell({
|
|||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composerPortalTarget === undefined ? composer : null}
|
||||||
activeTurnId={viewportTurnId}
|
activeTurnId={viewportTurnId}
|
||||||
activeTurnStartedHere={activeTurnStartedHere}
|
activeTurnStartedHere={activeTurnStartedHere}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
@@ -1558,6 +1586,19 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
</FilePreviewAvailabilityProvider>
|
</FilePreviewAvailabilityProvider>
|
||||||
</div>
|
</div>
|
||||||
|
{headerPortalTarget && headerActive
|
||||||
|
? createPortal(threadHeader, headerPortalTarget)
|
||||||
|
: null}
|
||||||
|
{composerPortalTarget ? createPortal(
|
||||||
|
<div
|
||||||
|
hidden={!composerActive}
|
||||||
|
aria-hidden={!composerActive}
|
||||||
|
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||||
|
>
|
||||||
|
{composer}
|
||||||
|
</div>,
|
||||||
|
composerPortalTarget,
|
||||||
|
) : null}
|
||||||
{filePreviewPath && historyKey ? (
|
{filePreviewPath && historyKey ? (
|
||||||
<FilePreviewPanel
|
<FilePreviewPanel
|
||||||
sessionKey={historyKey}
|
sessionKey={historyKey}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer: ReactNode;
|
composer?: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
activeTurnId?: string | null;
|
activeTurnId?: string | null;
|
||||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
|||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
const NEAR_TOP_PX = 96;
|
const NEAR_TOP_PX = 96;
|
||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
|
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
? forkBoundaryMessageCount - hiddenMessageCount
|
? forkBoundaryMessageCount - hiddenMessageCount
|
||||||
: null;
|
: null;
|
||||||
|
const hasComposer = composer !== null && composer !== undefined;
|
||||||
const scrollButtonBottom =
|
const scrollButtonBottom =
|
||||||
keyboardInsetBottom
|
keyboardInsetBottom
|
||||||
+ (composerDockHeight > 0
|
+ (composerDockHeight > 0
|
||||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
: hasComposer
|
||||||
|
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||||
|
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||||
const scrollViewportStyle =
|
const scrollViewportStyle =
|
||||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||||
|
|
||||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||||
data-layout={hasMessages ? "thread" : "hero"}
|
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-layout mx-auto grid min-h-full w-full",
|
"thread-layout mx-auto grid min-h-full w-full",
|
||||||
hasMessages
|
hasMessages
|
||||||
@@ -699,11 +703,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||||
|
hasComposer && "sm:items-end sm:pb-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{emptyState}
|
{emptyState}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasComposer ? (
|
||||||
<div
|
<div
|
||||||
ref={composerDockRef}
|
ref={composerDockRef}
|
||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
@@ -746,11 +756,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasComposer ? (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -34,6 +34,14 @@ import {
|
|||||||
shortWorkspacePath,
|
shortWorkspacePath,
|
||||||
} from "@/lib/workspace";
|
} from "@/lib/workspace";
|
||||||
|
|
||||||
|
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
|
||||||
|
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
|
||||||
|
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
|
||||||
|
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
|
||||||
|
if (normalized.startsWith("/Users/")) return macPlaceholder;
|
||||||
|
return "/home/name/project";
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceProjectPicker({
|
export function WorkspaceProjectPicker({
|
||||||
isHero,
|
isHero,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
|
|||||||
const [pathDraft, setPathDraft] = useState("");
|
const [pathDraft, setPathDraft] = useState("");
|
||||||
const [pathError, setPathError] = useState<string | null>(null);
|
const [pathError, setPathError] = useState<string | null>(null);
|
||||||
const [pickingFolder, setPickingFolder] = useState(false);
|
const [pickingFolder, setPickingFolder] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pathErrorId = useId();
|
||||||
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
||||||
const projectLabel = currentProjectScope
|
const projectLabel = currentProjectScope
|
||||||
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
||||||
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
|
|||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && visible && !disabled) setOpen(true);
|
if (!error || !visible || disabled) return;
|
||||||
|
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [disabled, error, visible]);
|
}, [disabled, error, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !error) return;
|
||||||
|
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [error, open]);
|
||||||
|
|
||||||
const applyProjectPath = useCallback(
|
const applyProjectPath = useCallback(
|
||||||
(projectPath: string, projectName?: string) => {
|
(projectPath: string, projectName?: string) => {
|
||||||
const base = scope ?? defaultScope;
|
const base = scope ?? defaultScope;
|
||||||
@@ -129,6 +148,7 @@ export function WorkspaceProjectPicker({
|
|||||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || pickingFolder}
|
disabled={disabled || pickingFolder}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={pathInputRef}
|
||||||
value={pathDraft}
|
value={pathDraft}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPathDraft(event.target.value);
|
setPathDraft(event.target.value);
|
||||||
setPathError(null);
|
setPathError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t("workspace.dialog.manualPlaceholder")}
|
placeholder={workspacePathPlaceholder(
|
||||||
|
defaultScope.project_path,
|
||||||
|
t("workspace.dialog.manualPlaceholder"),
|
||||||
|
)}
|
||||||
aria-label={t("workspace.dialog.manual")}
|
aria-label={t("workspace.dialog.manual")}
|
||||||
|
aria-invalid={pathError || error ? true : undefined}
|
||||||
|
aria-describedby={pathError || error ? pathErrorId : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
||||||
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
||||||
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{pathError || error ? (
|
{pathError || error ? (
|
||||||
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
|
<p
|
||||||
|
id={pathErrorId}
|
||||||
|
role="alert"
|
||||||
|
className="px-1 text-[11.5px] font-medium text-destructive"
|
||||||
|
>
|
||||||
{pathError ?? error}
|
{pathError ?? error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{!compact && error && !open ? (
|
||||||
|
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { Circle } from "lucide-react";
|
import { ChevronRight, Circle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const menuItemClassName =
|
const menuItemClassName =
|
||||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||||
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(menuItemClassName, inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
floatingSurfaceClassName,
|
||||||
|
floatingSurfaceMotionClassName,
|
||||||
|
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -123,5 +159,8 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,817 @@
|
|||||||
|
import {
|
||||||
|
Columns2,
|
||||||
|
Grid2X2,
|
||||||
|
PanelLeft,
|
||||||
|
PanelsTopLeft,
|
||||||
|
Plus,
|
||||||
|
Rows2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
createWorkbenchLayoutGeometry,
|
||||||
|
type EffectiveWorkbenchLayout,
|
||||||
|
resizeHandleRatio,
|
||||||
|
resizeHandleStyle,
|
||||||
|
splitRatioBounds,
|
||||||
|
type WorkbenchResizeHandle,
|
||||||
|
} from "@/components/workbench/workbench-layout";
|
||||||
|
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||||
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface WorkbenchPane {
|
||||||
|
key: string;
|
||||||
|
reactKey?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneRenderContext {
|
||||||
|
active: boolean;
|
||||||
|
headerPortalTarget: HTMLElement | null | undefined;
|
||||||
|
composerPortalTarget: HTMLElement | null | undefined;
|
||||||
|
headerActions: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneWorkbenchProps {
|
||||||
|
panes: WorkbenchPane[];
|
||||||
|
activePaneKey: string;
|
||||||
|
layout: WorkbenchLayout;
|
||||||
|
chrome?: boolean;
|
||||||
|
showLayoutControl: boolean;
|
||||||
|
addPaneDisabled?: boolean;
|
||||||
|
onActivatePane: (key: string) => void;
|
||||||
|
onAddPane: () => void;
|
||||||
|
onLayoutChange: (layout: WorkbenchLayout) => void;
|
||||||
|
onPaneOrderChange: (paneKeys: string[]) => void;
|
||||||
|
splitRatios?: number[];
|
||||||
|
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||||
|
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_MOTION_DURATION_MS = 260;
|
||||||
|
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
||||||
|
const EMPTY_SPLIT_RATIOS: number[] = [];
|
||||||
|
const IGNORE_SPLIT_RATIO_CHANGE = () => {};
|
||||||
|
|
||||||
|
const LAYOUT_CONTROLS: Array<{
|
||||||
|
icon: LucideIcon;
|
||||||
|
layout: WorkbenchLayout;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ icon: Columns2, layout: "columns", label: "Columns" },
|
||||||
|
{ icon: Rows2, layout: "rows", label: "Rows" },
|
||||||
|
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
||||||
|
{ icon: PanelsTopLeft, layout: "bsp", label: "BSP" },
|
||||||
|
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPaneAction(target: EventTarget | null): boolean {
|
||||||
|
return target instanceof Element
|
||||||
|
&& target.closest("[data-workbench-pane-action]") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePaneToSlot(
|
||||||
|
paneKeys: readonly string[],
|
||||||
|
paneKey: string,
|
||||||
|
targetKey: string,
|
||||||
|
): string[] {
|
||||||
|
const fromIndex = paneKeys.indexOf(paneKey);
|
||||||
|
const targetIndex = paneKeys.indexOf(targetKey);
|
||||||
|
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return [...paneKeys];
|
||||||
|
const next = paneKeys.filter((key) => key !== paneKey);
|
||||||
|
next.splice(targetIndex, 0, paneKey);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneSlotAtPoint(
|
||||||
|
rects: readonly DOMRect[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): number | null {
|
||||||
|
for (const [index, rect] of rects.entries()) {
|
||||||
|
if (
|
||||||
|
x >= rect.left
|
||||||
|
&& x <= rect.right
|
||||||
|
&& y >= rect.top
|
||||||
|
&& y <= rect.bottom
|
||||||
|
) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneInDirection(
|
||||||
|
rects: ReadonlyMap<string, DOMRect>,
|
||||||
|
paneKey: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
): string | null {
|
||||||
|
const source = rects.get(paneKey);
|
||||||
|
if (!source) return null;
|
||||||
|
const sourceX = source.left + source.width / 2;
|
||||||
|
const sourceY = source.top + source.height / 2;
|
||||||
|
let best: { key: string; score: number } | null = null;
|
||||||
|
for (const [key, rect] of rects) {
|
||||||
|
if (key === paneKey) continue;
|
||||||
|
const deltaX = rect.left + rect.width / 2 - sourceX;
|
||||||
|
const deltaY = rect.top + rect.height / 2 - sourceY;
|
||||||
|
const primary = direction === "left"
|
||||||
|
? -deltaX
|
||||||
|
: direction === "right"
|
||||||
|
? deltaX
|
||||||
|
: direction === "up"
|
||||||
|
? -deltaY
|
||||||
|
: deltaY;
|
||||||
|
if (primary <= 1) continue;
|
||||||
|
const cross = direction === "left" || direction === "right"
|
||||||
|
? Math.abs(deltaY)
|
||||||
|
: Math.abs(deltaX);
|
||||||
|
const score = primary + cross * 0.35;
|
||||||
|
if (!best || score < best.score) best = { key, score };
|
||||||
|
}
|
||||||
|
return best?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderIconButton({
|
||||||
|
disabled,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaneWorkbench({
|
||||||
|
panes,
|
||||||
|
activePaneKey,
|
||||||
|
layout,
|
||||||
|
chrome = true,
|
||||||
|
showLayoutControl,
|
||||||
|
addPaneDisabled = false,
|
||||||
|
onActivatePane,
|
||||||
|
onAddPane,
|
||||||
|
onLayoutChange,
|
||||||
|
onPaneOrderChange,
|
||||||
|
splitRatios = EMPTY_SPLIT_RATIOS,
|
||||||
|
onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE,
|
||||||
|
renderPane,
|
||||||
|
}: PaneWorkbenchProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const compact = useMediaQuery("(max-width: 767px)");
|
||||||
|
const effectiveLayout: EffectiveWorkbenchLayout = compact ? "compact" : layout;
|
||||||
|
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||||
|
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||||
|
const animationsRef = useRef(new Map<string, Animation>());
|
||||||
|
const sourceSplitRatiosKey = splitRatios.join("\u0000");
|
||||||
|
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
|
||||||
|
const previewSplitRatiosRef = useRef(splitRatios);
|
||||||
|
const resizeGestureRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
handle: WorkbenchResizeHandle;
|
||||||
|
changed: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [resizingRatioIndex, setResizingRatioIndex] = useState<number | null>(null);
|
||||||
|
const sourcePaneOrder = useMemo(
|
||||||
|
() => panes.map((pane) => pane.key),
|
||||||
|
[panes],
|
||||||
|
);
|
||||||
|
const sourcePaneOrderKey = sourcePaneOrder.join("\u0000");
|
||||||
|
const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder);
|
||||||
|
const previewPaneKeysRef = useRef(sourcePaneOrder);
|
||||||
|
const dragGestureRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
paneKey: string;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
slotRects: DOMRect[];
|
||||||
|
started: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [draggingPaneKey, setDraggingPaneKey] = useState<string | null>(null);
|
||||||
|
const displayedPanes = useMemo(() => {
|
||||||
|
const byKey = new Map(panes.map((pane) => [pane.key, pane]));
|
||||||
|
return [
|
||||||
|
...previewPaneKeys.map((key) => byKey.get(key)).filter(
|
||||||
|
(pane): pane is WorkbenchPane => pane !== undefined,
|
||||||
|
),
|
||||||
|
...panes.filter((pane) => !previewPaneKeys.includes(pane.key)),
|
||||||
|
];
|
||||||
|
}, [panes, previewPaneKeys]);
|
||||||
|
const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragGestureRef.current) return;
|
||||||
|
previewPaneKeysRef.current = sourcePaneOrder;
|
||||||
|
setPreviewPaneKeys(sourcePaneOrder);
|
||||||
|
}, [sourcePaneOrder, sourcePaneOrderKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizeGestureRef.current) return;
|
||||||
|
previewSplitRatiosRef.current = splitRatios;
|
||||||
|
setPreviewSplitRatios(splitRatios);
|
||||||
|
}, [sourceSplitRatiosKey, splitRatios]);
|
||||||
|
|
||||||
|
const measurePanes = useCallback(() => {
|
||||||
|
const rects = new Map<string, DOMRect>();
|
||||||
|
for (const [key, element] of paneRefs.current) {
|
||||||
|
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const captureLayout = useCallback(() => {
|
||||||
|
pendingRectsRef.current = measurePanes();
|
||||||
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
|
animationsRef.current.clear();
|
||||||
|
}, [measurePanes]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||||
|
pendingRectsRef.current = null;
|
||||||
|
const nextRects = measurePanes();
|
||||||
|
const reduceMotion = typeof window.matchMedia === "function"
|
||||||
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
if (!reduceMotion) {
|
||||||
|
for (const [key, nextRect] of nextRects) {
|
||||||
|
const previousRect = previousRects.get(key);
|
||||||
|
const element = paneRefs.current.get(key);
|
||||||
|
if (!element) continue;
|
||||||
|
if (!previousRect) {
|
||||||
|
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
||||||
|
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 180,
|
||||||
|
easing: LAYOUT_MOTION_EASING,
|
||||||
|
fill: "backwards",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
animationsRef.current.set(key, animation);
|
||||||
|
animation.addEventListener("finish", () => {
|
||||||
|
if (animationsRef.current.get(key) === animation) {
|
||||||
|
animationsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
||||||
|
const deltaX = previousRect.left - nextRect.left;
|
||||||
|
const deltaY = previousRect.top - nextRect.top;
|
||||||
|
const scaleX = previousRect.width / nextRect.width;
|
||||||
|
const scaleY = previousRect.height / nextRect.height;
|
||||||
|
if (
|
||||||
|
Math.abs(deltaX) < 0.5
|
||||||
|
&& Math.abs(deltaY) < 0.5
|
||||||
|
&& Math.abs(scaleX - 1) < 0.002
|
||||||
|
&& Math.abs(scaleY - 1) < 0.002
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof element.animate !== "function") continue;
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
||||||
|
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: LAYOUT_MOTION_DURATION_MS,
|
||||||
|
easing: LAYOUT_MOTION_EASING,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
animationsRef.current.set(key, animation);
|
||||||
|
animation.addEventListener("finish", () => {
|
||||||
|
if (animationsRef.current.get(key) === animation) {
|
||||||
|
animationsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastRectsRef.current = nextRects;
|
||||||
|
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
||||||
|
if (key === activePaneKey || isPaneAction(target)) return;
|
||||||
|
captureLayout();
|
||||||
|
onActivatePane(key);
|
||||||
|
}, [activePaneKey, captureLayout, onActivatePane]);
|
||||||
|
|
||||||
|
const handlePanePointerDown = useCallback((
|
||||||
|
key: string,
|
||||||
|
event: ReactPointerEvent<HTMLElement>,
|
||||||
|
) => {
|
||||||
|
activatePane(key, event.target);
|
||||||
|
}, [activatePane]);
|
||||||
|
|
||||||
|
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
||||||
|
activatePane(key, event.target);
|
||||||
|
}, [activatePane]);
|
||||||
|
|
||||||
|
const applyPaneOrder = useCallback((paneKey: string, targetKey: string) => {
|
||||||
|
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||||
|
if (next.every((key, index) => previewPaneKeysRef.current[index] === key)) return;
|
||||||
|
captureLayout();
|
||||||
|
previewPaneKeysRef.current = next;
|
||||||
|
setPreviewPaneKeys(next);
|
||||||
|
onPaneOrderChange(next);
|
||||||
|
}, [captureLayout, onPaneOrderChange]);
|
||||||
|
|
||||||
|
const handleMovePointerDown = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
event: ReactPointerEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
if (event.button !== 0 || compact || panes.length < 2) return;
|
||||||
|
const paneRects = measurePanes();
|
||||||
|
dragGestureRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
paneKey,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
slotRects: previewPaneKeysRef.current
|
||||||
|
.map((key) => paneRects.get(key))
|
||||||
|
.filter((rect): rect is DOMRect => rect !== undefined),
|
||||||
|
started: false,
|
||||||
|
};
|
||||||
|
setDraggingPaneKey(paneKey);
|
||||||
|
}, [compact, measurePanes, panes.length]);
|
||||||
|
|
||||||
|
const handleMovePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = dragGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
const distance = Math.hypot(
|
||||||
|
event.clientX - gesture.startX,
|
||||||
|
event.clientY - gesture.startY,
|
||||||
|
);
|
||||||
|
if (!gesture.started) {
|
||||||
|
if (distance < 8) return;
|
||||||
|
gesture.started = true;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const targetIndex = paneSlotAtPoint(gesture.slotRects, event.clientX, event.clientY);
|
||||||
|
const targetKey = targetIndex === null
|
||||||
|
? null
|
||||||
|
: previewPaneKeysRef.current[targetIndex] ?? null;
|
||||||
|
if (targetKey) applyPaneOrder(gesture.paneKey, targetKey);
|
||||||
|
}, [applyPaneOrder]);
|
||||||
|
|
||||||
|
const finishMoveGesture = useCallback(() => {
|
||||||
|
if (!dragGestureRef.current) return;
|
||||||
|
dragGestureRef.current = null;
|
||||||
|
setDraggingPaneKey(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingPaneKey) return;
|
||||||
|
|
||||||
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = dragGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
if ((event.buttons & 1) === 0) {
|
||||||
|
finishMoveGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleMovePointerMove(event);
|
||||||
|
};
|
||||||
|
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||||
|
if (dragGestureRef.current?.pointerId === event.pointerId) finishMoveGesture();
|
||||||
|
};
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousCursor = root.style.cursor;
|
||||||
|
root.style.cursor = "grabbing";
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.addEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.addEventListener("blur", finishMoveGesture);
|
||||||
|
return () => {
|
||||||
|
root.style.cursor = previousCursor;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.removeEventListener("blur", finishMoveGesture);
|
||||||
|
};
|
||||||
|
}, [draggingPaneKey, finishMoveGesture, handleMovePointerMove]);
|
||||||
|
|
||||||
|
const handleMoveKeyDown = useCallback((
|
||||||
|
paneKey: string,
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
const direction = (() => {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowLeft": return "left";
|
||||||
|
case "ArrowRight": return "right";
|
||||||
|
case "ArrowUp": return "up";
|
||||||
|
case "ArrowDown": return "down";
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!direction) return;
|
||||||
|
const targetKey = paneInDirection(measurePanes(), paneKey, direction);
|
||||||
|
if (!targetKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
|
||||||
|
captureLayout();
|
||||||
|
previewPaneKeysRef.current = next;
|
||||||
|
setPreviewPaneKeys(next);
|
||||||
|
onPaneOrderChange(next);
|
||||||
|
}, [captureLayout, measurePanes, onPaneOrderChange]);
|
||||||
|
|
||||||
|
const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry(
|
||||||
|
effectiveLayout,
|
||||||
|
panes.length,
|
||||||
|
previewSplitRatios,
|
||||||
|
), [effectiveLayout, panes.length, previewSplitRatios]);
|
||||||
|
|
||||||
|
const handleResizePointerDown = useCallback((
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
event: ReactPointerEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
if (event.button !== 0 || compact) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
previewSplitRatiosRef.current = layoutGeometry.splitRatios;
|
||||||
|
setPreviewSplitRatios(layoutGeometry.splitRatios);
|
||||||
|
resizeGestureRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
handle,
|
||||||
|
changed: false,
|
||||||
|
};
|
||||||
|
setResizingRatioIndex(handle.ratioIndex);
|
||||||
|
}, [compact, layoutGeometry.splitRatios]);
|
||||||
|
|
||||||
|
const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
const grid = gridRef.current;
|
||||||
|
if (!gesture || !grid || gesture.pointerId !== event.pointerId) return;
|
||||||
|
const rect = grid.getBoundingClientRect();
|
||||||
|
const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height;
|
||||||
|
if (axisExtent <= 0) return;
|
||||||
|
const normalizedPosition = gesture.handle.axis === "vertical"
|
||||||
|
? (event.clientX - rect.left) / rect.width
|
||||||
|
: (event.clientY - rect.top) / rect.height;
|
||||||
|
const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent);
|
||||||
|
const current = previewSplitRatiosRef.current;
|
||||||
|
if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = [...current];
|
||||||
|
next[gesture.handle.ratioIndex] = ratio;
|
||||||
|
gesture.changed = true;
|
||||||
|
previewSplitRatiosRef.current = next;
|
||||||
|
setPreviewSplitRatios(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finishResizeGesture = useCallback(() => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
if (!gesture) return;
|
||||||
|
resizeGestureRef.current = null;
|
||||||
|
setResizingRatioIndex(null);
|
||||||
|
if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]);
|
||||||
|
}, [onSplitRatiosChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizingRatioIndex === null) return;
|
||||||
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||||
|
const gesture = resizeGestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
if ((event.buttons & 1) === 0) {
|
||||||
|
finishResizeGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleResizePointerMove(event);
|
||||||
|
};
|
||||||
|
const handlePointerEnd = (event: globalThis.PointerEvent) => {
|
||||||
|
if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture();
|
||||||
|
};
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousCursor = root.style.cursor;
|
||||||
|
const previousUserSelect = root.style.userSelect;
|
||||||
|
root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical"
|
||||||
|
? "col-resize"
|
||||||
|
: "row-resize";
|
||||||
|
root.style.userSelect = "none";
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.addEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.addEventListener("blur", finishResizeGesture);
|
||||||
|
return () => {
|
||||||
|
root.style.cursor = previousCursor;
|
||||||
|
root.style.userSelect = previousUserSelect;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerEnd);
|
||||||
|
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||||
|
window.removeEventListener("blur", finishResizeGesture);
|
||||||
|
};
|
||||||
|
}, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]);
|
||||||
|
|
||||||
|
const handleResizeKeyDown = useCallback((
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp";
|
||||||
|
const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown";
|
||||||
|
if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return;
|
||||||
|
const rect = gridRef.current?.getBoundingClientRect();
|
||||||
|
const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height;
|
||||||
|
const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000);
|
||||||
|
const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||||
|
const step = event.shiftKey ? 0.1 : 0.03;
|
||||||
|
const nextRatio = event.key === "Home"
|
||||||
|
? bounds.min
|
||||||
|
: event.key === "End"
|
||||||
|
? bounds.max
|
||||||
|
: Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step)));
|
||||||
|
event.preventDefault();
|
||||||
|
const next = [...layoutGeometry.splitRatios];
|
||||||
|
next[handle.ratioIndex] = nextRatio;
|
||||||
|
previewSplitRatiosRef.current = next;
|
||||||
|
setPreviewSplitRatios(next);
|
||||||
|
onSplitRatiosChange(next);
|
||||||
|
}, [layoutGeometry.splitRatios, onSplitRatiosChange]);
|
||||||
|
|
||||||
|
const gridStyle = layoutGeometry.gridStyle;
|
||||||
|
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
||||||
|
?? LAYOUT_CONTROLS[0];
|
||||||
|
const headerActions = chrome ? (
|
||||||
|
<div
|
||||||
|
data-workbench-pane-action
|
||||||
|
className="host-no-drag flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{showLayoutControl ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("workbench.layout", {
|
||||||
|
defaultValue: "Pane layout",
|
||||||
|
})}
|
||||||
|
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={layout}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = value as WorkbenchLayout;
|
||||||
|
if (next === layout) return;
|
||||||
|
captureLayout();
|
||||||
|
onLayoutChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LAYOUT_CONTROLS.map((control) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={control.layout}
|
||||||
|
value={control.layout}
|
||||||
|
>
|
||||||
|
<control.icon aria-hidden />
|
||||||
|
{t(`workbench.layouts.${control.layout}`, {
|
||||||
|
defaultValue: control.label,
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<HeaderIconButton
|
||||||
|
disabled={addPaneDisabled}
|
||||||
|
icon={Plus}
|
||||||
|
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
||||||
|
onClick={() => {
|
||||||
|
captureLayout();
|
||||||
|
onAddPane();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
||||||
|
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
||||||
|
>
|
||||||
|
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||||
|
{chrome ? (
|
||||||
|
<header className="shrink-0 bg-background">
|
||||||
|
<div
|
||||||
|
ref={setHeaderPortalTarget}
|
||||||
|
data-testid="workbench-header-host"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className="relative min-h-0 flex-1 bg-background">
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
data-testid="pane-grid"
|
||||||
|
data-layout={effectiveLayout}
|
||||||
|
className={cn(
|
||||||
|
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
||||||
|
chrome && panes.length > 1 && "gap-px bg-border/55",
|
||||||
|
)}
|
||||||
|
style={gridStyle}
|
||||||
|
>
|
||||||
|
{displayedPanes.map((pane, index) => {
|
||||||
|
const active = pane.key === activePaneKey;
|
||||||
|
const hidden = effectiveLayout === "compact" && !active;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={pane.reactKey ?? pane.key}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) paneRefs.current.set(pane.key, element);
|
||||||
|
else paneRefs.current.delete(pane.key);
|
||||||
|
}}
|
||||||
|
hidden={hidden}
|
||||||
|
aria-label={pane.title}
|
||||||
|
data-active={active ? "true" : "false"}
|
||||||
|
data-dragging={draggingPaneKey === pane.key ? "true" : undefined}
|
||||||
|
data-testid={`workbench-pane-${pane.key}`}
|
||||||
|
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
||||||
|
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
||||||
|
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
||||||
|
style={layoutGeometry.paneStyles[index]}
|
||||||
|
>
|
||||||
|
{renderPane(pane, {
|
||||||
|
active,
|
||||||
|
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
||||||
|
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
||||||
|
headerActions,
|
||||||
|
})}
|
||||||
|
{chrome && panes.length > 1 && !compact ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
aria-hidden={active ? undefined : true}
|
||||||
|
aria-label={active ? t("workbench.movePane", {
|
||||||
|
defaultValue: "Move {{title}} pane",
|
||||||
|
title: pane.title,
|
||||||
|
}) : undefined}
|
||||||
|
aria-keyshortcuts={active ? "ArrowLeft ArrowRight ArrowUp ArrowDown" : undefined}
|
||||||
|
data-workbench-pane-action
|
||||||
|
data-testid={`pane-move-handle-${pane.key}`}
|
||||||
|
onPointerDown={(event) => handleMovePointerDown(pane.key, event)}
|
||||||
|
onKeyDown={(event) => handleMoveKeyDown(pane.key, event)}
|
||||||
|
className={cn(
|
||||||
|
"group host-no-drag absolute bottom-0 left-1/2 z-30 flex h-6 w-12 -translate-x-1/2 touch-none items-center justify-center rounded-full",
|
||||||
|
"cursor-grab focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:cursor-grabbing",
|
||||||
|
"transition-opacity duration-100 motion-reduce:transition-none",
|
||||||
|
active ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"h-[3px] w-9 rounded-full bg-foreground/35 transition-colors duration-100 motion-reduce:transition-none",
|
||||||
|
draggingPaneKey === pane.key
|
||||||
|
? "bg-foreground/65"
|
||||||
|
: "group-hover:bg-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{active ? (
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{t("workbench.movePaneHint", {
|
||||||
|
defaultValue: "Drag to move · Arrow keys also work",
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map(
|
||||||
|
(handle, index) => {
|
||||||
|
const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
|
||||||
|
const vertical = handle.axis === "vertical";
|
||||||
|
const bounds = splitRatioBounds(handle, 1000);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${handle.axis}-${handle.ratioIndex}`}
|
||||||
|
type="button"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={handle.axis}
|
||||||
|
aria-label={t("workbench.resizePaneBoundary", {
|
||||||
|
defaultValue: "Resize pane boundary {{index}}",
|
||||||
|
index: index + 1,
|
||||||
|
})}
|
||||||
|
aria-valuemin={Math.round(bounds.min * 100)}
|
||||||
|
aria-valuemax={Math.round(bounds.max * 100)}
|
||||||
|
aria-valuenow={Math.round(ratio * 100)}
|
||||||
|
aria-keyshortcuts={vertical
|
||||||
|
? "ArrowLeft ArrowRight Home End"
|
||||||
|
: "ArrowUp ArrowDown Home End"}
|
||||||
|
data-workbench-pane-action
|
||||||
|
data-workbench-resize-handle={handle.ratioIndex}
|
||||||
|
onPointerDown={(event) => handleResizePointerDown(handle, event)}
|
||||||
|
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
|
||||||
|
className={cn(
|
||||||
|
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||||
|
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
|
||||||
|
)}
|
||||||
|
style={resizeHandleStyle(handle)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
|
||||||
|
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
|
||||||
|
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
|
||||||
|
vertical
|
||||||
|
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
|
||||||
|
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chrome ? (
|
||||||
|
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||||
|
<div
|
||||||
|
ref={setComposerPortalTarget}
|
||||||
|
data-testid="workbench-composer-host"
|
||||||
|
className="mx-auto w-full max-w-[58rem]"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</TooltipProvider>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||||
|
|
||||||
|
export type EffectiveWorkbenchLayout = WorkbenchLayout | "compact";
|
||||||
|
|
||||||
|
interface PaneCell {
|
||||||
|
xStart: number;
|
||||||
|
xEnd: number;
|
||||||
|
yStart: number;
|
||||||
|
yEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchResizeHandle {
|
||||||
|
axis: "horizontal" | "vertical";
|
||||||
|
ratioIndex: number;
|
||||||
|
position: number;
|
||||||
|
crossStart: number;
|
||||||
|
crossEnd: number;
|
||||||
|
localStart: number;
|
||||||
|
localEnd: number;
|
||||||
|
beforeUnitCount: number;
|
||||||
|
afterUnitCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchLayoutGeometry {
|
||||||
|
gridStyle: CSSProperties;
|
||||||
|
paneStyles: Array<CSSProperties | undefined>;
|
||||||
|
resizeHandles: WorkbenchResizeHandle[];
|
||||||
|
splitRatios: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_RATIO = 0.05;
|
||||||
|
const MAX_RATIO = 0.95;
|
||||||
|
const MIN_PANE_EXTENT_PX = 160;
|
||||||
|
const MAIN_PANE_RATIO = 1.65 / 2.65;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioAt(
|
||||||
|
ratios: readonly number[],
|
||||||
|
index: number,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
const value = ratios[index];
|
||||||
|
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tracksTemplate(tracks: readonly Track[]): string {
|
||||||
|
return tracks
|
||||||
|
.map((track) => {
|
||||||
|
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
|
||||||
|
return `minmax(0, ${weight}fr)`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequentialTracks(
|
||||||
|
count: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
ratioOffset: number,
|
||||||
|
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
|
||||||
|
const tracks: Track[] = [];
|
||||||
|
const handles: WorkbenchResizeHandle[] = [];
|
||||||
|
const resolvedRatios: number[] = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < count - 1; index += 1) {
|
||||||
|
const remainingPaneCount = count - index;
|
||||||
|
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
|
||||||
|
const end = start + (1 - start) * ratio;
|
||||||
|
tracks.push({ start, end });
|
||||||
|
handles.push({
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex: ratioOffset + index,
|
||||||
|
position: end,
|
||||||
|
crossStart: 0,
|
||||||
|
crossEnd: 1,
|
||||||
|
localStart: start,
|
||||||
|
localEnd: 1,
|
||||||
|
beforeUnitCount: 1,
|
||||||
|
afterUnitCount: remainingPaneCount - 1,
|
||||||
|
});
|
||||||
|
resolvedRatios.push(ratio);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
tracks.push({ start, end: 1 });
|
||||||
|
return { tracks, handles, resolvedRatios };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellStyle(
|
||||||
|
columnStart: number,
|
||||||
|
columnEnd: number,
|
||||||
|
rowStart: number,
|
||||||
|
rowEnd: number,
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
gridColumn: `${columnStart} / ${columnEnd}`,
|
||||||
|
gridRow: `${rowStart} / ${rowEnd}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function singlePaneGeometry(): WorkbenchLayoutGeometry {
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr)",
|
||||||
|
gridTemplateRows: "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
paneStyles: [undefined],
|
||||||
|
resizeHandles: [],
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnsGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const split = sequentialTracks(paneCount, ratios, 0);
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(split.tracks),
|
||||||
|
gridTemplateRows: "minmax(0, 1fr)",
|
||||||
|
},
|
||||||
|
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
|
||||||
|
resizeHandles: split.handles,
|
||||||
|
splitRatios: split.resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const split = sequentialTracks(paneCount, ratios, 0);
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr)",
|
||||||
|
gridTemplateRows: tracksTemplate(split.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
|
||||||
|
resizeHandles: split.handles.map((handle) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal",
|
||||||
|
})),
|
||||||
|
splitRatios: split.resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const columnCount = Math.ceil(Math.sqrt(paneCount));
|
||||||
|
const rowCount = Math.ceil(paneCount / columnCount);
|
||||||
|
const columns = sequentialTracks(columnCount, ratios, 0);
|
||||||
|
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
|
||||||
|
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
|
||||||
|
column: index % columnCount,
|
||||||
|
row: Math.floor(index / columnCount),
|
||||||
|
}));
|
||||||
|
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
|
||||||
|
...handle,
|
||||||
|
beforeUnitCount: boundaryIndex + 1,
|
||||||
|
afterUnitCount: columnCount - boundaryIndex - 1,
|
||||||
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||||
|
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal" as const,
|
||||||
|
beforeUnitCount: boundaryIndex + 1,
|
||||||
|
afterUnitCount: rowCount - boundaryIndex - 1,
|
||||||
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(columns.tracks),
|
||||||
|
gridTemplateRows: tracksTemplate(rows.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: paneCells.map((cell) => cellStyle(
|
||||||
|
cell.column + 1,
|
||||||
|
cell.column + 2,
|
||||||
|
cell.row + 1,
|
||||||
|
cell.row + 2,
|
||||||
|
)),
|
||||||
|
resizeHandles: [...verticalHandles, ...horizontalHandles],
|
||||||
|
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainStackGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
|
||||||
|
const stack = sequentialTracks(paneCount - 1, ratios, 1);
|
||||||
|
const verticalHandle: WorkbenchResizeHandle = {
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex: 0,
|
||||||
|
position: columnRatio,
|
||||||
|
crossStart: 0,
|
||||||
|
crossEnd: 1,
|
||||||
|
localStart: 0,
|
||||||
|
localEnd: 1,
|
||||||
|
beforeUnitCount: 1,
|
||||||
|
afterUnitCount: 1,
|
||||||
|
};
|
||||||
|
const stackHandles = stack.handles.map((handle) => ({
|
||||||
|
...handle,
|
||||||
|
axis: "horizontal" as const,
|
||||||
|
crossStart: columnRatio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate([
|
||||||
|
{ start: 0, end: columnRatio },
|
||||||
|
{ start: columnRatio, end: 1 },
|
||||||
|
]),
|
||||||
|
gridTemplateRows: tracksTemplate(stack.tracks),
|
||||||
|
},
|
||||||
|
paneStyles: [
|
||||||
|
cellStyle(1, 2, 1, paneCount),
|
||||||
|
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
|
||||||
|
],
|
||||||
|
resizeHandles: [verticalHandle, ...stackHandles],
|
||||||
|
splitRatios: [columnRatio, ...stack.resolvedRatios],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBoundaries(values: readonly number[]): number[] {
|
||||||
|
return Array.from(new Set(values.map((value) => value.toFixed(8))))
|
||||||
|
.map(Number)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryIndex(boundaries: readonly number[], value: number): number {
|
||||||
|
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
|
||||||
|
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
|
||||||
|
? [cell.xStart, cell.xEnd]
|
||||||
|
: [cell.yStart, cell.yEnd]));
|
||||||
|
return Math.max(1, boundaries.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bspGeometry(
|
||||||
|
paneCount: number,
|
||||||
|
ratios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
|
||||||
|
const handles: WorkbenchResizeHandle[] = [];
|
||||||
|
const resolvedRatios: number[] = [];
|
||||||
|
|
||||||
|
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
|
||||||
|
const leaf = cells.pop();
|
||||||
|
if (!leaf) break;
|
||||||
|
const ratioIndex = paneIndex - 1;
|
||||||
|
const ratio = ratioAt(ratios, ratioIndex, 0.5);
|
||||||
|
resolvedRatios.push(ratio);
|
||||||
|
if (paneIndex % 2 === 1) {
|
||||||
|
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
|
||||||
|
cells.push(
|
||||||
|
{ ...leaf, xEnd: position },
|
||||||
|
{ ...leaf, xStart: position },
|
||||||
|
);
|
||||||
|
handles.push({
|
||||||
|
axis: "vertical",
|
||||||
|
ratioIndex,
|
||||||
|
position,
|
||||||
|
crossStart: leaf.yStart,
|
||||||
|
crossEnd: leaf.yEnd,
|
||||||
|
localStart: leaf.xStart,
|
||||||
|
localEnd: leaf.xEnd,
|
||||||
|
beforeUnitCount: 0,
|
||||||
|
afterUnitCount: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
|
||||||
|
cells.push(
|
||||||
|
{ ...leaf, yEnd: position },
|
||||||
|
{ ...leaf, yStart: position },
|
||||||
|
);
|
||||||
|
handles.push({
|
||||||
|
axis: "horizontal",
|
||||||
|
ratioIndex,
|
||||||
|
position,
|
||||||
|
crossStart: leaf.xStart,
|
||||||
|
crossEnd: leaf.xEnd,
|
||||||
|
localStart: leaf.yStart,
|
||||||
|
localEnd: leaf.yEnd,
|
||||||
|
beforeUnitCount: 0,
|
||||||
|
afterUnitCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const handle of handles) {
|
||||||
|
if (handle.axis === "vertical") {
|
||||||
|
const beforeCells = cells.filter((cell) => (
|
||||||
|
cell.xStart >= handle.localStart
|
||||||
|
&& cell.xEnd <= handle.position + Number.EPSILON
|
||||||
|
&& cell.yStart >= handle.crossStart
|
||||||
|
&& cell.yEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
const afterCells = cells.filter((cell) => (
|
||||||
|
cell.xStart >= handle.position - Number.EPSILON
|
||||||
|
&& cell.xEnd <= handle.localEnd
|
||||||
|
&& cell.yStart >= handle.crossStart
|
||||||
|
&& cell.yEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||||
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||||
|
} else {
|
||||||
|
const beforeCells = cells.filter((cell) => (
|
||||||
|
cell.yStart >= handle.localStart
|
||||||
|
&& cell.yEnd <= handle.position + Number.EPSILON
|
||||||
|
&& cell.xStart >= handle.crossStart
|
||||||
|
&& cell.xEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
const afterCells = cells.filter((cell) => (
|
||||||
|
cell.yStart >= handle.position - Number.EPSILON
|
||||||
|
&& cell.yEnd <= handle.localEnd
|
||||||
|
&& cell.xStart >= handle.crossStart
|
||||||
|
&& cell.xEnd <= handle.crossEnd
|
||||||
|
));
|
||||||
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
||||||
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
|
||||||
|
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
|
||||||
|
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
|
||||||
|
start,
|
||||||
|
end: columnBoundaries[index + 1],
|
||||||
|
}));
|
||||||
|
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
|
||||||
|
start,
|
||||||
|
end: rowBoundaries[index + 1],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridStyle: {
|
||||||
|
gridTemplateColumns: tracksTemplate(columnTracks),
|
||||||
|
gridTemplateRows: tracksTemplate(rowTracks),
|
||||||
|
},
|
||||||
|
paneStyles: cells.map((cell) => cellStyle(
|
||||||
|
boundaryIndex(columnBoundaries, cell.xStart) + 1,
|
||||||
|
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
|
||||||
|
boundaryIndex(rowBoundaries, cell.yStart) + 1,
|
||||||
|
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
|
||||||
|
)),
|
||||||
|
resizeHandles: handles,
|
||||||
|
splitRatios: resolvedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkbenchLayoutGeometry(
|
||||||
|
layout: EffectiveWorkbenchLayout,
|
||||||
|
paneCount: number,
|
||||||
|
splitRatios: readonly number[],
|
||||||
|
): WorkbenchLayoutGeometry {
|
||||||
|
const count = Math.max(1, paneCount);
|
||||||
|
if (layout === "compact" || count === 1) return singlePaneGeometry();
|
||||||
|
switch (layout) {
|
||||||
|
case "columns":
|
||||||
|
return columnsGeometry(count, splitRatios);
|
||||||
|
case "rows":
|
||||||
|
return rowsGeometry(count, splitRatios);
|
||||||
|
case "grid":
|
||||||
|
return gridGeometry(count, splitRatios);
|
||||||
|
case "main-stack":
|
||||||
|
return mainStackGeometry(count, splitRatios);
|
||||||
|
case "bsp":
|
||||||
|
return bspGeometry(count, splitRatios);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRatioBounds(
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
axisExtentPx: number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const localExtentPx = Math.max(
|
||||||
|
1,
|
||||||
|
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
|
||||||
|
);
|
||||||
|
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
|
||||||
|
const paneExtentPx = Math.min(
|
||||||
|
MIN_PANE_EXTENT_PX,
|
||||||
|
localExtentPx * 0.8 / paneCount,
|
||||||
|
);
|
||||||
|
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
|
||||||
|
const max = Math.min(
|
||||||
|
MAX_RATIO,
|
||||||
|
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
|
||||||
|
);
|
||||||
|
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeHandleRatio(
|
||||||
|
handle: WorkbenchResizeHandle,
|
||||||
|
normalizedPosition: number,
|
||||||
|
axisExtentPx: number,
|
||||||
|
): number {
|
||||||
|
const localRatio = (normalizedPosition - handle.localStart)
|
||||||
|
/ Math.max(0.01, handle.localEnd - handle.localStart);
|
||||||
|
const bounds = splitRatioBounds(handle, axisExtentPx);
|
||||||
|
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
|
||||||
|
if (handle.axis === "vertical") {
|
||||||
|
return {
|
||||||
|
left: `${handle.position * 100}%`,
|
||||||
|
top: `${handle.crossStart * 100}%`,
|
||||||
|
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: `${handle.position * 100}%`,
|
||||||
|
left: `${handle.crossStart * 100}%`,
|
||||||
|
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import type {
|
||||||
|
WorkbenchLayout,
|
||||||
|
WorkbenchState,
|
||||||
|
WorkbenchTabState,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
WorkbenchLayout,
|
||||||
|
WorkbenchState,
|
||||||
|
WorkbenchTabState,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export const MAX_WORKBENCH_PANES = 4;
|
||||||
|
|
||||||
|
export const WORKBENCH_LAYOUTS = [
|
||||||
|
"columns",
|
||||||
|
"rows",
|
||||||
|
"grid",
|
||||||
|
"bsp",
|
||||||
|
"main-stack",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface WorkbenchTabMatch {
|
||||||
|
tabKey: string;
|
||||||
|
tab: WorkbenchTabState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderedWorkbenchTab extends WorkbenchTabMatch {
|
||||||
|
paneKeys: string[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
||||||
|
version: 1,
|
||||||
|
tabs: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isLayout(value: unknown): value is WorkbenchLayout {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueKeys(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return Array.from(new Set(
|
||||||
|
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const title = value.trim();
|
||||||
|
return title || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSplitRatios(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio))
|
||||||
|
.slice(0, MAX_WORKBENCH_PANES - 1)
|
||||||
|
.map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTab(value: unknown): WorkbenchTabState {
|
||||||
|
const candidate = value && typeof value === "object"
|
||||||
|
? value as Partial<WorkbenchTabState>
|
||||||
|
: {};
|
||||||
|
const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES);
|
||||||
|
const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys)
|
||||||
|
.filter((key) => paneKeys.includes(key));
|
||||||
|
const layoutPaneKeys = [
|
||||||
|
...requestedLayoutPaneKeys,
|
||||||
|
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
explicit: candidate.explicit === true,
|
||||||
|
title: normalizeTitle(candidate.title),
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
activePaneKey:
|
||||||
|
typeof candidate.activePaneKey === "string"
|
||||||
|
&& paneKeys.includes(candidate.activePaneKey)
|
||||||
|
? candidate.activePaneKey
|
||||||
|
: paneKeys[0] ?? "",
|
||||||
|
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||||
|
splitRatios: normalizeSplitRatios(candidate.splitRatios),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function standaloneTabKeyBase(paneKey: string): string {
|
||||||
|
return `tab:${paneKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableStandaloneTabKey(
|
||||||
|
tabs: Readonly<Record<string, WorkbenchTabState>>,
|
||||||
|
paneKey: string,
|
||||||
|
): string {
|
||||||
|
const base = standaloneTabKeyBase(paneKey);
|
||||||
|
if (!tabs[base]) return base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (tabs[`${base}:${suffix}`]) suffix += 1;
|
||||||
|
return `${base}:${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWorkbenchTab(
|
||||||
|
paneKey: string,
|
||||||
|
title: string | null = null,
|
||||||
|
): WorkbenchTabState {
|
||||||
|
return {
|
||||||
|
explicit: false,
|
||||||
|
title: normalizeTitle(title),
|
||||||
|
paneKeys: [paneKey],
|
||||||
|
layoutPaneKeys: [paneKey],
|
||||||
|
activePaneKey: paneKey,
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return EMPTY_WORKBENCH_STATE;
|
||||||
|
}
|
||||||
|
const parsed = raw as { version?: unknown; tabs?: unknown };
|
||||||
|
if (parsed.version !== 1 || !parsed.tabs
|
||||||
|
|| typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) {
|
||||||
|
return EMPTY_WORKBENCH_STATE;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: Object.fromEntries(
|
||||||
|
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
): WorkbenchTabState | null {
|
||||||
|
return state.tabs[tabKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workbenchTabForPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchTabMatch {
|
||||||
|
const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey));
|
||||||
|
if (match) return { tabKey: match[0], tab: match[1] };
|
||||||
|
return {
|
||||||
|
tabKey: availableStandaloneTabKey(state.tabs, paneKey),
|
||||||
|
tab: defaultWorkbenchTab(paneKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureWorkbenchPaneTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
paneKey: string,
|
||||||
|
title: string | null = null,
|
||||||
|
): WorkbenchState {
|
||||||
|
if (!paneKey || Object.values(state.tabs).some((tab) => tab.paneKeys.includes(paneKey))) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const tabKey = availableStandaloneTabKey(state.tabs, paneKey);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[tabKey]: defaultWorkbenchTab(paneKey, title),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
||||||
|
): WorkbenchState {
|
||||||
|
const current = state.tabs[tabKey];
|
||||||
|
if (!current) return state;
|
||||||
|
const next = update(current);
|
||||||
|
if (next === current) return state;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[tabKey]: next,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
return attachWorkbenchPane(state, tabKey, paneKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey
|
||||||
|
? { ...tab, activePaneKey: paneKey }
|
||||||
|
: tab
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.explicit ? tab : { ...tab, explicit: true }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tab = state.tabs[tabKey];
|
||||||
|
if (!tab || !tab.paneKeys.includes(paneKey)) return state;
|
||||||
|
if (tab.paneKeys.length === 1) {
|
||||||
|
return tab.explicit
|
||||||
|
? updateTab(state, tabKey, (current) => ({
|
||||||
|
...current,
|
||||||
|
explicit: false,
|
||||||
|
title: null,
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
}))
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = tab.paneKeys.indexOf(paneKey);
|
||||||
|
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
||||||
|
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||||
|
const nextTabKey = availableStandaloneTabKey(state.tabs, paneKey);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tabs: {
|
||||||
|
...state.tabs,
|
||||||
|
[tabKey]: {
|
||||||
|
...tab,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
activePaneKey: tab.activePaneKey === paneKey
|
||||||
|
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||||
|
: tab.activePaneKey,
|
||||||
|
splitRatios: [],
|
||||||
|
},
|
||||||
|
[nextTabKey]: defaultWorkbenchTab(paneKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dissolveWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tab = state.tabs[tabKey];
|
||||||
|
if (!tab) return state;
|
||||||
|
if (tab.paneKeys.length === 1) {
|
||||||
|
return tab.explicit
|
||||||
|
? updateTab(state, tabKey, (current) => ({
|
||||||
|
...current,
|
||||||
|
explicit: false,
|
||||||
|
title: null,
|
||||||
|
layout: "columns",
|
||||||
|
splitRatios: [],
|
||||||
|
}))
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
delete tabs[tabKey];
|
||||||
|
for (const paneKey of tab.paneKeys) {
|
||||||
|
const standaloneTabKey = availableStandaloneTabKey(tabs, paneKey);
|
||||||
|
tabs[standaloneTabKey] = defaultWorkbenchTab(paneKey);
|
||||||
|
}
|
||||||
|
return { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachWorkbenchPane(
|
||||||
|
state: WorkbenchState,
|
||||||
|
targetTabKey: string,
|
||||||
|
paneKey: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
if (!targetTabKey || !paneKey) return state;
|
||||||
|
const target = state.tabs[targetTabKey];
|
||||||
|
if (!target) return state;
|
||||||
|
|
||||||
|
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
||||||
|
tab.paneKeys.includes(paneKey)
|
||||||
|
));
|
||||||
|
const sourceTabKey = sourceEntry?.[0];
|
||||||
|
const sourceTab = sourceEntry?.[1];
|
||||||
|
if (sourceTabKey === targetTabKey) {
|
||||||
|
return focusWorkbenchPane(state, targetTabKey, paneKey);
|
||||||
|
}
|
||||||
|
if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = { ...state.tabs };
|
||||||
|
if (sourceTabKey && sourceTab) {
|
||||||
|
const index = sourceTab.paneKeys.indexOf(paneKey);
|
||||||
|
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
||||||
|
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey);
|
||||||
|
if (sourcePaneKeys.length === 0) {
|
||||||
|
delete tabs[sourceTabKey];
|
||||||
|
} else {
|
||||||
|
tabs[sourceTabKey] = {
|
||||||
|
...sourceTab,
|
||||||
|
paneKeys: sourcePaneKeys,
|
||||||
|
layoutPaneKeys: sourceLayoutPaneKeys,
|
||||||
|
activePaneKey: sourceTab.activePaneKey === paneKey
|
||||||
|
? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)]
|
||||||
|
: sourceTab.activePaneKey,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTarget = tabs[targetTabKey];
|
||||||
|
if (!nextTarget) return state;
|
||||||
|
const paneKeys = nextTarget.paneKeys.includes(paneKey)
|
||||||
|
? nextTarget.paneKeys
|
||||||
|
: [...nextTarget.paneKeys, paneKey];
|
||||||
|
const layoutPaneKeys = nextTarget.layoutPaneKeys.includes(paneKey)
|
||||||
|
? nextTarget.layoutPaneKeys
|
||||||
|
: [...nextTarget.layoutPaneKeys, paneKey];
|
||||||
|
tabs[targetTabKey] = {
|
||||||
|
...nextTarget,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys,
|
||||||
|
activePaneKey: paneKey,
|
||||||
|
splitRatios: [],
|
||||||
|
};
|
||||||
|
return { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameWorkbenchTab(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
title: string,
|
||||||
|
): WorkbenchState {
|
||||||
|
const normalized = normalizeTitle(title);
|
||||||
|
if (!normalized) return state;
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.title === normalized ? tab : { ...tab, title: normalized }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchLayout(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
layout: WorkbenchLayout,
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => (
|
||||||
|
tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchSplitRatios(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
splitRatios: readonly number[],
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => {
|
||||||
|
const normalized = normalizeSplitRatios(splitRatios);
|
||||||
|
return normalized.length === tab.splitRatios.length
|
||||||
|
&& normalized.every((ratio, index) => ratio === tab.splitRatios[index])
|
||||||
|
? tab
|
||||||
|
: { ...tab, splitRatios: normalized };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkbenchPaneLayoutOrder(
|
||||||
|
state: WorkbenchState,
|
||||||
|
tabKey: string,
|
||||||
|
paneKeys: readonly string[],
|
||||||
|
): WorkbenchState {
|
||||||
|
return updateTab(state, tabKey, (tab) => {
|
||||||
|
const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key));
|
||||||
|
const layoutPaneKeys = [
|
||||||
|
...requested,
|
||||||
|
...tab.paneKeys.filter((key) => !requested.includes(key)),
|
||||||
|
];
|
||||||
|
return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key)
|
||||||
|
? tab
|
||||||
|
: { ...tab, layoutPaneKeys };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileWorkbench(
|
||||||
|
state: WorkbenchState,
|
||||||
|
validKeys: ReadonlySet<string>,
|
||||||
|
): WorkbenchState {
|
||||||
|
const tabs: Record<string, WorkbenchTabState> = {};
|
||||||
|
const claimedPaneKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||||
|
const paneKeys = tab.paneKeys
|
||||||
|
.filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
|
||||||
|
.slice(0, MAX_WORKBENCH_PANES);
|
||||||
|
if (paneKeys.length === 0) continue;
|
||||||
|
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
|
||||||
|
tabs[tabKey] = {
|
||||||
|
...tab,
|
||||||
|
paneKeys,
|
||||||
|
layoutPaneKeys: [
|
||||||
|
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
|
||||||
|
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
|
||||||
|
],
|
||||||
|
activePaneKey: paneKeys.includes(tab.activePaneKey)
|
||||||
|
? tab.activePaneKey
|
||||||
|
: paneKeys[0],
|
||||||
|
splitRatios: paneKeys.length === tab.paneKeys.length
|
||||||
|
&& paneKeys.every((key, index) => key === tab.paneKeys[index])
|
||||||
|
? tab.splitRatios
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const paneKey of validKeys) {
|
||||||
|
if (claimedPaneKeys.has(paneKey)) continue;
|
||||||
|
const tabKey = availableStandaloneTabKey(tabs, paneKey);
|
||||||
|
tabs[tabKey] = defaultWorkbenchTab(paneKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(state.tabs) === JSON.stringify(tabs)
|
||||||
|
? state
|
||||||
|
: { version: 1, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderWorkbenchTabs(
|
||||||
|
state: WorkbenchState,
|
||||||
|
orderedSessionKeys: readonly string[],
|
||||||
|
updatedAtByKey: ReadonlyMap<string, string | null | undefined>,
|
||||||
|
): OrderedWorkbenchTab[] {
|
||||||
|
const rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
|
||||||
|
const validKeys = new Set(orderedSessionKeys);
|
||||||
|
const reconciled = reconcileWorkbench(state, validKeys);
|
||||||
|
const tabs = Object.entries(reconciled.tabs).map(([tabKey, tab]) => {
|
||||||
|
const paneKeys = tab.paneKeys
|
||||||
|
.filter((key) => validKeys.has(key))
|
||||||
|
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
|
||||||
|
const updatedAt = paneKeys.reduce<string | null>((latest, paneKey) => {
|
||||||
|
const candidate = updatedAtByKey.get(paneKey) ?? null;
|
||||||
|
return dateToTime(candidate) > dateToTime(latest) ? candidate : latest;
|
||||||
|
}, null);
|
||||||
|
return { tabKey, tab, paneKeys, updatedAt };
|
||||||
|
});
|
||||||
|
|
||||||
|
return tabs.sort((left, right) => {
|
||||||
|
const updateOrder = dateToTime(right.updatedAt) - dateToTime(left.updatedAt);
|
||||||
|
if (updateOrder !== 0) return updateOrder;
|
||||||
|
const leftRank = Math.min(...left.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||||
|
const rightRank = Math.min(...right.paneKeys.map((key) => rank.get(key) ?? Infinity));
|
||||||
|
return leftRank - rightRank;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToTime(value: string | null | undefined): number {
|
||||||
|
const timestamp = Date.parse(value ?? "");
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
--radius: 0.4375rem;
|
--radius: 0.4375rem;
|
||||||
--sidebar: 40 8% 96.8%;
|
--sidebar: 40 8% 96.8%;
|
||||||
--sidebar-foreground: 0 0% 3.9%;
|
--sidebar-foreground: 0 0% 3.9%;
|
||||||
|
--sidebar-selected: 40 1% 89.4%;
|
||||||
--sidebar-accent: 0 0% 95.8%;
|
--sidebar-accent: 0 0% 95.8%;
|
||||||
--sidebar-accent-foreground: 0 0% 9%;
|
--sidebar-accent-foreground: 0 0% 9%;
|
||||||
--sidebar-border: 40 8% 90.5%;
|
--sidebar-border: 40 8% 90.5%;
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
--temporary-border: 27 96% 61%;
|
--temporary-border: 27 96% 61%;
|
||||||
--sidebar: var(--card);
|
--sidebar: var(--card);
|
||||||
--sidebar-foreground: 0 0% 98%;
|
--sidebar-foreground: 0 0% 98%;
|
||||||
|
--sidebar-selected: 0 0% 29.8%;
|
||||||
--sidebar-accent: var(--background);
|
--sidebar-accent: var(--background);
|
||||||
--sidebar-accent-foreground: 0 0% 98%;
|
--sidebar-accent-foreground: 0 0% 98%;
|
||||||
--sidebar-border: var(--border);
|
--sidebar-border: var(--border);
|
||||||
@@ -360,6 +362,9 @@
|
|||||||
.thread-layout[data-layout="thread"] {
|
.thread-layout[data-layout="thread"] {
|
||||||
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
||||||
}
|
}
|
||||||
|
.thread-layout[data-layout="external"] {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.thread-layout[data-layout="hero"] {
|
.thread-layout[data-layout="hero"] {
|
||||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||||
@@ -564,6 +569,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-pane {
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||||
@keyframes goal-shell-glow-breathe {
|
@keyframes goal-shell-glow-breathe {
|
||||||
0%,
|
0%,
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ import {
|
|||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
|
import {
|
||||||
|
closeReasoningStream,
|
||||||
|
filterCoveredFileEditToolEvents,
|
||||||
|
finalizeStreamedTurn,
|
||||||
|
findActiveAssistantPlaceholderIndex,
|
||||||
|
findFileEditTraceIndex,
|
||||||
|
findStreamingAssistantIndex,
|
||||||
|
isReasoningOnlyPlaceholder,
|
||||||
|
matchesTurn,
|
||||||
|
mergeFileEdits,
|
||||||
|
pruneReasoningOnlyPlaceholders,
|
||||||
|
replaceMessageAt,
|
||||||
|
stampLastAssistantCompletion,
|
||||||
|
stripCoveredFileEditToolHintsFromMessages,
|
||||||
|
turnFieldsFromEvent,
|
||||||
|
} from "@/lib/thread-event-projection";
|
||||||
|
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@@ -19,11 +36,8 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
ToolProgressEvent,
|
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIFileEdit,
|
|
||||||
UIMessage,
|
UIMessage,
|
||||||
UITurnPhase,
|
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -41,54 +55,9 @@ type PendingStreamEvent =
|
|||||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||||
|
|
||||||
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
|
||||||
|
|
||||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
||||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
function turnFieldsFromEvent(
|
|
||||||
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
|
|
||||||
fallbackPhase?: UITurnPhase,
|
|
||||||
): UIMessageTurnFields {
|
|
||||||
const fields: UIMessageTurnFields = {};
|
|
||||||
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
|
|
||||||
fields.turnId = ev.turn_id;
|
|
||||||
}
|
|
||||||
const phase = ev.turn_phase ?? fallbackPhase;
|
|
||||||
if (phase) fields.turnPhase = phase;
|
|
||||||
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
|
|
||||||
fields.turnSeq = ev.turn_seq;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
|
|
||||||
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
|
||||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
|
||||||
* receive later delta segments. */
|
|
||||||
function findStreamingAssistantIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
closedStreamIds: ReadonlySet<string>,
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): number | null {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const m = prev[i];
|
|
||||||
if (m.kind === "trace") continue;
|
|
||||||
if (
|
|
||||||
m.role === "assistant"
|
|
||||||
&& m.isStreaming
|
|
||||||
&& !closedStreamIds.has(m.id)
|
|
||||||
&& matchesTurn(m, turn)
|
|
||||||
) return i;
|
|
||||||
if (m.role === "user") break;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
||||||
*
|
*
|
||||||
@@ -152,102 +121,6 @@ function attachReasoningChunk(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the most recent assistant placeholder that an incoming answer
|
|
||||||
* delta should adopt instead of spawning a parallel row. We look for an
|
|
||||||
* empty-content assistant turn that is still marked ``isStreaming`` —
|
|
||||||
* typically created earlier by ``reasoning_delta``. Anything else means
|
|
||||||
* the model already produced an answer in a previous turn, so the new
|
|
||||||
* delta belongs in a fresh row.
|
|
||||||
*/
|
|
||||||
function findActiveAssistantPlaceholderIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): number | null {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (!last) return null;
|
|
||||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
|
||||||
if (last.content.length > 0) return null;
|
|
||||||
if (!last.isStreaming) return null;
|
|
||||||
if (!matchesTurn(last, turn)) return null;
|
|
||||||
return prev.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
|
||||||
const next = prev.slice();
|
|
||||||
next[index] = message;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the active reasoning stream segment, if any. Idempotent: a
|
|
||||||
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
|
|
||||||
*/
|
|
||||||
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = prev[i];
|
|
||||||
if (!candidate.reasoningStreaming) continue;
|
|
||||||
const latencyMs =
|
|
||||||
candidate.latencyMs === undefined
|
|
||||||
&& Number.isFinite(candidate.createdAt)
|
|
||||||
&& candidate.createdAt > 1_000_000_000_000
|
|
||||||
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
|
|
||||||
: candidate.latencyMs;
|
|
||||||
const merged: UIMessage = {
|
|
||||||
...candidate,
|
|
||||||
reasoningStreaming: false,
|
|
||||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
|
||||||
};
|
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
|
|
||||||
return (
|
|
||||||
message.role === "assistant"
|
|
||||||
&& message.kind !== "trace"
|
|
||||||
&& message.content.trim().length === 0
|
|
||||||
&& !!message.reasoning
|
|
||||||
&& !message.reasoningStreaming
|
|
||||||
&& !message.media?.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToolTrace(message: UIMessage | undefined): boolean {
|
|
||||||
return message?.kind === "trace";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
|
||||||
return prev.filter((message, index) => {
|
|
||||||
if (!isReasoningOnlyPlaceholder(message)) return true;
|
|
||||||
// A reasoning-only assistant row immediately followed by tool traces is
|
|
||||||
// the live equivalent of a persisted assistant tool-call message with
|
|
||||||
// empty content, reasoning_content, and tool_calls. Keep it so live render
|
|
||||||
// and history replay stay isomorphic.
|
|
||||||
return isToolTrace(prev[index + 1]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stampLastAssistantCompletion(
|
|
||||||
prev: UIMessage[],
|
|
||||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
|
||||||
turnId?: string,
|
|
||||||
): UIMessage[] {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const m = prev[i];
|
|
||||||
if (
|
|
||||||
m.role === "assistant"
|
|
||||||
&& m.kind !== "trace"
|
|
||||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
|
||||||
) {
|
|
||||||
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absorbCompleteAssistantMessage(
|
function absorbCompleteAssistantMessage(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||||
@@ -275,193 +148,6 @@ function absorbCompleteAssistantMessage(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
|
||||||
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
|
||||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
|
||||||
return `${edit.tool}|${edit.path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
|
||||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
|
||||||
return fileEditKey(edit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
|
|
||||||
const fn = (event as { function?: { name?: unknown } }).function;
|
|
||||||
const name = typeof event.name === "string"
|
|
||||||
? event.name
|
|
||||||
: typeof fn?.name === "string"
|
|
||||||
? fn.name
|
|
||||||
: "";
|
|
||||||
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
||||||
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
|
|
||||||
return `${callId}|${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
|
|
||||||
const key = toolEventFileEditKey(event);
|
|
||||||
if (!key) return false;
|
|
||||||
return messages.some((message) =>
|
|
||||||
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterCoveredFileEditToolEvents(
|
|
||||||
messages: UIMessage[],
|
|
||||||
events: ToolProgressEvent[],
|
|
||||||
): ToolProgressEvent[] {
|
|
||||||
if (events.length === 0) return events;
|
|
||||||
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
|
|
||||||
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
|
|
||||||
const events = message.toolEvents ?? [];
|
|
||||||
if (!events.length || incomingKeys.size === 0) return message;
|
|
||||||
|
|
||||||
const removedTraceLines = new Set<string>();
|
|
||||||
const keptEvents: ToolProgressEvent[] = [];
|
|
||||||
let changed = false;
|
|
||||||
for (const event of events) {
|
|
||||||
const key = toolEventFileEditKey(event);
|
|
||||||
if (key && incomingKeys.has(key)) {
|
|
||||||
changed = true;
|
|
||||||
for (const line of toolTraceLinesFromEvents([event])) {
|
|
||||||
removedTraceLines.add(line);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
keptEvents.push(event);
|
|
||||||
}
|
|
||||||
if (!changed) return message;
|
|
||||||
|
|
||||||
const previousTraces = message.traces?.length
|
|
||||||
? message.traces
|
|
||||||
: message.content
|
|
||||||
? [message.content]
|
|
||||||
: [];
|
|
||||||
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
traces: nextTraces,
|
|
||||||
content: nextTraces[nextTraces.length - 1] ?? "",
|
|
||||||
toolEvents: keptEvents.length ? keptEvents : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function traceMessageIsEmpty(message: UIMessage): boolean {
|
|
||||||
const traces = message.traces;
|
|
||||||
const hasTrace = traces?.length
|
|
||||||
? traces.some((line) => line.trim().length > 0)
|
|
||||||
: (message.content ?? "").trim().length > 0;
|
|
||||||
return (
|
|
||||||
message.kind === "trace"
|
|
||||||
&& !hasTrace
|
|
||||||
&& !message.toolEvents?.length
|
|
||||||
&& !message.fileEdits?.length
|
|
||||||
&& !message.media?.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCoveredFileEditToolHintsFromMessages(
|
|
||||||
messages: UIMessage[],
|
|
||||||
edits: UIFileEdit[],
|
|
||||||
turn: UIMessageTurnFields,
|
|
||||||
): UIMessage[] {
|
|
||||||
if (edits.length === 0) return messages;
|
|
||||||
let next = messages;
|
|
||||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = next[i];
|
|
||||||
if (candidate.role === "user") break;
|
|
||||||
if (candidate.kind !== "trace") continue;
|
|
||||||
if (!matchesTurn(candidate, turn)) continue;
|
|
||||||
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
|
|
||||||
if (cleaned === candidate) continue;
|
|
||||||
if (next === messages) next = [...messages];
|
|
||||||
if (traceMessageIsEmpty(cleaned)) {
|
|
||||||
next.splice(i, 1);
|
|
||||||
} else {
|
|
||||||
next[i] = cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
|
||||||
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
|
||||||
const inferredStatus =
|
|
||||||
edit.phase === "error"
|
|
||||||
? "error"
|
|
||||||
: edit.phase === "end"
|
|
||||||
? "done"
|
|
||||||
: "editing";
|
|
||||||
const normalized: UIFileEdit = {
|
|
||||||
...edit,
|
|
||||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
|
||||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
|
||||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
|
||||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
|
||||||
? edit.status
|
|
||||||
: inferredStatus,
|
|
||||||
};
|
|
||||||
if (edit.pending && !edit.path) normalized.pending = true;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
|
||||||
const next = [...(existing ?? [])];
|
|
||||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
|
||||||
for (const raw of incoming) {
|
|
||||||
const edit = normalizeFileEdit(raw);
|
|
||||||
if (!edit) continue;
|
|
||||||
const key = fileEditKey(edit);
|
|
||||||
let existingIndex = indexByKey.get(key);
|
|
||||||
if (existingIndex === undefined && edit.path) {
|
|
||||||
const eventKey = fileEditToolEventKey(edit);
|
|
||||||
const pendingIndex = next.findIndex((existing) =>
|
|
||||||
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
|
|
||||||
);
|
|
||||||
if (pendingIndex >= 0) existingIndex = pendingIndex;
|
|
||||||
}
|
|
||||||
if (existingIndex === undefined) {
|
|
||||||
indexByKey.set(key, next.length);
|
|
||||||
next.push(edit);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const merged = { ...next[existingIndex], ...edit };
|
|
||||||
if (edit.path && !edit.pending) delete merged.pending;
|
|
||||||
next[existingIndex] = merged;
|
|
||||||
indexByKey.set(key, existingIndex);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFileEditTraceIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
segmentId: string | null,
|
|
||||||
incoming: UIFileEdit[],
|
|
||||||
): number | null {
|
|
||||||
const incomingKeys = new Set(incoming.map(fileEditKey));
|
|
||||||
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = prev[i];
|
|
||||||
if (candidate.role === "user") break;
|
|
||||||
if (candidate.kind !== "trace") continue;
|
|
||||||
if (segmentId && candidate.activitySegmentId === segmentId) return i;
|
|
||||||
for (const existing of candidate.fileEdits ?? []) {
|
|
||||||
if (
|
|
||||||
incomingKeys.has(fileEditKey(existing))
|
|
||||||
|| (
|
|
||||||
!existing.path
|
|
||||||
&& existing.pending
|
|
||||||
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
|
|
||||||
)
|
|
||||||
) return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||||
@@ -507,17 +193,6 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
|||||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeStreamedTurn(
|
|
||||||
prev: UIMessage[],
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): UIMessage[] {
|
|
||||||
return prev.map((m) =>
|
|
||||||
m.isStreaming && matchesTurn(m, turn)
|
|
||||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||||
}
|
}
|
||||||
@@ -1104,7 +779,7 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
if (ev.event === "reasoning_end") {
|
if (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev));
|
setMessages((prev) => closeReasoningStream(prev, Date.now()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,12 +849,15 @@ export function useNanobotStream(
|
|||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
setMessages((prev) => closeReasoningStream(
|
||||||
|
attachReasoningChunk(
|
||||||
prev,
|
prev,
|
||||||
line,
|
line,
|
||||||
{ ensure: ensureActivitySegmentId },
|
{ ensure: ensureActivitySegmentId },
|
||||||
turnFieldsFromEvent(ev, "reasoning"),
|
turnFieldsFromEvent(ev, "reasoning"),
|
||||||
)));
|
),
|
||||||
|
Date.now(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
|
|||||||
@@ -257,13 +257,13 @@ export function useSessions(): {
|
|||||||
|
|
||||||
const deleteChat = useCallback(
|
const deleteChat = useCallback(
|
||||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
const result = await apiDeleteSession(tokenRef.current, key, options);
|
const result = await apiDeleteSession(client, key, options);
|
||||||
if (!result.deleted) return result;
|
if (!result.deleted) return result;
|
||||||
optimisticKeysRef.current.delete(key);
|
optimisticKeysRef.current.delete(key);
|
||||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSessionAutomations = useCallback(async (key: string) => {
|
const getSessionAutomations = useCallback(async (key: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import { normalizeWorkbenchState } from "@/components/workbench/workbench-model";
|
||||||
import { fetchSidebarState } from "@/lib/api";
|
import { fetchSidebarState } from "@/lib/api";
|
||||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
|||||||
project_name_overrides: {},
|
project_name_overrides: {},
|
||||||
tags_by_key: {},
|
tags_by_key: {},
|
||||||
collapsed_groups: {},
|
collapsed_groups: {},
|
||||||
|
workbench: { version: 1, tabs: {} },
|
||||||
view: {
|
view: {
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
show_previews: false,
|
show_previews: false,
|
||||||
@@ -93,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
|||||||
project_name_overrides: stringMap(value.project_name_overrides),
|
project_name_overrides: stringMap(value.project_name_overrides),
|
||||||
tags_by_key: tagsMap(value.tags_by_key),
|
tags_by_key: tagsMap(value.tags_by_key),
|
||||||
collapsed_groups: boolMap(value.collapsed_groups),
|
collapsed_groups: boolMap(value.collapsed_groups),
|
||||||
|
workbench: normalizeWorkbenchState(value.workbench),
|
||||||
view: {
|
view: {
|
||||||
density,
|
density,
|
||||||
show_previews: Boolean(view.show_previews),
|
show_previews: Boolean(view.show_previews),
|
||||||
@@ -144,6 +147,10 @@ export function useSidebarState(
|
|||||||
const { client, token } = useClient();
|
const { client, token } = useClient();
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||||
|
const connectionOpenRef = useRef(client.status === "open");
|
||||||
|
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
|
||||||
|
const persistenceInFlightRef = useRef(false);
|
||||||
|
const flushPersistenceRef = useRef<() => void>(() => {});
|
||||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
@@ -171,14 +178,63 @@ export function useSidebarState(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const flushPersistence = useCallback(() => {
|
||||||
|
if (
|
||||||
|
persistenceInFlightRef.current
|
||||||
|
|| !connectionOpenRef.current
|
||||||
|
|| pendingPersistenceRef.current === null
|
||||||
|
) return;
|
||||||
|
|
||||||
|
const next = pendingPersistenceRef.current;
|
||||||
|
pendingPersistenceRef.current = null;
|
||||||
|
persistenceInFlightRef.current = true;
|
||||||
|
void client.setSidebarState(next)
|
||||||
|
.then((saved) => {
|
||||||
|
persistenceInFlightRef.current = false;
|
||||||
|
if (pendingPersistenceRef.current === null) {
|
||||||
|
const canonical = normalizeSidebarState(saved);
|
||||||
|
stateRef.current = canonical;
|
||||||
|
setState(canonical);
|
||||||
|
}
|
||||||
|
flushPersistenceRef.current();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
persistenceInFlightRef.current = false;
|
||||||
|
if (pendingPersistenceRef.current === null) {
|
||||||
|
pendingPersistenceRef.current = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [client]);
|
||||||
|
flushPersistenceRef.current = flushPersistence;
|
||||||
|
|
||||||
|
const persist = useCallback((next: SidebarStatePayload) => {
|
||||||
|
pendingPersistenceRef.current = next;
|
||||||
|
flushPersistence();
|
||||||
|
}, [flushPersistence]);
|
||||||
|
|
||||||
|
useEffect(() => client.onStatus((status) => {
|
||||||
|
connectionOpenRef.current = status === "open";
|
||||||
|
if (status === "open") flushPersistence();
|
||||||
|
}), [client, flushPersistence]);
|
||||||
|
|
||||||
|
useEffect(() => client.onSidebarStateUpdate((incoming) => {
|
||||||
|
if (
|
||||||
|
persistenceInFlightRef.current
|
||||||
|
|| pendingPersistenceRef.current !== null
|
||||||
|
) return;
|
||||||
|
const loaded = normalizeSidebarState(incoming);
|
||||||
|
stateRef.current = loaded;
|
||||||
|
setState(loaded);
|
||||||
|
}), [client]);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||||
const next = normalizeSidebarState(updater(stateRef.current));
|
const next = normalizeSidebarState(updater(stateRef.current));
|
||||||
stateRef.current = next;
|
stateRef.current = next;
|
||||||
setState(next);
|
setState(next);
|
||||||
client.setSidebarState(next);
|
persist(next);
|
||||||
},
|
},
|
||||||
[client],
|
[persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
|
|||||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||||
export type VoiceRecorderErrorKey =
|
export type VoiceRecorderErrorKey =
|
||||||
| "failed"
|
| "failed"
|
||||||
|
| "insecureContext"
|
||||||
| "noDevice"
|
| "noDevice"
|
||||||
| "notConfigured"
|
| "notConfigured"
|
||||||
| "permission"
|
| "permission"
|
||||||
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
|
|||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||||
onClearError();
|
onClearError();
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
onError("insecureContext");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mediaDevices = navigator.mediaDevices;
|
const mediaDevices = navigator.mediaDevices;
|
||||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user