mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61537a8c5 | ||
|
|
612e714479 | ||
|
|
a739185740 | ||
|
|
9859e02215 | ||
|
|
95160a304d |
@@ -173,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test WebUI
|
- name: Test WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run test:coverage
|
run: bun run test
|
||||||
|
|
||||||
- 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, temporary chats, 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, 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,10 +250,9 @@ 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 and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
- configure providers, chat channels, Apps, Skills, and 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).
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ Important files:
|
|||||||
| Shell execution | `nanobot/agent/tools/shell.py` |
|
| Shell execution | `nanobot/agent/tools/shell.py` |
|
||||||
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
|
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
|
||||||
| Web search/fetch | `nanobot/agent/tools/web.py` |
|
| Web search/fetch | `nanobot/agent/tools/web.py` |
|
||||||
|
| Browser and computer use | `nanobot/agent/tools/browser_tool.py`, `nanobot/agent/tools/computer_use.py` |
|
||||||
| MCP tools | `nanobot/agent/tools/mcp.py` |
|
| MCP tools | `nanobot/agent/tools/mcp.py` |
|
||||||
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
|
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
|
||||||
| Image generation | `nanobot/agent/tools/image_generation.py` |
|
| Image generation | `nanobot/agent/tools/image_generation.py` |
|
||||||
@@ -188,7 +189,7 @@ Security-sensitive code paths include:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
|
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
|
||||||
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
|
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
|
||||||
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py` |
|
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py`, `nanobot/agent/tools/computer_use_backends/browser_playwright.py` |
|
||||||
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
|
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
|
||||||
| Channel access control | channel config in `nanobot/channels/*.py` |
|
| Channel access control | channel config in `nanobot/channels/*.py` |
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ the focused guides first and come back here for exact fields and defaults.
|
|||||||
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
|
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
|
||||||
| Configure voice transcription | [Transcription Settings](#transcription-settings) |
|
| Configure voice transcription | [Transcription Settings](#transcription-settings) |
|
||||||
| Tune channel defaults | [Channel Settings](#channel-settings) |
|
| Tune channel defaults | [Channel Settings](#channel-settings) |
|
||||||
|
| Enable browser or desktop control | [Browser and Computer Use](#browser-and-computer-use) |
|
||||||
| Configure web search and fetch | [Web Tools](#web-tools) |
|
| Configure web search and fetch | [Web Tools](#web-tools) |
|
||||||
| Enable image generation | [Image Generation](#image-generation) |
|
| Enable image generation | [Image Generation](#image-generation) |
|
||||||
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
|
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
|
||||||
@@ -1670,6 +1671,62 @@ When a channel `send()` raises, nanobot retries at the channel-manager layer. By
|
|||||||
>
|
>
|
||||||
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
|
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
|
||||||
|
|
||||||
|
## Browser and Computer Use
|
||||||
|
|
||||||
|
Browser and desktop control are optional and disabled by default. Install their runtime first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'nanobot-ai[computer-use]'
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
For normal web interaction, prefer the DOM-based `browser` tool. It gives the model numbered
|
||||||
|
element references and works without vision. Use `computer_use` when the model must see and act
|
||||||
|
on pixels; its `desktop` backend controls the real local machine, while its `browser` backend
|
||||||
|
controls an isolated Playwright page.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"browser": {
|
||||||
|
"enable": true,
|
||||||
|
"allowedDomains": ["example.com"]
|
||||||
|
},
|
||||||
|
"computerUse": {
|
||||||
|
"enable": false,
|
||||||
|
"backend": "desktop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `tools.browser.enable` | `false` | Register the DOM-based `browser` tool |
|
||||||
|
| `tools.browser.allowedDomains` | `[]` | Optional top-level navigation allowlist; entries include subdomains |
|
||||||
|
| `tools.browser.includeScreenshot` | `false` | Attach a screenshot after browser actions |
|
||||||
|
| `tools.browser.maxSessions` | `8` | Maximum retained browser sessions; least-recently-used state is closed first |
|
||||||
|
| `tools.computerUse.enable` | `false` | Register pixel-based `computer_use` |
|
||||||
|
| `tools.computerUse.backend` | `"desktop"` | `"desktop"` or `"browser"` |
|
||||||
|
| `tools.computerUse.allowedDomains` | `[]` | Navigation allowlist for the browser backend |
|
||||||
|
| `tools.computerUse.targetWidth` / `targetHeight` | `1280` / `800` | Maximum screenshot dimensions exposed to the model |
|
||||||
|
| `tools.computerUse.maxSessions` | `8` | Maximum retained sessions for the browser backend |
|
||||||
|
|
||||||
|
Each nanobot session gets separate browser state. Browser HTTP and WebSocket traffic passes
|
||||||
|
through the shared SSRF policy; local, private, link-local, and metadata targets are blocked
|
||||||
|
unless explicitly permitted with `tools.ssrfWhitelist`. When `maxSessions` is reached, the
|
||||||
|
least-recently-used browser state is closed. `file:` URLs are not accepted.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Browser URL checks are defense in depth, not an egress sandbox: Chromium performs its own DNS
|
||||||
|
> resolution after validation. Use OS/container network isolation when browsing hostile pages.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The desktop backend can click, type, and change state outside the workspace. Enabling it is an
|
||||||
|
> explicit trust decision: use a trusted model and input source, and run nanobot in a disposable
|
||||||
|
> OS account or VM when unattended. The workspace restriction is not an OS sandbox. Desktop text
|
||||||
|
> input supports ASCII key events; use the browser backend when Unicode text input is required.
|
||||||
|
|
||||||
## Web Tools
|
## Web Tools
|
||||||
|
|
||||||
nanobot incorporates basic tools for accessing the web. These include searching via APIs, and fetching arbitrary web pages in Markdown format. They are enabled by default, and can be configured in `~/.nanobot/config.json` under `tools.web`.
|
nanobot incorporates basic tools for accessing the web. These include searching via APIs, and fetching arbitrary web pages in Markdown format. They are enabled by default, and can be configured in `~/.nanobot/config.json` under `tools.web`.
|
||||||
|
|||||||
@@ -270,12 +270,6 @@ 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
|
||||||
|
|||||||
+19
-68
@@ -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 and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||||
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||||
settings, and Automations in one place.
|
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 persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
| Topics | Start, switch, search, fork, and delete browser 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 and manage installed skills, or discover skills from supported marketplaces |
|
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||||
| 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,10 +90,6 @@ 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.
|
||||||
|
|
||||||
@@ -107,28 +103,6 @@ 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
|
||||||
@@ -171,8 +145,7 @@ 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, or drag that topic from the sidebar into the composer. Plain text
|
reference; plain text that happens to start with `@` does not attach history.
|
||||||
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
|
||||||
@@ -231,20 +204,10 @@ After an App or integration is available, mention it from the composer with
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
The Skills view shows the skill instructions available to the agent, including
|
||||||
You can search and filter them, inspect their instructions and setup
|
built-in skills and workspace-provided skills. Check this view when you want to
|
||||||
requirements, enable or disable them, and delete workspace skills you no longer
|
know whether nanobot already has a focused workflow for a task before you ask it
|
||||||
want.
|
to perform that task.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -325,17 +288,10 @@ 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.
|
||||||
|
|
||||||
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||||
capture only in secure contexts. Voice input works on same-machine localhost;
|
install missing nanobot support packages, such as adding a channel dependency,
|
||||||
from another device, serve the WebUI over HTTPS with a certificate that device
|
are blocked by default. To let trusted remote administrators change the Python
|
||||||
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
environment through the WebUI, opt in explicitly:
|
||||||
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
|
||||||
{
|
{
|
||||||
@@ -346,13 +302,12 @@ perform those installations 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 nanobot's Python environment and install workspace skill
|
trusted to change the Python environment that nanobot runs in. If you publish
|
||||||
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||||
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
as remote access and leave package installs disabled unless that is intentional.
|
||||||
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`. skills.sh marketplace installs use `npx` instead.
|
`PIP_INDEX_URL`.
|
||||||
|
|
||||||
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.
|
||||||
@@ -367,10 +322,6 @@ 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).
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ COMPACTABLE_TOOLS = frozenset({
|
|||||||
"read_file", "exec", "grep", "find_files",
|
"read_file", "exec", "grep", "find_files",
|
||||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||||
})
|
})
|
||||||
|
VISUAL_TOOLS = frozenset({"browser", "computer_use"})
|
||||||
|
STALE_SCREENSHOT_PLACEHOLDER = {
|
||||||
|
"type": "text",
|
||||||
|
"text": "[Earlier screenshot omitted; use the latest screenshot from this tool.]",
|
||||||
|
}
|
||||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||||
@@ -41,6 +46,12 @@ PLACEHOLDER_TEXTS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_image_block(value: object) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
return cast(dict[str, Any], value).get("type") in {"image_url", "input_image"}
|
||||||
|
|
||||||
|
|
||||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||||
|
|
||||||
@@ -84,6 +95,10 @@ class ContextGovernor:
|
|||||||
updated = self.drop_orphan_tool_results(updated)
|
updated = self.drop_orphan_tool_results(updated)
|
||||||
updated = self.backfill_missing_tool_results(updated)
|
updated = self.backfill_missing_tool_results(updated)
|
||||||
updated = self.apply_tool_result_budget(config, updated)
|
updated = self.apply_tool_result_budget(config, updated)
|
||||||
|
updated = self.drop_stale_visual_tool_images(
|
||||||
|
updated,
|
||||||
|
start_index=config.inflight_start_index,
|
||||||
|
)
|
||||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||||
updated = self.snip_history(config, updated)
|
updated = self.snip_history(config, updated)
|
||||||
updated = self.drop_orphan_tool_results(updated)
|
updated = self.drop_orphan_tool_results(updated)
|
||||||
@@ -326,6 +341,35 @@ class ContextGovernor:
|
|||||||
updated[idx]["content"] = normalized
|
updated[idx]["content"] = normalized
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def drop_stale_visual_tool_images(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
start_index: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Keep only the latest in-flight screenshot from each visual tool."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
updated = messages
|
||||||
|
for idx in range(len(messages) - 1, start_index - 1, -1):
|
||||||
|
message = messages[idx]
|
||||||
|
name = str(message.get("name") or "")
|
||||||
|
content = message.get("content")
|
||||||
|
if message.get("role") != "tool" or name not in VISUAL_TOOLS:
|
||||||
|
continue
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
content_blocks = cast(list[object], content)
|
||||||
|
blocks = [block for block in content_blocks if not _is_image_block(block)]
|
||||||
|
if len(blocks) == len(content_blocks):
|
||||||
|
continue
|
||||||
|
if name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
continue
|
||||||
|
if updated is messages:
|
||||||
|
updated = [dict(item) for item in messages]
|
||||||
|
updated[idx]["content"] = [dict(STALE_SCREENSHOT_PLACEHOLDER), *blocks]
|
||||||
|
return updated
|
||||||
|
|
||||||
def compact_inflight_overflow(
|
def compact_inflight_overflow(
|
||||||
self,
|
self,
|
||||||
config: ContextGovernanceConfig,
|
config: ContextGovernanceConfig,
|
||||||
|
|||||||
+4
-11
@@ -36,7 +36,6 @@ 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,
|
||||||
@@ -198,11 +197,6 @@ 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."""
|
||||||
@@ -454,6 +448,7 @@ 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)
|
||||||
@@ -628,13 +623,10 @@ class AgentLoop:
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
registered = loader.load(ctx, self.tools)
|
||||||
|
|
||||||
# MyTool receives only the explicit runtime-control capability.
|
# MyTool needs runtime state reference — manual registration
|
||||||
if self.tools_config.my.enable:
|
if self.tools_config.my.enable:
|
||||||
self.tools.register(
|
self.tools.register(
|
||||||
MyTool(
|
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||||
runtime_control=AgentRuntimeControl(self),
|
|
||||||
modify_allowed=self.tools_config.my.allow_set,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
@@ -1405,6 +1397,7 @@ class AgentLoop:
|
|||||||
cleanup_steps = (
|
cleanup_steps = (
|
||||||
self.subagents.close,
|
self.subagents.close,
|
||||||
self._exec_session_manager.close_all,
|
self._exec_session_manager.close_all,
|
||||||
|
*(() if not hasattr(self, "tools") else (self.tools.close,)),
|
||||||
lambda: agent_context.close_mcp(self),
|
lambda: agent_context.close_mcp(self),
|
||||||
)
|
)
|
||||||
for cleanup in cleanup_steps:
|
for cleanup in cleanup_steps:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -158,10 +157,6 @@ 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(
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ class Tool(ABC):
|
|||||||
"""Return optional per-turn prompt context owned by this tool."""
|
"""Return optional per-turn prompt context owned by this tool."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Release resources owned by the tool. Safe to call repeatedly."""
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""DOM-based browser automation by element reference."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
from nanobot.agent.tools.schema import (
|
||||||
|
BooleanSchema,
|
||||||
|
IntegerSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
|
from nanobot.config_base import Base
|
||||||
|
from nanobot.utils.helpers import build_image_content_blocks
|
||||||
|
|
||||||
|
_ACTIONS = [
|
||||||
|
"navigate",
|
||||||
|
"snapshot",
|
||||||
|
"click",
|
||||||
|
"type",
|
||||||
|
"select",
|
||||||
|
"scroll",
|
||||||
|
"key",
|
||||||
|
"back",
|
||||||
|
"read_text",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserToolConfig(Base):
|
||||||
|
"""browser (DOM) tool configuration."""
|
||||||
|
|
||||||
|
enable: bool = False
|
||||||
|
start_url: str = "about:blank"
|
||||||
|
headless: bool = True
|
||||||
|
width: int = Field(default=1280, ge=320, le=4096)
|
||||||
|
height: int = Field(default=800, ge=240, le=4096)
|
||||||
|
allowed_domains: list[str] = Field(default_factory=list)
|
||||||
|
include_screenshot: bool = False
|
||||||
|
max_elements: int = Field(default=200, ge=1, le=1000)
|
||||||
|
max_sessions: int = Field(default=8, ge=1, le=64)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_elements(elements: list[dict[str, Any]]) -> str:
|
||||||
|
if not elements:
|
||||||
|
return "Interactive elements: (none found — try scrolling or read_text)"
|
||||||
|
lines: list[str] = []
|
||||||
|
for e in elements:
|
||||||
|
tag = str(e.get("tag") or "")
|
||||||
|
typ = str(e.get("type") or "")
|
||||||
|
label = tag + (f"[{typ}]" if typ else "")
|
||||||
|
line = f"[{e.get('ref')}] {label}"
|
||||||
|
name = str(e.get("name") or "").strip()
|
||||||
|
if name:
|
||||||
|
line += f' "{name}"'
|
||||||
|
href = str(e.get("href") or "")
|
||||||
|
if href and tag == "a":
|
||||||
|
line += f" -> {href[:60]}"
|
||||||
|
lines.append(line)
|
||||||
|
return "Interactive elements (act with the [ref] number):\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
action=StringSchema("The action to perform.", enum=_ACTIONS),
|
||||||
|
ref=IntegerSchema(
|
||||||
|
description="Element ref number from the latest snapshot (click/type/select).",
|
||||||
|
minimum=1,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
text=StringSchema(
|
||||||
|
"Text to type (action=type) or key/combo like 'Enter'/'ctrl+a' (action=key).",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
url=StringSchema("URL to open (action=navigate).", nullable=True),
|
||||||
|
value=StringSchema("Option value/label to choose (action=select).", nullable=True),
|
||||||
|
submit=BooleanSchema(description="Press Enter after typing (action=type).", nullable=True),
|
||||||
|
scroll_direction=StringSchema(
|
||||||
|
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
|
||||||
|
),
|
||||||
|
scroll_amount=IntegerSchema(
|
||||||
|
description="Scroll clicks (action=scroll).",
|
||||||
|
minimum=1,
|
||||||
|
maximum=100,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
required=["action"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class BrowserTool(Tool):
|
||||||
|
"""Browse and act on web pages by element ref (DOM-based, works with any model)."""
|
||||||
|
|
||||||
|
_scopes = {"core"}
|
||||||
|
|
||||||
|
name = "browser" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
|
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
|
"Control a web browser by acting on page elements by their [ref] number. "
|
||||||
|
"Each call returns the current page URL plus a fresh numbered list of the page's "
|
||||||
|
"interactive elements; pick a [ref] to click/type/select — no pixel coordinates "
|
||||||
|
"needed. A page may already be open: call 'snapshot' FIRST to see it. Only use "
|
||||||
|
"'navigate' for a specific URL you were explicitly given — never guess a URL. "
|
||||||
|
"Move between pages by clicking links/buttons via their [ref]. Use 'read_text' to "
|
||||||
|
"read page text. Re-read the element list after each action; refs are reassigned."
|
||||||
|
)
|
||||||
|
|
||||||
|
config_key = "browser"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls) -> type[BrowserToolConfig]:
|
||||||
|
return BrowserToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
|
return bool(ctx.config.browser.enable)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
|
return cls(ctx.config.browser)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: BrowserToolConfig | None = None,
|
||||||
|
*,
|
||||||
|
backend_impl: Any = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config or BrowserToolConfig()
|
||||||
|
runtime = None
|
||||||
|
if backend_impl is None:
|
||||||
|
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
|
||||||
|
runtime = BrowserRuntime(headless=self.config.headless)
|
||||||
|
self._runtime = runtime
|
||||||
|
self._execution_lock = asyncio.Lock()
|
||||||
|
self._backends = SessionBackendPool(
|
||||||
|
self._make_backend,
|
||||||
|
backend_impl,
|
||||||
|
max_backends=self.config.max_sessions,
|
||||||
|
finalizer=runtime.close if runtime is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exclusive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _make_backend(self) -> Any:
|
||||||
|
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
|
||||||
|
return BrowserBackend(
|
||||||
|
width=self.config.width,
|
||||||
|
height=self.config.height,
|
||||||
|
start_url=self.config.start_url,
|
||||||
|
allowed_domains=self.config.allowed_domains,
|
||||||
|
runtime=self._runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _req_ref(params: dict[str, Any], action: str) -> Any:
|
||||||
|
ref = params.get("ref")
|
||||||
|
if ref is None:
|
||||||
|
raise ValueError(f"action '{action}' requires an element 'ref' from the snapshot")
|
||||||
|
return ref
|
||||||
|
|
||||||
|
async def _dispatch(self, backend: Any, action: str, p: dict[str, Any]) -> tuple[str, str | None]:
|
||||||
|
"""Return (status, direct_text). If direct_text is set, it is returned as-is
|
||||||
|
(no snapshot appended)."""
|
||||||
|
if action == "navigate":
|
||||||
|
url = p.get("url")
|
||||||
|
if not url:
|
||||||
|
raise ValueError("action 'navigate' requires 'url'")
|
||||||
|
await backend.navigate(str(url))
|
||||||
|
return f"Navigated to {url}", None
|
||||||
|
|
||||||
|
if action == "snapshot":
|
||||||
|
return "Snapshot of the current page", None
|
||||||
|
|
||||||
|
if action == "click":
|
||||||
|
ref = self._req_ref(p, action)
|
||||||
|
await backend.click_ref(ref)
|
||||||
|
return f"Clicked element [{ref}]", None
|
||||||
|
|
||||||
|
if action == "type":
|
||||||
|
ref = self._req_ref(p, action)
|
||||||
|
text = p.get("text")
|
||||||
|
if text is None:
|
||||||
|
raise ValueError("action 'type' requires 'text'")
|
||||||
|
submit = bool(p.get("submit"))
|
||||||
|
await backend.fill_ref(ref, str(text), submit=submit)
|
||||||
|
return f"Typed into [{ref}]" + (" and pressed Enter" if submit else ""), None
|
||||||
|
|
||||||
|
if action == "select":
|
||||||
|
ref = self._req_ref(p, action)
|
||||||
|
value = p.get("value")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("action 'select' requires 'value'")
|
||||||
|
await backend.select_ref(ref, str(value))
|
||||||
|
return f"Selected '{value}' in [{ref}]", None
|
||||||
|
|
||||||
|
if action == "scroll":
|
||||||
|
direction = str(p.get("scroll_direction") or "down").lower()
|
||||||
|
if direction not in ("up", "down", "left", "right"):
|
||||||
|
raise ValueError("'scroll_direction' must be up/down/left/right")
|
||||||
|
await backend.scroll_page(direction, int(p.get("scroll_amount") or 3))
|
||||||
|
return f"Scrolled {direction}", None
|
||||||
|
|
||||||
|
if action == "key":
|
||||||
|
combo = p.get("text")
|
||||||
|
if not combo:
|
||||||
|
raise ValueError("action 'key' requires 'text' (e.g. 'Enter')")
|
||||||
|
await backend.key(str(combo))
|
||||||
|
return f"Pressed {combo}", None
|
||||||
|
|
||||||
|
if action == "back":
|
||||||
|
await backend.go_back()
|
||||||
|
return "Navigated back", None
|
||||||
|
|
||||||
|
if action == "read_text":
|
||||||
|
txt = await backend.read_text()
|
||||||
|
return "", f"Page text:\n{txt}"
|
||||||
|
|
||||||
|
raise ValueError(f"unknown action '{action}'")
|
||||||
|
|
||||||
|
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||||
|
async with self._execution_lock:
|
||||||
|
return await self._execute(action, **kwargs)
|
||||||
|
|
||||||
|
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||||
|
action = (action or "").strip()
|
||||||
|
if action not in _ACTIONS:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend = await self._backends.get()
|
||||||
|
except ImportError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: could not initialize browser backend: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, direct = await self._dispatch(backend, action, kwargs)
|
||||||
|
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
|
||||||
|
raise ValueError(f"navigation was blocked: {blocked}")
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error executing browser '{action}': {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if direct is not None:
|
||||||
|
return direct
|
||||||
|
|
||||||
|
try:
|
||||||
|
elements = await backend.dom_snapshot(self.config.max_elements)
|
||||||
|
snapshot = _format_elements(elements)
|
||||||
|
except Exception as exc:
|
||||||
|
snapshot = f"(could not read page elements: {type(exc).__name__}: {exc})"
|
||||||
|
try:
|
||||||
|
current = await backend.current_url()
|
||||||
|
except Exception:
|
||||||
|
current = ""
|
||||||
|
header = f"{status}\nCurrent page: {current}" if current else status
|
||||||
|
text_out = f"{header}\n\n{snapshot}"
|
||||||
|
|
||||||
|
if self.config.include_screenshot:
|
||||||
|
try:
|
||||||
|
png = await backend.screenshot()
|
||||||
|
return build_image_content_blocks(png, "image/png", "", text_out)
|
||||||
|
except Exception:
|
||||||
|
return text_out
|
||||||
|
return text_out
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._backends.close()
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""Screenshot-based computer control."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
from nanobot.agent.tools.schema import (
|
||||||
|
IntegerSchema,
|
||||||
|
NumberSchema,
|
||||||
|
StringSchema,
|
||||||
|
tool_parameters_schema,
|
||||||
|
)
|
||||||
|
from nanobot.config_base import Base
|
||||||
|
from nanobot.utils.helpers import build_image_content_blocks
|
||||||
|
|
||||||
|
_ACTIONS = [
|
||||||
|
"screenshot",
|
||||||
|
"left_click",
|
||||||
|
"right_click",
|
||||||
|
"middle_click",
|
||||||
|
"double_click",
|
||||||
|
"triple_click",
|
||||||
|
"mouse_move",
|
||||||
|
"left_click_drag",
|
||||||
|
"scroll",
|
||||||
|
"type",
|
||||||
|
"key",
|
||||||
|
"wait",
|
||||||
|
"navigate",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CLICK_BUTTONS = {
|
||||||
|
"left_click": "left",
|
||||||
|
"double_click": "left",
|
||||||
|
"triple_click": "left",
|
||||||
|
"right_click": "right",
|
||||||
|
"middle_click": "middle",
|
||||||
|
}
|
||||||
|
_CLICK_COUNTS = {"double_click": 2, "triple_click": 3}
|
||||||
|
|
||||||
|
_MAX_WAIT_S = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerUseToolConfig(Base):
|
||||||
|
"""computer_use tool configuration."""
|
||||||
|
|
||||||
|
enable: bool = False
|
||||||
|
backend: Literal["desktop", "browser"] = "desktop"
|
||||||
|
target_width: int = Field(default=1280, ge=320, le=4096)
|
||||||
|
target_height: int = Field(default=800, ge=240, le=4096)
|
||||||
|
allowed_domains: list[str] = Field(default_factory=list)
|
||||||
|
start_url: str = "about:blank"
|
||||||
|
headless: bool = True
|
||||||
|
max_sessions: int = Field(default=8, ge=1, le=64)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_size(width: int, height: int, max_width: int, max_height: int) -> tuple[int, int]:
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return max(1, max_width), max(1, max_height)
|
||||||
|
scale = min(max_width / width, max_height / height, 1.0)
|
||||||
|
return max(1, round(width * scale)), max(1, round(height * scale))
|
||||||
|
|
||||||
|
|
||||||
|
def _scale_point(
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
source: tuple[int, int],
|
||||||
|
target: tuple[int, int],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
width, height = source
|
||||||
|
target_width, target_height = target
|
||||||
|
real_x = round(x * width / target_width) if target_width else x
|
||||||
|
real_y = round(y * height / target_height) if target_height else y
|
||||||
|
return (
|
||||||
|
max(0, min(real_x, max(0, width - 1))),
|
||||||
|
max(0, min(real_y, max(0, height - 1))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_parameters(
|
||||||
|
tool_parameters_schema(
|
||||||
|
action=StringSchema("The action to perform.", enum=_ACTIONS),
|
||||||
|
x=IntegerSchema(
|
||||||
|
description="X coordinate in the pixel space of the screenshot you were last shown.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
y=IntegerSchema(
|
||||||
|
description="Y coordinate in the pixel space of the screenshot you were last shown.",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
text=StringSchema(
|
||||||
|
"Text to type (action=type; desktop supports ASCII), or a key/combo like "
|
||||||
|
"'ctrl+s' or 'Enter' (action=key).",
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
scroll_direction=StringSchema(
|
||||||
|
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
|
||||||
|
),
|
||||||
|
scroll_amount=IntegerSchema(
|
||||||
|
description="Number of scroll clicks (action=scroll).",
|
||||||
|
minimum=1,
|
||||||
|
maximum=100,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
duration=NumberSchema(
|
||||||
|
description="Seconds to wait (action=wait).",
|
||||||
|
minimum=0,
|
||||||
|
maximum=_MAX_WAIT_S,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
url=StringSchema("URL to open (action=navigate, browser backend only).", nullable=True),
|
||||||
|
required=["action"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class ComputerUseTool(Tool):
|
||||||
|
"""Control a computer (desktop or browser) by looking at screenshots and acting."""
|
||||||
|
|
||||||
|
_scopes = {"core"} # never exposed to subagents — security-sensitive
|
||||||
|
|
||||||
|
name = "computer_use" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
|
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
|
"Control a computer via screenshots and mouse/keyboard. Each call performs ONE "
|
||||||
|
"action and returns a fresh screenshot of the resulting screen. Coordinates (x, y) "
|
||||||
|
"are in the pixel space of the screenshot you were last shown (top-left is 0,0). "
|
||||||
|
"The 'browser' backend additionally supports the 'navigate' action. Always start "
|
||||||
|
"with a 'screenshot' to see the screen, then act based on what you observe; after "
|
||||||
|
"each action re-check the new screenshot before the next step."
|
||||||
|
)
|
||||||
|
|
||||||
|
config_key = "computer_use"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_cls(cls) -> type[ComputerUseToolConfig]:
|
||||||
|
return ComputerUseToolConfig
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
|
return bool(ctx.config.computer_use.enable)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
|
return cls(ctx.config.computer_use)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ComputerUseToolConfig | None = None,
|
||||||
|
*,
|
||||||
|
backend_impl: Any = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config or ComputerUseToolConfig()
|
||||||
|
runtime = None
|
||||||
|
if backend_impl is None and self.config.backend == "browser":
|
||||||
|
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
|
||||||
|
runtime = BrowserRuntime(headless=self.config.headless)
|
||||||
|
self._runtime = runtime
|
||||||
|
self._execution_lock = asyncio.Lock()
|
||||||
|
self._backends = SessionBackendPool(
|
||||||
|
self._make_backend,
|
||||||
|
backend_impl,
|
||||||
|
max_backends=1 if self.config.backend == "desktop" else self.config.max_sessions,
|
||||||
|
finalizer=runtime.close if runtime is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exclusive(self) -> bool:
|
||||||
|
# Stateful single environment; must not run alongside other tools.
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _make_backend(self) -> Any:
|
||||||
|
if self.config.backend == "browser":
|
||||||
|
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
|
||||||
|
return BrowserBackend(
|
||||||
|
width=self.config.target_width,
|
||||||
|
height=self.config.target_height,
|
||||||
|
start_url=self.config.start_url,
|
||||||
|
allowed_domains=self.config.allowed_domains,
|
||||||
|
runtime=self._runtime,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
|
||||||
|
return DesktopBackend()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _downscale_png(png: bytes, target: tuple[int, int]) -> bytes:
|
||||||
|
try:
|
||||||
|
from PIL import Image # noqa: PLC0415
|
||||||
|
except Exception as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Pillow is required for computer_use. Install: pip install 'nanobot-ai[computer-use]'"
|
||||||
|
) from exc
|
||||||
|
tw, th = target
|
||||||
|
with Image.open(io.BytesIO(png)) as img:
|
||||||
|
if (img.width, img.height) == (tw, th):
|
||||||
|
return png
|
||||||
|
resized = img.convert("RGB").resize((tw, th)) # pyright: ignore[reportUnknownMemberType]
|
||||||
|
out = io.BytesIO()
|
||||||
|
resized.save(out, format="PNG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
async def _dispatch(
|
||||||
|
self,
|
||||||
|
backend: Any,
|
||||||
|
action: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
source: tuple[int, int],
|
||||||
|
target: tuple[int, int],
|
||||||
|
) -> str:
|
||||||
|
def _xy() -> tuple[int, int]:
|
||||||
|
x, y = params.get("x"), params.get("y")
|
||||||
|
if x is None or y is None:
|
||||||
|
raise ValueError(f"action '{action}' requires integer 'x' and 'y'")
|
||||||
|
return _scale_point(int(x), int(y), source, target)
|
||||||
|
|
||||||
|
if action == "screenshot":
|
||||||
|
return "Took a screenshot"
|
||||||
|
|
||||||
|
if action == "wait":
|
||||||
|
duration = params.get("duration")
|
||||||
|
secs = 1.0 if duration is None else float(duration)
|
||||||
|
secs = max(0.0, min(secs, _MAX_WAIT_S))
|
||||||
|
await asyncio.sleep(secs)
|
||||||
|
return f"Waited {secs:g}s"
|
||||||
|
|
||||||
|
if action in _CLICK_BUTTONS:
|
||||||
|
rx, ry = _xy()
|
||||||
|
await backend.click(rx, ry, _CLICK_BUTTONS[action], _CLICK_COUNTS.get(action, 1))
|
||||||
|
return f"{action} at ({rx}, {ry})"
|
||||||
|
|
||||||
|
if action == "mouse_move":
|
||||||
|
rx, ry = _xy()
|
||||||
|
await backend.move(rx, ry)
|
||||||
|
return f"Moved to ({rx}, {ry})"
|
||||||
|
|
||||||
|
if action == "left_click_drag":
|
||||||
|
rx, ry = _xy()
|
||||||
|
await backend.drag(rx, ry)
|
||||||
|
return f"Dragged to ({rx}, {ry})"
|
||||||
|
|
||||||
|
if action == "scroll":
|
||||||
|
rx, ry = _xy()
|
||||||
|
direction = str(params.get("scroll_direction") or "down").lower()
|
||||||
|
if direction not in ("up", "down", "left", "right"):
|
||||||
|
raise ValueError("'scroll_direction' must be up/down/left/right")
|
||||||
|
amount = int(params.get("scroll_amount") or 3)
|
||||||
|
await backend.scroll(rx, ry, direction, amount)
|
||||||
|
return f"Scrolled {direction} by {amount} at ({rx}, {ry})"
|
||||||
|
|
||||||
|
if action == "type":
|
||||||
|
text = params.get("text")
|
||||||
|
if not text:
|
||||||
|
raise ValueError("action 'type' requires 'text'")
|
||||||
|
await backend.type_text(str(text))
|
||||||
|
return f"Typed {len(str(text))} characters"
|
||||||
|
|
||||||
|
if action == "key":
|
||||||
|
combo = params.get("text")
|
||||||
|
if not combo:
|
||||||
|
raise ValueError("action 'key' requires 'text' (e.g. 'ctrl+s')")
|
||||||
|
await backend.key(str(combo))
|
||||||
|
return f"Pressed {combo}"
|
||||||
|
|
||||||
|
if action == "navigate":
|
||||||
|
url = params.get("url")
|
||||||
|
if not url:
|
||||||
|
raise ValueError("action 'navigate' requires 'url'")
|
||||||
|
await backend.navigate(str(url))
|
||||||
|
return f"Navigated to {url}"
|
||||||
|
|
||||||
|
raise ValueError(f"unknown action '{action}'")
|
||||||
|
|
||||||
|
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||||
|
async with self._execution_lock:
|
||||||
|
return await self._execute(action, **kwargs)
|
||||||
|
|
||||||
|
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
|
||||||
|
action = (action or "").strip()
|
||||||
|
if action not in _ACTIONS:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend = await self._backends.get()
|
||||||
|
real_w, real_h = await backend.dimensions()
|
||||||
|
except ImportError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error: could not initialize computer_use backend: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
source = (real_w, real_h)
|
||||||
|
target = _fit_size(real_w, real_h, self.config.target_width, self.config.target_height)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await self._dispatch(backend, action, kwargs, source, target)
|
||||||
|
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
|
||||||
|
raise ValueError(f"navigation was blocked: {blocked}")
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except NotImplementedError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult.error(
|
||||||
|
f"Error executing computer_use '{action}': {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return a fresh screenshot so the model sees the result of its action.
|
||||||
|
try:
|
||||||
|
png = await backend.screenshot()
|
||||||
|
png = self._downscale_png(png, target)
|
||||||
|
except ImportError as exc:
|
||||||
|
return ToolResult.error(f"Error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
return f"{status}\n(Could not capture screenshot: {type(exc).__name__}: {exc})"
|
||||||
|
|
||||||
|
label = f"{status} | screen {target[0]}x{target[1]} ({backend.environment})"
|
||||||
|
return build_image_content_blocks(png, "image/png", "", label)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._backends.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Computer-use backend adapters."""
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Backend interface for the ``computer_use`` tool.
|
||||||
|
|
||||||
|
A backend is the *actuator* + *screenshot source* for one execution environment
|
||||||
|
(the local desktop, a headless browser, a VM, ...). The tool layer owns the
|
||||||
|
agent loop, coordinate scaling, screenshot downscaling and safety gating; a
|
||||||
|
backend only has to perform primitive actions and grab a screenshot.
|
||||||
|
|
||||||
|
Coordinate contract: every ``x``/``y`` passed to a backend is already in **real
|
||||||
|
device pixels** (the same pixel space as :meth:`screenshot`). The tool scales the
|
||||||
|
model's target-space coordinates to real pixels before calling the backend, so
|
||||||
|
backends never deal with the downscaled space.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerBackend(ABC):
|
||||||
|
"""Primitive GUI actions + screenshot for one execution environment."""
|
||||||
|
|
||||||
|
#: "desktop" or "browser" — surfaced to the model so it knows the context.
|
||||||
|
environment: str = "desktop"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Return the real screenshot pixel size as ``(width, height)``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def screenshot(self) -> bytes:
|
||||||
|
"""Return a PNG screenshot of the current screen at real pixel size."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||||
|
"""Click at ``(x, y)``. ``button`` in {left,right,middle}; ``count`` for double/triple."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def move(self, x: int, y: int) -> None:
|
||||||
|
"""Move the cursor to ``(x, y)`` without clicking."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def drag(self, x: int, y: int) -> None:
|
||||||
|
"""Press at the current cursor position and drag to ``(x, y)``, then release."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||||
|
"""Scroll at ``(x, y)``. ``direction`` in {up,down,left,right}; ``amount`` in clicks."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def type_text(self, text: str) -> None:
|
||||||
|
"""Type ``text`` at the current focus."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def key(self, combo: str) -> None:
|
||||||
|
"""Press a key or combo, e.g. ``"ctrl+s"`` / ``"Enter"`` (backend-specific syntax)."""
|
||||||
|
|
||||||
|
async def navigate(self, url: str) -> None:
|
||||||
|
"""Navigate to ``url`` (browser backends only)."""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"'navigate' is not supported by the {self.environment} backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Release any resources (browser process, etc.). Safe to call repeatedly."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionBackendPool:
|
||||||
|
"""Keep stateful backends isolated by nanobot session."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
factory: Callable[[], Any],
|
||||||
|
injected: Any = None,
|
||||||
|
*,
|
||||||
|
max_backends: int = 8,
|
||||||
|
finalizer: Callable[[], Awaitable[None]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if max_backends < 1:
|
||||||
|
raise ValueError("max_backends must be at least 1")
|
||||||
|
self._factory = factory
|
||||||
|
self._injected = injected
|
||||||
|
self._max_backends = max_backends
|
||||||
|
self._finalizer = finalizer
|
||||||
|
self._backends: OrderedDict[str, Any] = OrderedDict()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def get(self) -> Any:
|
||||||
|
async with self._lock:
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("computer-use backend pool is closed")
|
||||||
|
if self._injected is not None:
|
||||||
|
return self._injected
|
||||||
|
key = current_request_session_key() or "default"
|
||||||
|
backend = self._backends.get(key)
|
||||||
|
if backend is not None:
|
||||||
|
self._backends.move_to_end(key)
|
||||||
|
return backend
|
||||||
|
if len(self._backends) >= self._max_backends:
|
||||||
|
_, stale = self._backends.popitem(last=False)
|
||||||
|
await stale.close()
|
||||||
|
backend = self._factory()
|
||||||
|
self._backends[key] = backend
|
||||||
|
return backend
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
backends = (
|
||||||
|
[self._injected]
|
||||||
|
if self._injected is not None
|
||||||
|
else list(self._backends.values())
|
||||||
|
)
|
||||||
|
self._injected = None
|
||||||
|
self._backends.clear()
|
||||||
|
finalizer, self._finalizer = self._finalizer, None
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(backend.close() for backend in backends if backend is not None),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
errors = [result for result in results if isinstance(result, BaseException)]
|
||||||
|
if finalizer is not None:
|
||||||
|
try:
|
||||||
|
await finalizer()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
if len(errors) == 1:
|
||||||
|
raise errors[0]
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup("failed to close computer-use backends", errors)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Playwright backend shared by browser and computer_use."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any, cast
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
|
||||||
|
from nanobot.security.network import validate_url_target
|
||||||
|
|
||||||
|
_MISSING = (
|
||||||
|
"Browser computer-use backend needs 'playwright'. Install with: "
|
||||||
|
"pip install 'nanobot-ai[computer-use]' && playwright install chromium"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCROLL_PIXELS = 100 # one "scroll click" ~= this many pixels
|
||||||
|
|
||||||
|
# Tags visible interactive elements with data-nanobot-ref and returns a compact
|
||||||
|
# list. Refs are reassigned per call. Used by DOM/accessibility mode.
|
||||||
|
_SNAPSHOT_JS = r"""
|
||||||
|
(max) => {
|
||||||
|
const SEL = 'a,button,input,textarea,select,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=switch],[onclick],[contenteditable=""],[contenteditable=true]';
|
||||||
|
const out = [];
|
||||||
|
let ref = 0;
|
||||||
|
for (const el of document.querySelectorAll(SEL)) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
if (r.width <= 0 || r.height <= 0) continue;
|
||||||
|
if (s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') continue;
|
||||||
|
ref++;
|
||||||
|
el.setAttribute('data-nanobot-ref', String(ref));
|
||||||
|
let name = (el.getAttribute('aria-label') || el.innerText || el.value ||
|
||||||
|
el.getAttribute('placeholder') || el.getAttribute('name') ||
|
||||||
|
el.getAttribute('title') || '');
|
||||||
|
name = name.replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||||
|
out.push({
|
||||||
|
ref: ref,
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
role: el.getAttribute('role') || '',
|
||||||
|
type: el.getAttribute('type') || '',
|
||||||
|
name: name,
|
||||||
|
href: el.getAttribute('href') || ''
|
||||||
|
});
|
||||||
|
if (out.length >= max) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# CUA/xdotool-ish modifier names -> Playwright modifiers.
|
||||||
|
_MODIFIERS = {
|
||||||
|
"ctrl": "Control", "control": "Control",
|
||||||
|
"alt": "Alt", "option": "Alt",
|
||||||
|
"shift": "Shift",
|
||||||
|
"cmd": "Meta", "meta": "Meta", "super": "Meta", "win": "Meta",
|
||||||
|
}
|
||||||
|
# Common single-key names -> Playwright key names.
|
||||||
|
_KEYS = {
|
||||||
|
"return": "Enter", "enter": "Enter", "tab": "Tab", "esc": "Escape",
|
||||||
|
"escape": "Escape", "backspace": "Backspace", "delete": "Delete",
|
||||||
|
"space": "Space", "up": "ArrowUp", "down": "ArrowDown",
|
||||||
|
"left": "ArrowLeft", "right": "ArrowRight",
|
||||||
|
"page_down": "PageDown", "pagedown": "PageDown",
|
||||||
|
"page_up": "PageUp", "pageup": "PageUp", "home": "Home", "end": "End",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_browser_url(
|
||||||
|
url: str,
|
||||||
|
allowed_domains: Sequence[str] = (),
|
||||||
|
*,
|
||||||
|
navigation: bool = True,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if url == "about:blank":
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not navigation and parsed.scheme in {"blob", "data"}:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
target = url
|
||||||
|
if parsed.scheme in {"ws", "wss"}:
|
||||||
|
target = urlunparse(parsed._replace(scheme="https" if parsed.scheme == "wss" else "http"))
|
||||||
|
|
||||||
|
if navigation and allowed_domains:
|
||||||
|
host = (parsed.hostname or "").rstrip(".").lower()
|
||||||
|
allowed = any(
|
||||||
|
normalized and (host == normalized or host.endswith(f".{normalized}"))
|
||||||
|
for domain in allowed_domains
|
||||||
|
if (normalized := domain.strip().lstrip(".").rstrip(".").lower())
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return False, f"host {host or '<missing>'} is not in allowed_domains"
|
||||||
|
|
||||||
|
return validate_url_target(target)
|
||||||
|
|
||||||
|
|
||||||
|
def _playwright_key(combo: str) -> str:
|
||||||
|
parts = [p.strip() for p in combo.split("+") if p.strip()]
|
||||||
|
out: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
low = part.lower()
|
||||||
|
if low in _MODIFIERS:
|
||||||
|
out.append(_MODIFIERS[low])
|
||||||
|
elif low in _KEYS:
|
||||||
|
out.append(_KEYS[low])
|
||||||
|
elif len(part) == 1:
|
||||||
|
out.append(part)
|
||||||
|
else:
|
||||||
|
out.append(part.capitalize())
|
||||||
|
return "+".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserRuntime:
|
||||||
|
"""One lazily started browser process shared by isolated session contexts."""
|
||||||
|
|
||||||
|
def __init__(self, *, headless: bool = True) -> None:
|
||||||
|
self._headless = headless
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._playwright: Any = None
|
||||||
|
self._browser: Any = None
|
||||||
|
|
||||||
|
async def get(self) -> Any:
|
||||||
|
if self._browser is not None:
|
||||||
|
return self._browser
|
||||||
|
async with self._lock:
|
||||||
|
if self._browser is not None:
|
||||||
|
return self._browser
|
||||||
|
try:
|
||||||
|
playwright = importlib.import_module("playwright.async_api")
|
||||||
|
async_playwright = cast(Any, playwright).async_playwright
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(_MISSING) from exc
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
try:
|
||||||
|
self._browser = await self._playwright.chromium.launch(
|
||||||
|
headless=self._headless
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
await self.close()
|
||||||
|
raise
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
browser, playwright = self._browser, self._playwright
|
||||||
|
self._browser = self._playwright = None
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
closers = (
|
||||||
|
browser.close if browser is not None else None,
|
||||||
|
playwright.stop if playwright is not None else None,
|
||||||
|
)
|
||||||
|
for close in closers:
|
||||||
|
if close is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await close()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
if len(errors) == 1:
|
||||||
|
raise errors[0]
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup("failed to close browser runtime", errors)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserBackend(ComputerBackend):
|
||||||
|
environment = "browser"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
width: int = 1280,
|
||||||
|
height: int = 800,
|
||||||
|
headless: bool = True,
|
||||||
|
start_url: str = "about:blank",
|
||||||
|
allowed_domains: Sequence[str] = (),
|
||||||
|
runtime: BrowserRuntime | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._width = width
|
||||||
|
self._height = height
|
||||||
|
self._start_url = start_url
|
||||||
|
self._allowed_domains = tuple(allowed_domains)
|
||||||
|
self._runtime = runtime or BrowserRuntime(headless=headless)
|
||||||
|
self._owns_runtime = runtime is None
|
||||||
|
self._context: Any = None
|
||||||
|
self._page: Any = None
|
||||||
|
self._last_pos = (0, 0)
|
||||||
|
self._blocked_navigation: str | None = None
|
||||||
|
|
||||||
|
async def _require_url(self, url: str, label: str) -> None:
|
||||||
|
ok, error = await asyncio.to_thread(
|
||||||
|
_validate_browser_url,
|
||||||
|
url,
|
||||||
|
self._allowed_domains,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"{label} is blocked: {error}")
|
||||||
|
|
||||||
|
async def _route_request(self, route: Any) -> None:
|
||||||
|
request = route.request
|
||||||
|
navigation = bool(request.is_navigation_request())
|
||||||
|
ok, error = await asyncio.to_thread(
|
||||||
|
_validate_browser_url,
|
||||||
|
request.url,
|
||||||
|
self._allowed_domains,
|
||||||
|
navigation=navigation,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
await route.continue_()
|
||||||
|
return
|
||||||
|
if navigation:
|
||||||
|
self._blocked_navigation = error
|
||||||
|
logger.warning("Blocked browser request to {}: {}", request.url, error)
|
||||||
|
await route.abort("blockedbyclient")
|
||||||
|
|
||||||
|
async def _route_web_socket(self, web_socket: Any) -> None:
|
||||||
|
ok, error = await asyncio.to_thread(
|
||||||
|
_validate_browser_url,
|
||||||
|
web_socket.url,
|
||||||
|
self._allowed_domains,
|
||||||
|
navigation=False,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Blocked browser WebSocket to {}: {}", web_socket.url, error)
|
||||||
|
await web_socket.close(code=1008, reason="Blocked by nanobot network policy")
|
||||||
|
return
|
||||||
|
await web_socket.connect_to_server()
|
||||||
|
|
||||||
|
def pop_blocked_navigation(self) -> str | None:
|
||||||
|
error = self._blocked_navigation
|
||||||
|
self._blocked_navigation = None
|
||||||
|
return error
|
||||||
|
|
||||||
|
async def _ensure(self) -> Any:
|
||||||
|
if self._page is not None:
|
||||||
|
return self._page
|
||||||
|
await self._require_url(self._start_url, "start_url")
|
||||||
|
try:
|
||||||
|
browser = await self._runtime.get()
|
||||||
|
self._context = await browser.new_context(
|
||||||
|
viewport={"width": self._width, "height": self._height},
|
||||||
|
device_scale_factor=1,
|
||||||
|
service_workers="block",
|
||||||
|
)
|
||||||
|
await self._context.route("**/*", self._route_request)
|
||||||
|
await self._context.route_web_socket("**/*", self._route_web_socket)
|
||||||
|
self._page = await self._context.new_page()
|
||||||
|
if self._start_url != "about:blank":
|
||||||
|
await self._page.goto(self._start_url)
|
||||||
|
return self._page
|
||||||
|
except BaseException:
|
||||||
|
await self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def dimensions(self) -> tuple[int, int]:
|
||||||
|
await self._ensure()
|
||||||
|
vp = self._page.viewport_size or {"width": self._width, "height": self._height}
|
||||||
|
return vp["width"], vp["height"]
|
||||||
|
|
||||||
|
async def screenshot(self) -> bytes:
|
||||||
|
page = await self._ensure()
|
||||||
|
return await page.screenshot()
|
||||||
|
|
||||||
|
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.mouse.click(x, y, button=button, click_count=count)
|
||||||
|
self._last_pos = (x, y)
|
||||||
|
|
||||||
|
async def move(self, x: int, y: int) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
self._last_pos = (x, y)
|
||||||
|
|
||||||
|
async def drag(self, x: int, y: int) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
sx, sy = self._last_pos
|
||||||
|
await page.mouse.move(sx, sy)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
await page.mouse.up()
|
||||||
|
self._last_pos = (x, y)
|
||||||
|
|
||||||
|
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
pixels = max(1, amount) * _SCROLL_PIXELS
|
||||||
|
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
|
||||||
|
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
|
||||||
|
await page.mouse.wheel(dx, dy)
|
||||||
|
|
||||||
|
async def type_text(self, text: str) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
|
||||||
|
async def key(self, combo: str) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
key = _playwright_key(combo)
|
||||||
|
if key:
|
||||||
|
await page.keyboard.press(key)
|
||||||
|
|
||||||
|
async def navigate(self, url: str) -> None:
|
||||||
|
await self._require_url(url, "navigation")
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.goto(url)
|
||||||
|
self._last_pos = (0, 0)
|
||||||
|
|
||||||
|
# --- DOM / accessibility mode (act by element ref, not pixels) ---
|
||||||
|
|
||||||
|
async def dom_snapshot(self, max_elements: int = 200) -> list[dict[str, Any]]:
|
||||||
|
"""Tag visible interactive elements with ``data-nanobot-ref`` and return them.
|
||||||
|
|
||||||
|
Each entry: ``{ref, tag, role, type, name, href}``. Refs are reassigned on
|
||||||
|
every snapshot, so callers should act on the latest snapshot.
|
||||||
|
"""
|
||||||
|
page = await self._ensure()
|
||||||
|
return cast(list[dict[str, Any]], await page.evaluate(_SNAPSHOT_JS, max_elements))
|
||||||
|
|
||||||
|
def _ref_selector(self, ref: int) -> str:
|
||||||
|
return f'[data-nanobot-ref="{int(ref)}"]'
|
||||||
|
|
||||||
|
async def click_ref(self, ref: int) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.click(self._ref_selector(ref), timeout=5000)
|
||||||
|
|
||||||
|
async def fill_ref(self, ref: int, text: str, submit: bool = False) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
sel = self._ref_selector(ref)
|
||||||
|
await page.fill(sel, text, timeout=5000)
|
||||||
|
if submit:
|
||||||
|
await page.press(sel, "Enter")
|
||||||
|
|
||||||
|
async def select_ref(self, ref: int, value: str) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
sel = self._ref_selector(ref)
|
||||||
|
try:
|
||||||
|
await page.select_option(sel, value, timeout=3000)
|
||||||
|
except Exception:
|
||||||
|
# Models usually pass the visible label, not the option value.
|
||||||
|
await page.select_option(sel, label=value, timeout=3000)
|
||||||
|
|
||||||
|
async def scroll_page(self, direction: str, amount: int) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
pixels = max(1, amount) * _SCROLL_PIXELS
|
||||||
|
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
|
||||||
|
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
|
||||||
|
await page.evaluate("([x, y]) => window.scrollBy(x, y)", [dx, dy])
|
||||||
|
|
||||||
|
async def go_back(self) -> None:
|
||||||
|
page = await self._ensure()
|
||||||
|
await page.go_back()
|
||||||
|
|
||||||
|
async def read_text(self, max_chars: int = 4000) -> str:
|
||||||
|
page = await self._ensure()
|
||||||
|
txt = await page.evaluate("() => document.body ? document.body.innerText : ''")
|
||||||
|
return (txt or "")[:max_chars]
|
||||||
|
|
||||||
|
async def current_url(self) -> str:
|
||||||
|
page = await self._ensure()
|
||||||
|
return page.url
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
context = self._context
|
||||||
|
self._context = self._page = None
|
||||||
|
error: BaseException | None = None
|
||||||
|
if context is not None:
|
||||||
|
try:
|
||||||
|
await context.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
error = exc
|
||||||
|
if self._owns_runtime:
|
||||||
|
try:
|
||||||
|
await self._runtime.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
if error is not None:
|
||||||
|
raise BaseExceptionGroup("failed to close browser backend", [error, exc])
|
||||||
|
raise
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""PyAutoGUI desktop backend with HiDPI coordinate correction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
|
||||||
|
|
||||||
|
_MISSING = (
|
||||||
|
"Desktop computer-use backend needs 'pyautogui' and 'pillow'. "
|
||||||
|
"Install with: pip install 'nanobot-ai[computer-use]'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# xdotool/CUA-style key names -> PyAutoGUI key names.
|
||||||
|
_KEY_ALIASES = {
|
||||||
|
"return": "enter",
|
||||||
|
"ctrl": "ctrl",
|
||||||
|
"control": "ctrl",
|
||||||
|
"cmd": "command",
|
||||||
|
"super": "win",
|
||||||
|
"win": "win",
|
||||||
|
"page_down": "pagedown",
|
||||||
|
"page_up": "pageup",
|
||||||
|
"pagedown": "pagedown",
|
||||||
|
"pageup": "pageup",
|
||||||
|
"esc": "esc",
|
||||||
|
"escape": "esc",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopBackend(ComputerBackend):
|
||||||
|
environment = "desktop"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pg: Any = None
|
||||||
|
self._ratio_x = 1.0
|
||||||
|
self._ratio_y = 1.0
|
||||||
|
self._dims: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
def _ensure(self) -> Any:
|
||||||
|
if self._pg is not None:
|
||||||
|
return self._pg
|
||||||
|
try:
|
||||||
|
import pyautogui # noqa: PLC0415
|
||||||
|
except Exception as exc: # ImportError, or platform display errors
|
||||||
|
raise ImportError(_MISSING) from exc
|
||||||
|
self._pg = pyautogui
|
||||||
|
return pyautogui
|
||||||
|
|
||||||
|
def _grab_png_and_size(self) -> tuple[bytes, int, int]:
|
||||||
|
pg = self._ensure()
|
||||||
|
img = pg.screenshot()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
width, height = img.size
|
||||||
|
# Refresh logical<->physical ratio from the actual grab.
|
||||||
|
try:
|
||||||
|
logical_w, logical_h = pg.size()
|
||||||
|
self._ratio_x = (logical_w / width) if width else 1.0
|
||||||
|
self._ratio_y = (logical_h / height) if height else 1.0
|
||||||
|
except Exception:
|
||||||
|
self._ratio_x = self._ratio_y = 1.0
|
||||||
|
self._dims = (width, height)
|
||||||
|
return buf.getvalue(), width, height
|
||||||
|
|
||||||
|
def _to_logical(self, x: int, y: int) -> tuple[int, int]:
|
||||||
|
return round(x * self._ratio_x), round(y * self._ratio_y)
|
||||||
|
|
||||||
|
async def dimensions(self) -> tuple[int, int]:
|
||||||
|
if self._dims is not None:
|
||||||
|
return self._dims
|
||||||
|
_, w, h = await asyncio.to_thread(self._grab_png_and_size)
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
async def screenshot(self) -> bytes:
|
||||||
|
png, _, _ = await asyncio.to_thread(self._grab_png_and_size)
|
||||||
|
return png
|
||||||
|
|
||||||
|
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
|
||||||
|
pg = self._ensure()
|
||||||
|
lx, ly = self._to_logical(x, y)
|
||||||
|
await asyncio.to_thread(pg.click, lx, ly, clicks=count, button=button)
|
||||||
|
|
||||||
|
async def move(self, x: int, y: int) -> None:
|
||||||
|
pg = self._ensure()
|
||||||
|
lx, ly = self._to_logical(x, y)
|
||||||
|
await asyncio.to_thread(pg.moveTo, lx, ly)
|
||||||
|
|
||||||
|
async def drag(self, x: int, y: int) -> None:
|
||||||
|
pg = self._ensure()
|
||||||
|
lx, ly = self._to_logical(x, y)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
pg.dragTo,
|
||||||
|
lx,
|
||||||
|
ly,
|
||||||
|
duration=0.3,
|
||||||
|
tween=pg.easeInOutQuad,
|
||||||
|
button="left",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
|
||||||
|
pg = self._ensure()
|
||||||
|
lx, ly = self._to_logical(x, y)
|
||||||
|
clicks = max(1, amount)
|
||||||
|
await asyncio.to_thread(pg.moveTo, lx, ly)
|
||||||
|
if direction in ("up", "down"):
|
||||||
|
await asyncio.to_thread(pg.scroll, clicks if direction == "up" else -clicks)
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(pg.hscroll, clicks if direction == "right" else -clicks)
|
||||||
|
|
||||||
|
async def type_text(self, text: str) -> None:
|
||||||
|
if not text.isascii():
|
||||||
|
raise ValueError(
|
||||||
|
"desktop text input supports ASCII key events only; "
|
||||||
|
"use the browser backend for Unicode text"
|
||||||
|
)
|
||||||
|
pg = self._ensure()
|
||||||
|
await asyncio.to_thread(pg.typewrite, text, 0.01)
|
||||||
|
|
||||||
|
async def key(self, combo: str) -> None:
|
||||||
|
pg = self._ensure()
|
||||||
|
keys = [
|
||||||
|
_KEY_ALIASES.get(part.strip().lower(), part.strip().lower())
|
||||||
|
for part in combo.split("+")
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
if len(keys) == 1:
|
||||||
|
await asyncio.to_thread(pg.press, keys[0])
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(pg.hotkey, *keys)
|
||||||
@@ -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_control",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -187,5 +187,8 @@ class _LegacyErrorPrefixTool(Tool):
|
|||||||
return ToolResult.error(result)
|
return ToolResult.error(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._wrapped.close()
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def __getattr__(self, name: str) -> Any:
|
||||||
return getattr(self._wrapped, name)
|
return getattr(self._wrapped, name)
|
||||||
|
|||||||
+29
-19
@@ -975,8 +975,11 @@ 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", server_stack: AsyncExitStack
|
name: str, cfg: "MCPServerConfig"
|
||||||
) -> bool:
|
) -> tuple[str, AsyncExitStack | None]:
|
||||||
|
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:
|
||||||
@@ -988,7 +991,8 @@ 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)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
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)
|
||||||
@@ -999,7 +1003,8 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1017,7 +1022,8 @@ 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))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1044,7 +1050,8 @@ async def connect_mcp_servers(
|
|||||||
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))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(
|
||||||
@@ -1060,7 +1067,8 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
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))
|
||||||
@@ -1163,7 +1171,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 True
|
return name, server_stack
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1183,7 +1191,9 @@ async def connect_mcp_servers(
|
|||||||
"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)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
return False
|
with suppress(Exception):
|
||||||
|
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"
|
||||||
@@ -1193,30 +1203,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:
|
||||||
async with AsyncExitStack() as stack:
|
_, stack = await open_single_server(name, cfg)
|
||||||
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 as exc:
|
except BaseException:
|
||||||
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()
|
||||||
|
|||||||
@@ -200,6 +200,19 @@ class ToolRegistry:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
|
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close every registered tool, attempting all cleanups."""
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
for tool in self._tools.values():
|
||||||
|
try:
|
||||||
|
await tool.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
if len(errors) == 1:
|
||||||
|
raise errors[0]
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup("failed to close tools", errors)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
"""Get list of registered tool names."""
|
"""Get list of registered tool names."""
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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: ...
|
||||||
+182
-213
@@ -1,7 +1,8 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
# Tool.execute accepts heterogeneous schemas.
|
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||||
# pyright: reportIncompatibleMethodOverride=false
|
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||||
|
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,13 +14,7 @@ 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_control import (
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
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:
|
||||||
@@ -33,28 +28,25 @@ 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."""
|
||||||
|
|
||||||
@@ -87,10 +79,7 @@ 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
|
||||||
@@ -114,6 +103,13 @@ 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},
|
||||||
@@ -127,15 +123,15 @@ class MyTool(Tool):
|
|||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_control = runtime_control
|
self._runtime_state = runtime_state
|
||||||
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_control = self._runtime_control
|
result._runtime_state = self._runtime_state
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -212,12 +208,9 @@ class MyTool(Tool):
|
|||||||
# Path resolution
|
# Path resolution
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _resolve_path(
|
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||||
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"
|
||||||
@@ -225,13 +218,17 @@ 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"
|
||||||
obj: object = snapshot.as_mapping()
|
try:
|
||||||
for part in parts:
|
if isinstance(obj, Mapping):
|
||||||
if not _is_string_mapping(obj):
|
mapping = cast(Mapping[str, Any], obj)
|
||||||
return None, f"'{part}' not found"
|
if part in mapping:
|
||||||
if part not in obj:
|
obj = mapping[part]
|
||||||
return None, f"'{part}' not found in mapping"
|
else:
|
||||||
obj = obj[part]
|
return None, f"'{part}' not found in mapping"
|
||||||
|
else:
|
||||||
|
obj = getattr(obj, part)
|
||||||
|
except (KeyError, AttributeError) as e:
|
||||||
|
return None, f"'{part}' not found: {e}"
|
||||||
return obj, None
|
return obj, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -245,48 +242,20 @@ class MyTool(Tool):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(
|
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||||
st: "SubagentStatus | Mapping[str, object]",
|
elapsed = time.monotonic() - st.started_at
|
||||||
indent: str = " ",
|
tool_summary = ", ".join(
|
||||||
) -> str:
|
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||||
if isinstance(st, Mapping):
|
) or "none"
|
||||||
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: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||||
f"{indent}tools: {tool_summary}",
|
f"{indent}tools: {tool_summary}",
|
||||||
f"{indent}usage: {usage or 'n/a'}",
|
f"{indent}usage: {st.usage or 'n/a'}",
|
||||||
]
|
]
|
||||||
if error:
|
if st.error:
|
||||||
lines.append(f"{indent}error: {error}")
|
lines.append(f"{indent}error: {st.error}")
|
||||||
if stop_reason:
|
if st.stop_reason:
|
||||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -295,38 +264,29 @@ 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}"
|
||||||
if _is_subagent_status_snapshot(val):
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
task_statuses = getattr(val, "_task_statuses", None)
|
||||||
detail = MyTool._format_status(val, " ")
|
if isinstance(task_statuses, dict):
|
||||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
return MyTool._format_value(task_statuses, key)
|
||||||
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 (
|
and _is_subagent_status(next(iter(mapping.values())))
|
||||||
_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(mapping)} subagent(s):"]
|
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||||
for tid, st in mapping.items():
|
for tid, st in status_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)
|
||||||
@@ -351,6 +311,32 @@ 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
|
||||||
|
|
||||||
@@ -380,12 +366,7 @@ 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
|
||||||
values: dict[str, object] = {
|
return True, getattr(runtime, key)
|
||||||
"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:
|
||||||
@@ -394,64 +375,62 @@ 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(request_values, key)
|
return self._format_value(
|
||||||
|
{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(request_values[field], key)
|
return self._format_value(getattr(request_ctx, 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(snapshot, key)
|
obj, err = self._resolve_path(key)
|
||||||
if err:
|
if err:
|
||||||
|
# "scratchpad" alias for _runtime_vars
|
||||||
if key == "scratchpad":
|
if key == "scratchpad":
|
||||||
return (
|
rv = self._runtime_state._runtime_vars
|
||||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||||
if snapshot.scratchpad
|
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||||
else "scratchpad is empty"
|
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||||
)
|
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:
|
||||||
snapshot = self._runtime_control.snapshot()
|
state = self._runtime_state
|
||||||
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 values[k], k))
|
parts.append(self._format_value(value if found else getattr(state, k, None), 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 snapshot.model_preset,
|
value if found else state.model_preset,
|
||||||
"model_preset",
|
"model_preset",
|
||||||
))
|
))
|
||||||
for k in (
|
# Other useful top-level keys shown in description
|
||||||
"workspace",
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||||
"provider_retry_mode",
|
if _has_real_attr(state, k):
|
||||||
"max_tool_result_chars",
|
parts.append(self._format_value(getattr(state, k, None), k))
|
||||||
"_current_iteration",
|
# Token usage
|
||||||
"web_config",
|
usage = state._last_usage
|
||||||
"exec_config",
|
if usage:
|
||||||
"subagents",
|
parts.append(self._format_value(usage, "_last_usage"))
|
||||||
):
|
rv = state._runtime_vars
|
||||||
parts.append(self._format_value(values[k], k))
|
if rv:
|
||||||
if snapshot.last_usage:
|
parts.append(self._format_value(rv, "scratchpad"))
|
||||||
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 --
|
||||||
@@ -475,49 +454,48 @@ 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")
|
||||||
snapshot = self._runtime_control.snapshot()
|
parent, err = self._resolve_path(parent_path)
|
||||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
|
||||||
if err:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
self._audit("modify", f"READ_ONLY {key}")
|
if isinstance(parent, dict):
|
||||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
parent[leaf] = value
|
||||||
|
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)
|
||||||
if key in RUNTIME_COMMAND_KEYS:
|
return self._modify_free(key, value)
|
||||||
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()
|
||||||
old = self._runtime_control.snapshot().model_preset
|
|
||||||
try:
|
|
||||||
runtime = self._runtime_control.set_model_preset(
|
|
||||||
name,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
except (KeyError, ValueError) as exc:
|
|
||||||
message = str(exc.args[0]) if exc.args else str(exc)
|
|
||||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
|
||||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
|
||||||
if session_key:
|
if session_key:
|
||||||
|
try:
|
||||||
|
runtime = self._runtime_state.set_session_model_preset(
|
||||||
|
session_key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
self._audit("modify", f"model_preset = {name!r}")
|
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}"
|
||||||
)
|
)
|
||||||
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
result = self._modify_free("model_preset", name)
|
||||||
|
if isinstance(result, ToolResult) and result.is_error:
|
||||||
|
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
@@ -530,7 +508,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 = self._runtime_control.snapshot().as_mapping()[key]
|
old = getattr(self._runtime_state, 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"]:
|
||||||
@@ -543,46 +521,41 @@ 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_control.set_model(cast(str, value))
|
self._runtime_state.set_runtime_model(cast(str, value))
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||||
else:
|
else:
|
||||||
self._runtime_control.set_max_iterations(cast(int, value))
|
setattr(self._runtime_state, key, value)
|
||||||
|
if key == "max_iterations" and hasattr(
|
||||||
|
self._runtime_state,
|
||||||
|
"_sync_subagent_runtime_limits",
|
||||||
|
):
|
||||||
|
self._runtime_state._sync_subagent_runtime_limits()
|
||||||
self._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_runtime_setting(self, key: str, value: Any) -> str:
|
def _modify_free(self, key: str, value: Any) -> str:
|
||||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
if _has_real_attr(self._runtime_state, key):
|
||||||
if key == "workspace":
|
old = getattr(self._runtime_state, key)
|
||||||
if not isinstance(value, str):
|
if isinstance(old, (str, int, float, bool)):
|
||||||
return ToolResult.error(
|
old_t: type[Any] = type(old)
|
||||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
new_t = cast(type[Any], type(value))
|
||||||
)
|
if old_t is float and new_t is int:
|
||||||
self._runtime_control.set_workspace_display(value)
|
pass # int → float coercion allowed
|
||||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
elif old_t is not new_t:
|
||||||
return f"Set workspace = {value!r} (was {old!r})"
|
self._audit(
|
||||||
old_t = type(old)
|
"modify",
|
||||||
new_t = cast(type[Any], type(value))
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
if old_t is float and new_t is int:
|
)
|
||||||
pass
|
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||||
elif old_t is not new_t:
|
try:
|
||||||
self._audit(
|
setattr(self._runtime_state, key, value)
|
||||||
"modify",
|
except (ValueError, KeyError) as e:
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||||
)
|
self._audit("modify", f"REJECTED {key}: {message}")
|
||||||
return ToolResult.error(
|
return ToolResult.error(f"Error: {message}")
|
||||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
)
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
if key == "provider_retry_mode":
|
|
||||||
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
|
||||||
elif key == "max_tool_result_chars":
|
|
||||||
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
|
||||||
else:
|
|
||||||
raise AssertionError(f"Unhandled runtime command: {key}")
|
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
|
||||||
|
|
||||||
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
|
||||||
if callable(value):
|
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")
|
||||||
@@ -590,16 +563,12 @@ 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}")
|
||||||
try:
|
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||||
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: {exc}. Remove unused keys first.")
|
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
old = self._runtime_state._runtime_vars.get(key)
|
||||||
|
self._runtime_state._runtime_vars[key] = value
|
||||||
|
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||||
return 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, reportMissingTypeStubs]
|
from olostep import ( # pyright: ignore[reportMissingImports]
|
||||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ 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,7 +15,6 @@ 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";
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
|
token={token}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,7 +92,6 @@ 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);
|
||||||
@@ -115,7 +114,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -101,14 +101,8 @@ 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
|
||||||
@@ -176,7 +170,6 @@ 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,13 +373,6 @@ 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
|
||||||
|
|
||||||
@@ -765,9 +758,6 @@ 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(
|
||||||
@@ -1115,152 +1105,6 @@ 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
|
|
||||||
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,
|
||||||
@@ -1301,12 +1145,6 @@ 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,16 +3,12 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -46,12 +42,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -129,38 +119,6 @@ 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())
|
||||||
@@ -899,98 +857,6 @@ 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,
|
||||||
@@ -1000,33 +866,23 @@ 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": "webui_request",
|
"type": "set_sidebar_state",
|
||||||
"request_id": request_id,
|
"state": {
|
||||||
"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"
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
conn.send.assert_not_awaited()
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ok": True,
|
|
||||||
"result": saved,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -3031,15 +2887,7 @@ 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"},
|
||||||
@@ -3123,14 +2971,11 @@ 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 _webui_mutate(
|
provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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()
|
||||||
@@ -3140,18 +2985,22 @@ 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 _webui_mutate(
|
custom_provider_created = await _http_get(
|
||||||
webui_client,
|
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||||
"settings.provider.create",
|
headers={
|
||||||
{
|
"Authorization": "Bearer tok",
|
||||||
"name": "Company Gateway",
|
"X-Nanobot-Provider-Values": json.dumps(
|
||||||
"apiBase": "https://gateway.example/v1",
|
{
|
||||||
"apiKey": "sk-company",
|
"name": "Company Gateway",
|
||||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
"apiBase": "https://gateway.example/v1",
|
||||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
"apiKey": "sk-company",
|
||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||||
"thinkingStyle": "enable_thinking",
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
|
"proxy": "http://127.0.0.1:7890",
|
||||||
|
"thinkingStyle": "enable_thinking",
|
||||||
|
}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert custom_provider_created.status_code == 200
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3166,10 +3015,11 @@ 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 _webui_mutate(
|
local_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||||
|
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()
|
||||||
@@ -3179,44 +3029,38 @@ 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 _webui_mutate(
|
updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||||
{
|
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||||
"model": "atomic_chat/test",
|
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||||
"provider": "atomic_chat",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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 _webui_mutate(
|
preset_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=deep",
|
||||||
{"model_preset": "deep"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
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 _webui_mutate(
|
bad_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=missing",
|
||||||
{"model_preset": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _webui_mutate(
|
created_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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()
|
||||||
@@ -3230,15 +3074,11 @@ 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 _webui_mutate(
|
updated_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.update",
|
f"{port}/api/settings/model-configurations/update"
|
||||||
{
|
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||||
"name": "fast-writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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()
|
||||||
@@ -3249,10 +3089,11 @@ 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 _webui_mutate(
|
call_order_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_call_order.update",
|
f"{port}/api/settings/model-call-order/update"
|
||||||
{"order": ["fast-writing", "deep"]},
|
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||||
|
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()
|
||||||
@@ -3260,27 +3101,20 @@ 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 _webui_mutate(
|
duplicate_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-4.1-mini",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _webui_mutate(
|
search_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||||
{
|
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||||
"provider": "searxng",
|
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||||
"base_url": "https://search.example.com",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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()
|
||||||
@@ -3292,13 +3126,10 @@ 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 _webui_mutate(
|
network_safety_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.network_safety.update",
|
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||||
{
|
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()
|
||||||
@@ -3308,17 +3139,13 @@ 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 _webui_mutate(
|
image_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||||
{
|
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
||||||
"enabled": True,
|
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
||||||
"provider": "openrouter",
|
"&max_images_per_turn=3",
|
||||||
"model": "openai/gpt-image-1",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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()
|
||||||
@@ -3330,14 +3157,11 @@ 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 _webui_mutate(
|
image_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"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
|
||||||
@@ -3345,17 +3169,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 _webui_mutate(
|
bad_web = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
||||||
{"provider": "duckduckgo", "max_results": 99},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _webui_mutate(
|
bad_image = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?provider=missing",
|
||||||
{"provider": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3392,8 +3216,6 @@ 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
|
||||||
|
|
||||||
@@ -3426,17 +3248,11 @@ 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:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
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
|
||||||
@@ -3444,8 +3260,6 @@ 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
|
||||||
|
|
||||||
@@ -3477,25 +3291,17 @@ 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:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
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,7 +22,6 @@ 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
|
||||||
@@ -73,7 +72,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
try:
|
||||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -90,7 +89,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -189,7 +187,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
await session.channel.connect_fetch_qr_code()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -206,17 +204,6 @@ 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)
|
||||||
@@ -247,7 +234,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
await session.channel.connect_fetch_qr_code()
|
||||||
)
|
)
|
||||||
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, *, force: bool = False) -> tuple[str, str]:
|
async def _fetch_qr_code(self) -> tuple[str, str]:
|
||||||
"""Fetch a QR code without existing credentials when forced."""
|
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
||||||
local_tokens = [] if force else self._local_token_list()
|
local_tokens = 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, *, force: bool = False) -> bool:
|
async def _qr_login(self) -> bool:
|
||||||
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
"""Perform QR code login flow. Returns True on success."""
|
||||||
try:
|
try:
|
||||||
refresh_count = 0
|
refresh_count = 0
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
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,16 +825,11 @@ 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(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
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
|
||||||
@@ -851,7 +846,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(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
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)
|
||||||
@@ -898,8 +893,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, *, force: bool = False) -> tuple[str, str]:
|
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
||||||
return await self._fetch_qr_code(force=force)
|
return await self._fetch_qr_code()
|
||||||
|
|
||||||
async def connect_poll_qr_code(
|
async def connect_poll_qr_code(
|
||||||
self,
|
self,
|
||||||
@@ -952,14 +947,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if force:
|
if force:
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._get_updates_buf = ""
|
self._get_updates_buf = ""
|
||||||
if self._token or (not force and self._load_state()):
|
if self._token or 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(force=force)
|
return await self._qr_login()
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ 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(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
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(
|
||||||
@@ -88,31 +86,14 @@ 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)
|
||||||
|
|
||||||
observed_force: list[bool] = []
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
|
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"
|
||||||
@@ -135,9 +116,7 @@ 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(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
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(
|
||||||
@@ -183,9 +162,7 @@ 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(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-verify", "https://qr.example/verify"
|
return "qr-verify", "https://qr.example/verify"
|
||||||
|
|
||||||
responses = [
|
responses = [
|
||||||
@@ -227,7 +204,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -244,12 +221,7 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_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(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
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(
|
||||||
@@ -265,8 +237,8 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
|
|||||||
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"] == "failed"
|
assert completed["status"] == "succeeded"
|
||||||
assert "new WeChat login" in completed["message"]
|
assert "already connected" 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"
|
||||||
|
|
||||||
|
|
||||||
@@ -283,9 +255,7 @@ 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(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
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(
|
||||||
@@ -298,7 +268,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=False)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
assert completed["status"] == "failed"
|
||||||
|
|||||||
@@ -196,86 +196,6 @@ 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,7 +27,6 @@ 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,
|
||||||
@@ -65,7 +64,6 @@ 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");
|
||||||
@@ -152,7 +150,7 @@ export function WeixinPanel({
|
|||||||
setSaveState("idle");
|
setSaveState("idle");
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
client,
|
context.token,
|
||||||
"weixin",
|
"weixin",
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
channelValuesForSave(editableFieldsRef.current, values),
|
||||||
{ enable: context.enabled },
|
{ enable: context.enabled },
|
||||||
@@ -170,7 +168,7 @@ export function WeixinPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [client]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -669,7 +669,6 @@ 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]:
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from nanobot.config_base import Base
|
|||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
|
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
|
||||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
@@ -399,6 +401,16 @@ class ToolsConfig(Base):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||||
|
browser: BrowserToolConfig = Field(
|
||||||
|
default_factory=lambda: _lazy_default(
|
||||||
|
"nanobot.agent.tools.browser_tool", "BrowserToolConfig"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
computer_use: ComputerUseToolConfig = Field(
|
||||||
|
default_factory=lambda: _lazy_default(
|
||||||
|
"nanobot.agent.tools.computer_use", "ComputerUseToolConfig"
|
||||||
|
)
|
||||||
|
)
|
||||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||||
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
|
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
|
||||||
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||||
@@ -670,7 +682,9 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||||
|
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
|
||||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||||
from nanobot.agent.tools.self import MyToolConfig
|
from nanobot.agent.tools.self import MyToolConfig
|
||||||
@@ -680,6 +694,8 @@ def _resolve_tool_config_refs() -> None:
|
|||||||
# Re-export into this module's namespace
|
# Re-export into this module's namespace
|
||||||
mod = sys.modules[__name__]
|
mod = sys.modules[__name__]
|
||||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||||
|
mod.BrowserToolConfig = BrowserToolConfig # type: ignore[attr-defined]
|
||||||
|
mod.ComputerUseToolConfig = ComputerUseToolConfig # type: ignore[attr-defined]
|
||||||
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
|
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
|
||||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -671,6 +671,73 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
dumped = str(content)
|
dumped = str(content)
|
||||||
return dumped or "(empty)"
|
return dumped or "(empty)"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _move_tool_images_to_user(
|
||||||
|
cls,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Adapt multimodal tool results to Chat Completions' text-only tool role."""
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
pending_images: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def flush_images(next_message: dict[str, Any] | None = None) -> None:
|
||||||
|
if not pending_images:
|
||||||
|
if next_message is not None:
|
||||||
|
updated.append(next_message)
|
||||||
|
return
|
||||||
|
content: list[dict[str, Any]] = [
|
||||||
|
*pending_images,
|
||||||
|
{"type": "text", "text": "Images returned by the preceding tool call(s)."},
|
||||||
|
]
|
||||||
|
pending_images.clear()
|
||||||
|
if next_message is not None and next_message.get("role") == "user":
|
||||||
|
existing = next_message.get("content")
|
||||||
|
if isinstance(existing, str):
|
||||||
|
content.append({"type": "text", "text": existing})
|
||||||
|
elif isinstance(existing, list):
|
||||||
|
content.extend(cast(list[dict[str, Any]], existing))
|
||||||
|
updated.append({**next_message, "content": content})
|
||||||
|
else:
|
||||||
|
updated.append({"role": "user", "content": content})
|
||||||
|
if next_message is not None:
|
||||||
|
updated.append(next_message)
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
content = message.get("content")
|
||||||
|
if message.get("role") == "tool" and isinstance(content, list):
|
||||||
|
blocks = cast(list[object], content)
|
||||||
|
images: list[dict[str, Any]] = []
|
||||||
|
text_blocks: list[object] = []
|
||||||
|
for block in blocks:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
block_data = cast(dict[str, Any], block)
|
||||||
|
image_url = block_data.get("image_url")
|
||||||
|
if block_data.get("type") == "image_url" and isinstance(
|
||||||
|
image_url, dict
|
||||||
|
):
|
||||||
|
images.append({"type": "image_url", "image_url": image_url})
|
||||||
|
continue
|
||||||
|
text_blocks.append(block_data)
|
||||||
|
else:
|
||||||
|
text_blocks.append(block)
|
||||||
|
if images:
|
||||||
|
updated.append({
|
||||||
|
**message,
|
||||||
|
"content": (
|
||||||
|
cls._coerce_content_to_string(text_blocks)
|
||||||
|
if text_blocks
|
||||||
|
else "(image returned)"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
pending_images.extend(images)
|
||||||
|
continue
|
||||||
|
if message.get("role") != "tool":
|
||||||
|
flush_images(message)
|
||||||
|
else:
|
||||||
|
updated.append(message)
|
||||||
|
flush_images()
|
||||||
|
return updated
|
||||||
|
|
||||||
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||||
@@ -824,9 +891,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
model_name = self._request_model_name(model_name)
|
model_name = self._request_model_name(model_name)
|
||||||
|
|
||||||
|
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
"messages": self._move_tool_images_to_user(sanitized_messages),
|
||||||
}
|
}
|
||||||
|
|
||||||
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
|
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ _TOOL_RESULT_PREVIEW_CHARS = 1200
|
|||||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||||
|
_IMAGE_TOKEN_ESTIMATE = 2048
|
||||||
_TRUNCATED_SUFFIX = "\n... (truncated)"
|
_TRUNCATED_SUFFIX = "\n... (truncated)"
|
||||||
|
|
||||||
|
|
||||||
@@ -676,6 +677,7 @@ def _estimate_prompt_tokens_with_source(
|
|||||||
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
reasoning_content, tool_call_id, name, plus per-message framing overhead.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
image_tokens = 0
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -687,6 +689,8 @@ def _estimate_prompt_tokens_with_source(
|
|||||||
text = part.get("text", "")
|
text = part.get("text", "")
|
||||||
if isinstance(text, str) and text:
|
if isinstance(text, str) and text:
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
|
elif part is not None and part.get("type") in {"image_url", "input_image"}:
|
||||||
|
image_tokens += _IMAGE_TOKEN_ESTIMATE
|
||||||
|
|
||||||
tc = msg.get("tool_calls")
|
tc = msg.get("tool_calls")
|
||||||
if tc:
|
if tc:
|
||||||
@@ -709,7 +713,7 @@ def _estimate_prompt_tokens_with_source(
|
|||||||
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
|
||||||
)
|
)
|
||||||
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
|
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
|
||||||
return message_tokens + tool_tokens + per_message_overhead, "tiktoken"
|
return message_tokens + image_tokens + tool_tokens + per_message_overhead, "tiktoken"
|
||||||
except Exception:
|
except Exception:
|
||||||
tool_payload = (
|
tool_payload = (
|
||||||
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
|
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
|
||||||
@@ -718,7 +722,7 @@ def _estimate_prompt_tokens_with_source(
|
|||||||
)
|
)
|
||||||
payload = message_payload + tool_payload
|
payload = message_payload + tool_payload
|
||||||
estimated = len(payload.encode("utf-8"))
|
estimated = len(payload.encode("utf-8"))
|
||||||
return estimated + per_message_overhead, "heuristic"
|
return estimated + image_tokens + per_message_overhead, "heuristic"
|
||||||
|
|
||||||
|
|
||||||
def estimate_prompt_tokens(
|
def estimate_prompt_tokens(
|
||||||
@@ -734,6 +738,7 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|||||||
"""Estimate prompt tokens contributed by one persisted message."""
|
"""Estimate prompt tokens contributed by one persisted message."""
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
image_tokens = 0
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
parts.append(content)
|
parts.append(content)
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
@@ -743,6 +748,8 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|||||||
text = part.get("text", "")
|
text = part.get("text", "")
|
||||||
if isinstance(text, str) and text:
|
if isinstance(text, str) and text:
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
|
elif part is not None and part.get("type") in {"image_url", "input_image"}:
|
||||||
|
image_tokens += _IMAGE_TOKEN_ESTIMATE
|
||||||
else:
|
else:
|
||||||
parts.append(json.dumps(raw_part, ensure_ascii=False))
|
parts.append(json.dumps(raw_part, ensure_ascii=False))
|
||||||
elif content is not None:
|
elif content is not None:
|
||||||
@@ -760,13 +767,13 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|||||||
parts.append(rc)
|
parts.append(rc)
|
||||||
|
|
||||||
payload = "\n".join(parts)
|
payload = "\n".join(parts)
|
||||||
if not payload:
|
if not payload and not image_tokens:
|
||||||
return 4
|
return 4
|
||||||
try:
|
try:
|
||||||
enc = _get_token_encoding()
|
enc = _get_token_encoding()
|
||||||
return max(4, len(enc.encode(payload)) + 4)
|
return max(4, len(enc.encode(payload)) + image_tokens + 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
return max(4, len(payload.encode("utf-8")) + 4)
|
return max(4, len(payload.encode("utf-8")) + image_tokens + 4)
|
||||||
|
|
||||||
|
|
||||||
def estimate_prompt_tokens_chain(
|
def estimate_prompt_tokens_chain(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -90,8 +89,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(config_path: Path | None = None) -> CliAppManager:
|
def _manager() -> CliAppManager:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = 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,
|
||||||
@@ -103,12 +102,8 @@ def _manager(config_path: Path | None = None) -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(
|
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||||
*,
|
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)
|
||||||
@@ -123,16 +118,11 @@ async def cli_apps_payload(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_action(
|
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
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(config_path) if config_path is not None else _manager()
|
manager = _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ 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
|
||||||
@@ -31,7 +29,6 @@ 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
|
||||||
@@ -53,7 +50,6 @@ 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,
|
||||||
@@ -67,7 +63,6 @@ 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()
|
||||||
@@ -107,7 +102,6 @@ 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,
|
||||||
@@ -121,7 +115,6 @@ 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,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 TYPE_CHECKING, Any, Literal, Mapping, cast
|
from typing import Any, Literal, Mapping, cast
|
||||||
|
|
||||||
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
|
||||||
@@ -25,9 +25,6 @@ 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]+",
|
||||||
@@ -844,9 +841,8 @@ 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_path) if config_path is not None else load_config()
|
config = 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)
|
||||||
@@ -932,11 +928,7 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def mcp_presets_test_action(
|
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||||
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
|
||||||
|
|
||||||
@@ -949,22 +941,16 @@ async def mcp_presets_test_action(
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = resolve_config_env_vars(
|
config = resolve_config_env_vars(load_config())
|
||||||
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(
|
return mcp_presets_payload(last_action={
|
||||||
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:
|
||||||
@@ -982,7 +968,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
if cfg.command and not _command_available(cfg.command):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -993,7 +979,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
@@ -1054,11 +1040,7 @@ async def mcp_presets_test_action(
|
|||||||
|
|
||||||
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(
|
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||||
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:
|
||||||
@@ -1239,35 +1221,24 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(
|
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
config = load_config()
|
||||||
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)
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
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"))
|
||||||
config.tools.mcp_servers.update(servers)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action={
|
||||||
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
|
||||||
|
|
||||||
@@ -1278,40 +1249,29 @@ def custom_mcp_action(
|
|||||||
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, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
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(
|
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
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_path) if config_path is not None else load_config()
|
config = 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, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||||
last_action=_action_message(action, preset),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1327,7 +1287,7 @@ def mcp_presets_action(
|
|||||||
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, config_path)
|
save_config(config)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
@@ -1343,10 +1303,7 @@ def mcp_presets_action(
|
|||||||
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(
|
payload = mcp_presets_payload(last_action=last_action)
|
||||||
last_action=last_action,
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1382,21 +1339,13 @@ 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(config_path=config_path)
|
return mcp_presets_payload()
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query, config_path=config_path)
|
return await mcp_presets_test_action(query)
|
||||||
if config is not None:
|
if action in _CUSTOM_ACTIONS:
|
||||||
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,7 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -16,13 +15,8 @@ from nanobot.webui.http_utils import query_first
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
def nanobot_features_payload() -> 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:
|
||||||
@@ -38,19 +32,13 @@ 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(
|
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||||
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)
|
||||||
@@ -62,9 +50,5 @@ 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(
|
return disable_optional_feature(name, instance_id=instance_id)
|
||||||
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)
|
||||||
|
|||||||
+164
-168
@@ -14,11 +14,11 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from typing import Any, Literal, cast
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -49,9 +49,6 @@ from nanobot.webui.workspaces import (
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
RuntimeSurface = Literal["browser", "native"]
|
RuntimeSurface = Literal["browser", "native"]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def _version_payload() -> dict[str, Any]:
|
def _version_payload() -> dict[str, Any]:
|
||||||
"""Return version info for the settings payload."""
|
"""Return version info for the settings payload."""
|
||||||
@@ -136,6 +133,9 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||||
|
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||||
|
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
||||||
|
_webui_oauth_flows_lock = threading.Lock()
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
@@ -148,21 +148,6 @@ class WebUISettingsError(ValueError):
|
|||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
def _load_settings_config(config_path: Path | None) -> Config:
|
|
||||||
return load_config(config_path) if config_path is not None else load_config()
|
|
||||||
|
|
||||||
|
|
||||||
def _save_settings_config(config: Config, config_path: Path | None) -> None:
|
|
||||||
if config_path is None:
|
|
||||||
save_config(config)
|
|
||||||
else:
|
|
||||||
save_config(config, config_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_config_path(config_path: Path | None) -> Path:
|
|
||||||
return config_path if config_path is not None else get_config_path()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||||
return "native" if surface in {"native", "desktop"} else "browser"
|
return "native" if surface in {"native", "desktop"} else "browser"
|
||||||
|
|
||||||
@@ -779,11 +764,7 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def provider_models_payload(
|
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||||
|
|
||||||
The result is advisory only: users can always type a custom model id. This
|
The result is advisory only: users can always type a custom model id. This
|
||||||
@@ -794,7 +775,7 @@ def provider_models_payload(
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1136,9 +1117,8 @@ def settings_payload(
|
|||||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||||
restart_required_sections: list[str] | None = None,
|
restart_required_sections: list[str] | None = None,
|
||||||
apply_state: dict[str, Any] | None = None,
|
apply_state: dict[str, Any] | None = None,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
@@ -1281,6 +1261,11 @@ def settings_payload(
|
|||||||
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"computer_use": {
|
||||||
|
"browser_enabled": config.tools.browser.enable,
|
||||||
|
"enabled": config.tools.computer_use.enable,
|
||||||
|
"backend": config.tools.computer_use.backend,
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"host": config.api.host,
|
"host": config.api.host,
|
||||||
"port": config.api.port,
|
"port": config.api.port,
|
||||||
@@ -1319,7 +1304,7 @@ def settings_payload(
|
|||||||
"providers": _transcription_provider_rows(config),
|
"providers": _transcription_provider_rows(config),
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"config_path": str(_settings_config_path(config_path).expanduser()),
|
"config_path": str(get_config_path().expanduser()),
|
||||||
"workspace_path": str(config.workspace_path),
|
"workspace_path": str(config.workspace_path),
|
||||||
"gateway_host": config.gateway.host,
|
"gateway_host": config.gateway.host,
|
||||||
"gateway_port": config.gateway.port,
|
"gateway_port": config.gateway.port,
|
||||||
@@ -1361,18 +1346,14 @@ def settings_payload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
def settings_usage_payload() -> dict[str, Any]:
|
||||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
def update_agent_settings(
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
@@ -1449,15 +1430,11 @@ def update_agent_settings(
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def create_model_configuration(
|
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||||
raw_name = (_query_first(query, "name") or label).strip()
|
raw_name = (_query_first(query, "name") or label).strip()
|
||||||
model = (_query_first(query, "model") or "").strip()
|
model = (_query_first(query, "model") or "").strip()
|
||||||
@@ -1471,7 +1448,7 @@ def create_model_configuration(
|
|||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
name = _model_configuration_slug(raw_name or label)
|
name = _model_configuration_slug(raw_name or label)
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if name in config.model_presets:
|
if name in config.model_presets:
|
||||||
raise WebUISettingsError("configuration already exists", status=409)
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
@@ -1504,22 +1481,18 @@ def create_model_configuration(
|
|||||||
temperature=temperature if temperature is not None else base.temperature,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
payload = settings_payload(config_path=config_path)
|
payload = settings_payload()
|
||||||
payload["created_model_preset"] = name
|
payload["created_model_preset"] = name
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_model_configuration(
|
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
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 or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
preset = config.model_presets.get(name)
|
preset = config.model_presets.get(name)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
@@ -1586,15 +1559,11 @@ def update_model_configuration(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_model_call_order(
|
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||||
if raw_order is None:
|
if raw_order is None:
|
||||||
raise WebUISettingsError("model call order is required")
|
raise WebUISettingsError("model call order is required")
|
||||||
@@ -1616,7 +1585,7 @@ def update_model_call_order(
|
|||||||
cast(str, name).strip()
|
cast(str, name).strip()
|
||||||
for name in cast(list[object], order)
|
for name in cast(list[object], order)
|
||||||
]
|
]
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
_, editable = _model_call_order_state(config)
|
_, editable = _model_call_order_state(config)
|
||||||
if not editable:
|
if not editable:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
@@ -1635,17 +1604,13 @@ def update_model_call_order(
|
|||||||
):
|
):
|
||||||
defaults.model_preset = normalized_order[0]
|
defaults.model_preset = normalized_order[0]
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def migrate_model_configurations(
|
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||||
_query: QueryParams | None = None,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Materialize legacy primary/inline model settings as named presets."""
|
"""Materialize legacy primary/inline model settings as named presets."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = config.resolve_preset()
|
primary = config.resolve_preset()
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
@@ -1698,20 +1663,16 @@ def migrate_model_configurations(
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def delete_model_configuration(
|
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
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 or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
@@ -1725,15 +1686,11 @@ def delete_model_configuration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
del config.model_presets[name]
|
del config.model_presets[name]
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def create_provider_settings(
|
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise WebUISettingsError("provider name is required")
|
raise WebUISettingsError("provider name is required")
|
||||||
@@ -1758,7 +1715,7 @@ def create_provider_settings(
|
|||||||
if not api_base:
|
if not api_base:
|
||||||
raise WebUISettingsError("API base is required")
|
raise WebUISettingsError("API base is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if _provider_display_name_exists(config, display_name):
|
if _provider_display_name_exists(config, display_name):
|
||||||
raise WebUISettingsError("provider already exists", status=409)
|
raise WebUISettingsError("provider already exists", status=409)
|
||||||
|
|
||||||
@@ -1767,22 +1724,18 @@ def create_provider_settings(
|
|||||||
updates["api_type"] = "auto"
|
updates["api_type"] = "auto"
|
||||||
provider_config = _validated_provider_config(None, updates)
|
provider_config = _validated_provider_config(None, updates)
|
||||||
setattr(config.providers, provider_key, provider_config)
|
setattr(config.providers, provider_key, provider_config)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
payload = settings_payload(config_path=config_path)
|
payload = settings_payload()
|
||||||
payload["created_provider"] = provider_key
|
payload["created_provider"] = provider_key
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_provider_settings(
|
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1824,7 +1777,7 @@ def update_provider_settings(
|
|||||||
changed = updated_provider_config != provider_config
|
changed = updated_provider_config != provider_config
|
||||||
if changed:
|
if changed:
|
||||||
setattr(config.providers, provider_key, updated_provider_config)
|
setattr(config.providers, provider_key, updated_provider_config)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
restart_required = (
|
restart_required = (
|
||||||
changed
|
changed
|
||||||
@@ -1832,15 +1785,10 @@ def update_provider_settings(
|
|||||||
and image_config.provider == provider_key
|
and image_config.provider == provider_key
|
||||||
and get_image_gen_provider(provider_key) is not None
|
and get_image_gen_provider(provider_key) is not None
|
||||||
)
|
)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def login_oauth_provider(
|
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -1855,10 +1803,7 @@ def login_oauth_provider(
|
|||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(
|
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||||
_load_settings_config(config_path),
|
|
||||||
config_path=config_path,
|
|
||||||
).providers.openai_codex.proxy or None
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
remote_browser_value = _query_first(query, "remote_browser")
|
remote_browser_value = _query_first(query, "remote_browser")
|
||||||
@@ -1876,7 +1821,7 @@ def login_oauth_provider(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
oauth_flows.register(spec.name, flow_id, flow)
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1900,16 +1845,13 @@ def login_oauth_provider(
|
|||||||
token = login_github_copilot(print_fn=lambda _message: None)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(
|
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||||
_load_settings_config(config_path),
|
|
||||||
config_path=config_path,
|
|
||||||
).providers.xai_grok.proxy or None
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
try:
|
try:
|
||||||
@@ -1920,7 +1862,7 @@ def login_oauth_provider(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
oauth_flows.register(spec.name, flow_id, flow)
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1936,9 +1878,6 @@ def login_oauth_provider(
|
|||||||
def complete_oauth_provider(
|
def complete_oauth_provider(
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
authorization_response: str | None = None,
|
authorization_response: str | None = None,
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
@@ -1948,7 +1887,7 @@ def complete_oauth_provider(
|
|||||||
if not flow_id:
|
if not flow_id:
|
||||||
raise WebUISettingsError("flow_id is required")
|
raise WebUISettingsError("flow_id is required")
|
||||||
|
|
||||||
flow = oauth_flows.get(spec.name, flow_id)
|
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||||
|
|
||||||
@@ -1970,7 +1909,7 @@ def complete_oauth_provider(
|
|||||||
except WebUISettingsError:
|
except WebUISettingsError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
oauth_flows.remove(spec.name, flow_id, flow)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||||
if token is None:
|
if token is None:
|
||||||
return {
|
return {
|
||||||
@@ -1978,18 +1917,13 @@ def complete_oauth_provider(
|
|||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"flow_id": flow_id,
|
||||||
}
|
}
|
||||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
||||||
if not token.access:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def logout_oauth_provider(
|
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -2003,7 +1937,7 @@ def logout_oauth_provider(
|
|||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||||
oauth_flows.clear(spec.name)
|
_clear_webui_oauth_flows(spec.name)
|
||||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||||
elif spec.name == "github_copilot":
|
elif spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
@@ -2014,23 +1948,77 @@ def logout_oauth_provider(
|
|||||||
elif spec.name == "xai_grok":
|
elif spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||||
|
|
||||||
oauth_flows.clear(spec.name)
|
_clear_webui_oauth_flows(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||||
|
|
||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_network_safety_settings(
|
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
||||||
query: QueryParams,
|
discarded: list[Any] = []
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
||||||
|
if existing.expired:
|
||||||
|
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
||||||
|
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
||||||
|
oldest_id = next(iter(_webui_oauth_flows))
|
||||||
|
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
||||||
|
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
||||||
|
for existing in discarded:
|
||||||
|
existing.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
|
if registered is None or registered[0] != provider_name:
|
||||||
|
return None
|
||||||
|
flow = registered[1]
|
||||||
|
if not flow.expired:
|
||||||
|
return flow
|
||||||
|
_webui_oauth_flows.pop(flow_id, None)
|
||||||
|
flow.cancel()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_webui_oauth_flow(
|
||||||
|
provider_name: str,
|
||||||
|
flow_id: str,
|
||||||
|
flow: Any,
|
||||||
*,
|
*,
|
||||||
config_path: Path | None = None,
|
cancel: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
|
if (
|
||||||
|
registered is not None
|
||||||
|
and registered[0] == provider_name
|
||||||
|
and registered[1] is flow
|
||||||
|
):
|
||||||
|
_webui_oauth_flows.pop(flow_id)
|
||||||
|
if cancel:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
flow_ids = [
|
||||||
|
flow_id
|
||||||
|
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
||||||
|
if registered_provider == provider_name
|
||||||
|
]
|
||||||
|
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||||
|
for flow in flows:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
raw_allow = (
|
raw_allow = (
|
||||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
||||||
@@ -2039,7 +2027,7 @@ def update_network_safety_settings(
|
|||||||
if raw_allow is None and raw_default_access_mode is None:
|
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")
|
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
changed = False
|
changed = False
|
||||||
if raw_allow is not None:
|
if raw_allow is not None:
|
||||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
@@ -2048,7 +2036,7 @@ def update_network_safety_settings(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
if raw_default_access_mode is not None:
|
if raw_default_access_mode is not None:
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
if default_access_mode == "restricted":
|
if default_access_mode == "restricted":
|
||||||
@@ -2059,20 +2047,40 @@ def update_network_safety_settings(
|
|||||||
write_webui_default_access_mode(default_access_mode)
|
write_webui_default_access_mode(default_access_mode)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise WebUISettingsError(str(exc)) from exc
|
raise WebUISettingsError(str(exc)) from exc
|
||||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
def update_web_search_settings(
|
def update_computer_use_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
raw_browser = _query_first_alias(query, "browser_enabled", "browserEnabled")
|
||||||
*,
|
raw_computer = _query_first_alias(query, "enabled", "computerEnabled")
|
||||||
config_path: Path | None = None,
|
if raw_browser is None and raw_computer is None:
|
||||||
) -> dict[str, Any]:
|
raise WebUISettingsError("browser_enabled or enabled is required")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
changed = False
|
||||||
|
if raw_browser is not None:
|
||||||
|
browser_enabled = _parse_bool(raw_browser, "browser_enabled")
|
||||||
|
if config.tools.browser.enable != browser_enabled:
|
||||||
|
config.tools.browser.enable = browser_enabled
|
||||||
|
changed = True
|
||||||
|
if raw_computer is not None:
|
||||||
|
computer_enabled = _parse_bool(raw_computer, "enabled")
|
||||||
|
if config.tools.computer_use.enable != computer_enabled:
|
||||||
|
config.tools.computer_use.enable = computer_enabled
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
save_config(config)
|
||||||
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
|
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
||||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
if provider_option is None:
|
if provider_option is None:
|
||||||
raise WebUISettingsError("unknown web search provider")
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
search_config = config.tools.web.search
|
search_config = config.tools.web.search
|
||||||
web_config = config.tools.web
|
web_config = config.tools.web
|
||||||
previous_provider = search_config.provider
|
previous_provider = search_config.provider
|
||||||
@@ -2151,17 +2159,13 @@ def update_web_search_settings(
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def update_api_settings(
|
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Update the managed OpenAI-compatible API configuration."""
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
api = config.api
|
api = config.api
|
||||||
|
|
||||||
host = _query_first(query, "host")
|
host = _query_first(query, "host")
|
||||||
@@ -2198,16 +2202,12 @@ def update_api_settings(
|
|||||||
if not is_loopback_host(api.host) and not api.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")
|
raise WebUISettingsError("an API key is required when the API is available on the network")
|
||||||
|
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(
|
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2300,16 +2300,12 @@ def update_image_generation_settings(
|
|||||||
raise WebUISettingsError("image generation provider is not configured")
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(
|
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
transcription = config.transcription
|
transcription = config.transcription
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2374,5 +2370,5 @@ def update_transcription_settings(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|||||||
+207
-254
@@ -13,6 +13,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
@@ -30,7 +31,7 @@ from nanobot.channels.contracts import (
|
|||||||
)
|
)
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.optional_features import (
|
from nanobot.optional_features import (
|
||||||
OptionalFeatureError,
|
OptionalFeatureError,
|
||||||
extra_installed,
|
extra_installed,
|
||||||
@@ -39,6 +40,7 @@ from nanobot.optional_features import (
|
|||||||
)
|
)
|
||||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||||
|
from nanobot.webui.http_utils import case_insensitive_header
|
||||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||||
from nanobot.webui.http_utils import query_first as _query_first
|
from nanobot.webui.http_utils import query_first as _query_first
|
||||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||||
@@ -62,6 +64,7 @@ from nanobot.webui.settings_api import (
|
|||||||
settings_usage_payload,
|
settings_usage_payload,
|
||||||
update_agent_settings,
|
update_agent_settings,
|
||||||
update_api_settings,
|
update_api_settings,
|
||||||
|
update_computer_use_settings,
|
||||||
update_image_generation_settings,
|
update_image_generation_settings,
|
||||||
update_model_call_order,
|
update_model_call_order,
|
||||||
update_model_configuration,
|
update_model_configuration,
|
||||||
@@ -70,13 +73,21 @@ 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 WebUISettingsServices
|
|
||||||
from nanobot.webui.version_check import check_for_update
|
from nanobot.webui.version_check import check_for_update
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||||
|
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||||
|
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||||
|
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||||
|
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
||||||
|
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
||||||
|
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
@@ -102,63 +113,6 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
|||||||
"/api/settings/mcp-presets/tools": "tools",
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_MUTATION_PATHS = frozenset({
|
|
||||||
"/api/settings/update",
|
|
||||||
"/api/settings/model-configurations/create",
|
|
||||||
"/api/settings/model-configurations/update",
|
|
||||||
"/api/settings/model-configurations/delete",
|
|
||||||
"/api/settings/model-configurations/migrate",
|
|
||||||
"/api/settings/model-call-order/update",
|
|
||||||
"/api/settings/provider/update",
|
|
||||||
"/api/settings/provider/create",
|
|
||||||
"/api/settings/provider/oauth-login",
|
|
||||||
"/api/settings/provider/oauth-login/complete",
|
|
||||||
"/api/settings/provider/oauth-logout",
|
|
||||||
"/api/settings/web-search/update",
|
|
||||||
"/api/settings/api-service/start",
|
|
||||||
"/api/settings/api-service/stop",
|
|
||||||
"/api/settings/image-generation/update",
|
|
||||||
"/api/settings/transcription/update",
|
|
||||||
"/api/settings/network-safety/update",
|
|
||||||
"/api/settings/cli-apps/install",
|
|
||||||
"/api/settings/cli-apps/update",
|
|
||||||
"/api/settings/cli-apps/uninstall",
|
|
||||||
"/api/settings/cli-apps/test",
|
|
||||||
"/api/settings/nanobot-features/enable",
|
|
||||||
"/api/settings/nanobot-features/disable",
|
|
||||||
"/api/settings/channels/validate",
|
|
||||||
"/api/settings/channels/configure",
|
|
||||||
"/api/settings/pairing/approve",
|
|
||||||
"/api/settings/pairing/deny",
|
|
||||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
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 _query_value(value: Any) -> str:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
|
||||||
return {
|
|
||||||
key: [_query_value(value)]
|
|
||||||
for key, value in payload.items()
|
|
||||||
if key
|
|
||||||
and key not in {"authorization_response", "channel", "values"}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsRouter:
|
class WebUISettingsRouter:
|
||||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||||
@@ -166,7 +120,6 @@ class WebUISettingsRouter:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
settings: WebUISettingsServices,
|
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
logger: Any,
|
logger: Any,
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
@@ -178,7 +131,6 @@ class WebUISettingsRouter:
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = settings
|
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._check_api_token = check_api_token
|
self._check_api_token = check_api_token
|
||||||
@@ -193,15 +145,6 @@ class WebUISettingsRouter:
|
|||||||
self._channel_connectors: dict[str, Any] = {}
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||||
if self.is_mutation_path(path) and not getattr(
|
|
||||||
request,
|
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR,
|
|
||||||
False,
|
|
||||||
):
|
|
||||||
return self._error_response(
|
|
||||||
405,
|
|
||||||
"WebUI mutations require an authenticated WebSocket",
|
|
||||||
)
|
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
if path == "/api/settings/usage":
|
if path == "/api/settings/usage":
|
||||||
@@ -232,6 +175,8 @@ class WebUISettingsRouter:
|
|||||||
return await self._handle_settings_provider_oauth(request, "logout")
|
return await self._handle_settings_provider_oauth(request, "logout")
|
||||||
if path == "/api/settings/web-search/update":
|
if path == "/api/settings/web-search/update":
|
||||||
return self._handle_settings_web_search_update(request)
|
return self._handle_settings_web_search_update(request)
|
||||||
|
if path == "/api/settings/computer-use/update":
|
||||||
|
return self._handle_settings_computer_use_update(request)
|
||||||
if path == "/api/settings/api-service":
|
if path == "/api/settings/api-service":
|
||||||
return self._handle_settings_api_service(request)
|
return self._handle_settings_api_service(request)
|
||||||
if path == "/api/settings/api-service/start":
|
if path == "/api/settings/api-service/start":
|
||||||
@@ -288,17 +233,7 @@ class WebUISettingsRouter:
|
|||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_mutation_path(path: str) -> bool:
|
|
||||||
return (
|
|
||||||
path in _SETTINGS_MUTATION_PATHS
|
|
||||||
or _channel_connect_route(path) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
payload = _mutation_payload(request)
|
|
||||||
if payload is not None:
|
|
||||||
return _payload_query(payload)
|
|
||||||
return self._parse_query(request.path)
|
return self._parse_query(request.path)
|
||||||
|
|
||||||
def _authorized(self, request: WsRequest) -> bool:
|
def _authorized(self, request: WsRequest) -> bool:
|
||||||
@@ -328,18 +263,70 @@ class WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
query = self._query(request)
|
||||||
|
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("MCP settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[object, Any], payload)
|
||||||
|
merged = {key: list(values) for key, values in query.items()}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
else:
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if text:
|
||||||
|
merged[key] = [text]
|
||||||
|
return merged
|
||||||
|
|
||||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
query = self._query(request)
|
||||||
|
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("provider settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
try:
|
||||||
|
payload = json.loads(unquote(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[object, Any], payload)
|
||||||
|
|
||||||
|
merged = {key: list(values) for key, values in query.items()}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value
|
||||||
|
elif value is None:
|
||||||
|
text = ""
|
||||||
|
else:
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
merged[key] = [text]
|
||||||
|
return merged
|
||||||
|
|
||||||
def _handle_settings(self, request: WsRequest) -> Response:
|
def _handle_settings(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(
|
return self._json_response(
|
||||||
self._with_restart_state(
|
self._with_restart_state(
|
||||||
self.settings.read(
|
settings_payload(
|
||||||
settings_payload,
|
|
||||||
surface=self._runtime_surface,
|
surface=self._runtime_surface,
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
)
|
)
|
||||||
@@ -349,7 +336,7 @@ class WebUISettingsRouter:
|
|||||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(self.settings.read(settings_usage_payload))
|
return self._json_response(settings_usage_payload())
|
||||||
|
|
||||||
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -395,7 +382,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
payload = update_agent_settings(self._query(request))
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -404,10 +391,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = create_model_configuration(self._query(request))
|
||||||
create_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -416,10 +400,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_model_configuration(self._query(request))
|
||||||
update_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -428,10 +409,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = delete_model_configuration(self._query(request))
|
||||||
delete_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -440,10 +418,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = migrate_model_configurations(self._query(request))
|
||||||
migrate_model_configurations,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -452,10 +427,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_model_call_order(self._query(request))
|
||||||
update_model_call_order,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -464,10 +436,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||||
update_provider_settings,
|
|
||||||
self._parse_provider_settings_query(request)
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -477,10 +446,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||||
create_provider_settings,
|
|
||||||
self._parse_provider_settings_query(request)
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -489,11 +455,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||||
self.settings.read,
|
|
||||||
provider_models_payload,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -511,33 +473,27 @@ class WebUISettingsRouter:
|
|||||||
query = self._query(request)
|
query = self._query(request)
|
||||||
try:
|
try:
|
||||||
if action == "login":
|
if action == "login":
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||||
self.settings.read,
|
|
||||||
login_oauth_provider,
|
|
||||||
query,
|
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
|
||||||
elif action == "complete":
|
elif action == "complete":
|
||||||
raw_response = (_mutation_payload(request) or {}).get(
|
authorization_response = case_insensitive_header(
|
||||||
"authorization_response"
|
request.headers,
|
||||||
|
_OAUTH_CALLBACK_HEADER,
|
||||||
|
) or case_insensitive_header(
|
||||||
|
request.headers,
|
||||||
|
_OAUTH_CODE_HEADER,
|
||||||
)
|
)
|
||||||
if raw_response is not None and not isinstance(raw_response, str):
|
if (
|
||||||
raise WebUISettingsError("OAuth authorization response must be a string")
|
len(authorization_response.encode("utf-8"))
|
||||||
authorization_response = raw_response
|
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("OAuth authorization response is too large")
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
self.settings.read,
|
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
authorization_response or None,
|
authorization_response or None,
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||||
self.settings.read,
|
|
||||||
logout_oauth_provider,
|
|
||||||
query,
|
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
if payload.get("status") in {"authorization_required", "pending"}:
|
if payload.get("status") in {"authorization_required", "pending"}:
|
||||||
@@ -548,14 +504,26 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_web_search_settings(self._query(request))
|
||||||
update_web_search_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||||
|
|
||||||
|
def _handle_settings_computer_use_update(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
query = self._query(request)
|
||||||
|
try:
|
||||||
|
payload = update_computer_use_settings(query)
|
||||||
|
except WebUISettingsError as e:
|
||||||
|
return self._error_response(e.status, e.message)
|
||||||
|
if payload.get("requires_restart"):
|
||||||
|
if "browser_enabled" in query or "browserEnabled" in query:
|
||||||
|
self._restart_sections.add("browser")
|
||||||
|
if "enabled" in query or "computerEnabled" in query:
|
||||||
|
self._restart_sections.add("runtime")
|
||||||
|
return self._json_response(self._with_restart_state(payload))
|
||||||
|
|
||||||
def _handle_settings_api_service(self, request: WsRequest) -> Response:
|
def _handle_settings_api_service(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
@@ -570,22 +538,19 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
{"name": ["api"]},
|
{"name": ["api"]},
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
self.settings.mutate(
|
update_api_settings(self._parse_api_service_settings_query(request))
|
||||||
update_api_settings,
|
config = load_config()
|
||||||
self._parse_api_service_settings_query(request),
|
|
||||||
)
|
|
||||||
config = self.settings.config.load()
|
|
||||||
runtime = self._api_runtime()
|
runtime = self._api_runtime()
|
||||||
options = ApiStartOptions(
|
options = ApiStartOptions(
|
||||||
host=config.api.host,
|
host=config.api.host,
|
||||||
port=config.api.port,
|
port=config.api.port,
|
||||||
workspace=str(config.workspace_path),
|
workspace=str(config.workspace_path),
|
||||||
config_path=str(self.settings.config.path),
|
config_path=str(get_config_path().expanduser().resolve(strict=False)),
|
||||||
)
|
)
|
||||||
current = runtime.status()
|
current = runtime.status()
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -602,12 +567,33 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(self._api_service_payload(last_action="started"))
|
return self._json_response(self._api_service_payload(last_action="started"))
|
||||||
|
|
||||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
payload = _mutation_payload(request)
|
query = self._query(request)
|
||||||
if payload is not None:
|
if "api_key" in query or "apiKey" in query:
|
||||||
api_key = payload.get("api_key")
|
raise WebUISettingsError("API service API key must be provided in the private header")
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
||||||
raise WebUISettingsError("API service API key must be a string")
|
if not raw:
|
||||||
return self._query(request)
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("API service settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid API service settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("API service settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
|
|
||||||
|
unknown = set(payload) - {"api_key"}
|
||||||
|
if unknown:
|
||||||
|
raise WebUISettingsError("API service settings payload contains an invalid key")
|
||||||
|
api_key = payload.get("api_key")
|
||||||
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
|
raise WebUISettingsError("API service API key must be a string")
|
||||||
|
|
||||||
|
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
||||||
|
if api_key is not None:
|
||||||
|
merged["api_key"] = [api_key]
|
||||||
|
return merged
|
||||||
|
|
||||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -621,11 +607,13 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, self._api_runtime_message(result.message))
|
return self._error_response(500, self._api_runtime_message(result.message))
|
||||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||||
|
|
||||||
def _api_runtime(self) -> ApiRuntime:
|
@staticmethod
|
||||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
def _api_runtime() -> ApiRuntime:
|
||||||
|
config_path = get_config_path().expanduser().resolve(strict=False)
|
||||||
|
return ApiRuntime(paths=api_runtime_paths(config_path))
|
||||||
|
|
||||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||||
config = self.settings.config.load()
|
config = load_config()
|
||||||
status = self._api_runtime().status()
|
status = self._api_runtime().status()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
@@ -669,10 +657,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_image_generation_settings(self._query(request))
|
||||||
update_image_generation_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -707,10 +692,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_transcription_settings(self._query(request))
|
||||||
update_transcription_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -719,10 +701,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_network_safety_settings(self._query(request))
|
||||||
update_network_safety_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -736,10 +715,7 @@ class WebUISettingsRouter:
|
|||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
payload = await cli_apps_payload(
|
payload = await cli_apps_payload(installed_only=installed_only)
|
||||||
installed_only=installed_only,
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
return self._error_response(500, "failed to load CLI Apps")
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
@@ -753,12 +729,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||||
cli_apps_action,
|
|
||||||
action,
|
|
||||||
self._query(request),
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -773,29 +744,12 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
payload = await asyncio.to_thread(nanobot_features_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load nanobot features")
|
self.logger.exception("failed to load nanobot features")
|
||||||
return self._error_response(500, "failed to load nanobot features")
|
return self._error_response(500, "failed to load nanobot features")
|
||||||
return self._json_response(self._with_channel_runtime_status(payload))
|
return self._json_response(self._with_channel_runtime_status(payload))
|
||||||
|
|
||||||
def _nanobot_features_payload(self) -> dict[str, Any]:
|
|
||||||
return nanobot_features_payload(config_path=self.settings.config.path)
|
|
||||||
|
|
||||||
def _nanobot_features_action(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
allow_install: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return self.settings.mutate(
|
|
||||||
nanobot_features_action,
|
|
||||||
action,
|
|
||||||
query,
|
|
||||||
allow_install=allow_install,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_settings_nanobot_features_action(
|
async def _handle_settings_nanobot_features_action(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -806,7 +760,7 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
action,
|
action,
|
||||||
self._query(request),
|
self._query(request),
|
||||||
allow_install=action != "enable"
|
allow_install=action != "enable"
|
||||||
@@ -914,7 +868,7 @@ class WebUISettingsRouter:
|
|||||||
saved = await asyncio.to_thread(
|
saved = await asyncio.to_thread(
|
||||||
self._save_channel_config_values,
|
self._save_channel_config_values,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values(request),
|
self._parse_channel_values_header(request),
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -929,7 +883,7 @@ class WebUISettingsRouter:
|
|||||||
"saved_keys": saved,
|
"saved_keys": saved,
|
||||||
}
|
}
|
||||||
if not enable:
|
if not enable:
|
||||||
features = await asyncio.to_thread(self._nanobot_features_payload)
|
features = await asyncio.to_thread(nanobot_features_payload)
|
||||||
features = self._with_channel_runtime_status(features)
|
features = self._with_channel_runtime_status(features)
|
||||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
@@ -940,7 +894,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
feature_query,
|
feature_query,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
@@ -970,7 +924,7 @@ class WebUISettingsRouter:
|
|||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
validate_channel_config,
|
validate_channel_config,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values(request),
|
self._parse_channel_values_header(request),
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -980,14 +934,19 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, "failed to validate channel settings")
|
return self._error_response(500, "failed to validate channel settings")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
|
|
||||||
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
||||||
payload = _mutation_payload(request)
|
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
||||||
if payload is None or "values" not in payload:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
values = payload.get("values")
|
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
||||||
if not isinstance(values, dict):
|
raise WebUISettingsError("channel settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid channel settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||||
return cast(dict[str, Any], values)
|
return cast(dict[str, Any], payload)
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
@@ -1008,47 +967,44 @@ class WebUISettingsRouter:
|
|||||||
if not raw_values:
|
if not raw_values:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def update(config: Config) -> list[str]:
|
config = load_config()
|
||||||
section = getattr(config.channels, name, None)
|
section = getattr(config.channels, name, None)
|
||||||
channel_config = channel_instance_config(
|
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 = self._coerce_channel_value(raw_key, raw_value, value_type)
|
||||||
|
if value is _SKIP_FIELD:
|
||||||
|
continue
|
||||||
|
self._assign_channel_config_value(channel_config, field, value)
|
||||||
|
saved.append(raw_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_section = channel_update_instance_config(
|
||||||
plugin,
|
plugin,
|
||||||
section,
|
section,
|
||||||
|
channel_config,
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
saved: list[str] = []
|
raise WebUISettingsError(
|
||||||
prefix = f"channels.{name}."
|
f"Invalid {name} configuration: {exc}",
|
||||||
for raw_key, raw_value in raw_values.items():
|
status=400,
|
||||||
if not raw_key:
|
) from exc
|
||||||
raise WebUISettingsError(
|
setattr(config.channels, name, updated_section)
|
||||||
"channel settings payload contains an invalid key"
|
save_config(config)
|
||||||
)
|
return saved
|
||||||
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 = self._coerce_channel_value(raw_key, raw_value, value_type)
|
|
||||||
if value is _SKIP_FIELD:
|
|
||||||
continue
|
|
||||||
self._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
|
|
||||||
|
|
||||||
return self.settings.config.update(update)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_channel_value(
|
def _coerce_channel_value(
|
||||||
@@ -1171,14 +1127,14 @@ class WebUISettingsRouter:
|
|||||||
target["instance_id"] = [str(payload["instance_id"])]
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
target,
|
target,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
except OptionalFeatureError as exc:
|
except OptionalFeatureError as exc:
|
||||||
features = self._feature_runtime_fallback(
|
features = self._feature_runtime_fallback(
|
||||||
self._nanobot_features_payload(),
|
nanobot_features_payload(),
|
||||||
message=(
|
message=(
|
||||||
f"{channel_name} connected, but enabling channel support failed: "
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
f"{exc.message}"
|
f"{exc.message}"
|
||||||
@@ -1199,9 +1155,7 @@ class WebUISettingsRouter:
|
|||||||
if _is_local_browser_request(connection, request.headers):
|
if _is_local_browser_request(connection, request.headers):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
return bool(
|
return bool(load_config().tools.webui_allow_remote_package_install)
|
||||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load remote package install policy")
|
self.logger.exception("failed to load remote package install policy")
|
||||||
return False
|
return False
|
||||||
@@ -1218,7 +1172,6 @@ class WebUISettingsRouter:
|
|||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
self._parse_mcp_settings_query(request),
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
config=self.settings.config,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = getattr(e, "status", 500)
|
status = getattr(e, "status", 500)
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -10,7 +10,6 @@ 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__
|
||||||
|
|
||||||
@@ -43,13 +42,7 @@ def check_for_update() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_cache = (now, latest)
|
_cache = (now, latest)
|
||||||
|
|
||||||
if not isinstance(latest, str) or not latest:
|
if not latest or latest == __version__:
|
||||||
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__,
|
||||||
|
|||||||
+25
-175
@@ -17,10 +17,9 @@ 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 quote, unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -119,60 +118,7 @@ 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
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||||
_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",
|
|
||||||
}
|
|
||||||
|
|
||||||
_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'
|
||||||
@@ -204,7 +150,6 @@ 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)
|
||||||
@@ -214,33 +159,6 @@ 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
|
||||||
@@ -293,7 +211,6 @@ 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,
|
||||||
@@ -314,7 +231,6 @@ 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()
|
||||||
@@ -333,7 +249,6 @@ 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,
|
||||||
@@ -370,86 +285,11 @@ 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,
|
||||||
@@ -806,7 +646,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 = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
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,
|
||||||
@@ -902,7 +742,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 = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
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")
|
||||||
@@ -1134,7 +974,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 = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
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 ""
|
||||||
@@ -1175,7 +1015,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 = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
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"}:
|
||||||
@@ -1207,7 +1047,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(_request_query(request), "name") or ""
|
name = _query_first(_parse_query(request.path), "name") or ""
|
||||||
try:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1254,14 +1094,18 @@ 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")
|
||||||
payload = _mutation_payload(request)
|
query = _parse_query(request.path)
|
||||||
state_value = payload.get("state") if payload is not None else None
|
raw_state = _query_first(query, "state")
|
||||||
if state_value is None:
|
if raw_state is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
if not isinstance(state_value, dict):
|
try:
|
||||||
|
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], state_value))
|
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1330,10 +1174,16 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
payload = _mutation_payload(request)
|
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||||
if payload is None or "values" not in payload:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
values = payload.get("values")
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -37,7 +37,6 @@ 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",
|
||||||
@@ -89,6 +88,11 @@ pdf = [
|
|||||||
olostep = [
|
olostep = [
|
||||||
"olostep>=0.1.0; python_version < '3.14'",
|
"olostep>=0.1.0; python_version < '3.14'",
|
||||||
]
|
]
|
||||||
|
computer-use = [
|
||||||
|
"pyautogui>=0.9.54",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"playwright>=1.48.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from nanobot.agent.context_governance import ContextGovernor
|
from nanobot.agent.context_governance import ContextGovernor
|
||||||
|
|
||||||
|
|
||||||
|
def _image_result(label: str) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{label}"}},
|
||||||
|
{"type": "text", "text": label},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _assistant_tool_call(call_id: str) -> dict:
|
def _assistant_tool_call(call_id: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -37,3 +44,21 @@ def test_drop_orphan_tool_results_drops_duplicate_tool_result() -> None:
|
|||||||
tool_results = [m for m in result if m.get("role") == "tool"]
|
tool_results = [m for m in result if m.get("role") == "tool"]
|
||||||
assert len(tool_results) == 1
|
assert len(tool_results) == 1
|
||||||
assert tool_results[0]["content"] == "first"
|
assert tool_results[0]["content"] == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_stale_visual_tool_images_keeps_latest_per_tool() -> None:
|
||||||
|
messages = [
|
||||||
|
{"role": "tool", "name": "computer_use", "content": _image_result("history")},
|
||||||
|
{"role": "tool", "name": "computer_use", "content": _image_result("old")},
|
||||||
|
{"role": "tool", "name": "browser", "content": _image_result("browser")},
|
||||||
|
{"role": "tool", "name": "computer_use", "content": _image_result("latest")},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = ContextGovernor.drop_stale_visual_tool_images(messages, start_index=1)
|
||||||
|
|
||||||
|
assert result is not messages
|
||||||
|
assert result[0]["content"] == messages[0]["content"]
|
||||||
|
assert [block["type"] for block in result[1]["content"]] == ["text", "text"]
|
||||||
|
assert result[2]["content"] == messages[2]["content"]
|
||||||
|
assert result[3]["content"] == messages[3]["content"]
|
||||||
|
assert messages[1]["content"][0]["type"] == "image_url"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -35,13 +34,6 @@ 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
|
||||||
@@ -248,7 +240,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
output = tool._inspect_all()
|
output = tool._inspect_all()
|
||||||
assert "model_preset: 'fast'" in output
|
assert "model_preset: 'fast'" in output
|
||||||
|
|
||||||
@@ -258,7 +250,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
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"
|
||||||
@@ -271,7 +263,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "default")
|
result = tool._modify("model_preset", "default")
|
||||||
|
|
||||||
@@ -288,7 +280,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "missing")
|
result = tool._modify("model_preset", "missing")
|
||||||
|
|
||||||
@@ -303,7 +295,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -326,7 +318,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -351,7 +343,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
session = loop.sessions.get_or_create("cli:one")
|
session = loop.sessions.get_or_create("cli:one")
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
@@ -374,7 +366,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 = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
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
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ class TestHandleStop:
|
|||||||
|
|
||||||
loop.subagents.close = close_subagents
|
loop.subagents.close = close_subagents
|
||||||
loop._exec_session_manager.close_all = AsyncMock()
|
loop._exec_session_manager.close_all = AsyncMock()
|
||||||
|
loop.tools.close = AsyncMock()
|
||||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
assert events == ["turn_cancelled", "resources_closed"]
|
assert events == ["turn_cancelled", "resources_closed"]
|
||||||
assert task.cancelled()
|
assert task.cancelled()
|
||||||
|
loop.tools.close.assert_awaited_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_close_mcp_serializes_duplicate_cleanup(self):
|
async def test_close_mcp_serializes_duplicate_cleanup(self):
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ 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"
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"""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,12 +8,10 @@ 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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -29,16 +27,13 @@ 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 = ExecToolConfig()
|
loop.exec_config = MagicMock()
|
||||||
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.last_usage = loop._last_usage
|
loop._runtime_vars = {}
|
||||||
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 = []
|
||||||
@@ -50,7 +45,9 @@ def _make_mock_loop(**overrides):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = WebToolsConfig()
|
loop.web_config = MagicMock()
|
||||||
|
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
|
||||||
@@ -58,13 +55,10 @@ 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():
|
||||||
@@ -73,10 +67,10 @@ def _make_mock_loop(**overrides):
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _make_tool(loop=None):
|
def _make_tool(runtime_state=None):
|
||||||
if loop is None:
|
if runtime_state is None:
|
||||||
loop = _make_mock_loop()
|
runtime_state = _make_mock_loop()
|
||||||
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
return MyTool(runtime_state=runtime_state)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -93,10 +87,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_scratchpad(self):
|
async def test_inspect_includes_runtime_vars(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
tool = _make_tool(loop=loop)
|
loop._runtime_vars = {"task": "review"}
|
||||||
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "task" in result
|
assert "task" in result
|
||||||
|
|
||||||
@@ -156,7 +150,9 @@ 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()
|
||||||
tool = _make_tool(loop=loop)
|
loop.web_config = MagicMock()
|
||||||
|
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
|
||||||
|
|
||||||
@@ -164,7 +160,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(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
|
|
||||||
@@ -183,16 +179,20 @@ 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.search = WebSearchConfig(
|
loop.web_config = MagicMock()
|
||||||
provider="tavily",
|
loop.web_config.search = SearchConfig()
|
||||||
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 "tavily" in result
|
assert "provider='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_control.snapshot().max_iterations == 80
|
assert tool._runtime_state.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_control.snapshot().max_iterations == 40
|
assert tool._runtime_state.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_control.snapshot().max_iterations == 80
|
assert tool._runtime_state.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(loop=loop)
|
tool = _make_tool(runtime_state=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_control.snapshot().provider_retry_mode == "persistent"
|
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_new_key_stores_in_scratchpad(self):
|
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||||
"""Modifying an unknown key should store it in the scratchpad."""
|
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
||||||
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_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
assert tool._runtime_state._runtime_vars["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_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
assert tool._runtime_state._runtime_vars["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_control.snapshot().scratchpad["data"] == {"a": 1}
|
assert tool._runtime_state._runtime_vars["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_control.snapshot().provider_retry_mode == "standard"
|
assert tool._runtime_state.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_control.snapshot().max_tool_result_chars == 16000
|
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -486,12 +486,11 @@ class TestModifyOpen:
|
|||||||
assert "protected" in result
|
assert "protected" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_workspace_preserves_display_compatibility(self):
|
async def test_modify_workspace_allowed(self):
|
||||||
"""The compatibility value is isolated from filesystem security boundaries."""
|
"""workspace was READONLY in v1, now freely modifiable."""
|
||||||
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):
|
||||||
@@ -585,28 +584,28 @@ class TestUnknownAction:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# scratchpad limits
|
# runtime_vars limits (from code review)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestScratchpadLimits:
|
class TestRuntimeVarsLimits:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scratchpad_rejects_at_max_keys(self):
|
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||||
tool = _make_tool()
|
loop = _make_mock_loop()
|
||||||
for i in range(64):
|
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
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 tool._runtime_control.snapshot().scratchpad
|
assert "overflow" not in loop._runtime_vars
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||||
tool = _make_tool()
|
loop = _make_mock_loop()
|
||||||
for i in range(64):
|
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
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 tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
assert loop._runtime_vars["key_0"] == "updated"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -845,7 +844,7 @@ class TestInspectTaskStatuses:
|
|||||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
@@ -866,7 +865,7 @@ class TestInspectTaskStatuses:
|
|||||||
stop_reason="completed",
|
stop_reason="completed",
|
||||||
)
|
)
|
||||||
loop.subagents._task_statuses = {"xyz": status}
|
loop.subagents._task_statuses = {"xyz": status}
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
@@ -880,10 +879,7 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
def _make_readonly_tool(self):
|
def _make_readonly_tool(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(
|
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||||
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):
|
||||||
@@ -908,13 +904,13 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# scratchpad inspection
|
# runtime vars check fallback (Fix #1: cross-turn memory)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestScratchpadInspection:
|
class TestRuntimeVarsInspectFallback:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_scratchpad_value_after_modify(self):
|
async def test_inspect_runtime_var_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)
|
||||||
@@ -922,14 +918,14 @@ class TestScratchpadInspection:
|
|||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_scratchpad_string(self):
|
async def test_inspect_runtime_var_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_scratchpad_dict(self):
|
async def test_inspect_runtime_var_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")
|
||||||
@@ -962,7 +958,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(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
|
|
||||||
@@ -971,7 +967,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(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
|
|
||||||
@@ -980,7 +976,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(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
|
|
||||||
@@ -996,7 +992,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(loop=loop)
|
tool = _make_tool(runtime_state=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
|
||||||
|
|
||||||
@@ -1087,8 +1083,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": ModelPresetConfig(model="fast-model")}
|
presets = {"fast": {"model": "fast-model"}}
|
||||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||||
|
|
||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
action="set",
|
action="set",
|
||||||
@@ -1097,14 +1093,14 @@ class TestSecurityAttributeProtection:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "read-only" in result
|
assert "read-only" in result
|
||||||
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
assert presets == {"fast": {"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(loop=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(runtime_state=_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")
|
||||||
|
|
||||||
@@ -1154,8 +1150,7 @@ 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 = {}
|
||||||
loop.last_usage = loop._last_usage
|
tool = _make_tool(runtime_state=loop)
|
||||||
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(tmp_path) -> None:
|
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||||
provider = MagicMock()
|
loop = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
loop.max_iterations = 40
|
||||||
loop = AgentLoop(
|
loop._runtime_vars = {}
|
||||||
bus=MessageBus(),
|
loop.subagents = MagicMock()
|
||||||
provider=provider,
|
loop.subagents.max_iterations = loop.max_iterations
|
||||||
workspace=tmp_path,
|
|
||||||
max_iterations=40,
|
def _sync_subagent_runtime_limits() -> None:
|
||||||
)
|
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,6 +241,24 @@ 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,7 +651,6 @@ 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)
|
||||||
@@ -661,7 +660,6 @@ 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",
|
||||||
@@ -740,7 +738,6 @@ 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(
|
||||||
@@ -759,7 +756,6 @@ 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(
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
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,6 +1231,27 @@ 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(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_completions_moves_tool_images_after_parallel_results():
|
||||||
|
image = {
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||||
|
"_meta": {"path": "screen.png"},
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "a"}, {"id": "b"}]},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "a",
|
||||||
|
"content": [image, {"type": "text", "text": "clicked"}],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "b", "content": "other result"},
|
||||||
|
{"role": "assistant", "content": "done"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = OpenAICompatProvider._move_tool_images_to_user(messages)
|
||||||
|
|
||||||
|
assert result[1]["content"] == "clicked"
|
||||||
|
assert result[2] == messages[2]
|
||||||
|
assert result[3]["role"] == "user"
|
||||||
|
assert result[3]["content"][0] == {
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||||
|
}
|
||||||
|
assert result[4] == messages[3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_completions_merges_tool_images_into_following_user_message():
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "a",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "continue"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = OpenAICompatProvider._move_tool_images_to_user(messages)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["content"] == "(image returned)"
|
||||||
|
assert result[1]["role"] == "user"
|
||||||
|
assert result[1]["content"][-1] == {"type": "text", "text": "continue"}
|
||||||
@@ -243,6 +243,27 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Tests for DOM-based browser control."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.browser_tool import BrowserTool, BrowserToolConfig
|
||||||
|
from nanobot.agent.tools.computer_use_backends import browser_playwright
|
||||||
|
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
|
||||||
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDomBackend:
|
||||||
|
environment = "browser"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
self.elements = [
|
||||||
|
{"ref": 1, "tag": "button", "role": "", "type": "", "name": "Submit", "href": ""},
|
||||||
|
{"ref": 2, "tag": "input", "role": "", "type": "text", "name": "your name", "href": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def navigate(self, url):
|
||||||
|
self.calls.append(("navigate", url))
|
||||||
|
|
||||||
|
async def dom_snapshot(self, max_elements=200):
|
||||||
|
return self.elements
|
||||||
|
|
||||||
|
async def click_ref(self, ref):
|
||||||
|
self.calls.append(("click", ref))
|
||||||
|
|
||||||
|
async def fill_ref(self, ref, text, submit=False):
|
||||||
|
self.calls.append(("fill", ref, text, submit))
|
||||||
|
|
||||||
|
async def select_ref(self, ref, value):
|
||||||
|
self.calls.append(("select", ref, value))
|
||||||
|
|
||||||
|
async def scroll_page(self, direction, amount):
|
||||||
|
self.calls.append(("scroll", direction, amount))
|
||||||
|
|
||||||
|
async def key(self, combo):
|
||||||
|
self.calls.append(("key", combo))
|
||||||
|
|
||||||
|
async def go_back(self):
|
||||||
|
self.calls.append(("back",))
|
||||||
|
|
||||||
|
async def read_text(self, max_chars=4000):
|
||||||
|
return "the number is 42"
|
||||||
|
|
||||||
|
async def current_url(self):
|
||||||
|
return "http://test.local/page"
|
||||||
|
|
||||||
|
async def screenshot(self):
|
||||||
|
from PIL import Image
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (1280, 800), (0, 0, 0)).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.calls.append(("close",))
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(**kw):
|
||||||
|
fb = _FakeDomBackend()
|
||||||
|
return BrowserTool(BrowserToolConfig(**kw), backend_impl=fb), fb
|
||||||
|
|
||||||
|
|
||||||
|
def _route(url: str, *, navigation: bool):
|
||||||
|
return SimpleNamespace(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
url=url,
|
||||||
|
is_navigation_request=MagicMock(return_value=navigation),
|
||||||
|
),
|
||||||
|
abort=AsyncMock(),
|
||||||
|
continue_=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigAndMetadata:
|
||||||
|
def test_defaults_off(self):
|
||||||
|
cfg = BrowserToolConfig()
|
||||||
|
assert cfg.enable is False
|
||||||
|
assert cfg.headless is True
|
||||||
|
assert cfg.include_screenshot is False
|
||||||
|
assert cfg.max_elements == 200
|
||||||
|
assert cfg.max_sessions == 8
|
||||||
|
|
||||||
|
def test_enabled_reads_config(self):
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.config.browser.enable = True
|
||||||
|
assert BrowserTool.enabled(ctx) is True
|
||||||
|
ctx.config.browser.enable = False
|
||||||
|
assert BrowserTool.enabled(ctx) is False
|
||||||
|
|
||||||
|
def test_create_from_ctx(self):
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.config.browser = BrowserToolConfig(enable=True, allowed_domains=["example.com"])
|
||||||
|
tool = BrowserTool.create(ctx)
|
||||||
|
assert isinstance(tool, BrowserTool)
|
||||||
|
assert tool.config.allowed_domains == ["example.com"]
|
||||||
|
|
||||||
|
def test_metadata(self):
|
||||||
|
tool, _ = _tool()
|
||||||
|
assert tool.name == "browser"
|
||||||
|
assert tool.exclusive is True
|
||||||
|
assert tool.read_only is False
|
||||||
|
assert "subagent" not in tool._scopes
|
||||||
|
|
||||||
|
def test_schema_actions(self):
|
||||||
|
tool, _ = _tool()
|
||||||
|
enum = tool.parameters["properties"]["action"]["enum"]
|
||||||
|
for a in ("navigate", "snapshot", "click", "type", "read_text"):
|
||||||
|
assert a in enum
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatch:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigate_returns_snapshot(self):
|
||||||
|
tool, fb = _tool()
|
||||||
|
result = await tool.execute(action="navigate", url="https://example.com")
|
||||||
|
assert ("navigate", "https://example.com") in fb.calls
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "Navigated to https://example.com" in result
|
||||||
|
# snapshot of interactive elements is appended
|
||||||
|
assert '[1] button "Submit"' in result
|
||||||
|
assert '[2] input[text] "your name"' in result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("action", "kwargs", "expected"),
|
||||||
|
[
|
||||||
|
("click", {"ref": 1}, ("click", 1)),
|
||||||
|
("type", {"ref": 2, "text": "Ada", "submit": True}, ("fill", 2, "Ada", True)),
|
||||||
|
("select", {"ref": 2, "value": "opt1"}, ("select", 2, "opt1")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_element_actions(self, action, kwargs, expected):
|
||||||
|
tool, fb = _tool()
|
||||||
|
result = await tool.execute(action=action, **kwargs)
|
||||||
|
assert expected in fb.calls
|
||||||
|
assert "Interactive elements" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_and_key_and_back(self):
|
||||||
|
tool, fb = _tool()
|
||||||
|
await tool.execute(action="scroll", scroll_direction="down", scroll_amount=4)
|
||||||
|
await tool.execute(action="key", text="Enter")
|
||||||
|
await tool.execute(action="back")
|
||||||
|
assert ("scroll", "down", 4) in fb.calls
|
||||||
|
assert ("key", "Enter") in fb.calls
|
||||||
|
assert ("back",) in fb.calls
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_text_returns_text_no_snapshot(self):
|
||||||
|
tool, _ = _tool()
|
||||||
|
result = await tool.execute(action="read_text")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "the number is 42" in result
|
||||||
|
assert "Interactive elements" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_include_screenshot_returns_blocks(self):
|
||||||
|
tool, _ = _tool(include_screenshot=True)
|
||||||
|
result = await tool.execute(action="click", ref=1)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
imgs = [b for b in result if b.get("type") == "image_url"]
|
||||||
|
texts = [b for b in result if b.get("type") == "text"]
|
||||||
|
assert imgs and texts
|
||||||
|
assert "Clicked element [1]" in texts[-1]["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calls_are_serialized_across_sessions(self):
|
||||||
|
class SlowBackend(_FakeDomBackend):
|
||||||
|
active = 0
|
||||||
|
max_active = 0
|
||||||
|
|
||||||
|
async def dom_snapshot(self, max_elements=200):
|
||||||
|
self.active += 1
|
||||||
|
self.max_active = max(self.max_active, self.active)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
self.active -= 1
|
||||||
|
return await super().dom_snapshot(max_elements)
|
||||||
|
|
||||||
|
backend = SlowBackend()
|
||||||
|
tool = BrowserTool(backend_impl=backend)
|
||||||
|
|
||||||
|
async def snapshot(session: str):
|
||||||
|
with request_context(
|
||||||
|
RequestContext(channel="test", chat_id=session, session_key=session)
|
||||||
|
):
|
||||||
|
return await tool.execute(action="snapshot")
|
||||||
|
|
||||||
|
await asyncio.gather(snapshot("a"), snapshot("b"))
|
||||||
|
|
||||||
|
assert backend.max_active == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorsAndPolicy:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "error"),
|
||||||
|
[
|
||||||
|
({"action": "teleport"}, "unknown action"),
|
||||||
|
({"action": "click"}, "requires an element 'ref'"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_errors_are_returned_to_model(self, kwargs, error):
|
||||||
|
tool, _ = _tool()
|
||||||
|
result = await tool.execute(**kwargs)
|
||||||
|
assert isinstance(result, str) and error in result
|
||||||
|
assert result.is_error is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_blocks_disallowed_navigation(self):
|
||||||
|
backend = BrowserBackend(allowed_domains=["example.com"])
|
||||||
|
page = SimpleNamespace(goto=AsyncMock())
|
||||||
|
backend._page = page
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="allowed_domains"):
|
||||||
|
await backend.navigate("https://evil.test/")
|
||||||
|
|
||||||
|
page.goto.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_allows_subdomain_navigation(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
check = MagicMock(return_value=(True, ""))
|
||||||
|
monkeypatch.setattr(browser_playwright, "validate_url_target", check)
|
||||||
|
backend = BrowserBackend(allowed_domains=["example.com"])
|
||||||
|
page = SimpleNamespace(goto=AsyncMock())
|
||||||
|
backend._page = page
|
||||||
|
|
||||||
|
await backend.navigate("https://app.example.com/x")
|
||||||
|
|
||||||
|
page.goto.assert_awaited_once_with("https://app.example.com/x")
|
||||||
|
check.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"ws://localhost/socket",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_network_policy_blocks_local_targets(self, url: str):
|
||||||
|
backend = BrowserBackend()
|
||||||
|
with pytest.raises(ValueError, match="blocked"):
|
||||||
|
await backend.navigate(url)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_intercepts_blocked_navigation(self):
|
||||||
|
backend = BrowserBackend(allowed_domains=["example.com"])
|
||||||
|
route = _route("https://evil.test/", navigation=True)
|
||||||
|
|
||||||
|
await backend._route_request(route)
|
||||||
|
|
||||||
|
route.abort.assert_awaited_once_with("blockedbyclient")
|
||||||
|
route.continue_.assert_not_awaited()
|
||||||
|
assert "allowed_domains" in (backend.pop_blocked_navigation() or "")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_intercepts_private_subresource(self):
|
||||||
|
backend = BrowserBackend()
|
||||||
|
route = _route(
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
navigation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await backend._route_request(route)
|
||||||
|
|
||||||
|
route.abort.assert_awaited_once_with("blockedbyclient")
|
||||||
|
assert backend.pop_blocked_navigation() is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_does_not_apply_navigation_allowlist_to_subresources(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
browser_playwright,
|
||||||
|
"validate_url_target",
|
||||||
|
MagicMock(return_value=(True, "")),
|
||||||
|
)
|
||||||
|
backend = BrowserBackend(allowed_domains=["example.com"])
|
||||||
|
route = _route("https://cdn.other.test/app.js", navigation=False)
|
||||||
|
|
||||||
|
await backend._route_request(route)
|
||||||
|
|
||||||
|
route.continue_.assert_awaited_once()
|
||||||
|
route.abort.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_intercepts_private_websocket(self):
|
||||||
|
backend = BrowserBackend()
|
||||||
|
web_socket = SimpleNamespace(
|
||||||
|
url="ws://127.0.0.1/socket",
|
||||||
|
close=AsyncMock(),
|
||||||
|
connect_to_server=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await backend._route_web_socket(web_socket)
|
||||||
|
|
||||||
|
web_socket.close.assert_awaited_once()
|
||||||
|
web_socket.connect_to_server.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_rejects_file_start_url_before_launch(self):
|
||||||
|
backend = BrowserBackend(start_url="file:///etc/passwd")
|
||||||
|
with pytest.raises(ValueError, match="start_url is blocked"):
|
||||||
|
await backend.dimensions()
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Tests for screenshot-based computer control."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.computer_use import ComputerUseTool, ComputerUseToolConfig
|
||||||
|
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend, SessionBackendPool
|
||||||
|
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
|
||||||
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBackend(ComputerBackend):
|
||||||
|
"""Records actuation calls and serves a solid-colour PNG of a fixed size."""
|
||||||
|
|
||||||
|
environment = "desktop"
|
||||||
|
|
||||||
|
def __init__(self, width: int = 2560, height: int = 1600):
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
self._w, self._h = width, height
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def dimensions(self) -> tuple[int, int]:
|
||||||
|
return (self._w, self._h)
|
||||||
|
|
||||||
|
async def screenshot(self) -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.new("RGB", (self._w, self._h), (10, 20, 30))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
async def click(self, x, y, button="left", count=1):
|
||||||
|
self.calls.append(("click", x, y, button, count))
|
||||||
|
|
||||||
|
async def move(self, x, y):
|
||||||
|
self.calls.append(("move", x, y))
|
||||||
|
|
||||||
|
async def drag(self, x, y):
|
||||||
|
self.calls.append(("drag", x, y))
|
||||||
|
|
||||||
|
async def scroll(self, x, y, direction, amount):
|
||||||
|
self.calls.append(("scroll", x, y, direction, amount))
|
||||||
|
|
||||||
|
async def type_text(self, text):
|
||||||
|
self.calls.append(("type", text))
|
||||||
|
|
||||||
|
async def key(self, combo):
|
||||||
|
self.calls.append(("key", combo))
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
# navigate() inherited -> raises NotImplementedError (desktop has no navigate)
|
||||||
|
|
||||||
|
|
||||||
|
def _split(result):
|
||||||
|
assert isinstance(result, list), f"expected content blocks, got {result!r}"
|
||||||
|
images = [b for b in result if isinstance(b, dict) and b.get("type") == "image_url"]
|
||||||
|
texts = [b for b in result if isinstance(b, dict) and b.get("type") == "text"]
|
||||||
|
return images, texts
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(**kw):
|
||||||
|
fb = _FakeBackend(width=kw.pop("w", 2560), height=kw.pop("h", 1600))
|
||||||
|
config = ComputerUseToolConfig(target_width=1280, target_height=800, **kw)
|
||||||
|
tool = ComputerUseTool(config, backend_impl=fb)
|
||||||
|
return tool, fb
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- config + metadata ---------------------------
|
||||||
|
|
||||||
|
class TestConfigAndMetadata:
|
||||||
|
def test_defaults_off(self):
|
||||||
|
cfg = ComputerUseToolConfig()
|
||||||
|
assert cfg.enable is False
|
||||||
|
assert cfg.backend == "desktop"
|
||||||
|
assert (cfg.target_width, cfg.target_height) == (1280, 800)
|
||||||
|
assert cfg.max_sessions == 8
|
||||||
|
assert "require_approval" not in type(cfg).model_fields
|
||||||
|
|
||||||
|
def test_tools_config_accepts_camel_case(self):
|
||||||
|
cfg = ToolsConfig.model_validate({
|
||||||
|
"browser": {"enable": True, "maxSessions": 4},
|
||||||
|
"computerUse": {"enable": True, "backend": "browser", "maxSessions": 6},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert cfg.browser.enable is True
|
||||||
|
assert cfg.browser.max_sessions == 4
|
||||||
|
assert cfg.computer_use.enable is True
|
||||||
|
assert cfg.computer_use.backend == "browser"
|
||||||
|
assert cfg.computer_use.max_sessions == 6
|
||||||
|
dumped = cfg.model_dump(by_alias=True)
|
||||||
|
assert "computerUse" in dumped
|
||||||
|
assert dumped["computerUse"]["maxSessions"] == 6
|
||||||
|
|
||||||
|
def test_enabled_reads_config(self):
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.config.computer_use.enable = True
|
||||||
|
assert ComputerUseTool.enabled(ctx) is True
|
||||||
|
ctx.config.computer_use.enable = False
|
||||||
|
assert ComputerUseTool.enabled(ctx) is False
|
||||||
|
|
||||||
|
def test_create_from_ctx(self):
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.config.computer_use = ComputerUseToolConfig(
|
||||||
|
enable=True, backend="browser", target_width=1024, target_height=768
|
||||||
|
)
|
||||||
|
tool = ComputerUseTool.create(ctx)
|
||||||
|
assert isinstance(tool, ComputerUseTool)
|
||||||
|
assert tool.config.backend == "browser"
|
||||||
|
assert (tool.config.target_width, tool.config.target_height) == (1024, 768)
|
||||||
|
|
||||||
|
def test_tool_metadata(self):
|
||||||
|
tool, _ = _tool()
|
||||||
|
assert tool.name == "computer_use"
|
||||||
|
assert tool.exclusive is True
|
||||||
|
assert tool.read_only is False
|
||||||
|
assert tool.concurrency_safe is False
|
||||||
|
# not exposed to subagents
|
||||||
|
assert "subagent" not in tool._scopes
|
||||||
|
|
||||||
|
def test_schema_has_action_enum(self):
|
||||||
|
tool, _ = _tool()
|
||||||
|
action = tool.parameters["properties"]["action"]
|
||||||
|
assert "screenshot" in action["enum"]
|
||||||
|
assert "left_click" in action["enum"]
|
||||||
|
assert tool.parameters["required"] == ["action"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- execute dispatch ---------------------------
|
||||||
|
|
||||||
|
class TestExecute:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_screenshot_returns_image_blocks(self):
|
||||||
|
tool, fb = _tool()
|
||||||
|
result = await tool.execute(action="screenshot")
|
||||||
|
images, texts = _split(result)
|
||||||
|
assert len(images) == 1
|
||||||
|
assert images[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||||
|
assert "1280x800" in texts[-1]["text"]
|
||||||
|
assert fb.calls == [] # screenshot performs no actuation
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_left_click_scales_coordinates(self):
|
||||||
|
tool, fb = _tool() # real 2560x1600 -> target 1280x800 (2x)
|
||||||
|
result = await tool.execute(action="left_click", x=100, y=50)
|
||||||
|
assert fb.calls == [("click", 200, 100, "left", 1)]
|
||||||
|
_, texts = _split(result)
|
||||||
|
assert "left_click at (200, 100)" in texts[-1]["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_click_clamps_coordinates_to_screen(self):
|
||||||
|
tool, fb = _tool()
|
||||||
|
await tool.execute(action="left_click", x=5000, y=-10)
|
||||||
|
assert fb.calls == [("click", 2559, 0, "left", 1)]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("action", "kwargs", "expected"),
|
||||||
|
[
|
||||||
|
("double_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 2)),
|
||||||
|
("triple_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 3)),
|
||||||
|
("right_click", {"x": 5, "y": 5}, ("click", 10, 10, "right", 1)),
|
||||||
|
("middle_click", {"x": 5, "y": 5}, ("click", 10, 10, "middle", 1)),
|
||||||
|
(
|
||||||
|
"scroll",
|
||||||
|
{"x": 100, "y": 100, "scroll_direction": "down", "scroll_amount": 5},
|
||||||
|
("scroll", 200, 200, "down", 5),
|
||||||
|
),
|
||||||
|
("type", {"text": "hello"}, ("type", "hello")),
|
||||||
|
("key", {"text": "ctrl+s"}, ("key", "ctrl+s")),
|
||||||
|
("mouse_move", {"x": 10, "y": 10}, ("move", 20, 20)),
|
||||||
|
("left_click_drag", {"x": 20, "y": 30}, ("drag", 40, 60)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_actions_dispatch_to_backend(self, action, kwargs, expected):
|
||||||
|
tool, fb = _tool()
|
||||||
|
await tool.execute(action=action, **kwargs)
|
||||||
|
assert fb.calls == [expected]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait(self):
|
||||||
|
tool, fb = _tool()
|
||||||
|
result = await tool.execute(action="wait", duration=0.0)
|
||||||
|
_, texts = _split(result)
|
||||||
|
assert "Waited" in texts[-1]["text"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "error"),
|
||||||
|
[
|
||||||
|
({"action": "frobnicate"}, "unknown action"),
|
||||||
|
({"action": "left_click"}, "requires"),
|
||||||
|
({"action": "navigate", "url": "https://example.com"}, "Error"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_errors_are_returned_to_model(self, kwargs, error):
|
||||||
|
tool, _ = _tool()
|
||||||
|
result = await tool.execute(**kwargs)
|
||||||
|
assert isinstance(result, str) and error in result
|
||||||
|
assert result.is_error is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_pool_isolates_sessions_and_closes_all():
|
||||||
|
created: list[_FakeBackend] = []
|
||||||
|
finalized: list[bool] = []
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
backend = _FakeBackend()
|
||||||
|
created.append(backend)
|
||||||
|
return backend
|
||||||
|
|
||||||
|
async def finalize():
|
||||||
|
finalized.append(all(backend.closed for backend in created))
|
||||||
|
|
||||||
|
pool = SessionBackendPool(factory, finalizer=finalize)
|
||||||
|
with request_context(RequestContext(channel="test", chat_id="a", session_key="test:a")):
|
||||||
|
first = await pool.get()
|
||||||
|
assert await pool.get() is first
|
||||||
|
with request_context(RequestContext(channel="test", chat_id="b", session_key="test:b")):
|
||||||
|
second = await pool.get()
|
||||||
|
|
||||||
|
assert first is not second
|
||||||
|
await pool.close()
|
||||||
|
assert len(created) == 2
|
||||||
|
assert all(backend.closed for backend in created)
|
||||||
|
assert finalized == [True]
|
||||||
|
|
||||||
|
await pool.close()
|
||||||
|
assert finalized == [True]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_pool_evicts_least_recently_used_session():
|
||||||
|
created: list[_FakeBackend] = []
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
backend = _FakeBackend()
|
||||||
|
created.append(backend)
|
||||||
|
return backend
|
||||||
|
|
||||||
|
pool = SessionBackendPool(factory, max_backends=2)
|
||||||
|
contexts = [
|
||||||
|
RequestContext(channel="test", chat_id=key, session_key=f"test:{key}")
|
||||||
|
for key in ("a", "b", "c")
|
||||||
|
]
|
||||||
|
with request_context(contexts[0]):
|
||||||
|
first = await pool.get()
|
||||||
|
with request_context(contexts[1]):
|
||||||
|
second = await pool.get()
|
||||||
|
with request_context(contexts[0]):
|
||||||
|
assert await pool.get() is first
|
||||||
|
with request_context(contexts[2]):
|
||||||
|
await pool.get()
|
||||||
|
|
||||||
|
assert first.closed is False
|
||||||
|
assert second.closed is True
|
||||||
|
await pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_desktop_tool_serializes_calls_across_sessions():
|
||||||
|
class SlowBackend(_FakeBackend):
|
||||||
|
active = 0
|
||||||
|
max_active = 0
|
||||||
|
|
||||||
|
async def dimensions(self):
|
||||||
|
self.active += 1
|
||||||
|
self.max_active = max(self.max_active, self.active)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
self.active -= 1
|
||||||
|
return await super().dimensions()
|
||||||
|
|
||||||
|
backend = SlowBackend(width=1280, height=800)
|
||||||
|
tool = ComputerUseTool(backend_impl=backend)
|
||||||
|
|
||||||
|
async def screenshot(session: str):
|
||||||
|
with request_context(RequestContext(channel="test", chat_id=session, session_key=session)):
|
||||||
|
return await tool.execute(action="screenshot")
|
||||||
|
|
||||||
|
await asyncio.gather(screenshot("a"), screenshot("b"))
|
||||||
|
|
||||||
|
assert backend.max_active == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_desktop_backend_uses_safe_pyautogui_calls():
|
||||||
|
pg = MagicMock()
|
||||||
|
pg.easeInOutQuad = object()
|
||||||
|
backend = DesktopBackend()
|
||||||
|
backend._pg = pg
|
||||||
|
|
||||||
|
await backend.drag(10, 20)
|
||||||
|
await backend.scroll(10, 20, "down", 3)
|
||||||
|
|
||||||
|
assert pg.dragTo.call_args.kwargs == {
|
||||||
|
"duration": 0.3,
|
||||||
|
"tween": pg.easeInOutQuad,
|
||||||
|
"button": "left",
|
||||||
|
}
|
||||||
|
pg.scroll.assert_called_once_with(-3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_desktop_backend_rejects_unicode_instead_of_typing_incorrect_keys():
|
||||||
|
pg = MagicMock()
|
||||||
|
backend = DesktopBackend()
|
||||||
|
backend._pg = pg
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ASCII"):
|
||||||
|
await backend.type_text("你好")
|
||||||
|
|
||||||
|
pg.typewrite.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_backend_preserves_pyautogui_failsafe(monkeypatch):
|
||||||
|
pg = SimpleNamespace(FAILSAFE=True)
|
||||||
|
monkeypatch.setitem(sys.modules, "pyautogui", pg)
|
||||||
|
|
||||||
|
backend = DesktopBackend()
|
||||||
|
assert backend._ensure() is pg
|
||||||
|
assert pg.FAILSAFE is True
|
||||||
@@ -5,13 +5,10 @@ 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")
|
||||||
@@ -174,58 +171,6 @@ 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."""
|
||||||
|
|||||||
@@ -1082,22 +1082,10 @@ 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:
|
||||||
bad_session = _make_fake_session([])
|
sessions = {"good": _make_fake_session(["demo"])}
|
||||||
|
|
||||||
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:
|
||||||
@@ -1111,7 +1099,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" and failure_mode == "exception":
|
if params.command == "bad":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1121,8 +1109,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(
|
||||||
{
|
{
|
||||||
"bad": MCPServerConfig(command="bad"),
|
|
||||||
"good": MCPServerConfig(command="good"),
|
"good": MCPServerConfig(command="good"),
|
||||||
|
"bad": MCPServerConfig(command="bad"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1133,36 +1121,6 @@ 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],
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ class _FakeTool(Tool):
|
|||||||
async def execute(self, **kwargs: Any) -> Any:
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class _ClosableTool(_FakeTool):
|
||||||
|
def __init__(self, name: str, *, error: BaseException | None = None):
|
||||||
|
super().__init__(name)
|
||||||
|
self.closed = False
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
|
||||||
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
|
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
for definition in definitions:
|
for definition in definitions:
|
||||||
@@ -58,6 +70,24 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_attempts_every_registered_tool() -> None:
|
||||||
|
registry = ToolRegistry()
|
||||||
|
broken = _ClosableTool("broken", error=RuntimeError("close failed"))
|
||||||
|
healthy = _ClosableTool("healthy")
|
||||||
|
registry.register(broken)
|
||||||
|
registry.register(healthy)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await registry.close()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert str(exc) == "close failed"
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected close failure")
|
||||||
|
|
||||||
|
assert broken.closed is True
|
||||||
|
assert healthy.closed is True
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_call_rejects_near_miss_tool_name_with_suggestion() -> None:
|
def test_prepare_call_rejects_near_miss_tool_name_with_suggestion() -> None:
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
registry.register(_FakeTool("read_file"))
|
registry.register(_FakeTool("read_file"))
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ def test_estimate_prompt_tokens_chain_falls_back_without_provider_counter() -> N
|
|||||||
assert source == "tiktoken"
|
assert source == "tiktoken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_blocks_have_bounded_token_cost() -> None:
|
||||||
|
text = [{"role": "tool", "content": [{"type": "text", "text": "screen"}]}]
|
||||||
|
small_image = [{
|
||||||
|
"role": "tool",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,A"}},
|
||||||
|
{"type": "text", "text": "screen"},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
large_image = [{
|
||||||
|
"role": "tool",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64," + "A" * 100_000},
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "screen"},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
|
||||||
|
text_tokens = estimate_prompt_tokens(text)
|
||||||
|
small_tokens = estimate_prompt_tokens(small_image)
|
||||||
|
large_tokens = estimate_prompt_tokens(large_image)
|
||||||
|
|
||||||
|
assert small_tokens >= text_tokens + 2_000
|
||||||
|
assert large_tokens == small_tokens
|
||||||
|
assert estimate_message_tokens(large_image[0]) >= text_tokens + 2_000
|
||||||
|
assert estimate_message_tokens({"role": "user", "content": small_image[0]["content"][:1]}) > 2_000
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -> None:
|
def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -> None:
|
||||||
tokens, source = estimate_prompt_tokens_chain(
|
tokens, source = estimate_prompt_tokens_chain(
|
||||||
_BrokenCounterProvider(),
|
_BrokenCounterProvider(),
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -12,6 +12,7 @@ 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,
|
||||||
@@ -28,6 +29,7 @@ from nanobot.webui.settings_api import (
|
|||||||
settings_usage_payload,
|
settings_usage_payload,
|
||||||
update_agent_settings,
|
update_agent_settings,
|
||||||
update_api_settings,
|
update_api_settings,
|
||||||
|
update_computer_use_settings,
|
||||||
update_model_call_order,
|
update_model_call_order,
|
||||||
update_model_configuration,
|
update_model_configuration,
|
||||||
update_network_safety_settings,
|
update_network_safety_settings,
|
||||||
@@ -35,17 +37,11 @@ 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:
|
||||||
@@ -1012,6 +1008,27 @@ def test_settings_payload_includes_network_safety_fields(
|
|||||||
assert payload["advanced"]["ssrf_whitelist_count"] == 1
|
assert payload["advanced"]["ssrf_whitelist_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_payload_includes_computer_use_tools(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.tools.browser.enable = True
|
||||||
|
config.tools.computer_use.enable = True
|
||||||
|
config.tools.computer_use.backend = "browser"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
payload = settings_payload()
|
||||||
|
|
||||||
|
assert payload["computer_use"] == {
|
||||||
|
"browser_enabled": True,
|
||||||
|
"enabled": True,
|
||||||
|
"backend": "browser",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_includes_exec_path_flags(
|
def test_settings_payload_includes_exec_path_flags(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -1379,6 +1396,32 @@ def test_update_network_safety_settings_writes_local_service_flag(
|
|||||||
assert payload["requires_restart"] is True
|
assert payload["requires_restart"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_computer_use_settings_writes_only_requested_switches(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
browser_payload = update_computer_use_settings({"browser_enabled": ["true"]})
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert saved.tools.browser.enable is True
|
||||||
|
assert saved.tools.computer_use.enable is False
|
||||||
|
assert browser_payload["requires_restart"] is True
|
||||||
|
|
||||||
|
computer_payload = update_computer_use_settings({"computerEnabled": ["true"]})
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert saved.tools.browser.enable is True
|
||||||
|
assert saved.tools.computer_use.enable is True
|
||||||
|
assert computer_payload["computer_use"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_computer_use_settings_requires_a_switch() -> None:
|
||||||
|
with pytest.raises(WebUISettingsError, match="browser_enabled or enabled"):
|
||||||
|
update_computer_use_settings({})
|
||||||
|
|
||||||
|
|
||||||
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
|
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -1495,7 +1538,6 @@ 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"
|
||||||
@@ -1528,10 +1570,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
fake_start,
|
fake_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||||
{"provider": ["openai-codex"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
@@ -1557,17 +1596,15 @@ 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 **_kwargs: {"settings": "ready"},
|
lambda: {"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 == {
|
||||||
@@ -1585,7 +1622,6 @@ 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)
|
||||||
@@ -1611,11 +1647,10 @@ 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:
|
||||||
oauth_flows.clear("openai_codex")
|
_clear_webui_oauth_flows("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
|
||||||
@@ -1624,7 +1659,6 @@ 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__
|
||||||
|
|
||||||
@@ -1636,10 +1670,7 @@ 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(
|
login_oauth_provider({"provider": ["openai-codex"]})
|
||||||
{"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. "
|
||||||
@@ -1649,7 +1680,6 @@ 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__
|
||||||
|
|
||||||
@@ -1661,10 +1691,7 @@ 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(
|
login_oauth_provider({"provider": ["github-copilot"]})
|
||||||
{"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. "
|
||||||
@@ -1675,7 +1702,6 @@ 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"
|
||||||
@@ -1697,10 +1723,7 @@ 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(
|
payload = login_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"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
|
||||||
@@ -1724,17 +1747,15 @@ 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 **_kwargs: {"settings": "ready"},
|
lambda: {"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 == {
|
||||||
@@ -1749,7 +1770,6 @@ 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)
|
||||||
@@ -1762,10 +1782,7 @@ 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(
|
login_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"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) == (
|
||||||
@@ -1777,7 +1794,6 @@ 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)
|
||||||
@@ -1791,10 +1807,7 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
|||||||
lambda: token_path,
|
lambda: token_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logout_oauth_provider(
|
logout_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"provider": ["xai-grok"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not token_path.exists()
|
assert not token_path.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import 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,
|
||||||
@@ -31,39 +27,27 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("provider", "authorization_response"),
|
("provider", "header_name", "authorization_response"),
|
||||||
[
|
[
|
||||||
("xai_grok", "secret"),
|
("xai_grok", "X-Nanobot-OAuth-Code", "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_websocket_payload(
|
async def test_oauth_completion_reads_private_response_header(
|
||||||
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(
|
def complete(query, authorization_response=None):
|
||||||
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",
|
||||||
@@ -73,13 +57,19 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
|
|
||||||
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 = _mutation_request(
|
request = SimpleNamespace(
|
||||||
"/api/settings/provider/oauth-login/complete",
|
path=(
|
||||||
{
|
"/api/settings/provider/oauth-login/complete"
|
||||||
"provider": provider,
|
f"?provider={provider}&flow_id=flow-123"
|
||||||
"flow_id": "flow-123",
|
),
|
||||||
"authorization_response": authorization_response,
|
headers=Headers(
|
||||||
},
|
[
|
||||||
|
(
|
||||||
|
header_name,
|
||||||
|
authorization_response,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await router.dispatch(
|
response = await router.dispatch(
|
||||||
@@ -99,29 +89,28 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_response": authorization_response,
|
"authorization_response": authorization_response,
|
||||||
}
|
}
|
||||||
assert request.path == "/api/settings/provider/oauth-login/complete"
|
assert authorization_response not in request.path
|
||||||
assert not request.headers
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("route_path", "function_name", "payload", "expected_query"),
|
("request_path", "route_path", "function_name", "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"]']},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -129,19 +118,19 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
@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, *, config_path=None):
|
def mutate(query):
|
||||||
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 = _mutation_request(route_path, payload)
|
request = SimpleNamespace(path=request_path, headers=Headers())
|
||||||
|
|
||||||
response = await _router().dispatch(None, request, route_path)
|
response = await _router().dispatch(None, request, route_path)
|
||||||
|
|
||||||
@@ -151,77 +140,52 @@ async def test_model_preset_mutation_routes(
|
|||||||
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(
|
@pytest.mark.parametrize(
|
||||||
("update_info", "expected"),
|
("query", "expected_query", "expected_sections"),
|
||||||
[
|
[
|
||||||
(None, {"updateAvailable": None}),
|
|
||||||
(
|
(
|
||||||
{
|
"browser_enabled=true",
|
||||||
"currentVersion": "1.2.0",
|
{"browser_enabled": ["true"]},
|
||||||
"latestVersion": "1.3.0",
|
["browser"],
|
||||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
),
|
||||||
},
|
(
|
||||||
{
|
"computerEnabled=true",
|
||||||
"updateAvailable": {
|
{"computerEnabled": ["true"]},
|
||||||
"currentVersion": "1.2.0",
|
["runtime"],
|
||||||
"latestVersion": "1.3.0",
|
),
|
||||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
(
|
||||||
}
|
"browserEnabled=true&enabled=true",
|
||||||
},
|
{"browserEnabled": ["true"], "enabled": ["true"]},
|
||||||
|
["browser", "runtime"],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_version_check_route_returns_stable_payload(
|
async def test_computer_use_update_route(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch,
|
||||||
update_info: dict[str, str] | None,
|
query: str,
|
||||||
expected: dict[str, object],
|
expected_query: dict[str, list[str]],
|
||||||
|
expected_sections: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
captured: dict[str, object] = {}
|
||||||
"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)
|
def update(query):
|
||||||
|
captured["query"] = query
|
||||||
|
return {"requires_restart": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.update_computer_use_settings", update)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
path=f"/api/settings/computer-use/update?{query}",
|
||||||
|
headers=Headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await _router().dispatch(
|
||||||
|
None,
|
||||||
|
request,
|
||||||
|
"/api/settings/computer-use/update",
|
||||||
|
)
|
||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body) == expected
|
assert captured["query"] == expected_query
|
||||||
|
assert json.loads(response.body)["restart_required_sections"] == expected_sections
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
"@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",
|
||||||
@@ -62,8 +61,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -106,8 +103,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -196,10 +191,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -218,8 +209,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -478,8 +467,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -686,8 +673,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -756,8 +741,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -772,8 +755,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -782,8 +763,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -820,8 +799,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -868,16 +845,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -926,10 +893,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1036,8 +999,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1062,8 +1023,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1078,8 +1037,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1206,8 +1163,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1220,14 +1175,10 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1238,8 +1189,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1248,8 +1197,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1348,8 +1295,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1372,12 +1317,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -1414,8 +1353,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1428,16 +1365,12 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1454,24 +1387,14 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1492,8 +1415,6 @@
|
|||||||
|
|
||||||
"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,7 +44,6 @@
|
|||||||
"@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",
|
||||||
@@ -76,20 +75,6 @@
|
|||||||
"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",
|
||||||
@@ -363,13 +348,6 @@
|
|||||||
"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",
|
||||||
@@ -1012,119 +990,6 @@
|
|||||||
"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,
|
||||||
@@ -1206,17 +1071,6 @@
|
|||||||
"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"
|
||||||
@@ -3149,39 +3003,6 @@
|
|||||||
"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,
|
||||||
@@ -4426,13 +4247,6 @@
|
|||||||
"@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,
|
||||||
@@ -4889,23 +4703,6 @@
|
|||||||
"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": {
|
||||||
@@ -4971,27 +4768,6 @@
|
|||||||
"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,
|
||||||
@@ -5003,39 +4779,6 @@
|
|||||||
"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",
|
||||||
@@ -5067,16 +4810,6 @@
|
|||||||
"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,
|
||||||
@@ -5410,13 +5143,6 @@
|
|||||||
"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",
|
||||||
@@ -5652,76 +5378,6 @@
|
|||||||
"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,
|
||||||
@@ -5931,47 +5587,6 @@
|
|||||||
"@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",
|
||||||
@@ -6958,16 +6573,6 @@
|
|||||||
"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"
|
||||||
@@ -7094,13 +6699,6 @@
|
|||||||
"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",
|
||||||
@@ -7163,30 +6761,6 @@
|
|||||||
"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,
|
||||||
@@ -8045,19 +7619,6 @@
|
|||||||
"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,
|
||||||
@@ -8136,22 +7697,6 @@
|
|||||||
"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",
|
||||||
@@ -8184,20 +7729,6 @@
|
|||||||
"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,
|
||||||
@@ -8258,19 +7789,6 @@
|
|||||||
"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,
|
||||||
@@ -8346,21 +7864,6 @@
|
|||||||
"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,
|
||||||
@@ -9018,41 +8521,6 @@
|
|||||||
"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,7 +8,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
"@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",
|
||||||
|
|||||||
+92
-453
@@ -8,30 +8,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
import { 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 {
|
|
||||||
WORKBENCH_STORAGE_KEY,
|
|
||||||
MAX_WORKBENCH_PANES,
|
|
||||||
addWorkbenchPane,
|
|
||||||
attachWorkbenchPane,
|
|
||||||
detachWorkbenchPane,
|
|
||||||
ensureWorkbenchTab,
|
|
||||||
focusWorkbenchPane,
|
|
||||||
parseWorkbenchState,
|
|
||||||
promoteWorkbenchPane,
|
|
||||||
reconcileWorkbench,
|
|
||||||
setWorkbenchLayout,
|
|
||||||
workbenchChildPaneKeys,
|
|
||||||
workbenchTab,
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -138,14 +120,6 @@ const RenameChatDialog = lazy(async () => {
|
|||||||
return { default: module.RenameChatDialog };
|
return { default: module.RenameChatDialog };
|
||||||
});
|
});
|
||||||
|
|
||||||
function readWorkbenchState(): WorkbenchState {
|
|
||||||
try {
|
|
||||||
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
|
|
||||||
} catch {
|
|
||||||
return parseWorkbenchState(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SurfaceLoadingFallback() {
|
function SurfaceLoadingFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -342,23 +316,13 @@ 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) {
|
if (!secret) return;
|
||||||
setValidationError("required");
|
|
||||||
inputRef.current?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
onSecret(secret);
|
onSecret(secret);
|
||||||
};
|
};
|
||||||
@@ -369,57 +333,27 @@ 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="space-y-2">
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
<h1 className="text-sm font-medium text-foreground">
|
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||||
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||||
</h1>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
id="webui-access-password"
|
|
||||||
name="webui-access-password"
|
|
||||||
type={passwordVisible ? "text" : "password"}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
aria-invalid={validationError ? true : undefined}
|
|
||||||
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
|
||||||
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
|
||||||
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>
|
</div>
|
||||||
|
{failed && (
|
||||||
|
<p className="text-center text-sm text-destructive">
|
||||||
|
{t("app.auth.invalid")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("app.auth.placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={submitting}
|
disabled={!value.trim() || submitting}
|
||||||
>
|
>
|
||||||
{t("app.auth.submit")}
|
{t("app.auth.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1060,18 +994,9 @@ 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(readWorkbenchState);
|
|
||||||
const [creatingPane, setCreatingPane] = useState(false);
|
|
||||||
const childPaneKeys = useMemo(
|
|
||||||
() => workbenchChildPaneKeys(workbenchState),
|
|
||||||
[workbenchState],
|
|
||||||
);
|
|
||||||
const topicSessions = useMemo(
|
|
||||||
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
|
|
||||||
[childPaneKeys, sessions],
|
|
||||||
);
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<{
|
const [pendingDelete, setPendingDelete] = useState<{
|
||||||
items: SidebarDeleteItem[];
|
key: string;
|
||||||
|
label: string;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRename, setPendingRename] = useState<{
|
const [pendingRename, setPendingRename] = useState<{
|
||||||
@@ -1192,17 +1117,6 @@ function Shell({
|
|||||||
}
|
}
|
||||||
}, [hostSidebarOpen]);
|
}, [hostSidebarOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
WORKBENCH_STORAGE_KEY,
|
|
||||||
JSON.stringify(workbenchState),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore storage errors (private mode, etc.)
|
|
||||||
}
|
|
||||||
}, [workbenchState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeSessionUpdateChatIds(updatedChatIds);
|
writeSessionUpdateChatIds(updatedChatIds);
|
||||||
}, [updatedChatIds]);
|
}, [updatedChatIds]);
|
||||||
@@ -1266,19 +1180,9 @@ 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 activeTabState = useMemo(() => (
|
|
||||||
activeKey && !temporarySessions[activeKey]
|
|
||||||
? workbenchTab(workbenchState, activeKey)
|
|
||||||
: null
|
|
||||||
), [activeKey, temporarySessions, workbenchState]);
|
|
||||||
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 = activePaneSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1298,13 +1202,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activePaneSession?.workspaceScope) {
|
if (activeSession?.workspaceScope) {
|
||||||
return activePaneSession.workspaceScope;
|
return activeSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activePaneSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1340,18 +1244,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [loading, sessions]);
|
}, [loading, sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) 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;
|
|
||||||
}
|
|
||||||
return ensureWorkbenchTab(reconciled, activeKey);
|
|
||||||
});
|
|
||||||
}, [activeKey, loading, sessions, temporarySessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||||
@@ -1856,7 +1748,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 = topicSessions.find((session) => !archived.has(session.key));
|
const next = sessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1864,7 +1756,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onReorderSessions = useCallback(
|
const onReorderSessions = useCallback(
|
||||||
@@ -1893,47 +1785,6 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onAddPane = useCallback(async () => {
|
|
||||||
const tabKey = activeKey;
|
|
||||||
if (
|
|
||||||
!tabKey
|
|
||||||
|| !activeSession
|
|
||||||
|| creatingPane
|
|
||||||
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
|
||||||
|| temporarySessionsRef.current[tabKey]
|
|
||||||
) return;
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
setCreatingPane(true);
|
|
||||||
try {
|
|
||||||
const scope = activeWorkspaceScope;
|
|
||||||
const chatId = await createChat(scope);
|
|
||||||
const paneKey = `websocket:${chatId}`;
|
|
||||||
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
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,
|
|
||||||
activeTabState,
|
|
||||||
activeWorkspaceScope,
|
|
||||||
createChat,
|
|
||||||
creatingPane,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
@@ -2011,15 +1862,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return topicSessions[0]?.key ?? null;
|
return sessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, topicSessions]);
|
}, [activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2126,42 +1977,30 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activePaneSession,
|
temporaryChatActive ? null : activeSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const items = pendingDelete.items;
|
const key = pendingDelete.key;
|
||||||
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 !== null && deletingKeys.has(activeKey);
|
const deletingActive = activeKey === key;
|
||||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||||
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 result = await deleteChat(
|
||||||
const item = items[index];
|
key,
|
||||||
const result = await deleteChat(
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
item.key,
|
);
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
if (result.blocked_by_automations) {
|
||||||
);
|
setPendingDelete({
|
||||||
if (result.blocked_by_automations) {
|
...pendingDelete,
|
||||||
setPendingDelete({
|
automations: result.automations ?? [],
|
||||||
items: items.slice(index),
|
});
|
||||||
automations: result.automations ?? [],
|
return;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
@@ -2174,24 +2013,18 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
let automations: SessionAutomationJob[] = [];
|
||||||
if (uniqueItems.length === 0) return;
|
try {
|
||||||
const automationResults = await Promise.allSettled(
|
automations = await getSessionAutomations(key);
|
||||||
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
} catch {
|
||||||
);
|
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||||
const automations = automationResults.flatMap((result) => (
|
}
|
||||||
result.status === "fulfilled" ? result.value : []
|
setPendingDelete({ key, label, automations });
|
||||||
));
|
|
||||||
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();
|
||||||
@@ -2208,7 +2041,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(client, action, code);
|
const payload = await runPairingAction(getToken(), 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;
|
||||||
@@ -2223,7 +2056,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, refreshPairingRequests],
|
[getToken, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
@@ -2236,117 +2069,13 @@ 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 headerTitle = temporaryChatActive
|
const headerTitle = temporaryChatActive
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||||
: activeSession
|
: activeSession
|
||||||
? titleForSession(activeSession)
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
|
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]));
|
|
||||||
return activeTabState.paneKeys
|
|
||||||
.map((key) => byKey.get(key))
|
|
||||||
.filter((session): session is ChatSummary => session !== undefined);
|
|
||||||
}, [activeTabState, sessions]);
|
|
||||||
const paneChromeEnabled = Boolean(
|
|
||||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
|
||||||
);
|
|
||||||
const renderedWorkbenchPanes = useMemo(() => {
|
|
||||||
if (paneChromeEnabled && activeKey) {
|
|
||||||
return workbenchPaneSessions.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`,
|
|
||||||
title: titleForSession(session),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [{
|
|
||||||
key: activeKey ?? "new-topic",
|
|
||||||
reactKey: "tab-root",
|
|
||||||
title: headerTitle,
|
|
||||||
}];
|
|
||||||
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]);
|
|
||||||
const renderedActivePaneKey = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.activePaneKey
|
|
||||||
: renderedWorkbenchPanes[0].key;
|
|
||||||
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.layout
|
|
||||||
: "columns";
|
|
||||||
const sidebarPaneGroups = useMemo(() => {
|
|
||||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
return Object.fromEntries(topicSessions.map((topic) => {
|
|
||||||
const tab = workbenchTab(workbenchState, topic.key);
|
|
||||||
const panes = tab.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 [topic.key, {
|
|
||||||
topicKey: topic.key,
|
|
||||||
activePaneKey: tab.activePaneKey,
|
|
||||||
panes,
|
|
||||||
}];
|
|
||||||
}));
|
|
||||||
}, [sessions, titleForSession, topicSessions, workbenchState]);
|
|
||||||
const attachableTabKeys = useMemo(() => (
|
|
||||||
topicSessions
|
|
||||||
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1)
|
|
||||||
.map((session) => session.key)
|
|
||||||
), [topicSessions, workbenchState]);
|
|
||||||
const paneAcceptingTabKeys = useMemo(() => (
|
|
||||||
topicSessions
|
|
||||||
.filter((session) => (
|
|
||||||
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
|
|
||||||
))
|
|
||||||
.map((session) => session.key)
|
|
||||||
), [topicSessions, workbenchState]);
|
|
||||||
const activePaneLimitReached = Boolean(
|
|
||||||
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
|
||||||
if (!activeKey) return;
|
|
||||||
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey));
|
|
||||||
}, [activeKey]);
|
|
||||||
|
|
||||||
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
if (activeKey !== tabKey) {
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: tabKey,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeKey, navigate]);
|
|
||||||
|
|
||||||
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
|
|
||||||
if (paneKey === tabKey) return;
|
|
||||||
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
if (activeKey === paneKey) {
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: tabKey,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeKey, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2379,7 +2108,7 @@ function Shell({
|
|||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions: topicSessions,
|
sessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
loading,
|
loading,
|
||||||
@@ -2388,17 +2117,9 @@ function Shell({
|
|||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestDeleteMany,
|
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
paneGroups: sidebarPaneGroups,
|
|
||||||
onSelectPane: onSelectSidebarPane,
|
|
||||||
onDetachPane: onDetachWorkbenchPane,
|
|
||||||
onPromotePane: onPromoteWorkbenchPane,
|
|
||||||
attachableTabKeys,
|
|
||||||
paneAcceptingTabKeys,
|
|
||||||
onAttachPane: onAttachWorkbenchPane,
|
|
||||||
onReorderSessions,
|
onReorderSessions,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
@@ -2421,9 +2142,7 @@ function Shell({
|
|||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
viewState: sidebarState.view,
|
viewState: sidebarState.view,
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: topicSessions.filter(
|
archivedCount: sidebarState.archived_keys.length,
|
||||||
(session) => sidebarState.archived_keys.includes(session.key),
|
|
||||||
).length,
|
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2559,7 +2278,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={topicSessions}
|
sessions={sessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2578,114 +2297,35 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PaneWorkbench
|
<ThreadShell
|
||||||
panes={renderedWorkbenchPanes}
|
session={activeSession}
|
||||||
activePaneKey={renderedActivePaneKey}
|
sessions={sessions}
|
||||||
layout={renderedWorkbenchLayout}
|
title={headerTitle}
|
||||||
chrome={paneChromeEnabled}
|
temporary={temporaryChatRequested}
|
||||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
temporaryChatIds={temporaryChatIds}
|
||||||
onActivatePane={onActivateWorkbenchPane}
|
temporaryChatEnabled={temporaryChatEnabled}
|
||||||
onAddPane={onAddPane}
|
onTemporaryChatEnabledChange={
|
||||||
onLayoutChange={(layout) => {
|
!activeKey ? onTemporaryChatEnabledChange : undefined
|
||||||
if (!activeKey) return;
|
}
|
||||||
setWorkbenchState((current) => (
|
onToggleSidebar={toggleSidebar}
|
||||||
setWorkbenchLayout(current, activeKey, layout)
|
onNewChat={onNewChat}
|
||||||
));
|
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||||
}}
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
renderPane={(pane, context) => {
|
onTurnEnd={onTurnEnd}
|
||||||
if (!paneChromeEnabled) {
|
theme={theme}
|
||||||
return (
|
onToggleTheme={toggle}
|
||||||
<ThreadShell
|
hideSidebarToggleForHostChrome
|
||||||
session={activeSession}
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
sessions={sessions}
|
hideHeader={false}
|
||||||
title={headerTitle}
|
workspaceScope={activeWorkspaceScope}
|
||||||
temporary={temporaryChatRequested}
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
temporaryChatIds={temporaryChatIds}
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
onTemporaryChatEnabledChange={
|
workspaceError={workspaceError}
|
||||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onToggleSidebar={toggleSidebar}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
onNewChat={onNewChat}
|
skills={skills}
|
||||||
onCreateChat={
|
|
||||||
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
|
||||||
}
|
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
|
||||||
onTurnEnd={onTurnEnd}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggle}
|
|
||||||
hideSidebarToggleForHostChrome
|
|
||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
|
||||||
hideHeader={false}
|
|
||||||
workspaceScope={activeWorkspaceScope}
|
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
|
||||||
workspaceScopeDisabled={activeChatRunning}
|
|
||||||
workspaceError={workspaceError}
|
|
||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
|
||||||
settingsSnapshot={settingsSnapshot}
|
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
|
||||||
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,
|
|
||||||
})}
|
|
||||||
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" && (
|
||||||
@@ -2718,8 +2358,7 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.items[0]?.label ?? ""}
|
title={pendingDelete.label}
|
||||||
count={pendingDelete.items.length}
|
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent,
|
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
BringToFront,
|
|
||||||
CornerDownRight,
|
|
||||||
Folder,
|
Folder,
|
||||||
ListChecks,
|
|
||||||
MessageCircleDashed,
|
MessageCircleDashed,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PanelsTopLeft,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Square,
|
|
||||||
SquareCheckBig,
|
|
||||||
SquareMinus,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Unplug,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,9 +25,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -57,13 +43,7 @@ import {
|
|||||||
visibleSessionsForGroup,
|
visibleSessionsForGroup,
|
||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
import {
|
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||||
clearDraggedSession,
|
|
||||||
writeDraggedPane,
|
|
||||||
writeDraggedSession,
|
|
||||||
type DraggedPane,
|
|
||||||
} from "@/lib/session-drag";
|
|
||||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
|
||||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
@@ -72,21 +52,6 @@ const INITIAL_VISIBLE_SESSIONS = 160;
|
|||||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||||
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||||
|
|
||||||
export interface SidebarPaneGroup {
|
|
||||||
topicKey: string;
|
|
||||||
activePaneKey: string;
|
|
||||||
panes: Array<{
|
|
||||||
key: string;
|
|
||||||
chatId: string;
|
|
||||||
title: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarDeleteItem {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatListProps {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
temporarySessions?: ChatSummary[];
|
||||||
@@ -94,17 +59,9 @@ interface ChatListProps {
|
|||||||
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;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
attachableTabKeys?: string[];
|
|
||||||
paneAcceptingTabKeys?: string[];
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
onReorderSessions?: (keys: string[]) => void;
|
onReorderSessions?: (keys: string[]) => void;
|
||||||
onToggleGroup?: (groupId: string) => void;
|
onToggleGroup?: (groupId: string) => void;
|
||||||
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
||||||
@@ -135,17 +92,9 @@ export const ChatList = memo(function ChatList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestDeleteMany,
|
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
paneGroups = {},
|
|
||||||
onSelectPane,
|
|
||||||
onDetachPane,
|
|
||||||
onPromotePane,
|
|
||||||
attachableTabKeys = [],
|
|
||||||
paneAcceptingTabKeys = [],
|
|
||||||
onAttachPane,
|
|
||||||
onReorderSessions,
|
onReorderSessions,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
@@ -175,49 +124,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
edge: "before" | "after";
|
edge: "before" | "after";
|
||||||
key: string;
|
key: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [draggedSessionHeight, setDraggedSessionHeight] = useState(0);
|
|
||||||
const [draggedPane, setDraggedPane] = useState<DraggedPane | null>(null);
|
|
||||||
const [tabAttachTargetKey, setTabAttachTargetKey] = useState<string | null>(null);
|
|
||||||
const tabAttachTargetRef = useRef<string | null>(null);
|
|
||||||
const tabRowRefs = useRef(new Map<string, HTMLLIElement>());
|
|
||||||
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
|
||||||
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>());
|
|
||||||
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
|
||||||
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined;
|
|
||||||
const selectedRowKey = selectedPaneGroup
|
|
||||||
? selectedPaneGroup.activePaneKey
|
|
||||||
: activeKey;
|
|
||||||
const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]);
|
|
||||||
const paneAcceptingTabs = useMemo(
|
|
||||||
() => new Set(paneAcceptingTabKeys),
|
|
||||||
[paneAcceptingTabKeys],
|
|
||||||
);
|
|
||||||
const deleteItemsByKey = useMemo(() => {
|
|
||||||
const items = new Map<string, SidebarDeleteItem>();
|
|
||||||
for (const group of Object.values(paneGroups)) {
|
|
||||||
for (const pane of group.panes) {
|
|
||||||
items.set(pane.key, { key: pane.key, label: pane.title });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const session of sessions) {
|
|
||||||
if (items.has(session.key)) continue;
|
|
||||||
items.set(session.key, {
|
|
||||||
key: session.key,
|
|
||||||
label: displayTitle(session, titleOverrides, t("chat.newChat")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [paneGroups, sessions, t, titleOverrides]);
|
|
||||||
const paneMoveTargets = useMemo(() => sessions
|
|
||||||
.filter((session) => paneAcceptingTabs.has(session.key))
|
|
||||||
.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId,
|
|
||||||
})), [deleteItemsByKey, paneAcceptingTabs, sessions]);
|
|
||||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||||
pinned: t("chat.groups.pinned"),
|
pinned: t("chat.groups.pinned"),
|
||||||
all: t("chat.groups.all"),
|
all: t("chat.groups.all"),
|
||||||
@@ -289,80 +196,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
}, [showArchived, sort]);
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deleteSelectionMode) return;
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return;
|
|
||||||
setDeleteSelectionMode(false);
|
|
||||||
setSelectedDeleteKeys(new Set());
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [deleteSelectionMode]);
|
|
||||||
|
|
||||||
const measureTabRows = useCallback(() => {
|
|
||||||
const rects = new Map<string, DOMRect>();
|
|
||||||
for (const [key, row] of tabRowRefs.current) {
|
|
||||||
rects.set(key, row.getBoundingClientRect());
|
|
||||||
}
|
|
||||||
return rects;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateTabAttachTarget = useCallback((next: string | null) => {
|
|
||||||
if (tabAttachTargetRef.current === next) return;
|
|
||||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
|
||||||
tabLayoutAnimationsRef.current.clear();
|
|
||||||
pendingTabRectsRef.current = measureTabRows();
|
|
||||||
tabAttachTargetRef.current = next;
|
|
||||||
setTabAttachTargetKey(next);
|
|
||||||
}, [measureTabRows]);
|
|
||||||
|
|
||||||
const resetDragState = useCallback(() => {
|
|
||||||
clearDraggedSession();
|
|
||||||
setDraggedSessionKey(null);
|
|
||||||
setDraggedPane(null);
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(0);
|
|
||||||
}, [updateTabAttachTarget]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const previousRects = pendingTabRectsRef.current;
|
|
||||||
if (!previousRects) return;
|
|
||||||
pendingTabRectsRef.current = null;
|
|
||||||
const nextRects = measureTabRows();
|
|
||||||
const reduceMotion = typeof window.matchMedia === "function"
|
|
||||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
if (reduceMotion) return;
|
|
||||||
for (const [key, nextRect] of nextRects) {
|
|
||||||
const previousRect = previousRects.get(key);
|
|
||||||
const row = tabRowRefs.current.get(key);
|
|
||||||
if (!previousRect || !row || typeof row.animate !== "function") continue;
|
|
||||||
const deltaY = previousRect.top - nextRect.top;
|
|
||||||
if (Math.abs(deltaY) < 0.5) continue;
|
|
||||||
const animation = row.animate(
|
|
||||||
[
|
|
||||||
{ transform: `translateY(${deltaY}px)` },
|
|
||||||
{ transform: "translateY(0)" },
|
|
||||||
],
|
|
||||||
{
|
|
||||||
duration: 180,
|
|
||||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tabLayoutAnimationsRef.current.set(key, animation);
|
|
||||||
animation.addEventListener("finish", () => {
|
|
||||||
if (tabLayoutAnimationsRef.current.get(key) === animation) {
|
|
||||||
tabLayoutAnimationsRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
}
|
|
||||||
}, [measureTabRows, tabAttachTargetKey]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@@ -385,48 +218,10 @@ export const ChatList = memo(function ChatList({
|
|||||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||||
|
|
||||||
const canReorderSession = (targetKey: string) => (
|
const canReorderSession = (targetKey: string) => (
|
||||||
!deleteSelectionMode
|
!!draggedSessionKey
|
||||||
&& !!draggedSessionKey
|
|
||||||
&& draggedSessionKey !== targetKey
|
&& draggedSessionKey !== targetKey
|
||||||
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
||||||
);
|
);
|
||||||
const beginDeleteSelection = (keys: string[]) => {
|
|
||||||
setDeleteSelectionMode(true);
|
|
||||||
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
|
|
||||||
};
|
|
||||||
const toggleDeleteSelection = (keys: string[]) => {
|
|
||||||
setSelectedDeleteKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
|
|
||||||
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
|
|
||||||
for (const key of validKeys) {
|
|
||||||
if (remove) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const closeDeleteSelection = () => {
|
|
||||||
setDeleteSelectionMode(false);
|
|
||||||
setSelectedDeleteKeys(new Set());
|
|
||||||
};
|
|
||||||
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
if (onRequestDeleteMany) onRequestDeleteMany(items);
|
|
||||||
else if (items.length === 1) onRequestDelete(items[0].key, items[0].label);
|
|
||||||
};
|
|
||||||
const requestDeleteKeys = (keys: string[]) => {
|
|
||||||
requestDeleteItems(keys
|
|
||||||
.map((key) => deleteItemsByKey.get(key))
|
|
||||||
.filter((item): item is SidebarDeleteItem => item !== undefined));
|
|
||||||
};
|
|
||||||
const confirmDeleteSelection = () => {
|
|
||||||
requestDeleteKeys(Array.from(selectedDeleteKeys));
|
|
||||||
closeDeleteSelection();
|
|
||||||
};
|
|
||||||
const draggedItemTitle = draggedPane
|
|
||||||
? deleteItemsByKey.get(draggedPane.paneKey)?.label
|
|
||||||
: draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined;
|
|
||||||
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
||||||
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
||||||
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
||||||
@@ -445,7 +240,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||||
<SidebarSelectionHighlight
|
<SidebarSelectionHighlight
|
||||||
targetRef={activeRowRef}
|
targetRef={activeRowRef}
|
||||||
activeId={draggedSessionKey || draggedPane ? null : selectedRowKey}
|
activeId={activeKey}
|
||||||
scope="sessions"
|
scope="sessions"
|
||||||
data-chat-list-content
|
data-chat-list-content
|
||||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||||
@@ -470,12 +265,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
);
|
);
|
||||||
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
||||||
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
||||||
const reorderOffsets = sessionReorderOffsets(
|
|
||||||
visibleSessions.map((session) => session.key),
|
|
||||||
draggedSessionKey,
|
|
||||||
sessionDropTarget,
|
|
||||||
draggedSessionHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
||||||
@@ -509,28 +298,12 @@ export const ChatList = memo(function ChatList({
|
|||||||
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{visibleSessions.map((s) => {
|
{visibleSessions.map((s) => {
|
||||||
const topicActive = s.key === activeKey;
|
const active = s.key === activeKey;
|
||||||
const paneGroup = paneGroups[s.key];
|
|
||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
});
|
});
|
||||||
const generatedTitle = s.title?.trim() || "";
|
const generatedTitle = s.title?.trim() || "";
|
||||||
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
||||||
const resolvedPaneGroup = paneGroup ?? {
|
|
||||||
topicKey: s.key,
|
|
||||||
activePaneKey: s.key,
|
|
||||||
panes: [{ key: s.key, chatId: s.chatId, title }],
|
|
||||||
};
|
|
||||||
const active = topicActive && resolvedPaneGroup.activePaneKey === s.key;
|
|
||||||
const paneCount = resolvedPaneGroup.panes.length;
|
|
||||||
const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key);
|
|
||||||
const tabSelected = tabDeleteKeys.every((key) => (
|
|
||||||
selectedDeleteKeys.has(key)
|
|
||||||
));
|
|
||||||
const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => (
|
|
||||||
selectedDeleteKeys.has(key)
|
|
||||||
));
|
|
||||||
const isAttachTarget = tabAttachTargetKey === s.key;
|
|
||||||
const tooltipTitle =
|
const tooltipTitle =
|
||||||
titleOverrides[s.key]?.trim() ||
|
titleOverrides[s.key]?.trim() ||
|
||||||
generatedTitle ||
|
generatedTitle ||
|
||||||
@@ -545,83 +318,24 @@ export const ChatList = memo(function ChatList({
|
|||||||
const projectMode = group.kind === "project";
|
const projectMode = group.kind === "project";
|
||||||
const activityState = running.has(s.chatId)
|
const activityState = running.has(s.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
: updated.has(s.chatId) && !topicActive
|
: updated.has(s.chatId) && !active
|
||||||
? "updated"
|
? "updated"
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={s.key}
|
key={s.key}
|
||||||
ref={(element) => {
|
className="relative min-w-0"
|
||||||
if (element) tabRowRefs.current.set(s.key, element);
|
|
||||||
else tabRowRefs.current.delete(s.key);
|
|
||||||
}}
|
|
||||||
data-session-dragging={draggedSessionKey === s.key ? "true" : undefined}
|
|
||||||
data-session-displaced={reorderOffsets.has(s.key) ? "true" : undefined}
|
|
||||||
data-tab-attach-target={tabAttachTargetKey === s.key ? "true" : undefined}
|
|
||||||
className={cn(
|
|
||||||
"relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
|
|
||||||
draggedSessionKey === s.key && "opacity-0",
|
|
||||||
isAttachTarget
|
|
||||||
&& "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transform: reorderOffsets.has(s.key)
|
|
||||||
? `translateY(${reorderOffsets.get(s.key)}px)`
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
const relativeY = rect.height > 0
|
|
||||||
? (event.clientY - rect.top) / rect.height
|
|
||||||
: 0.5;
|
|
||||||
const paneCanAttach = Boolean(
|
|
||||||
!deleteSelectionMode
|
|
||||||
&& draggedPane
|
|
||||||
&& draggedPane.sourceTabKey !== s.key
|
|
||||||
&& paneAcceptingTabs.has(s.key)
|
|
||||||
&& onAttachPane,
|
|
||||||
);
|
|
||||||
const tabCanAttach = Boolean(
|
|
||||||
!deleteSelectionMode
|
|
||||||
&& draggedSessionKey
|
|
||||||
&& draggedSessionKey !== s.key
|
|
||||||
&& attachableTabs.has(draggedSessionKey)
|
|
||||||
&& paneAcceptingTabs.has(s.key)
|
|
||||||
&& relativeY >= 0.25
|
|
||||||
&& relativeY <= 0.75
|
|
||||||
&& onAttachPane,
|
|
||||||
);
|
|
||||||
if (paneCanAttach || tabCanAttach) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(s.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
if (!canReorderSession(s.key)) return;
|
if (!canReorderSession(s.key)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
event.dataTransfer.dropEffect = "move";
|
||||||
const nextTarget = {
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setSessionDropTarget({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
||||||
} as const;
|
});
|
||||||
setSessionDropTarget((current) => (
|
|
||||||
current?.key === nextTarget.key && current.edge === nextTarget.edge
|
|
||||||
? current
|
|
||||||
: nextTarget
|
|
||||||
));
|
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (tabAttachTargetKey === s.key && onAttachPane) {
|
|
||||||
const paneKey = draggedPane?.paneKey ?? draggedSessionKey;
|
|
||||||
if (paneKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
onAttachPane(paneKey, s.key);
|
|
||||||
}
|
|
||||||
resetDragState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!canReorderSession(s.key)) return;
|
if (!canReorderSession(s.key)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
@@ -629,13 +343,23 @@ export const ChatList = memo(function ChatList({
|
|||||||
? "before"
|
? "before"
|
||||||
: "after";
|
: "after";
|
||||||
reorderSession(s.key, edge);
|
reorderSession(s.key, edge);
|
||||||
resetDragState();
|
setDraggedSessionKey(null);
|
||||||
|
setSessionDropTarget(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{sessionDropTarget?.key === s.key ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-session-drop-edge={sessionDropTarget.edge}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
|
||||||
|
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={active ? activeRowRef : undefined}
|
ref={active ? activeRowRef : undefined}
|
||||||
data-chat-row={s.key}
|
data-chat-row={s.key}
|
||||||
data-sidebar-tab={s.key}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||||
@@ -643,75 +367,36 @@ export const ChatList = memo(function ChatList({
|
|||||||
active
|
active
|
||||||
? "text-sidebar-accent-foreground"
|
? "text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||||
isAttachTarget
|
|
||||||
&& "bg-sidebar-accent/65 text-sidebar-accent-foreground",
|
|
||||||
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onSelect(s.key)}
|
||||||
if (deleteSelectionMode) {
|
draggable
|
||||||
toggleDeleteSelection(tabDeleteKeys);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (topicActive && paneGroup && onSelectPane) {
|
|
||||||
onSelectPane(s.key, s.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelect(s.key);
|
|
||||||
}}
|
|
||||||
draggable={!deleteSelectionMode}
|
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setDraggedSessionKey(s.key);
|
setDraggedSessionKey(s.key);
|
||||||
setDraggedPane(null);
|
|
||||||
setSessionDropTarget(null);
|
setSessionDropTarget(null);
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(
|
|
||||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
|
||||||
?? event.currentTarget.getBoundingClientRect().height,
|
|
||||||
);
|
|
||||||
writeDraggedSession(event.dataTransfer, s.key);
|
writeDraggedSession(event.dataTransfer, s.key);
|
||||||
}}
|
}}
|
||||||
onDragEnd={resetDragState}
|
onDragEnd={() => {
|
||||||
|
clearDraggedSession();
|
||||||
|
setDraggedSessionKey(null);
|
||||||
|
setSessionDropTarget(null);
|
||||||
|
}}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
|
|
||||||
title={tooltipTitle}
|
title={tooltipTitle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
|
"min-w-0 flex-1 overflow-hidden text-left",
|
||||||
deleteSelectionMode
|
"cursor-grab active:cursor-grabbing",
|
||||||
? "cursor-default"
|
|
||||||
: "cursor-grab active:cursor-grabbing",
|
|
||||||
compact ? "py-1" : "py-1.5",
|
compact ? "py-1" : "py-1.5",
|
||||||
projectMode && "pl-7",
|
projectMode && "pl-7",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<SelectionIndicator
|
|
||||||
checked={tabSelected}
|
|
||||||
partial={tabPartiallySelected}
|
|
||||||
/>
|
|
||||||
) : paneCount > 1 || isAttachTarget ? (
|
|
||||||
<PanelsTopLeft
|
|
||||||
aria-hidden
|
|
||||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="min-w-0 flex-1 overflow-hidden">
|
|
||||||
{projectMode ? (
|
{projectMode ? (
|
||||||
<span className="flex w-full min-w-0 items-baseline gap-2">
|
<span className="flex w-full min-w-0 items-baseline gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{paneCount > 1 ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
|
||||||
>
|
|
||||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||||
{timestamp ? (
|
{timestamp ? (
|
||||||
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||||
@@ -724,14 +409,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{paneCount > 1 ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
|
||||||
>
|
|
||||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -745,16 +422,15 @@ export const ChatList = memo(function ChatList({
|
|||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<SessionActivityIndicator state={activityState} />
|
<SessionActivityIndicator state={activityState} />
|
||||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
||||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||||
"focus-visible:opacity-100",
|
"focus-visible:opacity-100",
|
||||||
topicActive && "opacity-100",
|
active && "opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-label={t("chat.actions", { title })}
|
aria-label={t("chat.actions", { title })}
|
||||||
>
|
>
|
||||||
@@ -766,17 +442,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
portalContainer={actionMenuPortalContainer}
|
portalContainer={actionMenuPortalContainer}
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{paneGroup
|
|
||||||
&& paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0
|
|
||||||
&& onPromotePane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onPromotePane(s.key, s.key)}>
|
|
||||||
<BringToFront className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.promotePane", {
|
|
||||||
defaultValue: "Make {{title}} the primary pane",
|
|
||||||
title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => onTogglePin(s.key)}
|
onSelect={() => onTogglePin(s.key)}
|
||||||
>
|
>
|
||||||
@@ -803,71 +468,18 @@ export const ChatList = memo(function ChatList({
|
|||||||
)}
|
)}
|
||||||
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{attachableTabs.has(s.key) && onAttachPane ? (
|
|
||||||
<MoveToTabSubmenu
|
|
||||||
targets={paneMoveTargets.filter((target) => target.key !== s.key)}
|
|
||||||
onMove={(targetKey) => onAttachPane(s.key, targetKey)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => beginDeleteSelection(tabDeleteKeys)}
|
|
||||||
>
|
|
||||||
<ListChecks className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.select", { defaultValue: "Select" })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
tone="destructive"
|
tone="destructive"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0);
|
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 shrink-0" />
|
<Trash2 className="h-4 w-4 shrink-0" />
|
||||||
{t("chat.delete")}
|
{t("chat.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu> : null}
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
{paneCount > 1 || isAttachTarget ? (
|
|
||||||
<ActivePaneRows
|
|
||||||
group={resolvedPaneGroup}
|
|
||||||
tabTitle={title}
|
|
||||||
tabActive={topicActive}
|
|
||||||
activeRowRef={activeRowRef}
|
|
||||||
running={running}
|
|
||||||
updated={updated}
|
|
||||||
onSelectPane={onSelectPane}
|
|
||||||
onRequestDelete={onRequestDelete}
|
|
||||||
onRequestRename={onRequestRename}
|
|
||||||
onDetachPane={onDetachPane}
|
|
||||||
onPromotePane={onPromotePane}
|
|
||||||
moveTargets={paneMoveTargets.filter((target) => (
|
|
||||||
target.key !== resolvedPaneGroup.topicKey
|
|
||||||
))}
|
|
||||||
onAttachPane={onAttachPane}
|
|
||||||
deleteSelectionMode={deleteSelectionMode}
|
|
||||||
selectedDeleteKeys={selectedDeleteKeys}
|
|
||||||
onToggleDeleteSelection={toggleDeleteSelection}
|
|
||||||
onBeginDeleteSelection={beginDeleteSelection}
|
|
||||||
dropPreview={isAttachTarget && draggedItemTitle ? {
|
|
||||||
paneTitle: draggedItemTitle,
|
|
||||||
targetTitle: title,
|
|
||||||
} : null}
|
|
||||||
draggedPaneKey={draggedPane?.paneKey ?? null}
|
|
||||||
onPaneDragStart={(event, pane) => {
|
|
||||||
setDraggedPane(pane);
|
|
||||||
setDraggedSessionKey(null);
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(
|
|
||||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
|
||||||
?? event.currentTarget.getBoundingClientRect().height,
|
|
||||||
);
|
|
||||||
writeDraggedPane(event.dataTransfer, pane);
|
|
||||||
}}
|
|
||||||
onPaneDragEnd={resetDragState}
|
|
||||||
actionMenuPortalContainer={actionMenuPortalContainer}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -898,329 +510,11 @@ export const ChatList = memo(function ChatList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<div
|
|
||||||
data-testid="delete-selection-bar"
|
|
||||||
className="sticky bottom-2 z-30 mx-1 mt-3 flex min-h-11 items-center gap-2 rounded-2xl border border-sidebar-border/80 bg-popover/95 p-1.5 pl-2 shadow-[0_10px_30px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeDeleteSelection}
|
|
||||||
aria-label={t("chat.cancelSelection", {
|
|
||||||
defaultValue: "Cancel selection",
|
|
||||||
})}
|
|
||||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" aria-hidden />
|
|
||||||
</button>
|
|
||||||
<span className="min-w-0 flex-1 truncate px-1 text-[12.5px] font-medium text-foreground/85">
|
|
||||||
{t("chat.selectedCount", {
|
|
||||||
defaultValue: "{{count}} selected",
|
|
||||||
count: selectedDeleteKeys.size,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={selectedDeleteKeys.size === 0}
|
|
||||||
onClick={confirmDeleteSelection}
|
|
||||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-[12px] font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("chat.deleteSelected", { defaultValue: "Delete" })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SidebarSelectionHighlight>
|
</SidebarSelectionHighlight>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function sessionReorderOffsets(
|
|
||||||
keys: string[],
|
|
||||||
draggedKey: string | null,
|
|
||||||
target: { edge: "before" | "after"; key: string } | null,
|
|
||||||
draggedHeight: number,
|
|
||||||
): Map<string, number> {
|
|
||||||
const offsets = new Map<string, number>();
|
|
||||||
if (!draggedKey || !target || draggedHeight <= 0) return offsets;
|
|
||||||
const sourceIndex = keys.indexOf(draggedKey);
|
|
||||||
if (sourceIndex < 0 || target.key === draggedKey) return offsets;
|
|
||||||
const remaining = keys.filter((key) => key !== draggedKey);
|
|
||||||
const targetIndex = remaining.indexOf(target.key);
|
|
||||||
if (targetIndex < 0) return offsets;
|
|
||||||
const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0);
|
|
||||||
|
|
||||||
if (sourceIndex < finalIndex) {
|
|
||||||
for (let index = sourceIndex + 1; index <= finalIndex; index += 1) {
|
|
||||||
offsets.set(keys[index], -draggedHeight);
|
|
||||||
}
|
|
||||||
} else if (sourceIndex > finalIndex) {
|
|
||||||
for (let index = finalIndex; index < sourceIndex; index += 1) {
|
|
||||||
offsets.set(keys[index], draggedHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offsets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivePaneRows({
|
|
||||||
group,
|
|
||||||
tabTitle,
|
|
||||||
tabActive,
|
|
||||||
activeRowRef,
|
|
||||||
running,
|
|
||||||
updated,
|
|
||||||
onSelectPane,
|
|
||||||
onRequestDelete,
|
|
||||||
onRequestRename,
|
|
||||||
onDetachPane,
|
|
||||||
onPromotePane,
|
|
||||||
moveTargets,
|
|
||||||
onAttachPane,
|
|
||||||
deleteSelectionMode,
|
|
||||||
selectedDeleteKeys,
|
|
||||||
onToggleDeleteSelection,
|
|
||||||
onBeginDeleteSelection,
|
|
||||||
dropPreview,
|
|
||||||
draggedPaneKey,
|
|
||||||
onPaneDragStart,
|
|
||||||
onPaneDragEnd,
|
|
||||||
actionMenuPortalContainer,
|
|
||||||
}: {
|
|
||||||
group: SidebarPaneGroup;
|
|
||||||
tabTitle: string;
|
|
||||||
tabActive: boolean;
|
|
||||||
activeRowRef: RefObject<HTMLDivElement>;
|
|
||||||
running: ReadonlySet<string>;
|
|
||||||
updated: ReadonlySet<string>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
|
||||||
onRequestRename: (key: string, label: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
moveTargets: Array<{ key: string; title: string }>;
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
deleteSelectionMode: boolean;
|
|
||||||
selectedDeleteKeys: ReadonlySet<string>;
|
|
||||||
onToggleDeleteSelection: (keys: string[]) => void;
|
|
||||||
onBeginDeleteSelection: (keys: string[]) => void;
|
|
||||||
dropPreview: { paneTitle: string; targetTitle: string } | null;
|
|
||||||
draggedPaneKey: string | null;
|
|
||||||
onPaneDragStart: (event: DragEvent<HTMLButtonElement>, pane: DraggedPane) => void;
|
|
||||||
onPaneDragEnd: () => void;
|
|
||||||
actionMenuPortalContainer?: HTMLElement | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
aria-label={t("workbench.panesInTab", {
|
|
||||||
defaultValue: "Panes in {{title}}",
|
|
||||||
title: tabTitle,
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
"relative ml-5 mr-1 mt-0.5 space-y-0.5 rounded-bl-lg border-l border-sidebar-border/60 py-0.5 pl-2 pr-0.5",
|
|
||||||
dropPreview && "pb-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{childPanes.map((pane) => {
|
|
||||||
const index = group.panes.findIndex((candidate) => candidate.key === pane.key);
|
|
||||||
const active = tabActive && pane.key === group.activePaneKey;
|
|
||||||
const activityState = running.has(pane.chatId)
|
|
||||||
? "running"
|
|
||||||
: updated.has(pane.chatId) && !active
|
|
||||||
? "updated"
|
|
||||||
: null;
|
|
||||||
const paneActionsLabel = t("workbench.paneActions", {
|
|
||||||
defaultValue: "{{title}} pane actions",
|
|
||||||
title: pane.title,
|
|
||||||
});
|
|
||||||
const selected = selectedDeleteKeys.has(pane.key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={pane.key}
|
|
||||||
data-pane-dragging={draggedPaneKey === pane.key ? "true" : undefined}
|
|
||||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-sidebar-border/45"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={active ? activeRowRef : undefined}
|
|
||||||
data-chat-row={pane.key}
|
|
||||||
data-sidebar-pane={pane.key}
|
|
||||||
className={cn(
|
|
||||||
"group/pane flex min-h-7 min-w-0 items-center gap-1 rounded-lg px-2 text-[12.5px]",
|
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
active
|
|
||||||
? "text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/72 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
|
||||||
deleteSelectionMode && selected
|
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (deleteSelectionMode) {
|
|
||||||
onToggleDeleteSelection([pane.key]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectPane?.(group.topicKey, pane.key);
|
|
||||||
}}
|
|
||||||
draggable={!deleteSelectionMode}
|
|
||||||
onDragStart={(event) => onPaneDragStart(event, {
|
|
||||||
paneKey: pane.key,
|
|
||||||
sourceTabKey: group.topicKey,
|
|
||||||
})}
|
|
||||||
onDragEnd={onPaneDragEnd}
|
|
||||||
aria-current={active ? "true" : undefined}
|
|
||||||
aria-pressed={deleteSelectionMode ? selected : undefined}
|
|
||||||
title={pane.title}
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 flex-1 items-center gap-2 py-1 text-left font-medium leading-5",
|
|
||||||
deleteSelectionMode ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<SelectionIndicator checked={selected} partial={false} />
|
|
||||||
) : null}
|
|
||||||
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
|
|
||||||
</button>
|
|
||||||
<SessionActivityIndicator state={activityState} />
|
|
||||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
|
|
||||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/pane:opacity-100",
|
|
||||||
"focus-visible:opacity-100",
|
|
||||||
active && "opacity-100",
|
|
||||||
)}
|
|
||||||
aria-label={paneActionsLabel}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className={ACTION_MENU_CONTENT_CLASS}
|
|
||||||
portalContainer={actionMenuPortalContainer}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
{index > 0 && onPromotePane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onPromotePane(group.topicKey, pane.key)}>
|
|
||||||
<BringToFront className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.promotePane", {
|
|
||||||
defaultValue: "Make {{title}} the primary pane",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => onRequestRename(pane.key, pane.title)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onDetachPane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onDetachPane(group.topicKey, pane.key)}>
|
|
||||||
<Unplug className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.detachPane", {
|
|
||||||
defaultValue: "Move {{title}} to its own topic",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onAttachPane ? (
|
|
||||||
<MoveToTabSubmenu
|
|
||||||
targets={moveTargets}
|
|
||||||
onMove={(targetKey) => onAttachPane(pane.key, targetKey)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => onBeginDeleteSelection([pane.key])}
|
|
||||||
>
|
|
||||||
<ListChecks className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.select", { defaultValue: "Select" })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
tone="destructive"
|
|
||||||
onSelect={() => {
|
|
||||||
window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.delete")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{dropPreview ? (
|
|
||||||
<li
|
|
||||||
data-pane-drop-preview
|
|
||||||
role="status"
|
|
||||||
aria-label={t("workbench.dropPane", {
|
|
||||||
defaultValue: "Move {{pane}} into {{tab}}",
|
|
||||||
pane: dropPreview.paneTitle,
|
|
||||||
tab: dropPreview.targetTitle,
|
|
||||||
})}
|
|
||||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-primary/45"
|
|
||||||
>
|
|
||||||
<div className="flex min-h-7 items-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.07] px-2 text-[12.5px] font-medium text-foreground/80 shadow-[inset_0_0_0_1px_hsl(var(--background)/0.5)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-150">
|
|
||||||
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-primary/75" aria-hidden />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{dropPreview.paneTitle}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectionIndicator({
|
|
||||||
checked,
|
|
||||||
partial,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
partial: boolean;
|
|
||||||
}) {
|
|
||||||
const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square;
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 shrink-0",
|
|
||||||
checked || partial ? "text-primary" : "text-muted-foreground/55",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MoveToTabSubmenu({
|
|
||||||
targets,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
targets: Array<{ key: string; title: string }>;
|
|
||||||
onMove: (targetKey: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (targets.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
|
|
||||||
{t("workbench.moveToTab", { defaultValue: "Move to tab" })}
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{targets.map((target) => (
|
|
||||||
<DropdownMenuItem key={target.key} onSelect={() => onMove(target.key)}>
|
|
||||||
<span className="max-w-56 truncate">{target.title}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemporaryChatSection({
|
function TemporaryChatSection({
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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;
|
||||||
@@ -27,7 +26,6 @@ interface DeleteConfirmProps {
|
|||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
count = 1,
|
|
||||||
automations = [],
|
automations = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -35,7 +33,6 @@ 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 (
|
||||||
@@ -50,25 +47,12 @@ 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">
|
||||||
{multiple
|
{t("deleteConfirm.title", { title })}
|
||||||
? 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
|
||||||
? multiple
|
? t("deleteConfirm.automationsDescription")
|
||||||
? t("deleteConfirm.automationsDescriptionMany", {
|
: t("deleteConfirm.description")}
|
||||||
defaultValue: "Linked automations will also be deleted.",
|
|
||||||
})
|
|
||||||
: t("deleteConfirm.automationsDescription")
|
|
||||||
: multiple
|
|
||||||
? t("deleteConfirm.descriptionMany", {
|
|
||||||
defaultValue: "This action cannot be undone.",
|
|
||||||
})
|
|
||||||
: t("deleteConfirm.description")}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{hasAutomations ? (
|
{hasAutomations ? (
|
||||||
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
|
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import { ChatList } from "@/components/ChatList";
|
||||||
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,
|
||||||
@@ -43,17 +39,9 @@ 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;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
attachableTabKeys?: string[];
|
|
||||||
paneAcceptingTabKeys?: string[];
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
onReorderSessions: (keys: string[]) => void;
|
onReorderSessions: (keys: string[]) => void;
|
||||||
onToggleGroup: (groupId: string) => void;
|
onToggleGroup: (groupId: string) => void;
|
||||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||||
@@ -242,17 +230,9 @@ 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}
|
||||||
onToggleArchive={props.onToggleArchive}
|
onToggleArchive={props.onToggleArchive}
|
||||||
paneGroups={props.paneGroups}
|
|
||||||
onSelectPane={props.onSelectPane}
|
|
||||||
onDetachPane={props.onDetachPane}
|
|
||||||
onPromotePane={props.onPromotePane}
|
|
||||||
attachableTabKeys={props.attachableTabKeys}
|
|
||||||
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
|
|
||||||
onAttachPane={props.onAttachPane}
|
|
||||||
onReorderSessions={props.onReorderSessions}
|
onReorderSessions={props.onReorderSessions}
|
||||||
onToggleGroup={props.onToggleGroup}
|
onToggleGroup={props.onToggleGroup}
|
||||||
onRequestRenameProject={props.onRequestRenameProject}
|
onRequestRenameProject={props.onRequestRenameProject}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ import {
|
|||||||
startApiService,
|
startApiService,
|
||||||
stopApiService,
|
stopApiService,
|
||||||
updateAutomation,
|
updateAutomation,
|
||||||
|
updateComputerUseSettings,
|
||||||
updateImageGenerationSettings,
|
updateImageGenerationSettings,
|
||||||
updateMcpServerTools,
|
updateMcpServerTools,
|
||||||
updateModelCallOrder,
|
updateModelCallOrder,
|
||||||
@@ -179,6 +180,7 @@ import type {
|
|||||||
AutomationUpdatePayload,
|
AutomationUpdatePayload,
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
CliAppsPayload,
|
CliAppsPayload,
|
||||||
|
ComputerUseSettingsUpdate,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
McpPresetInfo,
|
McpPresetInfo,
|
||||||
McpPresetsPayload,
|
McpPresetsPayload,
|
||||||
@@ -724,7 +726,7 @@ export function SettingsView({
|
|||||||
hostChromeInset = false,
|
hostChromeInset = false,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { client, getToken, token } = useClient();
|
const { getToken, token } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const remoteBrowserAccess =
|
const remoteBrowserAccess =
|
||||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||||
@@ -762,6 +764,7 @@ export function SettingsView({
|
|||||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||||
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
||||||
|
const [computerUseSaving, setComputerUseSaving] = useState<"browser" | "computer" | null>(null);
|
||||||
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
|
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
|
||||||
const [apiServiceLoading, setApiServiceLoading] = useState(false);
|
const [apiServiceLoading, setApiServiceLoading] = useState(false);
|
||||||
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
|
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
|
||||||
@@ -872,7 +875,7 @@ export function SettingsView({
|
|||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
client,
|
getToken(),
|
||||||
providerOAuthFlow.provider,
|
providerOAuthFlow.provider,
|
||||||
providerOAuthFlow.flow_id,
|
providerOAuthFlow.flow_id,
|
||||||
);
|
);
|
||||||
@@ -902,7 +905,7 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
|
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSettings || settings !== null) return;
|
if (!initialSettings || settings !== null) return;
|
||||||
@@ -1002,7 +1005,7 @@ export function SettingsView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!pageVisible
|
!pageVisible
|
||||||
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|
|| !["channels", "models", "browser", "runtime", "advanced"].includes(activeSection)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1301,7 +1304,7 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
setModelConfigurationSaving(true);
|
setModelConfigurationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await createModelConfiguration(client, {
|
const payload = await createModelConfiguration(token, {
|
||||||
label,
|
label,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
@@ -1319,7 +1322,7 @@ export function SettingsView({
|
|||||||
|
|
||||||
let finalPayload = payload;
|
let finalPayload = payload;
|
||||||
if (nextOrder) {
|
if (nextOrder) {
|
||||||
const orderedPayload = await updateModelCallOrder(client, nextOrder);
|
const orderedPayload = await updateModelCallOrder(token, nextOrder);
|
||||||
applyPayload(orderedPayload);
|
applyPayload(orderedPayload);
|
||||||
finalPayload = orderedPayload;
|
finalPayload = orderedPayload;
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1348,7 @@ export function SettingsView({
|
|||||||
const reasoningEffort = form.reasoningEffort || null;
|
const reasoningEffort = form.reasoningEffort || null;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateModelConfiguration(client, {
|
const payload = await updateModelConfiguration(token, {
|
||||||
name: selectedPreset.name,
|
name: selectedPreset.name,
|
||||||
label:
|
label:
|
||||||
form.presetLabel.trim() !== selectedPreset.label
|
form.presetLabel.trim() !== selectedPreset.label
|
||||||
@@ -1431,7 +1434,7 @@ export function SettingsView({
|
|||||||
setModelCallOrder(nextOrder);
|
setModelCallOrder(nextOrder);
|
||||||
setModelCallOrderSaving(true);
|
setModelCallOrderSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateModelCallOrder(client, nextOrder);
|
const payload = await updateModelCallOrder(token, nextOrder);
|
||||||
applyPayload(payload, { preserveAgentForm: true });
|
applyPayload(payload, { preserveAgentForm: true });
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1447,7 +1450,7 @@ export function SettingsView({
|
|||||||
if (modelMigrationSaving) return;
|
if (modelMigrationSaving) return;
|
||||||
setModelMigrationSaving(true);
|
setModelMigrationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await migrateModelConfigurations(client);
|
const payload = await migrateModelConfigurations(token);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1469,7 +1472,7 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
|
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setModelPresetPendingDelete(null);
|
setModelPresetPendingDelete(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1484,7 +1487,7 @@ export function SettingsView({
|
|||||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||||
setImageGenerationSaving(true);
|
setImageGenerationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
|
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||||
@@ -1502,7 +1505,7 @@ export function SettingsView({
|
|||||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||||
setTranscriptionSaving(true);
|
setTranscriptionSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateTranscriptionSettings(client, transcriptionForm);
|
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||||
@@ -1520,7 +1523,7 @@ export function SettingsView({
|
|||||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||||
setNetworkSafetySaving(true);
|
setNetworkSafetySaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
|
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
@@ -1544,7 +1547,7 @@ export function SettingsView({
|
|||||||
try {
|
try {
|
||||||
let latest = nanobotFeatures;
|
let latest = nanobotFeatures;
|
||||||
for (const name of missing) {
|
for (const name of missing) {
|
||||||
latest = await enableNanobotFeature(client, name);
|
latest = await enableNanobotFeature(token, name);
|
||||||
if (latest.requires_restart) {
|
if (latest.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
}
|
}
|
||||||
@@ -1559,6 +1562,34 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setComputerUseEnabled = async (
|
||||||
|
target: "browser" | "computer",
|
||||||
|
enabled: boolean,
|
||||||
|
) => {
|
||||||
|
if (!settings || computerUseSaving) return;
|
||||||
|
setComputerUseSaving(target);
|
||||||
|
try {
|
||||||
|
if (enabled && !(await installCapabilities(["computer-use"]))) return;
|
||||||
|
const update: ComputerUseSettingsUpdate = target === "browser"
|
||||||
|
? { browserEnabled: enabled }
|
||||||
|
: { computerEnabled: enabled };
|
||||||
|
const payload = await updateComputerUseSettings(token, update);
|
||||||
|
applyPayload(payload);
|
||||||
|
if (payload.requires_restart) {
|
||||||
|
setPendingRestartSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[target === "browser" ? "browser" : "runtime"]: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await maybeRestartHostEngine(payload);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setComputerUseSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleApiServiceAction = async (
|
const handleApiServiceAction = async (
|
||||||
action: "start" | "stop",
|
action: "start" | "stop",
|
||||||
values?: { host: string; port: number; timeout: number; apiKey?: string },
|
values?: { host: string; port: number; timeout: number; apiKey?: string },
|
||||||
@@ -1568,8 +1599,8 @@ export function SettingsView({
|
|||||||
setApiServiceError(null);
|
setApiServiceError(null);
|
||||||
try {
|
try {
|
||||||
const payload = action === "start"
|
const payload = action === "start"
|
||||||
? await startApiService(client, values!)
|
? await startApiService(token, values!)
|
||||||
: await stopApiService(client);
|
: await stopApiService(token);
|
||||||
setApiService(payload);
|
setApiService(payload);
|
||||||
const refreshed = await fetchNanobotFeatures(token);
|
const refreshed = await fetchNanobotFeatures(token);
|
||||||
setNanobotFeatures(refreshed);
|
setNanobotFeatures(refreshed);
|
||||||
@@ -1622,7 +1653,7 @@ export function SettingsView({
|
|||||||
if (field === "region") update.region = providerForm.region.trim();
|
if (field === "region") update.region = providerForm.region.trim();
|
||||||
if (field === "profile") update.profile = providerForm.profile.trim();
|
if (field === "profile") update.profile = providerForm.profile.trim();
|
||||||
}
|
}
|
||||||
const payload = await updateProviderSettings(client, update);
|
const payload = await updateProviderSettings(token, update);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||||
@@ -1656,7 +1687,7 @@ export function SettingsView({
|
|||||||
if (providerSaving) return false;
|
if (providerSaving) return false;
|
||||||
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
||||||
try {
|
try {
|
||||||
const payload = await createProviderSettings(client, {
|
const payload = await createProviderSettings(token, {
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
apiKey: draft.apiKey.trim() || undefined,
|
apiKey: draft.apiKey.trim() || undefined,
|
||||||
apiBase: draft.apiBase.trim(),
|
apiBase: draft.apiBase.trim(),
|
||||||
@@ -1698,11 +1729,12 @@ export function SettingsView({
|
|||||||
const payload =
|
const payload =
|
||||||
action === "login"
|
action === "login"
|
||||||
? await loginProviderOAuth(
|
? await loginProviderOAuth(
|
||||||
client,
|
token,
|
||||||
providerName,
|
providerName,
|
||||||
|
"",
|
||||||
providerName === "openai_codex" && remoteBrowserAccess,
|
providerName === "openai_codex" && remoteBrowserAccess,
|
||||||
)
|
)
|
||||||
: await logoutProviderOAuth(client, providerName);
|
: await logoutProviderOAuth(token, providerName);
|
||||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||||
try {
|
try {
|
||||||
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
||||||
@@ -1738,7 +1770,7 @@ export function SettingsView({
|
|||||||
setProviderOAuthDialogError(null);
|
setProviderOAuthDialogError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
client,
|
token,
|
||||||
flow.provider,
|
flow.provider,
|
||||||
flow.flow_id,
|
flow.flow_id,
|
||||||
authorizationResponse,
|
authorizationResponse,
|
||||||
@@ -1797,7 +1829,7 @@ export function SettingsView({
|
|||||||
update.apiKey = apiKey;
|
update.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||||
const payload = await updateWebSearchSettings(client, update);
|
const payload = await updateWebSearchSettings(token, update);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart || webFetchRestartRequired) {
|
if (payload.requires_restart || webFetchRestartRequired) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||||
@@ -1902,7 +1934,7 @@ export function SettingsView({
|
|||||||
setCliAppsMessage(null);
|
setCliAppsMessage(null);
|
||||||
setCliAppsError(null);
|
setCliAppsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runCliAppAction(client, action, name);
|
const payload = await runCliAppAction(token, action, name);
|
||||||
setCliApps(payload);
|
setCliApps(payload);
|
||||||
if (action !== "test") {
|
if (action !== "test") {
|
||||||
notifyCliAppsChanged(payload);
|
notifyCliAppsChanged(payload);
|
||||||
@@ -1933,8 +1965,8 @@ export function SettingsView({
|
|||||||
setNanobotFeaturesError(null);
|
setNanobotFeaturesError(null);
|
||||||
try {
|
try {
|
||||||
const payload = action === "enable"
|
const payload = action === "enable"
|
||||||
? await enableNanobotFeature(client, name)
|
? await enableNanobotFeature(token, name)
|
||||||
: await disableNanobotFeature(client, name);
|
: await disableNanobotFeature(token, name);
|
||||||
setNanobotFeatures(payload);
|
setNanobotFeatures(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
@@ -1954,7 +1986,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runAutomationAction(client, action, job.id);
|
const payload = await runAutomationAction(token, action, job.id);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
if (action === "delete") setAutomationPendingDelete(null);
|
if (action === "delete") setAutomationPendingDelete(null);
|
||||||
if (action === "run") {
|
if (action === "run") {
|
||||||
@@ -1976,7 +2008,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateAutomation(client, job.id, values);
|
const payload = await updateAutomation(token, job.id, values);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationPendingEdit(null);
|
setAutomationPendingEdit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1996,7 +2028,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runMcpPresetAction(client, action, name, values);
|
const payload = await runMcpPresetAction(token, action, name, values);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
if (action !== "test") {
|
if (action !== "test") {
|
||||||
@@ -2023,7 +2055,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await saveCustomMcpServer(client, {
|
const payload = await saveCustomMcpServer(token, {
|
||||||
name,
|
name,
|
||||||
transport: customMcpForm.transport,
|
transport: customMcpForm.transport,
|
||||||
command: customMcpForm.command,
|
command: customMcpForm.command,
|
||||||
@@ -2053,7 +2085,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await importMcpConfig(client, mcpConfigImport);
|
const payload = await importMcpConfig(token, mcpConfigImport);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
@@ -2074,7 +2106,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateMcpServerTools(client, name, enabledTools);
|
const payload = await updateMcpServerTools(token, name, enabledTools);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
@@ -2237,6 +2269,11 @@ export function SettingsView({
|
|||||||
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
|
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
|
||||||
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
|
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
|
||||||
capabilityError={nanobotFeaturesError}
|
capabilityError={nanobotFeaturesError}
|
||||||
|
browserAutomationEnabled={settings.computer_use?.browser_enabled ?? false}
|
||||||
|
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
|
||||||
|
computerUseSaving={computerUseSaving === "browser"}
|
||||||
|
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
|
||||||
|
onToggleBrowserAutomation={(enabled) => void setComputerUseEnabled("browser", enabled)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "channels":
|
case "channels":
|
||||||
@@ -2327,7 +2364,6 @@ export function SettingsView({
|
|||||||
onAction={handleAutomationAction}
|
onAction={handleAutomationAction}
|
||||||
onRequestEdit={setAutomationPendingEdit}
|
onRequestEdit={setAutomationPendingEdit}
|
||||||
onRequestDelete={setAutomationPendingDelete}
|
onRequestDelete={setAutomationPendingDelete}
|
||||||
onBackToChat={onBackToChat}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "skills":
|
case "skills":
|
||||||
@@ -2364,6 +2400,13 @@ export function SettingsView({
|
|||||||
onRestart={restartViaSettingsSurface}
|
onRestart={restartViaSettingsSurface}
|
||||||
isRestarting={isRestarting || hostEngineApplying}
|
isRestarting={isRestarting || hostEngineApplying}
|
||||||
requiresRestartPending={pendingRestartSections.runtime}
|
requiresRestartPending={pendingRestartSections.runtime}
|
||||||
|
computerControlEnabled={settings.computer_use?.enabled ?? false}
|
||||||
|
computerUseBackend={settings.computer_use?.backend ?? "desktop"}
|
||||||
|
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
|
||||||
|
computerUseSaving={computerUseSaving === "computer"}
|
||||||
|
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
|
||||||
|
capabilityError={nanobotFeaturesError}
|
||||||
|
onToggleComputerControl={(enabled) => void setComputerUseEnabled("computer", enabled)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -2448,7 +2491,7 @@ export function SettingsView({
|
|||||||
onSave={handleAutomationEdit}
|
onSave={handleAutomationEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
||||||
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
||||||
@@ -2512,7 +2555,7 @@ export function SettingsView({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2578,9 +2621,9 @@ function SettingsSidebar({
|
|||||||
{t("settings.backToChat")}
|
{t("settings.backToChat")}
|
||||||
</button>
|
</button>
|
||||||
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
||||||
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
|
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||||
{t("settings.sidebar.title")}
|
{t("settings.sidebar.title")}
|
||||||
</h1>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
@@ -3042,13 +3085,7 @@ function AppearanceSettings({
|
|||||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
|
||||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
|
||||||
description={tx(
|
|
||||||
"settings.legal.thirdPartyBrands",
|
|
||||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
checked={localPrefs.brandLogos}
|
checked={localPrefs.brandLogos}
|
||||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||||
@@ -5261,6 +5298,11 @@ function WebSettings({
|
|||||||
olostepFeature,
|
olostepFeature,
|
||||||
olostepInstalling,
|
olostepInstalling,
|
||||||
capabilityError,
|
capabilityError,
|
||||||
|
browserAutomationEnabled,
|
||||||
|
computerUseFeature,
|
||||||
|
computerUseSaving,
|
||||||
|
computerUseInstalling,
|
||||||
|
onToggleBrowserAutomation,
|
||||||
}: {
|
}: {
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
form: WebSearchSettingsUpdate;
|
form: WebSearchSettingsUpdate;
|
||||||
@@ -5280,6 +5322,11 @@ function WebSettings({
|
|||||||
olostepFeature?: NanobotFeatureInfo;
|
olostepFeature?: NanobotFeatureInfo;
|
||||||
olostepInstalling: boolean;
|
olostepInstalling: boolean;
|
||||||
capabilityError: string | null;
|
capabilityError: string | null;
|
||||||
|
browserAutomationEnabled: boolean;
|
||||||
|
computerUseFeature?: NanobotFeatureInfo;
|
||||||
|
computerUseSaving: boolean;
|
||||||
|
computerUseInstalling: boolean;
|
||||||
|
onToggleBrowserAutomation: (enabled: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
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 });
|
||||||
@@ -5308,9 +5355,46 @@ function WebSettings({
|
|||||||
: selectedProvider?.credential === "base_url"
|
: selectedProvider?.credential === "base_url"
|
||||||
? !baseUrl
|
? !baseUrl
|
||||||
: false;
|
: false;
|
||||||
|
const computerUseInstalled = computerUseFeature?.installed ?? true;
|
||||||
|
const browserAutomationDescription = computerUseInstalling
|
||||||
|
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
|
||||||
|
: computerUseInstalled
|
||||||
|
? tx(
|
||||||
|
"settings.help.browserAutomation",
|
||||||
|
"Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
|
||||||
|
)
|
||||||
|
: tx(
|
||||||
|
"settings.help.computerUseInstall",
|
||||||
|
"Required Python support will be installed when you turn this on.",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
|
<section>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{tx("settings.sections.browserAutomation", "Browser automation")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRow
|
||||||
|
title={tx("settings.rows.browserAutomation", "Browser automation")}
|
||||||
|
description={browserAutomationDescription}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
checked={browserAutomationEnabled}
|
||||||
|
disabled={computerUseSaving || computerUseInstalling}
|
||||||
|
onChange={onToggleBrowserAutomation}
|
||||||
|
ariaLabel={tx("settings.rows.browserAutomation", "Browser automation")}
|
||||||
|
label={browserAutomationEnabled
|
||||||
|
? tx("settings.values.on", "On")
|
||||||
|
: tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
{capabilityError ? (
|
||||||
|
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
||||||
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
|
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
|
||||||
@@ -5494,7 +5578,6 @@ function AutomationsSettings({
|
|||||||
onAction,
|
onAction,
|
||||||
onRequestEdit,
|
onRequestEdit,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onBackToChat,
|
|
||||||
}: {
|
}: {
|
||||||
payload: AutomationsPayload | null;
|
payload: AutomationsPayload | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -5509,7 +5592,6 @@ function AutomationsSettings({
|
|||||||
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
||||||
onRequestEdit: (job: SessionAutomationJob) => void;
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
||||||
onRequestDelete: (job: SessionAutomationJob) => void;
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
||||||
onBackToChat: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
@@ -5557,76 +5639,74 @@ function AutomationsSettings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{jobs.length ? (
|
<section className="shrink-0">
|
||||||
<section className="shrink-0">
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
||||||
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
|
||||||
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
|
{summaryOptions.map((option) => (
|
||||||
{summaryOptions.map((option) => (
|
<button
|
||||||
<button
|
key={option.value}
|
||||||
key={option.value}
|
type="button"
|
||||||
type="button"
|
onClick={() => onFilterChange(option.value)}
|
||||||
onClick={() => onFilterChange(option.value)}
|
className={cn(
|
||||||
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
||||||
|
filter === option.value && "bg-background text-foreground",
|
||||||
|
automationFilterToneClass(option.value, option.count, filter === option.value),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
|
||||||
filter === option.value && "bg-background text-foreground",
|
automationFilterCountClass(option.value, option.count),
|
||||||
automationFilterToneClass(option.value, option.count, filter === option.value),
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{option.label}</span>
|
{option.count}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
</button>
|
||||||
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
|
))}
|
||||||
automationFilterCountClass(option.value, option.count),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
||||||
<div className="relative min-w-0">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
|
||||||
placeholder={tx(
|
|
||||||
"settings.automations.search",
|
|
||||||
"Search task, message, linked chat, or schedule",
|
|
||||||
)}
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
|
|
||||||
SETTINGS_SEARCH_INPUT_CLASS,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
|
||||||
>
|
|
||||||
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
<span>{sortLabel[sort]}</span>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
|
||||||
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
|
||||||
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
|
||||||
<span>{sortLabel[value]}</span>
|
|
||||||
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
) : null}
|
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder={tx(
|
||||||
|
"settings.automations.search",
|
||||||
|
"Search task, message, linked chat, or schedule",
|
||||||
|
)}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
|
||||||
|
SETTINGS_SEARCH_INPUT_CLASS,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
<span>{sortLabel[sort]}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
|
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
||||||
|
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
||||||
|
<span>{sortLabel[value]}</span>
|
||||||
|
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
@@ -5684,35 +5764,13 @@ function AutomationsSettings({
|
|||||||
: tx("settings.automations.empty", "No automations yet.")}
|
: tx("settings.automations.empty", "No automations yet.")}
|
||||||
</div>
|
</div>
|
||||||
{!jobs.length ? (
|
{!jobs.length ? (
|
||||||
<>
|
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
||||||
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
{tx(
|
||||||
{tx(
|
"settings.automations.emptyHint",
|
||||||
"settings.automations.emptyHint",
|
"Create one from where it should run so nanobot keeps the right context.",
|
||||||
"Create automations in a chat so they keep the right context.",
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4 rounded-full"
|
|
||||||
onClick={onBackToChat}
|
|
||||||
>
|
|
||||||
{tx("settings.automations.emptyAction", "Open a chat")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4 rounded-full"
|
|
||||||
onClick={() => {
|
|
||||||
onQueryChange("");
|
|
||||||
onFilterChange("all");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tx("settings.automations.clearFilters", "Clear filters")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -7322,6 +7380,10 @@ function ChannelsSettings({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7424,23 +7486,6 @@ function AppsCatalogSettings({
|
|||||||
(cliAppsLoading || mcpPresetsLoading) &&
|
(cliAppsLoading || mcpPresetsLoading) &&
|
||||||
!cliApps &&
|
!cliApps &&
|
||||||
!mcpPresets;
|
!mcpPresets;
|
||||||
const cliAppCount = cliApps?.apps.length ?? 0;
|
|
||||||
const emptyTitle = normalizedQuery
|
|
||||||
? tx("settings.apps.empty", "No tools match your search.")
|
|
||||||
: filter === "cli"
|
|
||||||
? tx("settings.apps.emptyApps", "No apps available.")
|
|
||||||
: filter === "mcp"
|
|
||||||
? tx("settings.apps.emptyIntegrations", "No integrations available.")
|
|
||||||
: tx("settings.apps.emptyReady", "No tools are ready yet.");
|
|
||||||
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
|
|
||||||
? null
|
|
||||||
: filter === "cli"
|
|
||||||
? "mcp"
|
|
||||||
: filter === "mcp"
|
|
||||||
? (cliAppCount ? "cli" : null)
|
|
||||||
: cliAppCount
|
|
||||||
? "cli"
|
|
||||||
: "mcp";
|
|
||||||
const statusMessage =
|
const statusMessage =
|
||||||
cliError ||
|
cliError ||
|
||||||
mcpError ||
|
mcpError ||
|
||||||
@@ -7529,35 +7574,7 @@ function AppsCatalogSettings({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||||
<p>{emptyTitle}</p>
|
{tx("settings.apps.empty", "No tools match this view.")}
|
||||||
{normalizedQuery ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4 rounded-full"
|
|
||||||
onClick={() => onQueryChange("")}
|
|
||||||
>
|
|
||||||
{tx("settings.apps.clearSearch", "Clear search")}
|
|
||||||
</Button>
|
|
||||||
) : emptyBrowseTarget ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4 rounded-full"
|
|
||||||
onClick={() => onFilterChange(emptyBrowseTarget)}
|
|
||||||
>
|
|
||||||
{emptyBrowseTarget === "cli"
|
|
||||||
? tx("settings.apps.browseApps", "Browse apps")
|
|
||||||
: tx("settings.apps.browseIntegrations", "Browse integrations")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
|
||||||
{tx(
|
|
||||||
"settings.apps.emptyIntegrationsHint",
|
|
||||||
"Add a custom integration below.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -7573,6 +7590,8 @@ function AppsCatalogSettings({
|
|||||||
onImportConfig={onImportMcpConfig}
|
onImportConfig={onImportMcpConfig}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8836,6 +8855,13 @@ function AdvancedSettings({
|
|||||||
onSave,
|
onSave,
|
||||||
onRestart,
|
onRestart,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
|
computerControlEnabled,
|
||||||
|
computerUseBackend,
|
||||||
|
computerUseFeature,
|
||||||
|
computerUseSaving,
|
||||||
|
computerUseInstalling,
|
||||||
|
capabilityError,
|
||||||
|
onToggleComputerControl,
|
||||||
}: {
|
}: {
|
||||||
form: NetworkSafetySettingsUpdate;
|
form: NetworkSafetySettingsUpdate;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
@@ -8846,11 +8872,60 @@ function AdvancedSettings({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
isRestarting?: boolean;
|
isRestarting?: boolean;
|
||||||
|
computerControlEnabled: boolean;
|
||||||
|
computerUseBackend: "desktop" | "browser";
|
||||||
|
computerUseFeature?: NanobotFeatureInfo;
|
||||||
|
computerUseSaving: boolean;
|
||||||
|
computerUseInstalling: boolean;
|
||||||
|
capabilityError: string | null;
|
||||||
|
onToggleComputerControl: (enabled: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
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 computerUseInstalled = computerUseFeature?.installed ?? true;
|
||||||
|
const computerControlDescription = computerUseInstalling
|
||||||
|
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
|
||||||
|
: !computerUseInstalled
|
||||||
|
? tx(
|
||||||
|
"settings.help.computerUseInstall",
|
||||||
|
"Required Python support will be installed when you turn this on.",
|
||||||
|
)
|
||||||
|
: computerUseBackend === "browser"
|
||||||
|
? tx(
|
||||||
|
"settings.help.computerControlBrowser",
|
||||||
|
"Pixel-based control currently targets an isolated browser, as configured in config.json.",
|
||||||
|
)
|
||||||
|
: tx(
|
||||||
|
"settings.help.computerControl",
|
||||||
|
"Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
|
<section>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{tx("settings.sections.computerControl", "Computer control")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRow
|
||||||
|
title={tx("settings.rows.computerControl", "Computer control")}
|
||||||
|
description={computerControlDescription}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
checked={computerControlEnabled}
|
||||||
|
disabled={computerUseSaving || computerUseInstalling}
|
||||||
|
onChange={onToggleComputerControl}
|
||||||
|
ariaLabel={tx("settings.rows.computerControl", "Computer control")}
|
||||||
|
label={computerControlEnabled
|
||||||
|
? tx("settings.values.on", "On")
|
||||||
|
: tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
{capabilityError ? (
|
||||||
|
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{isNativeHostSurface
|
{isNativeHostSurface
|
||||||
@@ -9397,6 +9472,18 @@ function ProviderPickerIcon({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThirdPartyBrandNotice() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
|
||||||
|
{t("settings.legal.thirdPartyBrands", {
|
||||||
|
defaultValue:
|
||||||
|
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function orderUnconfiguredProviders(
|
function orderUnconfiguredProviders(
|
||||||
providers: SettingsPayload["providers"],
|
providers: SettingsPayload["providers"],
|
||||||
): SettingsPayload["providers"] {
|
): SettingsPayload["providers"] {
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client, getToken } = useClient();
|
const { 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(client, activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(getToken(), 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(client, activeSkill.name);
|
const payload = await deleteSkill(getToken(), 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 { client, getToken } = useClient();
|
const { 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(
|
||||||
client,
|
getToken(),
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ 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;
|
||||||
@@ -51,6 +50,7 @@ export type ChannelInstancesPanelCustomization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelInstancesPanel({
|
export function ChannelInstancesPanel({
|
||||||
|
token,
|
||||||
feature,
|
feature,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
chatAppsDocsUrl,
|
chatAppsDocsUrl,
|
||||||
@@ -58,6 +58,7 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
customization = {},
|
customization = {},
|
||||||
}: {
|
}: {
|
||||||
|
token: string;
|
||||||
feature: NanobotFeatureInfo;
|
feature: NanobotFeatureInfo;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
chatAppsDocsUrl?: string;
|
chatAppsDocsUrl?: string;
|
||||||
@@ -65,7 +66,6 @@ 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(client, feature.name, { instanceId: instance.id })
|
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
||||||
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
: await disableNanobotFeature(token, 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(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSave(instanceFields, fieldValues),
|
channelValuesForSave(instanceFields, fieldValues),
|
||||||
{ enable: selected.enabled, instanceId: selected.id },
|
{ enable: selected.enabled, instanceId: selected.id },
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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;
|
||||||
@@ -44,6 +43,7 @@ export type ChannelQrConnectPendingContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelQrConnectFlow({
|
export function ChannelQrConnectFlow({
|
||||||
|
token,
|
||||||
channelName,
|
channelName,
|
||||||
startOptions = {},
|
startOptions = {},
|
||||||
idleLabel,
|
idleLabel,
|
||||||
@@ -69,7 +69,6 @@ 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 });
|
||||||
@@ -79,6 +78,8 @@ 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;
|
||||||
@@ -128,7 +129,7 @@ export function ChannelQrConnectFlow({
|
|||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
@@ -162,7 +163,6 @@ 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(client, channelName, {
|
const payload = await startChannelConnect(tokenRef.current, 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, client, startDomain, startForce, startInstanceId, startMode]);
|
}, [channelName, 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(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
);
|
);
|
||||||
@@ -223,9 +223,10 @@ export function ChannelQrConnectFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
|
"",
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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,
|
||||||
@@ -149,6 +148,7 @@ 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,7 +269,6 @@ 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);
|
||||||
@@ -346,7 +345,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(client, feature.name, values);
|
const validationPayload = await validateChannel(token, feature.name, values);
|
||||||
setValidation(validationPayload);
|
setValidation(validationPayload);
|
||||||
if (!validationPayload.can_enable) {
|
if (!validationPayload.can_enable) {
|
||||||
setNotice(
|
setNotice(
|
||||||
@@ -356,7 +355,7 @@ function ChannelSetupSurface({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
values,
|
values,
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
@@ -378,7 +377,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await validateChannel(
|
const payload = await validateChannel(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,14 +179,9 @@ function getVoiceShortcutLabel(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadComposerProps {
|
interface ThreadComposerProps {
|
||||||
onSend: (
|
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
||||||
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;
|
||||||
@@ -941,7 +936,6 @@ export function ThreadComposer({
|
|||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
inputAriaLabel,
|
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
modelDetail = null,
|
modelDetail = null,
|
||||||
@@ -987,8 +981,6 @@ 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);
|
||||||
@@ -1079,7 +1071,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (interactionDisabled || files.length === 0) return;
|
if (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) {
|
||||||
@@ -1088,7 +1080,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueue, formatRejection, interactionDisabled],
|
[enqueue, formatRejection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1101,20 +1093,18 @@ export function ThreadComposer({
|
|||||||
} = useClipboardAndDrop(addFiles);
|
} = useClipboardAndDrop(addFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
|
if (disabled || hasTouchPrimaryPointer) return;
|
||||||
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);
|
||||||
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
|
}, [disabled, hasTouchPrimaryPointer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusRequest || interactionDisabled) return;
|
if (!focusRequest || disabled) return;
|
||||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [focusRequest, interactionDisabled]);
|
}, [disabled, focusRequest]);
|
||||||
|
|
||||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||||
|
|
||||||
@@ -1128,17 +1118,15 @@ 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 =
|
||||||
!interactionDisabled
|
!disabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
&& hasComposerContent;
|
&& hasComposerContent;
|
||||||
const canOpenModelSettings = Boolean(
|
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
||||||
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
|
|
||||||
);
|
|
||||||
const canQueueGuidance =
|
const canQueueGuidance =
|
||||||
isStreaming
|
isStreaming
|
||||||
&& !interactionDisabled
|
&& !disabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
@@ -1146,14 +1134,14 @@ export function ThreadComposer({
|
|||||||
&& !value.trimStart().startsWith("/");
|
&& !value.trimStart().startsWith("/");
|
||||||
|
|
||||||
const slashQuery = useMemo(() => {
|
const slashQuery = useMemo(() => {
|
||||||
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
if (disabled || 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();
|
||||||
}, [interactionDisabled, slashMenuDismissed, value]);
|
}, [disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const skillQuery = useMemo(() => {
|
const skillQuery = useMemo(() => {
|
||||||
if (interactionDisabled || slashMenuDismissed) return null;
|
if (disabled || 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);
|
||||||
@@ -1163,7 +1151,7 @@ export function ThreadComposer({
|
|||||||
start: match.index,
|
start: match.index,
|
||||||
text: match[1].toLowerCase(),
|
text: match[1].toLowerCase(),
|
||||||
};
|
};
|
||||||
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
|
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
if (!(isStreaming && onStop)) return slashCommands;
|
if (!(isStreaming && onStop)) return slashCommands;
|
||||||
@@ -1291,7 +1279,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 (interactionDisabled || cliAppMenuDismissed) return null;
|
if (disabled || 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);
|
||||||
@@ -1302,7 +1290,7 @@ export function ThreadComposer({
|
|||||||
start: caret - query.length - 1,
|
start: caret - query.length - 1,
|
||||||
end: caret,
|
end: caret,
|
||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||||
|
|
||||||
const availableSessionMentions = useMemo(
|
const availableSessionMentions = useMemo(
|
||||||
() => sessionMentionOptions(
|
() => sessionMentionOptions(
|
||||||
@@ -1592,7 +1580,7 @@ export function ThreadComposer({
|
|||||||
}, VOICE_ERROR_VISIBLE_MS);
|
}, VOICE_ERROR_VISIBLE_MS);
|
||||||
}, [clearVoiceErrorTimers, t]);
|
}, [clearVoiceErrorTimers, t]);
|
||||||
const voiceRecorder = useVoiceRecorder({
|
const voiceRecorder = useVoiceRecorder({
|
||||||
disabled: interactionDisabled,
|
disabled,
|
||||||
onClearError: clearInlineError,
|
onClearError: clearInlineError,
|
||||||
onError: setVoiceError,
|
onError: setVoiceError,
|
||||||
onTranscript: appendTranscription,
|
onTranscript: appendTranscription,
|
||||||
@@ -1726,7 +1714,7 @@ export function ThreadComposer({
|
|||||||
clearDraggedSession();
|
clearDraggedSession();
|
||||||
const preview = sessionDragPreview;
|
const preview = sessionDragPreview;
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
if (interactionDisabled) return true;
|
if (disabled) 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),
|
||||||
@@ -1744,17 +1732,11 @@ 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 (interactionDisabled) {
|
if (disabled) {
|
||||||
event.dataTransfer.dropEffect = "none";
|
event.dataTransfer.dropEffect = "none";
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -1783,7 +1765,7 @@ export function ThreadComposer({
|
|||||||
: { mention, start, end }
|
: { mention, start, end }
|
||||||
));
|
));
|
||||||
return true;
|
return true;
|
||||||
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
|
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDragPreview) return;
|
if (!sessionDragPreview) return;
|
||||||
@@ -2026,16 +2008,7 @@ export function ThreadComposer({
|
|||||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||||
const finalizeActiveTurn =
|
const finalizeActiveTurn =
|
||||||
slashLifecycle === "finalize_active_turn";
|
slashLifecycle === "finalize_active_turn";
|
||||||
const finishSend = () => {
|
onSend(
|
||||||
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
|
||||||
@@ -2046,19 +2019,13 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: options,
|
: options,
|
||||||
);
|
);
|
||||||
if (result instanceof Promise) {
|
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||||
setSendPending(true);
|
setQueuedPrompts([]);
|
||||||
void result
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
.then((accepted) => {
|
// preview here without affecting the rendered message.
|
||||||
if (accepted !== false) finishSend();
|
clear();
|
||||||
})
|
clearComposerText(!hasTouchPrimaryPointer);
|
||||||
.catch((error: unknown) => {
|
onQuotedContextChange?.(null);
|
||||||
console.error("Failed to send message", error);
|
|
||||||
})
|
|
||||||
.finally(() => setSendPending(false));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result !== false) finishSend();
|
|
||||||
}, [
|
}, [
|
||||||
activeCliMentionApps,
|
activeCliMentionApps,
|
||||||
activeMcpPresetMentions,
|
activeMcpPresetMentions,
|
||||||
@@ -2201,7 +2168,7 @@ export function ThreadComposer({
|
|||||||
[removeChip],
|
[removeChip],
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachButtonDisabled = interactionDisabled || full;
|
const attachButtonDisabled = disabled || 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,
|
||||||
@@ -2286,7 +2253,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]",
|
||||||
interactionDisabled && "opacity-60",
|
disabled && "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 &&
|
||||||
@@ -2401,8 +2368,8 @@ export function ThreadComposer({
|
|||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={interactionDisabled}
|
disabled={disabled}
|
||||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
aria-label={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",
|
||||||
@@ -2416,7 +2383,7 @@ export function ThreadComposer({
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
"mx-3 mb-1 max-h-10 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",
|
||||||
)}
|
)}
|
||||||
@@ -2476,7 +2443,7 @@ export function ThreadComposer({
|
|||||||
) : workspaceScope && !workspaceControlsHidden ? (
|
) : workspaceScope && !workspaceControlsHidden ? (
|
||||||
<WorkspaceAccessMenu
|
<WorkspaceAccessMenu
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
disabled={interactionDisabled || workspaceScopeDisabled}
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
@@ -2554,7 +2521,7 @@ export function ThreadComposer({
|
|||||||
<Button
|
<Button
|
||||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
|
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
||||||
aria-label={
|
aria-label={
|
||||||
showStopButton
|
showStopButton
|
||||||
? t("thread.composer.stop")
|
? t("thread.composer.stop")
|
||||||
@@ -2595,7 +2562,7 @@ export function ThreadComposer({
|
|||||||
<div className="composer-workspace-drawer-content">
|
<div className="composer-workspace-drawer-content">
|
||||||
<WorkspaceProjectPicker
|
<WorkspaceProjectPicker
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
|
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ 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;
|
||||||
@@ -36,11 +33,8 @@ 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,
|
||||||
@@ -60,21 +54,19 @@ 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"
|
aria-label={t("thread.header.toggleSidebar")}
|
||||||
aria-label={t("thread.header.toggleSidebar")}
|
onClick={onToggleSidebar}
|
||||||
onClick={onToggleSidebar}
|
className={cn(
|
||||||
className={cn(
|
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<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>
|
||||||
@@ -84,7 +76,6 @@ 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,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
import type { PointerEvent as ReactPointerEvent } 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";
|
||||||
@@ -312,17 +311,9 @@ 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;
|
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
@@ -607,17 +598,9 @@ 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,
|
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
@@ -1278,7 +1261,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 false;
|
if (booting) return;
|
||||||
setBooting(true);
|
setBooting(true);
|
||||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
@@ -1287,13 +1270,12 @@ export function ThreadShell({
|
|||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
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],
|
||||||
);
|
);
|
||||||
@@ -1422,7 +1404,6 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
inputAriaLabel={composerInputAriaLabel}
|
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
showHeroComposer
|
||||||
@@ -1467,7 +1448,6 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
disabled={booting}
|
disabled={booting}
|
||||||
inputAriaLabel={composerInputAriaLabel}
|
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
booting
|
booting
|
||||||
@@ -1527,33 +1507,28 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const threadHeader = !hideHeader ? (
|
|
||||||
<ThreadHeader
|
|
||||||
title={title}
|
|
||||||
onToggleSidebar={onToggleSidebar}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={onToggleTheme}
|
|
||||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
|
||||||
hideSidebarToggle={hideSidebarToggle}
|
|
||||||
hostChromeTitleInset={hostChromeTitleInset}
|
|
||||||
hideThemeButton={hideThemeButton}
|
|
||||||
hideTitle={hideHeaderTitle}
|
|
||||||
actions={headerActions}
|
|
||||||
minimal={!session && !loading}
|
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
|
||||||
sessionInfoAction={sessionInfoAction}
|
|
||||||
temporaryChatEnabled={temporaryChatEnabled}
|
|
||||||
temporaryChatDisabled={booting || turnActive}
|
|
||||||
onTemporaryChatEnabledChange={
|
|
||||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
<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">
|
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{headerPortalTarget === undefined ? threadHeader : null}
|
{!hideHeader ? (
|
||||||
|
<ThreadHeader
|
||||||
|
title={title}
|
||||||
|
onToggleSidebar={onToggleSidebar}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||||
|
hostChromeTitleInset={hostChromeTitleInset}
|
||||||
|
hideThemeButton={hideThemeButton}
|
||||||
|
minimal={!session && !loading}
|
||||||
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
|
sessionInfoAction={sessionInfoAction}
|
||||||
|
temporaryChatEnabled={temporaryChatEnabled}
|
||||||
|
temporaryChatDisabled={booting || turnActive}
|
||||||
|
onTemporaryChatEnabledChange={
|
||||||
|
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||||
>
|
>
|
||||||
@@ -1563,7 +1538,7 @@ export function ThreadShell({
|
|||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composerPortalTarget === undefined ? composer : null}
|
composer={composer}
|
||||||
activeTurnId={viewportTurnId}
|
activeTurnId={viewportTurnId}
|
||||||
activeTurnStartedHere={activeTurnStartedHere}
|
activeTurnStartedHere={activeTurnStartedHere}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
@@ -1583,19 +1558,6 @@ 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,7 +61,6 @@ 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;
|
||||||
@@ -267,14 +266,11 @@ 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
|
||||||
: hasComposer
|
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||||
? 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;
|
||||||
|
|
||||||
@@ -665,7 +661,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={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
data-layout={hasMessages ? "thread" : "hero"}
|
||||||
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
|
||||||
@@ -703,67 +699,58 @@ 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
|
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||||
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
|
||||||
|
ref={composerDockRef}
|
||||||
|
data-testid="thread-composer-dock"
|
||||||
|
onInputCapture={(event) => {
|
||||||
|
if (event.target instanceof HTMLTextAreaElement) {
|
||||||
|
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||||
|
threadMotionRef.current?.handleComposerInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInput={(event) => {
|
||||||
|
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||||
|
const previousScrollTop = composerInputScrollTopRef.current;
|
||||||
|
composerInputScrollTopRef.current = null;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && previousScrollTop !== null) {
|
||||||
|
// Textarea autosizing briefly collapses to `height: auto` while
|
||||||
|
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||||
|
// that intermediate layout; restore it before paint, then let
|
||||||
|
// ResizeObserver handle any real final composer height change.
|
||||||
|
scrollEl.scrollTop = previousScrollTop;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"row-start-2 z-10 w-full",
|
||||||
|
hasMessages ? "relative bg-background" : "relative self-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={composerDockRef}
|
|
||||||
data-testid="thread-composer-dock"
|
|
||||||
onInputCapture={(event) => {
|
|
||||||
if (event.target instanceof HTMLTextAreaElement) {
|
|
||||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
|
||||||
threadMotionRef.current?.handleComposerInput();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInput={(event) => {
|
|
||||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
|
||||||
const previousScrollTop = composerInputScrollTopRef.current;
|
|
||||||
composerInputScrollTopRef.current = null;
|
|
||||||
const scrollEl = scrollRef.current;
|
|
||||||
if (scrollEl && previousScrollTop !== null) {
|
|
||||||
// Textarea autosizing briefly collapses to `height: auto` while
|
|
||||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
|
||||||
// that intermediate layout; restore it before paint, then let
|
|
||||||
// ResizeObserver handle any real final composer height change.
|
|
||||||
scrollEl.scrollTop = previousScrollTop;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"row-start-2 z-10 w-full",
|
hasMessages
|
||||||
hasMessages ? "relative bg-background" : "relative self-center",
|
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||||
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
data-testid="thread-composer-motion"
|
||||||
hasMessages
|
className="mx-auto w-full max-w-[58rem]"
|
||||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div
|
{composer}
|
||||||
data-testid="thread-composer-motion"
|
|
||||||
className="mx-auto w-full max-w-[58rem]"
|
|
||||||
>
|
|
||||||
{composer}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{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, useId, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, 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,14 +34,6 @@ 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,
|
||||||
@@ -68,9 +60,6 @@ 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)
|
||||||
@@ -93,17 +82,9 @@ export function WorkspaceProjectPicker({
|
|||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error || !visible || disabled) return;
|
if (error && visible && !disabled) setOpen(true);
|
||||||
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;
|
||||||
@@ -148,7 +129,6 @@ 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")}
|
||||||
@@ -184,7 +164,6 @@ 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")}
|
||||||
@@ -242,20 +221,14 @@ 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={workspacePathPlaceholder(
|
placeholder={t("workspace.dialog.manualPlaceholder")}
|
||||||
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",
|
||||||
@@ -270,22 +243,13 @@ export function WorkspaceProjectPicker({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{pathError || error ? (
|
{pathError || error ? (
|
||||||
<p
|
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
|
||||||
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 { ChevronRight, Circle } from "lucide-react";
|
import { Circle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
@@ -13,7 +13,6 @@ 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`;
|
||||||
@@ -116,41 +115,6 @@ 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,
|
||||||
@@ -159,8 +123,5 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
import {
|
|
||||||
Columns2,
|
|
||||||
Grid2X2,
|
|
||||||
PanelLeft,
|
|
||||||
Plus,
|
|
||||||
Rows2,
|
|
||||||
Square,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type FocusEvent,
|
|
||||||
type PointerEvent,
|
|
||||||
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 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;
|
|
||||||
addPaneDisabled?: boolean;
|
|
||||||
onActivatePane: (key: string) => void;
|
|
||||||
onAddPane: () => void;
|
|
||||||
onLayoutChange: (layout: WorkbenchLayout) => 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 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: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
|
||||||
{ icon: Square, layout: "monocle", label: "Monocle" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties {
|
|
||||||
const count = Math.max(1, paneCount);
|
|
||||||
switch (layout) {
|
|
||||||
case "columns":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
};
|
|
||||||
case "rows":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
case "grid": {
|
|
||||||
const columns = Math.ceil(Math.sqrt(count));
|
|
||||||
const rows = Math.ceil(count / columns);
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
||||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "main-stack":
|
|
||||||
return count === 1
|
|
||||||
? {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
case "monocle":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paneCellStyle(
|
|
||||||
layout: WorkbenchLayout,
|
|
||||||
paneCount: number,
|
|
||||||
index: number,
|
|
||||||
): CSSProperties | undefined {
|
|
||||||
if (layout !== "main-stack" || paneCount < 2) return undefined;
|
|
||||||
return index === 0
|
|
||||||
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
|
|
||||||
: { gridColumn: 2, gridRow: index };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPaneAction(target: EventTarget | null): boolean {
|
|
||||||
return target instanceof Element
|
|
||||||
&& target.closest("[data-workbench-pane-action]") !== 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,
|
|
||||||
addPaneDisabled = false,
|
|
||||||
onActivatePane,
|
|
||||||
onAddPane,
|
|
||||||
onLayoutChange,
|
|
||||||
renderPane,
|
|
||||||
}: PaneWorkbenchProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const compact = useMediaQuery("(max-width: 767px)");
|
|
||||||
const effectiveLayout = compact ? "monocle" : layout;
|
|
||||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
|
||||||
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | 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 paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]);
|
|
||||||
|
|
||||||
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: PointerEvent<HTMLElement>,
|
|
||||||
) => {
|
|
||||||
activatePane(key, event.target);
|
|
||||||
}, [activatePane]);
|
|
||||||
|
|
||||||
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
|
||||||
activatePane(key, event.target);
|
|
||||||
}, [activatePane]);
|
|
||||||
|
|
||||||
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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="min-h-0 flex-1 bg-background">
|
|
||||||
<div
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
{panes.map((pane, index) => {
|
|
||||||
const active = pane.key === activePaneKey;
|
|
||||||
const hidden = effectiveLayout === "monocle" && !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-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={paneCellStyle(effectiveLayout, panes.length, index)}
|
|
||||||
>
|
|
||||||
{renderPane(pane, {
|
|
||||||
active,
|
|
||||||
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
|
||||||
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
|
||||||
headerActions,
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2";
|
|
||||||
export const MAX_WORKBENCH_PANES = 4;
|
|
||||||
|
|
||||||
export const WORKBENCH_LAYOUTS = [
|
|
||||||
"columns",
|
|
||||||
"rows",
|
|
||||||
"grid",
|
|
||||||
"main-stack",
|
|
||||||
"monocle",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number];
|
|
||||||
|
|
||||||
export interface WorkbenchTabState {
|
|
||||||
paneKeys: string[];
|
|
||||||
activePaneKey: string;
|
|
||||||
layout: WorkbenchLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkbenchState {
|
|
||||||
version: 2;
|
|
||||||
tabs: Record<string, WorkbenchTabState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
|
||||||
version: 2,
|
|
||||||
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 normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
|
|
||||||
const candidate = value && typeof value === "object"
|
|
||||||
? value as Partial<WorkbenchTabState>
|
|
||||||
: {};
|
|
||||||
const paneKeys = uniqueKeys(candidate.paneKeys);
|
|
||||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
|
||||||
? paneKeys
|
|
||||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
|
||||||
return {
|
|
||||||
paneKeys: normalizedPaneKeys,
|
|
||||||
activePaneKey:
|
|
||||||
typeof candidate.activePaneKey === "string"
|
|
||||||
&& normalizedPaneKeys.includes(candidate.activePaneKey)
|
|
||||||
? candidate.activePaneKey
|
|
||||||
: normalizedPaneKeys[0],
|
|
||||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkbenchState(serialized: string | null): WorkbenchState {
|
|
||||||
if (!serialized) return EMPTY_WORKBENCH_STATE;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
|
|
||||||
if (
|
|
||||||
parsed.version !== 2
|
|
||||||
|| !parsed.tabs
|
|
||||||
|| typeof parsed.tabs !== "object"
|
|
||||||
|| Array.isArray(parsed.tabs)
|
|
||||||
) {
|
|
||||||
return EMPTY_WORKBENCH_STATE;
|
|
||||||
}
|
|
||||||
const tabs = Object.fromEntries(
|
|
||||||
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
|
|
||||||
);
|
|
||||||
return { version: 2, tabs };
|
|
||||||
} catch {
|
|
||||||
return EMPTY_WORKBENCH_STATE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState {
|
|
||||||
return {
|
|
||||||
paneKeys: [tabKey],
|
|
||||||
activePaneKey: tabKey,
|
|
||||||
layout: "columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workbenchTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
): WorkbenchTabState {
|
|
||||||
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
|
||||||
): WorkbenchState {
|
|
||||||
const current = workbenchTab(state, tabKey);
|
|
||||||
const next = update(current);
|
|
||||||
if (state.tabs[tabKey] === next) return state;
|
|
||||||
return {
|
|
||||||
version: 2,
|
|
||||||
tabs: {
|
|
||||||
...state.tabs,
|
|
||||||
[tabKey]: next,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureWorkbenchTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
if (state.tabs[tabKey]) return state;
|
|
||||||
return updateTab(state, tabKey, (tab) => tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
if (tab.paneKeys.includes(paneKey)) {
|
|
||||||
if (tab.activePaneKey === paneKey) return tab;
|
|
||||||
return { ...tab, activePaneKey: paneKey };
|
|
||||||
}
|
|
||||||
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
|
|
||||||
return {
|
|
||||||
...tab,
|
|
||||||
paneKeys: [...tab.paneKeys, paneKey],
|
|
||||||
activePaneKey: 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 detachWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
const index = tab.paneKeys.indexOf(paneKey);
|
|
||||||
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab;
|
|
||||||
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
|
||||||
const activePaneKey = tab.activePaneKey === paneKey
|
|
||||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
|
||||||
: tab.activePaneKey;
|
|
||||||
return { ...tab, paneKeys, activePaneKey };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
targetTabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
if (!targetTabKey || !paneKey || targetTabKey === paneKey) 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 (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
|
||||||
if (
|
|
||||||
!targetBeforeMove.paneKeys.includes(paneKey)
|
|
||||||
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
|
|
||||||
) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = { ...state.tabs };
|
|
||||||
if (sourceTabKey && sourceTab) {
|
|
||||||
if (sourceTabKey === paneKey) {
|
|
||||||
delete tabs[sourceTabKey];
|
|
||||||
} else {
|
|
||||||
const index = sourceTab.paneKeys.indexOf(paneKey);
|
|
||||||
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
|
||||||
tabs[sourceTabKey] = {
|
|
||||||
...sourceTab,
|
|
||||||
paneKeys,
|
|
||||||
activePaneKey: sourceTab.activePaneKey === paneKey
|
|
||||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
|
||||||
: sourceTab.activePaneKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
|
||||||
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
|
|
||||||
? { ...targetTab, activePaneKey: paneKey }
|
|
||||||
: {
|
|
||||||
...targetTab,
|
|
||||||
paneKeys: [...targetTab.paneKeys, paneKey],
|
|
||||||
activePaneKey: paneKey,
|
|
||||||
};
|
|
||||||
return { version: 2, tabs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promoteWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
const index = tab.paneKeys.indexOf(paneKey);
|
|
||||||
if (index <= 0) return tab;
|
|
||||||
return {
|
|
||||||
...tab,
|
|
||||||
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setWorkbenchLayout(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
layout: WorkbenchLayout,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => (
|
|
||||||
tab.layout === layout ? tab : { ...tab, layout }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconcileWorkbench(
|
|
||||||
state: WorkbenchState,
|
|
||||||
validKeys: ReadonlySet<string>,
|
|
||||||
): WorkbenchState {
|
|
||||||
const tabs: Record<string, WorkbenchTabState> = {};
|
|
||||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
|
||||||
if (!validKeys.has(tabKey)) continue;
|
|
||||||
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key));
|
|
||||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
|
||||||
? paneKeys
|
|
||||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
|
||||||
tabs[tabKey] = {
|
|
||||||
...tab,
|
|
||||||
paneKeys: normalizedPaneKeys,
|
|
||||||
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey)
|
|
||||||
? tab.activePaneKey
|
|
||||||
: normalizedPaneKeys[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const serializedCurrent = JSON.stringify(state.tabs);
|
|
||||||
const serializedNext = JSON.stringify(tabs);
|
|
||||||
return serializedCurrent === serializedNext ? state : { version: 2, tabs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> {
|
|
||||||
const childKeys = new Set<string>();
|
|
||||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
|
||||||
for (const paneKey of tab.paneKeys) {
|
|
||||||
if (paneKey !== tabKey) childKeys.add(paneKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return childKeys;
|
|
||||||
}
|
|
||||||
@@ -360,9 +360,6 @@
|
|||||||
.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;
|
||||||
@@ -567,10 +564,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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,23 +10,6 @@ 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,
|
||||||
@@ -36,8 +19,11 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
|
ToolProgressEvent,
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
|
UIFileEdit,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
|
UITurnPhase,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -55,9 +41,54 @@ 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``.
|
||||||
*
|
*
|
||||||
@@ -121,6 +152,102 @@ 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">,
|
||||||
@@ -148,6 +275,193 @@ 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
|
||||||
@@ -193,6 +507,17 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -779,7 +1104,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, Date.now()));
|
setMessages((prev) => closeReasoningStream(prev));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,15 +1174,12 @@ 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(
|
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
||||||
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(client, key, options);
|
const result = await apiDeleteSession(tokenRef.current, 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) => {
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ 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 [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;
|
||||||
@@ -173,32 +171,14 @@ export function useSidebarState(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const persist = useCallback((next: SidebarStatePayload) => {
|
|
||||||
if (!connectionOpenRef.current) {
|
|
||||||
pendingPersistenceRef.current = next;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void client.setSidebarState(next).catch(() => {
|
|
||||||
// Sidebar persistence is best-effort; the optimistic local state remains usable.
|
|
||||||
});
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
useEffect(() => client.onStatus((status) => {
|
|
||||||
connectionOpenRef.current = status === "open";
|
|
||||||
if (status !== "open" || pendingPersistenceRef.current === null) return;
|
|
||||||
const pending = pendingPersistenceRef.current;
|
|
||||||
pendingPersistenceRef.current = null;
|
|
||||||
persist(pending);
|
|
||||||
}), [client, persist]);
|
|
||||||
|
|
||||||
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);
|
||||||
persist(next);
|
client.setSidebarState(next);
|
||||||
},
|
},
|
||||||
[persist],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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"
|
||||||
@@ -164,10 +163,6 @@ 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) {
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "Password",
|
"title": "Authentication required",
|
||||||
"showPassword": "Show password",
|
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
|
||||||
"hidePassword": "Hide password",
|
"placeholder": "Password",
|
||||||
"submit": "Connect",
|
"submit": "Connect",
|
||||||
"required": "Enter your password.",
|
"invalid": "Invalid password. Try again."
|
||||||
"invalid": "Incorrect password. Try again."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Account",
|
"section": "Account",
|
||||||
@@ -116,6 +115,8 @@
|
|||||||
"imageDefaults": "Defaults",
|
"imageDefaults": "Defaults",
|
||||||
"webSearch": "Web search",
|
"webSearch": "Web search",
|
||||||
"webBehavior": "Behavior",
|
"webBehavior": "Behavior",
|
||||||
|
"browserAutomation": "Browser automation",
|
||||||
|
"computerControl": "Computer control",
|
||||||
"cliApps": "CLI apps",
|
"cliApps": "CLI apps",
|
||||||
"mcp": "MCP services",
|
"mcp": "MCP services",
|
||||||
"regional": "Regional",
|
"regional": "Regional",
|
||||||
@@ -200,6 +201,8 @@
|
|||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
|
"browserAutomation": "Browser automation",
|
||||||
|
"computerControl": "Computer control",
|
||||||
"imageGeneration": "Image generation",
|
"imageGeneration": "Image generation",
|
||||||
"imageProvider": "Image provider",
|
"imageProvider": "Image provider",
|
||||||
"imageProviderStatus": "Provider status",
|
"imageProviderStatus": "Provider status",
|
||||||
@@ -245,6 +248,11 @@
|
|||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
|
"browserAutomation": "Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
|
||||||
|
"computerControl": "Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
|
||||||
|
"computerControlBrowser": "Pixel-based control currently targets an isolated browser, as configured in config.json.",
|
||||||
|
"computerUseInstall": "Required Python support will be installed when you turn this on.",
|
||||||
|
"computerUseInstalling": "Installing computer-use support...",
|
||||||
"imageGeneration": "Expose generate_image in chats when a configured image provider is available.",
|
"imageGeneration": "Expose generate_image in chats when a configured image provider is available.",
|
||||||
"imageProvider": "Choose the registry provider used by generate_image.",
|
"imageProvider": "Choose the registry provider used by generate_image.",
|
||||||
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
|
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
|
||||||
@@ -596,14 +604,7 @@
|
|||||||
"searchPlaceholder": "Search tools",
|
"searchPlaceholder": "Search tools",
|
||||||
"featured": "Tools",
|
"featured": "Tools",
|
||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No tools match your search.",
|
"empty": "No tools match this view.",
|
||||||
"emptyApps": "No apps available.",
|
|
||||||
"emptyIntegrations": "No integrations available.",
|
|
||||||
"emptyReady": "No tools are ready yet.",
|
|
||||||
"clearSearch": "Clear search",
|
|
||||||
"browseApps": "Browse apps",
|
|
||||||
"browseIntegrations": "Browse integrations",
|
|
||||||
"emptyIntegrationsHint": "Add a custom integration below.",
|
|
||||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -706,9 +707,7 @@
|
|||||||
"loading": "Loading automations...",
|
"loading": "Loading automations...",
|
||||||
"noMatches": "No automations match this view.",
|
"noMatches": "No automations match this view.",
|
||||||
"empty": "No automations yet.",
|
"empty": "No automations yet.",
|
||||||
"emptyHint": "Create automations in a chat so they keep the right context.",
|
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
|
||||||
"emptyAction": "Open a chat",
|
|
||||||
"clearFilters": "Clear filters",
|
|
||||||
"oneShot": "One-time",
|
"oneShot": "One-time",
|
||||||
"systemTask": "System-managed automation",
|
"systemTask": "System-managed automation",
|
||||||
"localTrigger": "Local trigger",
|
"localTrigger": "Local trigger",
|
||||||
@@ -965,10 +964,6 @@
|
|||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Show archived",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Hide archived",
|
"hideArchived": "Hide archived",
|
||||||
"select": "Select",
|
|
||||||
"cancelSelection": "Cancel selection",
|
|
||||||
"selectedCount": "{{count}} selected",
|
|
||||||
"deleteSelected": "Delete",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"newChat": "New topic",
|
"newChat": "New topic",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -983,13 +978,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Delete this topic?",
|
"title": "Delete this topic?",
|
||||||
"titleMany": "Delete {{count}} topics and panes?",
|
|
||||||
"description": "This action cannot be undone.",
|
"description": "This action cannot be undone.",
|
||||||
"descriptionMany": "This action cannot be undone.",
|
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Delete",
|
"confirm": "Delete",
|
||||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||||
"automationsDescriptionMany": "Linked automations will also be deleted.",
|
|
||||||
"moreAutomations": "+ {{count}} more",
|
"moreAutomations": "+ {{count}} more",
|
||||||
"confirmWithAutomations": "Delete",
|
"confirmWithAutomations": "Delete",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1164,7 +1156,6 @@
|
|||||||
"recordingStatus": "Recording {{time}}"
|
"recordingStatus": "Recording {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
"insecureContext": "Chrome and other browsers block microphone access on remote HTTP pages for security. Open this WebUI over HTTPS to use voice input.",
|
|
||||||
"unsupported": "Voice input is not supported in this browser.",
|
"unsupported": "Voice input is not supported in this browser.",
|
||||||
"permission": "Allow microphone access in the address bar, then retry.",
|
"permission": "Allow microphone access in the address bar, then retry.",
|
||||||
"notConfigured": "Configure a transcription provider first.",
|
"notConfigured": "Configure a transcription provider first.",
|
||||||
@@ -1385,26 +1376,6 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Conversation workbench",
|
|
||||||
"panes": "Panes",
|
|
||||||
"panesInTab": "Panes in {{title}}",
|
|
||||||
"dropPane": "Move {{pane}} into {{tab}}",
|
|
||||||
"moveToTab": "Move to tab",
|
|
||||||
"layout": "Pane layout",
|
|
||||||
"addPane": "Add pane",
|
|
||||||
"promotePane": "Make {{title}} the primary pane",
|
|
||||||
"paneActions": "{{title}} pane actions",
|
|
||||||
"detachPane": "Move {{title}} to its own topic",
|
|
||||||
"composerAria": "Message {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Columns",
|
|
||||||
"rows": "Rows",
|
|
||||||
"grid": "Grid",
|
|
||||||
"main-stack": "Main and stack",
|
|
||||||
"monocle": "Monocle"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -1418,7 +1389,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace was not changed",
|
"title": "Workspace was not changed",
|
||||||
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
|
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Message was not sent",
|
"title": "Message was not sent",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user