mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61537a8c5 | ||
|
|
612e714479 | ||
|
|
a739185740 | ||
|
|
9859e02215 | ||
|
|
95160a304d |
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
- name: Test WebUI
|
||||
working-directory: webui
|
||||
run: bun run test:coverage
|
||||
run: bun run test
|
||||
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
|
||||
@@ -125,6 +125,7 @@ Important files:
|
||||
| Shell execution | `nanobot/agent/tools/shell.py` |
|
||||
| Filesystem tools | `nanobot/agent/tools/filesystem.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` |
|
||||
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| Channel access control | channel config in `nanobot/channels/*.py` |
|
||||
|
||||
@@ -202,7 +203,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||
|
||||
|
||||
+57
-38
@@ -42,6 +42,7 @@ the focused guides first and come back here for exact fields and defaults.
|
||||
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
|
||||
| Configure voice transcription | [Transcription Settings](#transcription-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) |
|
||||
| Enable image generation | [Image Generation](#image-generation) |
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
|
||||
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`.
|
||||
@@ -2306,44 +2363,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
|
||||
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
|
||||
Agent Plugins v1 and may provide skills, MCP servers, or both:
|
||||
|
||||
```text
|
||||
plugins/
|
||||
└── release-tools/
|
||||
├── plugin.json
|
||||
├── mcp.json
|
||||
└── skills/
|
||||
└── release-notes/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Plugin skills use the same progressive loading and `$skill-name` invocation as workspace
|
||||
skills. A workspace skill wins when it has the same name as a plugin skill; plugin skills win
|
||||
over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and
|
||||
paths that resolve outside the plugin root are ignored.
|
||||
|
||||
Portable MCP servers declared in `mcp.json` appear in **Apps**, but are never started merely
|
||||
because a package exists. Enabling a plugin there is the explicit trust decision that activates
|
||||
its executable components. The host expands `PLUGIN_ROOT` and an isolated `PLUGIN_DATA`, checks
|
||||
package paths before launch, and hot-reloads MCP connections. Explicit `tools.mcpServers`
|
||||
configuration wins over a plugin server if their host names collide. The v1 host currently
|
||||
supports plugin `stdio` servers; unsupported remote transports are skipped independently.
|
||||
|
||||
Plugins may optionally declare a shell-free `extensions.dev.nanobot.installCommand` array. The
|
||||
local WebUI runs it once per plugin version before first enable; remote WebUI clients cannot run
|
||||
plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does
|
||||
not define a registry, so package distribution remains separate from discovery and execution.
|
||||
|
||||
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
|
||||
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
|
||||
refresh that package and uninstall removes it. The external executable remains managed by the
|
||||
CLI Apps installer rather than by the Agent Plugins manifest.
|
||||
|
||||
## Tool Hint Max Length
|
||||
|
||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||
|
||||
@@ -288,13 +288,6 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||
reverse proxy and use that proxy's HTTPS URL.
|
||||
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
@@ -329,10 +322,6 @@ If the page does not open, check these in order:
|
||||
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.
|
||||
|
||||
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
|
||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
"""Discover portable Agent Plugins from the agent workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||
|
||||
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_SKILL_FRONTMATTER = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
|
||||
_MANIFEST_FIELDS = {
|
||||
"$schema",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"homepage",
|
||||
"repository",
|
||||
"license",
|
||||
"keywords",
|
||||
"extensions",
|
||||
}
|
||||
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
|
||||
_AUTHOR_FIELDS = {"name", "email", "url"}
|
||||
_MCP_SERVER_FIELDS = {
|
||||
"stdio": {"type", "command", "args", "env", "cwd"},
|
||||
}
|
||||
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPluginSkill:
|
||||
"""One skill supplied by a valid Agent Plugins v1 package."""
|
||||
|
||||
name: str
|
||||
path: Path
|
||||
plugin: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPlugin:
|
||||
"""A validated Agent Plugins v1 package installed in the workspace."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
version: str
|
||||
description: str
|
||||
repository: str
|
||||
display_name: str
|
||||
category: str
|
||||
accent_color: str | None
|
||||
permissions: tuple[str, ...]
|
||||
install_command: tuple[str, ...]
|
||||
|
||||
|
||||
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return valid packages from ``<workspace>/plugins/*``."""
|
||||
workspace = workspace.expanduser().resolve()
|
||||
plugins_root = workspace / "plugins"
|
||||
if not plugins_root.is_dir():
|
||||
return []
|
||||
try:
|
||||
root = plugins_root.resolve(strict=True)
|
||||
except OSError:
|
||||
return []
|
||||
if not root.is_relative_to(workspace):
|
||||
logger.warning("Ignoring Agent Plugins directory outside the workspace")
|
||||
return []
|
||||
try:
|
||||
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
|
||||
return []
|
||||
|
||||
plugins: list[AgentPlugin] = []
|
||||
for candidate in candidates:
|
||||
plugin_root = _contained_directory(candidate, root)
|
||||
if plugin_root is None:
|
||||
continue
|
||||
plugin = _load_manifest(plugin_root)
|
||||
if plugin is not None:
|
||||
plugins.append(plugin)
|
||||
return plugins
|
||||
|
||||
|
||||
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
|
||||
"""Discover direct-child skills under ``<workspace>/plugins/*``.
|
||||
|
||||
Agent Plugins does not prescribe an install location. nanobot uses the
|
||||
workspace ``plugins`` directory so packages stay explicit and portable
|
||||
with the rest of the agent workspace.
|
||||
"""
|
||||
skills: list[AgentPluginSkill] = []
|
||||
for plugin in discover_agent_plugins(workspace):
|
||||
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
|
||||
return skills
|
||||
|
||||
|
||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
|
||||
if manifest is None:
|
||||
return None
|
||||
try:
|
||||
value = cast(object, json.loads(manifest.read_text(encoding="utf-8")))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Ignoring invalid Agent Plugin manifest '{}': {}", manifest, exc)
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
logger.warning("Ignoring Agent Plugin manifest '{}': expected a JSON object", manifest)
|
||||
return None
|
||||
|
||||
payload = cast(dict[str, Any], value)
|
||||
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
||||
return None
|
||||
name = payload.get("name")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or len(name) > 64
|
||||
or _PLUGIN_NAME.fullmatch(name) is None
|
||||
):
|
||||
logger.warning("Ignoring Agent Plugin manifest '{}': invalid name", manifest)
|
||||
return None
|
||||
if not _valid_optional_fields(payload):
|
||||
logger.warning("Ignoring Agent Plugin manifest '{}': invalid metadata", manifest)
|
||||
return None
|
||||
|
||||
for field in payload.keys() - _MANIFEST_FIELDS:
|
||||
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
|
||||
if "extensions" in payload and not isinstance(payload["extensions"], dict):
|
||||
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
|
||||
extension = payload.get("extensions")
|
||||
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||
nanobot_value = extension_payload.get("dev.nanobot")
|
||||
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||
return AgentPlugin(
|
||||
name=name,
|
||||
root=plugin_root,
|
||||
version=_string(payload.get("version")),
|
||||
description=_string(payload.get("description")),
|
||||
repository=_string(payload.get("repository")),
|
||||
display_name=_string(nanobot.get("displayName")) or name,
|
||||
category=_string(nanobot.get("category")) or "Plugin",
|
||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||
permissions=_string_tuple(nanobot.get("permissions")),
|
||||
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
||||
)
|
||||
|
||||
|
||||
def agent_plugin_mcp_servers(
|
||||
workspace: Path,
|
||||
configured: dict[str, MCPServerConfig] | None = None,
|
||||
) -> dict[str, MCPServerConfig]:
|
||||
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||
|
||||
User configuration wins on the unlikely event of a namespaced collision.
|
||||
"""
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for plugin in discover_agent_plugins(workspace):
|
||||
if not _enabled(workspace, plugin.name):
|
||||
continue
|
||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||
for name, server in plugin_servers.items():
|
||||
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
|
||||
servers[host_name] = server
|
||||
for name, server in (configured or {}).items():
|
||||
if name in servers:
|
||||
logger.warning("Configured MCP server '{}' overrides an Agent Plugin server", name)
|
||||
servers[name] = server
|
||||
return servers
|
||||
|
||||
|
||||
def agent_plugins_payload(workspace: Path) -> dict[str, Any]:
|
||||
"""Return installed Agent Plugins for the WebUI Apps surface."""
|
||||
plugins: list[dict[str, Any]] = []
|
||||
enabled_count = 0
|
||||
for plugin in discover_agent_plugins(workspace):
|
||||
mcp_servers = sorted(_plugin_mcp_servers(workspace, plugin))
|
||||
if not mcp_servers and not plugin.install_command:
|
||||
continue
|
||||
enabled = _enabled(workspace, plugin.name)
|
||||
enabled_count += int(enabled)
|
||||
plugins.append(
|
||||
{
|
||||
"name": plugin.name,
|
||||
"display_name": plugin.display_name,
|
||||
"version": plugin.version,
|
||||
"description": plugin.description,
|
||||
"category": plugin.category,
|
||||
"repository": plugin.repository,
|
||||
"accent_color": plugin.accent_color,
|
||||
"permissions": list(plugin.permissions),
|
||||
"mcp_servers": mcp_servers,
|
||||
"enabled": enabled,
|
||||
"setup_required": bool(plugin.install_command)
|
||||
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
|
||||
}
|
||||
)
|
||||
return {"plugins": plugins, "enabled_count": enabled_count}
|
||||
|
||||
|
||||
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[str, Any]:
|
||||
"""Enable or disable one installed plugin's executable MCP components."""
|
||||
plugin = next((item for item in discover_agent_plugins(workspace) if item.name == name), None)
|
||||
if plugin is None:
|
||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
if enabled:
|
||||
if plugin.install_command and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"):
|
||||
_run_install(plugin, data)
|
||||
_write_state(data / "setup-version", plugin.version or "unknown")
|
||||
_write_state(data / "enabled", "1")
|
||||
else:
|
||||
(data / "enabled").unlink(missing_ok=True)
|
||||
payload = agent_plugins_payload(workspace)
|
||||
payload["last_action"] = {
|
||||
"ok": True,
|
||||
"message": f"{plugin.display_name} {'enabled' if enabled else 'disabled'}.",
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
|
||||
if any(field in payload and not isinstance(payload[field], str) for field in _STRING_FIELDS):
|
||||
return False
|
||||
keywords = payload.get("keywords")
|
||||
if "keywords" in payload and (
|
||||
not isinstance(keywords, list)
|
||||
or not all(isinstance(keyword, str) for keyword in cast(list[object], keywords))
|
||||
):
|
||||
return False
|
||||
author = payload.get("author")
|
||||
if "author" not in payload:
|
||||
return True
|
||||
if not isinstance(author, dict):
|
||||
return False
|
||||
author_payload = cast(dict[str, object], author)
|
||||
return not (author_payload.keys() - _AUTHOR_FIELDS) and all(
|
||||
isinstance(value, str) for value in author_payload.values()
|
||||
)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
items = cast(list[object], value)
|
||||
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||
|
||||
|
||||
def _accent_color(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||
|
||||
|
||||
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
items = cast(list[object], value)
|
||||
if not 1 <= len(items) <= 32 or not all(
|
||||
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
|
||||
):
|
||||
return ()
|
||||
command = cast(str, items[0])
|
||||
if not command.startswith("./"):
|
||||
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
|
||||
return ()
|
||||
executable = _contained_file(plugin_root / command[2:], plugin_root)
|
||||
if executable is None:
|
||||
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
|
||||
return ()
|
||||
return (str(executable), *(cast(str, item) for item in items[1:]))
|
||||
|
||||
|
||||
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||
path = _contained_file(plugin.root / "mcp.json", plugin.root)
|
||||
if path is None:
|
||||
return {}
|
||||
try:
|
||||
value = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}': {}", plugin.name, exc)
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
payload = cast(dict[str, Any], value)
|
||||
raw_servers = payload.get("mcpServers")
|
||||
if (
|
||||
payload.keys() != {"$schema", "mcpServers"}
|
||||
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
|
||||
or not isinstance(raw_servers, dict)
|
||||
):
|
||||
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||
return {}
|
||||
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||
continue
|
||||
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||
if server is None:
|
||||
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||
continue
|
||||
servers[name] = server
|
||||
return servers
|
||||
|
||||
|
||||
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
payload = cast(dict[str, Any], raw)
|
||||
transport = payload.get("type")
|
||||
allowed = _MCP_SERVER_FIELDS.get(transport) if isinstance(transport, str) else None
|
||||
if allowed is None or payload.keys() - allowed:
|
||||
return None
|
||||
if transport == "stdio":
|
||||
command = _stdio_command(payload.get("command"), root)
|
||||
args = payload.get("args", [])
|
||||
env = payload.get("env", {})
|
||||
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||
if (
|
||||
command is None
|
||||
or not isinstance(args, list)
|
||||
or not all(isinstance(item, str) for item in cast(list[object], args))
|
||||
or not isinstance(env, dict)
|
||||
or cwd is None
|
||||
):
|
||||
return None
|
||||
env_payload = cast(dict[object, object], env)
|
||||
if any(
|
||||
not isinstance(key, str)
|
||||
or key in {"PLUGIN_ROOT", "PLUGIN_DATA"}
|
||||
or not isinstance(value, str)
|
||||
for key, value in env_payload.items()
|
||||
):
|
||||
return None
|
||||
string_env = cast(dict[str, str], env)
|
||||
replacements = {"${PLUGIN_ROOT}": str(root), "${PLUGIN_DATA}": str(data)}
|
||||
return MCPServerConfig(
|
||||
type="stdio",
|
||||
command=command,
|
||||
args=[_expand(item, replacements) for item in cast(list[str], args)],
|
||||
env={
|
||||
**{key: _expand(value, replacements) for key, value in string_env.items()},
|
||||
"PLUGIN_ROOT": str(root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
},
|
||||
cwd=str(cwd),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _stdio_command(value: object, root: Path) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
executable = _contained_file(root / value[2:], root)
|
||||
return str(executable) if executable is not None else None
|
||||
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||
if value is None:
|
||||
return root
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
return _contained_directory(root / value[2:], root)
|
||||
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||
relative = value[len(placeholder):].lstrip("/")
|
||||
candidate = (base / relative).resolve()
|
||||
if not candidate.is_relative_to(base):
|
||||
return None
|
||||
if base == data:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
candidate.chmod(0o700)
|
||||
return candidate if candidate.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _expand(value: str, replacements: dict[str, str]) -> str:
|
||||
for token, replacement in replacements.items():
|
||||
value = value.replace(token, replacement)
|
||||
return value
|
||||
|
||||
|
||||
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||
config_root = get_config_path().expanduser().resolve().parent
|
||||
plugin_data_root = config_root / "plugin-data"
|
||||
if create:
|
||||
plugin_data_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
resolved_plugin_data = plugin_data_root.resolve(strict=create)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Agent Plugin data root is unavailable") from exc
|
||||
if not resolved_plugin_data.is_relative_to(config_root):
|
||||
raise RuntimeError("Agent Plugin data root escapes the nanobot config directory")
|
||||
state_root = resolved_plugin_data / workspace_id
|
||||
if create:
|
||||
resolved_plugin_data.chmod(0o700)
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
resolved_state = state_root.resolve(strict=create)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Agent Plugin state directory is unavailable") from exc
|
||||
if not resolved_state.is_relative_to(config_root):
|
||||
raise RuntimeError("Agent Plugin state directory escapes the nanobot config directory")
|
||||
if create:
|
||||
resolved_state.chmod(0o700)
|
||||
data = resolved_state / name
|
||||
if create:
|
||||
data.mkdir(exist_ok=True)
|
||||
resolved_data = data.resolve(strict=True)
|
||||
if not resolved_data.is_relative_to(resolved_state):
|
||||
raise RuntimeError("Agent Plugin data directory escapes its state directory")
|
||||
resolved_data.chmod(0o700)
|
||||
return resolved_data
|
||||
return data
|
||||
|
||||
|
||||
def _enabled(workspace: Path, name: str) -> bool:
|
||||
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
|
||||
|
||||
|
||||
def _setup_version(workspace: Path, name: str) -> str:
|
||||
try:
|
||||
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
except (OSError, UnicodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _write_state(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.parent.chmod(0o700)
|
||||
temporary = path.with_name(f".{path.name}.tmp")
|
||||
temporary.write_text(value, encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(path)
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
||||
env = {
|
||||
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
|
||||
"PLUGIN_ROOT": str(plugin.root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
plugin.install_command,
|
||||
cwd=plugin.root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
|
||||
if result.returncode:
|
||||
output = (result.stderr or result.stdout).strip()[-2000:]
|
||||
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||
|
||||
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
|
||||
skills_root = plugin_root / "skills"
|
||||
if not skills_root.exists():
|
||||
return []
|
||||
resolved_skills_root = _contained_directory(skills_root, plugin_root)
|
||||
if resolved_skills_root is None:
|
||||
logger.warning("Ignoring invalid skills component in Agent Plugin '{}'", plugin_name)
|
||||
return []
|
||||
|
||||
try:
|
||||
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
|
||||
return []
|
||||
|
||||
skills: list[AgentPluginSkill] = []
|
||||
for candidate in candidates:
|
||||
skill_root = _contained_directory(candidate, resolved_skills_root)
|
||||
if skill_root is None:
|
||||
continue
|
||||
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
||||
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
|
||||
continue
|
||||
skills.append(
|
||||
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
|
||||
)
|
||||
return skills
|
||||
|
||||
|
||||
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
return False
|
||||
match = _SKILL_FRONTMATTER.match(content)
|
||||
if match is None:
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
||||
return False
|
||||
try:
|
||||
metadata = cast(object, yaml.safe_load(match.group(1)))
|
||||
except yaml.YAMLError:
|
||||
metadata = None
|
||||
if not isinstance(metadata, dict):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
||||
return False
|
||||
payload = cast(dict[object, object], metadata)
|
||||
name = payload.get("name")
|
||||
description = payload.get("description")
|
||||
valid = (
|
||||
name == directory_name
|
||||
and isinstance(name, str)
|
||||
and len(name) <= 64
|
||||
and _SKILL_NAME.fullmatch(name) is not None
|
||||
and isinstance(description, str)
|
||||
and 1 <= len(description.strip()) <= 1024
|
||||
)
|
||||
if not valid:
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
|
||||
return valid
|
||||
|
||||
|
||||
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||
|
||||
|
||||
def _contained_file(path: Path, root: Path) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
|
||||
@@ -33,6 +33,11 @@ COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"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.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
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:
|
||||
"""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.backfill_missing_tool_results(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.snip_history(config, updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
@@ -326,6 +341,35 @@ class ContextGovernor:
|
||||
updated[idx]["content"] = normalized
|
||||
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(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
|
||||
@@ -480,8 +480,6 @@ class AgentLoop:
|
||||
config,
|
||||
provider_snapshot_loader,
|
||||
)
|
||||
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
|
||||
|
||||
return cls(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -496,7 +494,7 @@ class AgentLoop:
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
timezone=defaults.timezone,
|
||||
unified_session=defaults.unified_session,
|
||||
@@ -1399,6 +1397,7 @@ class AgentLoop:
|
||||
cleanup_steps = (
|
||||
self.subagents.close,
|
||||
self._exec_session_manager.close_all,
|
||||
*(() if not hasattr(self, "tools") else (self.tools.close,)),
|
||||
lambda: agent_context.close_mcp(self),
|
||||
)
|
||||
for cleanup in cleanup_steps:
|
||||
|
||||
+9
-36
@@ -5,13 +5,10 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.agent_plugins import AgentPluginSkill
|
||||
|
||||
# Default builtin skills directory (relative to this file)
|
||||
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
||||
|
||||
@@ -36,7 +33,6 @@ class SkillsLoader:
|
||||
self.workspace_skills = workspace / "skills"
|
||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
self.plugin_skills: list[AgentPluginSkill] = []
|
||||
|
||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||
if not base.exists():
|
||||
@@ -64,26 +60,11 @@ class SkillsLoader:
|
||||
Returns:
|
||||
List of skill info dicts with 'name', 'path', 'source'.
|
||||
"""
|
||||
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||
|
||||
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
seen_names = {entry["name"] for entry in skills}
|
||||
for plugin_skill in self.plugin_skills:
|
||||
if plugin_skill.name in seen_names:
|
||||
continue
|
||||
skills.append(
|
||||
{
|
||||
"name": plugin_skill.name,
|
||||
"path": str(plugin_skill.path),
|
||||
"source": "plugin",
|
||||
"plugin": plugin_skill.plugin,
|
||||
}
|
||||
)
|
||||
seen_names.add(plugin_skill.name)
|
||||
workspace_names = {entry["name"] for entry in skills}
|
||||
if self.builtin_skills and self.builtin_skills.exists():
|
||||
skills.extend(
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
||||
)
|
||||
|
||||
if self.disabled_skills:
|
||||
@@ -103,20 +84,13 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Skill content or None if not found.
|
||||
"""
|
||||
workspace_path = self.workspace_skills / name / "SKILL.md"
|
||||
if workspace_path.exists():
|
||||
return workspace_path.read_text(encoding="utf-8")
|
||||
if not self.plugin_skills:
|
||||
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||
|
||||
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
|
||||
for plugin_skill in self.plugin_skills:
|
||||
if plugin_skill.name == name and plugin_skill.path.is_file():
|
||||
return plugin_skill.path.read_text(encoding="utf-8")
|
||||
roots = [self.workspace_skills]
|
||||
if self.builtin_skills:
|
||||
builtin_path = self.builtin_skills / name / "SKILL.md"
|
||||
if builtin_path.exists():
|
||||
return builtin_path.read_text(encoding="utf-8")
|
||||
roots.append(self.builtin_skills)
|
||||
for root in roots:
|
||||
path = root / name / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
@@ -171,7 +145,6 @@ class SkillsLoader:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
|
||||
@@ -220,6 +220,10 @@ class Tool(ABC):
|
||||
"""Return optional per-turn prompt context owned by this tool."""
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release resources owned by the tool. Safe to call repeatedly."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
"""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)
|
||||
@@ -187,5 +187,8 @@ class _LegacyErrorPrefixTool(Tool):
|
||||
return ToolResult.error(result)
|
||||
return result
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._wrapped.close()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
@@ -1296,14 +1296,10 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = agent_plugin_mcp_servers(
|
||||
config.workspace_path,
|
||||
config.tools.mcp_servers,
|
||||
)
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
|
||||
@@ -200,6 +200,19 @@ class ToolRegistry:
|
||||
except Exception as e:
|
||||
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
|
||||
def tool_names(self) -> list[str]:
|
||||
"""Get list of registered tool names."""
|
||||
|
||||
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
|
||||
|
||||
async def _search_olostep(self, query: str, n: int) -> str:
|
||||
try:
|
||||
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
||||
from olostep import ( # pyright: ignore[reportMissingImports]
|
||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError:
|
||||
return ToolResult.error(
|
||||
|
||||
+12
-75
@@ -18,7 +18,6 @@ from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
@@ -28,7 +27,6 @@ from nanobot.security.workspace_policy import is_path_within
|
||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||
_CATALOG_SOURCES = (
|
||||
@@ -43,8 +41,6 @@ _MAX_ARTIFACT_REPORT = 12
|
||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
||||
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
|
||||
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
|
||||
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
||||
_ARTIFACT_EXTENSIONS = frozenset({
|
||||
@@ -215,21 +211,10 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _legacy_skill_name(name: str) -> str:
|
||||
"""Return the workspace skill name emitted before Agent Plugins support."""
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _safe_skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
@@ -628,7 +613,7 @@ class CliAppManager:
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"source": str(data.get("source") or ""),
|
||||
"skill": self.skill_relative_path(installed_name),
|
||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
}
|
||||
)
|
||||
@@ -655,20 +640,7 @@ class CliAppManager:
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
skill_name = _safe_skill_name(name)
|
||||
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
|
||||
|
||||
def _legacy_skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _legacy_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _installed_skill_path(self, name: str) -> Path:
|
||||
path = self._skill_path(name)
|
||||
legacy_path = self._legacy_skill_path(name)
|
||||
return legacy_path if not path.is_file() and legacy_path.is_file() else path
|
||||
|
||||
def skill_relative_path(self, name: str) -> str:
|
||||
"""Return the existing skill path, falling back to the canonical plugin path."""
|
||||
return self._installed_skill_path(name).relative_to(self.workspace).as_posix()
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
@@ -705,7 +677,7 @@ class CliAppManager:
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"brand_color": brand_color,
|
||||
"skill_installed": self._installed_skill_path(name).is_file(),
|
||||
"skill_installed": self._skill_path(name).is_file(),
|
||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||
}
|
||||
|
||||
@@ -741,8 +713,7 @@ class CliAppManager:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
skill_path = _plugin_skill_relative_path(name)
|
||||
plugin_path = f"plugins/{_safe_skill_name(name)}"
|
||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
||||
capabilities = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -755,13 +726,13 @@ class CliAppManager:
|
||||
install = compact_dict({
|
||||
"supported": install_supported,
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": ["entry_point_available"] if entry_point else [],
|
||||
})
|
||||
remove = compact_dict({
|
||||
"supported": strategy != "unsupported",
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": (
|
||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||
if strategy not in {"bundled", "unsupported"}
|
||||
@@ -1061,10 +1032,11 @@ class CliAppManager:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
||||
return f"""---
|
||||
name: {_safe_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
description: >-
|
||||
{description}
|
||||
---
|
||||
|
||||
# {display}
|
||||
@@ -1100,53 +1072,18 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||
return note + "\n" + content
|
||||
|
||||
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
|
||||
"""Give a catalog skill the identity required by its plugin directory."""
|
||||
match = _SKILL_FRONTMATTER_RE.match(content)
|
||||
if match is None:
|
||||
return self._fallback_skill(app)
|
||||
try:
|
||||
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
|
||||
except yaml.YAMLError:
|
||||
return self._fallback_skill(app)
|
||||
description = metadata.get("description") if metadata is not None else None
|
||||
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
|
||||
return self._fallback_skill(app)
|
||||
|
||||
name = _safe_skill_name(str(app["name"]))
|
||||
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
|
||||
if not replaced:
|
||||
frontmatter = f"name: {name}\n{frontmatter}"
|
||||
body = content[match.end():].lstrip()
|
||||
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
path = self._skill_path(str(app["name"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = self._normalise_skill(content, app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _safe_skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self._legacy_skill_path(str(app["name"])).parent
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
plugin_root = self._skill_path(name).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self._legacy_skill_path(name).parent
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
installed = self._load_installed()
|
||||
|
||||
@@ -20,9 +20,6 @@ def runtime_lines_for_request(
|
||||
"""Return CLI App annotations from an immutable request snapshot."""
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
from nanobot.apps.cli import CliAppManager
|
||||
|
||||
manager = CliAppManager(workspace=workspace)
|
||||
structured_items = cast(list[Any], structured)
|
||||
mentions = [
|
||||
cast(Mapping[str, Any], item) for item in structured_items
|
||||
@@ -35,7 +32,7 @@ def runtime_lines_for_request(
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill={manager.skill_relative_path(str(item['name']))}). "
|
||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
|
||||
@@ -238,6 +238,20 @@ class TestStreamEndReactionCleanup:
|
||||
|
||||
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
|
||||
async def test_no_removal_when_not_stream_end(self):
|
||||
ch = _make_channel()
|
||||
|
||||
@@ -12,7 +12,9 @@ from nanobot.config_base import Base
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||
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.image_generation import ImageGenerationToolConfig
|
||||
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"))
|
||||
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"))
|
||||
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"))
|
||||
@@ -670,7 +682,9 @@ def _resolve_tool_config_refs() -> None:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.browser_tool import BrowserToolConfig
|
||||
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.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
@@ -680,6 +694,8 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
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.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
|
||||
@@ -671,6 +671,73 @@ class OpenAICompatProvider(LLMProvider):
|
||||
dumped = str(content)
|
||||
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]]:
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
@@ -824,9 +891,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
model_name = self._request_model_name(model_name)
|
||||
|
||||
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
||||
kwargs: dict[str, Any] = {
|
||||
"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
|
||||
|
||||
@@ -356,6 +356,7 @@ _TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||
_IMAGE_TOKEN_ESTIMATE = 2048
|
||||
_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.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
image_tokens = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
@@ -687,6 +689,8 @@ def _estimate_prompt_tokens_with_source(
|
||||
text = part.get("text", "")
|
||||
if isinstance(text, str) and 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")
|
||||
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
|
||||
)
|
||||
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:
|
||||
tool_payload = (
|
||||
("\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
|
||||
estimated = len(payload.encode("utf-8"))
|
||||
return estimated + per_message_overhead, "heuristic"
|
||||
return estimated + image_tokens + per_message_overhead, "heuristic"
|
||||
|
||||
|
||||
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."""
|
||||
content = message.get("content")
|
||||
parts: list[str] = []
|
||||
image_tokens = 0
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
@@ -743,6 +748,8 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||
text = part.get("text", "")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
elif part is not None and part.get("type") in {"image_url", "input_image"}:
|
||||
image_tokens += _IMAGE_TOKEN_ESTIMATE
|
||||
else:
|
||||
parts.append(json.dumps(raw_part, ensure_ascii=False))
|
||||
elif content is not None:
|
||||
@@ -760,13 +767,13 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
|
||||
parts.append(rc)
|
||||
|
||||
payload = "\n".join(parts)
|
||||
if not payload:
|
||||
if not payload and not image_tokens:
|
||||
return 4
|
||||
try:
|
||||
enc = _get_token_encoding()
|
||||
return max(4, len(enc.encode(payload)) + 4)
|
||||
return max(4, len(enc.encode(payload)) + image_tokens + 4)
|
||||
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(
|
||||
|
||||
@@ -16,7 +16,6 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.agent_plugins import agent_plugins_payload, set_agent_plugin_enabled
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||
@@ -838,44 +837,6 @@ def _custom_payload(
|
||||
}
|
||||
|
||||
|
||||
def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
|
||||
enabled = bool(plugin.get("enabled"))
|
||||
permissions = plugin.get("permissions")
|
||||
mcp_servers = plugin.get("mcp_servers")
|
||||
permission_names = (
|
||||
[str(item) for item in cast(list[object], permissions)]
|
||||
if isinstance(permissions, list)
|
||||
else []
|
||||
)
|
||||
server_names = (
|
||||
[str(item) for item in cast(list[object], mcp_servers)]
|
||||
if isinstance(mcp_servers, list)
|
||||
else []
|
||||
)
|
||||
return {
|
||||
"name": f"plugin-{plugin['name']}",
|
||||
"display_name": str(plugin.get("display_name") or plugin["name"]),
|
||||
"category": str(plugin.get("category") or "plugin"),
|
||||
"description": str(plugin.get("description") or "Agent Plugin"),
|
||||
"docs_url": str(plugin.get("repository") or ""),
|
||||
"transport": "stdio",
|
||||
"requires": ", ".join(permission_names),
|
||||
"note": "",
|
||||
"install_supported": True,
|
||||
"installed": True,
|
||||
"configured": enabled,
|
||||
"available": enabled,
|
||||
"status": "configured" if enabled else "not_installed",
|
||||
"logo_url": None,
|
||||
"brand_color": plugin.get("accent_color"),
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(server_names),
|
||||
"enabled_tools": ["*"],
|
||||
"tool_names": [],
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
|
||||
def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
@@ -893,17 +854,9 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
if name not in known
|
||||
]
|
||||
plugin_state = agent_plugins_payload(config.workspace_path)
|
||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||
plugin_rows = [
|
||||
row
|
||||
for plugin in plugin_state["plugins"]
|
||||
if (row := _agent_plugin_payload(plugin))["name"] not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
"installed_count": len(config.tools.mcp_servers)
|
||||
+ sum(int(row["configured"]) for row in plugin_rows),
|
||||
"presets": [*preset_rows, *custom_rows],
|
||||
"installed_count": len(config.tools.mcp_servers),
|
||||
}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
@@ -1390,27 +1343,6 @@ async def mcp_presets_settings_action(
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
if action is None:
|
||||
return mcp_presets_payload()
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if name.startswith("plugin-"):
|
||||
config = load_config()
|
||||
plugin_name = name.removeprefix("plugin-")
|
||||
installed = {
|
||||
str(plugin["name"])
|
||||
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
|
||||
}
|
||||
if name not in config.tools.mcp_servers and plugin_name in installed:
|
||||
if action not in {"enable", "remove"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
state = await asyncio.to_thread(
|
||||
set_agent_plugin_enabled,
|
||||
config.workspace_path,
|
||||
plugin_name,
|
||||
action == "enable",
|
||||
)
|
||||
payload = mcp_presets_payload(last_action=state.get("last_action"))
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
if action == "test":
|
||||
return await mcp_presets_test_action(query)
|
||||
if action in _CUSTOM_ACTIONS:
|
||||
|
||||
@@ -1261,6 +1261,11 @@ def settings_payload(
|
||||
"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": {
|
||||
"host": config.api.host,
|
||||
"port": config.api.port,
|
||||
@@ -2045,6 +2050,30 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
return settings_payload(requires_restart=changed)
|
||||
|
||||
|
||||
def update_computer_use_settings(query: QueryParams) -> dict[str, Any]:
|
||||
raw_browser = _query_first_alias(query, "browser_enabled", "browserEnabled")
|
||||
raw_computer = _query_first_alias(query, "enabled", "computerEnabled")
|
||||
if raw_browser is None and raw_computer is None:
|
||||
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_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||
|
||||
@@ -18,7 +18,6 @@ from urllib.parse import unquote
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.agent_plugins import agent_plugins_payload
|
||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||
@@ -65,6 +64,7 @@ from nanobot.webui.settings_api import (
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_computer_use_settings,
|
||||
update_image_generation_settings,
|
||||
update_model_call_order,
|
||||
update_model_configuration,
|
||||
@@ -175,6 +175,8 @@ class WebUISettingsRouter:
|
||||
return await self._handle_settings_provider_oauth(request, "logout")
|
||||
if path == "/api/settings/web-search/update":
|
||||
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":
|
||||
return self._handle_settings_api_service(request)
|
||||
if path == "/api/settings/api-service/start":
|
||||
@@ -223,12 +225,12 @@ class WebUISettingsRouter:
|
||||
if path == "/api/settings/pairing/deny":
|
||||
return self._handle_settings_pairing_action(request, "deny")
|
||||
if path == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(connection, request)
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
if path == "/api/settings/version-check":
|
||||
return await self._handle_settings_version_check(request)
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
return None
|
||||
|
||||
def _query(self, request: WsRequest) -> QueryParams:
|
||||
@@ -507,6 +509,21 @@ class WebUISettingsRouter:
|
||||
return self._error_response(e.status, e.message)
|
||||
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:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
@@ -1145,33 +1162,15 @@ class WebUISettingsRouter:
|
||||
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
query = self._parse_mcp_settings_query(request)
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if action == "enable" and name.startswith("plugin-"):
|
||||
config = load_config()
|
||||
plugin_names = {
|
||||
f"plugin-{plugin['name']}"
|
||||
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
|
||||
}
|
||||
if (
|
||||
name not in config.tools.mcp_servers
|
||||
and name in plugin_names
|
||||
and not self._allow_feature_package_install(connection, request)
|
||||
):
|
||||
return self._error_response(
|
||||
403,
|
||||
"Agent Plugin setup is restricted to the local WebUI",
|
||||
)
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
query,
|
||||
self._parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,7 +10,6 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from nanobot import __version__
|
||||
|
||||
@@ -43,13 +42,7 @@ def check_for_update() -> dict[str, Any] | None:
|
||||
return None
|
||||
_cache = (now, latest)
|
||||
|
||||
if not isinstance(latest, str) or not latest:
|
||||
return None
|
||||
try:
|
||||
if Version(latest) <= Version(__version__):
|
||||
return None
|
||||
except InvalidVersion:
|
||||
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
|
||||
if not latest or latest == __version__:
|
||||
return None
|
||||
return {
|
||||
"currentVersion": __version__,
|
||||
|
||||
@@ -88,6 +88,11 @@ pdf = [
|
||||
olostep = [
|
||||
"olostep>=0.1.0; python_version < '3.14'",
|
||||
]
|
||||
computer-use = [
|
||||
"pyautogui>=0.9.54",
|
||||
"pillow>=10.0.0",
|
||||
"playwright>=1.48.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=9.0.0,<10.0.0",
|
||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import agent_plugins
|
||||
from nanobot.agent.agent_plugins import (
|
||||
AGENT_PLUGIN_MCP_SCHEMA,
|
||||
AGENT_PLUGIN_SCHEMA,
|
||||
agent_plugin_mcp_servers,
|
||||
agent_plugins_payload,
|
||||
discover_agent_plugin_skills,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
|
||||
skill = root / "skills" / name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return skill
|
||||
|
||||
|
||||
def _write_plugin(
|
||||
workspace: Path,
|
||||
directory: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
manifest: dict[str, object] | None = None,
|
||||
) -> Path:
|
||||
root = workspace / "plugins" / directory
|
||||
root.mkdir(parents=True)
|
||||
payload = manifest or {
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": name or directory,
|
||||
}
|
||||
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
|
||||
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert loader.list_skills() == [
|
||||
{
|
||||
"name": "release-notes",
|
||||
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
||||
"source": "plugin",
|
||||
"plugin": "acme-tools",
|
||||
}
|
||||
]
|
||||
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
||||
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
|
||||
assert "### Agent Plugin skills" in loader.build_skills_summary()
|
||||
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
|
||||
|
||||
|
||||
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
assert loader.list_skills() == []
|
||||
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "release-notes")
|
||||
|
||||
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
|
||||
|
||||
shutil.rmtree(plugin)
|
||||
|
||||
assert loader.list_skills() == []
|
||||
assert loader.build_skills_summary() == ""
|
||||
|
||||
|
||||
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "direct")
|
||||
nested = plugin / "skills" / "group" / "nested"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "SKILL.md").write_text(
|
||||
"---\nname: nested\ndescription: Nested skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["direct"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest",
|
||||
[
|
||||
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
|
||||
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
|
||||
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "author": None},
|
||||
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "keywords": None},
|
||||
],
|
||||
)
|
||||
def test_invalid_agent_plugin_manifest_is_skipped(
|
||||
tmp_path: Path,
|
||||
manifest: dict[str, object],
|
||||
) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
|
||||
_write_skill(plugin, "example")
|
||||
|
||||
assert discover_agent_plugin_skills(tmp_path) == []
|
||||
|
||||
|
||||
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest={
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "demo",
|
||||
"futureField": True,
|
||||
"extensions": "invalid but non-fatal",
|
||||
},
|
||||
)
|
||||
_write_skill(plugin, "example")
|
||||
|
||||
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("skill_name", "frontmatter"),
|
||||
[
|
||||
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||
("missing-description", "name: missing-description"),
|
||||
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||
],
|
||||
)
|
||||
def test_invalid_agent_skill_is_skipped(
|
||||
tmp_path: Path,
|
||||
skill_name: str,
|
||||
frontmatter: str,
|
||||
) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
skill = plugin / "skills" / skill_name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
|
||||
|
||||
assert discover_agent_plugin_skills(tmp_path) == []
|
||||
|
||||
|
||||
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
_write_skill(plugin, "shared", description="Plugin version.")
|
||||
workspace_skill = tmp_path / "skills" / "shared"
|
||||
workspace_skill.mkdir(parents=True)
|
||||
(workspace_skill / "SKILL.md").write_text(
|
||||
"---\nname: shared\ndescription: Workspace version.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
outside = tmp_path / "outside"
|
||||
_write_skill(outside, "escaped")
|
||||
skills_root = plugin / "skills"
|
||||
skills_root.mkdir()
|
||||
try:
|
||||
(skills_root / "escaped").symlink_to(
|
||||
outside / "skills" / "escaped",
|
||||
target_is_directory=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
|
||||
assert discover_agent_plugin_skills(tmp_path) == []
|
||||
|
||||
|
||||
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: tmp_path / "config" / "config.json",
|
||||
)
|
||||
plugin = _write_plugin(tmp_path, "desktop")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(plugin / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"desktop": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/server",
|
||||
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||
"cwd": "${PLUGIN_ROOT}",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
servers = agent_plugin_mcp_servers(tmp_path)
|
||||
server = servers["desktop"]
|
||||
assert server.command == str(executable)
|
||||
assert server.cwd == str(plugin)
|
||||
assert server.env["PLUGIN_ROOT"] == str(plugin)
|
||||
assert server.args[0] == "--data"
|
||||
assert server.args[1].endswith("/state")
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_setup_command_runs_once_per_version(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: tmp_path / "config" / "config.json",
|
||||
)
|
||||
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"desktop",
|
||||
manifest={
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "desktop",
|
||||
"version": "1.2.3",
|
||||
"extensions": {"dev.nanobot": {"installCommand": ["./bin/install"]}},
|
||||
},
|
||||
)
|
||||
executable = plugin / "bin" / "install"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("setup", encoding="utf-8")
|
||||
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
|
||||
|
||||
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((command, cast(dict[str, str], kwargs["env"])))
|
||||
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == (str(executable),)
|
||||
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
|
||||
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
|
||||
assert agent_plugins_payload(tmp_path)["plugins"][0]["setup_required"] is False
|
||||
|
||||
|
||||
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: tmp_path / "config" / "config.json",
|
||||
)
|
||||
plugin = _write_plugin(tmp_path, "network")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(plugin / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||
"local": {"type": "stdio", "command": "./bin/server"},
|
||||
"escape": {"type": "stdio", "command": "../outside"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "network", True)
|
||||
|
||||
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
|
||||
|
||||
|
||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = tmp_path / "config"
|
||||
outside = tmp_path / "outside"
|
||||
config.mkdir()
|
||||
outside.mkdir()
|
||||
try:
|
||||
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: config / "config.json",
|
||||
)
|
||||
_write_plugin(tmp_path, "desktop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="escapes the nanobot config directory"):
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
@@ -1,6 +1,13 @@
|
||||
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:
|
||||
return {
|
||||
"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"]
|
||||
assert len(tool_results) == 1
|
||||
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"
|
||||
|
||||
@@ -76,11 +76,13 @@ class TestHandleStop:
|
||||
|
||||
loop.subagents.close = close_subagents
|
||||
loop._exec_session_manager.close_all = AsyncMock()
|
||||
loop.tools.close = AsyncMock()
|
||||
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
|
||||
await loop.close_mcp()
|
||||
|
||||
assert events == ["turn_cancelled", "resources_closed"]
|
||||
assert task.cancelled()
|
||||
loop.tools.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 '"' 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):
|
||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||
|
||||
@@ -241,6 +241,24 @@ async def test_file_edit_events_route_to_channel_capability(manager):
|
||||
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
|
||||
async def test_base_channel_file_edit_events_are_noop_safe():
|
||||
class _Plain(BaseChannel):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
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 config_with_proxy(proxy: str) -> Config:
|
||||
return Config.model_validate(
|
||||
|
||||
@@ -9,7 +9,6 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
@@ -392,9 +391,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("legacy", encoding="utf-8")
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
@@ -404,21 +400,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
assert "state_recorded" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "cli-app-gimp",
|
||||
"version": "1.0.0",
|
||||
"description": "Public duplicate entry",
|
||||
}
|
||||
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
|
||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||
assert [item.name for item in discover_agent_plugin_skills(manager.workspace)] == [
|
||||
"cli-app-gimp"
|
||||
]
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_run_argv_logs_command_exit_and_output(
|
||||
@@ -503,14 +487,7 @@ def test_install_records_available_cli_without_reinstalling(
|
||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||
skill = (
|
||||
manager.workspace
|
||||
/ "plugins"
|
||||
/ "cli-app-feishu"
|
||||
/ "skills"
|
||||
/ "cli-app-feishu"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
@@ -727,8 +704,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
@@ -741,7 +717,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert not plugin_dir.exists()
|
||||
assert not skill_dir.exists()
|
||||
|
||||
|
||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||
@@ -869,47 +845,19 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "public",
|
||||
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
{
|
||||
"name": "gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_underscored_skill_remains_visible_and_removable(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text(
|
||||
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manager._save_installed(
|
||||
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
|
||||
)
|
||||
|
||||
app = {
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
"install_cmd": "pip install cli-anything-unimol-tools",
|
||||
}
|
||||
|
||||
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
|
||||
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
|
||||
"skills/cli-app-unimol_tools/SKILL.md"
|
||||
)
|
||||
|
||||
manager.remove_skill("unimol_tools")
|
||||
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
assert "CLI App Mention: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
@@ -58,23 +58,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
assert "CLI App Attachment: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
|
||||
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||
|
||||
lines = runtime_lines_for_request(
|
||||
"please use @unimol_tools",
|
||||
{
|
||||
"cli_apps": [{
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
}],
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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
|
||||
async def test_run_custom_session_key(tmp_path):
|
||||
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
|
||||
@@ -28,6 +28,18 @@ class _FakeTool(Tool):
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
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]:
|
||||
names: list[str] = []
|
||||
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:
|
||||
registry = ToolRegistry()
|
||||
registry.register(_FakeTool("read_file"))
|
||||
|
||||
@@ -29,6 +29,36 @@ def test_estimate_prompt_tokens_chain_falls_back_without_provider_counter() -> N
|
||||
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:
|
||||
tokens, source = estimate_prompt_tokens_chain(
|
||||
_BrokenCounterProvider(),
|
||||
|
||||
@@ -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")
|
||||
@@ -1,66 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.agent_plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
McpPresetError,
|
||||
custom_mcp_action,
|
||||
mcp_presets_action,
|
||||
mcp_presets_payload,
|
||||
mcp_presets_settings_action,
|
||||
mcp_presets_test_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
|
||||
|
||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
|
||||
def _write_agent_plugin(workspace: Path) -> None:
|
||||
root = workspace / "plugins" / "desktop"
|
||||
command = root / "bin" / "server"
|
||||
command.parent.mkdir(parents=True, exist_ok=True)
|
||||
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(root / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "desktop",
|
||||
"description": "Control the local desktop.",
|
||||
"extensions": {
|
||||
"dev.nanobot": {
|
||||
"displayName": "Desktop Control",
|
||||
"accentColor": "#ff7a1a",
|
||||
"permissions": ["screen-recording"],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"desktop": {"type": "stdio", "command": "./bin/server"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
||||
|
||||
|
||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -99,65 +55,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||
|
||||
|
||||
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
_write_agent_plugin(load_config().workspace_path)
|
||||
|
||||
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||
assert row["name"] == "plugin-desktop"
|
||||
assert row["display_name"] == "Desktop Control"
|
||||
assert row["installed"] is True
|
||||
assert row["configured"] is False
|
||||
|
||||
async def reload() -> dict[str, object]:
|
||||
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
||||
|
||||
enabled = asyncio.run(
|
||||
mcp_presets_settings_action(
|
||||
"enable",
|
||||
{"name": ["plugin-desktop"]},
|
||||
reload_mcp=reload,
|
||||
)
|
||||
)
|
||||
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert enabled_row["configured"] is True
|
||||
assert enabled["requires_restart"] is False
|
||||
|
||||
disabled = asyncio.run(
|
||||
mcp_presets_settings_action(
|
||||
"remove",
|
||||
{"name": ["plugin-desktop"]},
|
||||
reload_mcp=reload,
|
||||
)
|
||||
)
|
||||
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert disabled_row["configured"] is False
|
||||
|
||||
|
||||
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
config_path = tmp_path / "config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["tools"] = {
|
||||
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
||||
}
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
_write_agent_plugin(load_config().workspace_path)
|
||||
|
||||
rows = [
|
||||
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
||||
]
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["source"] == "custom"
|
||||
|
||||
|
||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -29,6 +29,7 @@ from nanobot.webui.settings_api import (
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_computer_use_settings,
|
||||
update_model_call_order,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
@@ -1007,6 +1008,27 @@ def test_settings_payload_includes_network_safety_fields(
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1374,6 +1396,32 @@ def test_update_network_safety_settings_writes_local_service_flag(
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
@@ -142,59 +141,51 @@ async def test_model_preset_mutation_routes(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("update_info", "expected"),
|
||||
("query", "expected_query", "expected_sections"),
|
||||
[
|
||||
(None, {"updateAvailable": None}),
|
||||
(
|
||||
{
|
||||
"currentVersion": "1.2.0",
|
||||
"latestVersion": "1.3.0",
|
||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||
},
|
||||
{
|
||||
"updateAvailable": {
|
||||
"currentVersion": "1.2.0",
|
||||
"latestVersion": "1.3.0",
|
||||
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||
}
|
||||
},
|
||||
"browser_enabled=true",
|
||||
{"browser_enabled": ["true"]},
|
||||
["browser"],
|
||||
),
|
||||
(
|
||||
"computerEnabled=true",
|
||||
{"computerEnabled": ["true"]},
|
||||
["runtime"],
|
||||
),
|
||||
(
|
||||
"browserEnabled=true&enabled=true",
|
||||
{"browserEnabled": ["true"], "enabled": ["true"]},
|
||||
["browser", "runtime"],
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_check_route_returns_stable_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
update_info: dict[str, str] | None,
|
||||
expected: dict[str, object],
|
||||
async def test_computer_use_update_route(
|
||||
monkeypatch,
|
||||
query: str,
|
||||
expected_query: dict[str, list[str]],
|
||||
expected_sections: list[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.check_for_update",
|
||||
lambda: update_info,
|
||||
)
|
||||
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
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.status_code == 200
|
||||
assert json.loads(response.body) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_check_route_enforces_auth_and_bounds_failures(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
|
||||
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||
|
||||
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
|
||||
assert unauthorized is not None
|
||||
assert unauthorized.status_code == 401
|
||||
check.assert_not_called()
|
||||
|
||||
failed = await _router().dispatch(None, request, request.path)
|
||||
assert failed is not None
|
||||
assert failed.status_code == 500
|
||||
assert json.loads(failed.body) == {"error": "version check failed"}
|
||||
assert "upstream secret body" not in failed.body.decode()
|
||||
assert captured["query"] == expected_query
|
||||
assert json.loads(response.body)["restart_required_sections"] == expected_sections
|
||||
|
||||
@@ -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-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^10.4.0",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -756,8 +741,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
@@ -868,16 +845,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1062,8 +1023,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
@@ -1206,8 +1163,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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-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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -76,20 +75,6 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||
@@ -363,13 +348,6 @@
|
||||
"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": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||
@@ -1012,119 +990,6 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"dev": true,
|
||||
@@ -1206,17 +1071,6 @@
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"license": "MIT"
|
||||
@@ -3149,39 +3003,6 @@
|
||||
"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": {
|
||||
"version": "2.1.9",
|
||||
"dev": true,
|
||||
@@ -4426,13 +4247,6 @@
|
||||
"@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": {
|
||||
"version": "1.5.340",
|
||||
"dev": true,
|
||||
@@ -4889,23 +4703,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.2.2",
|
||||
"engines": {
|
||||
@@ -4971,27 +4768,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": {
|
||||
"version": "6.0.2",
|
||||
"dev": true,
|
||||
@@ -5003,39 +4779,6 @@
|
||||
"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": {
|
||||
"version": "17.6.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
||||
@@ -5067,16 +4810,6 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"dev": true,
|
||||
@@ -5410,13 +5143,6 @@
|
||||
"version": "1.0.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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -5652,76 +5378,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.21.7",
|
||||
"dev": true,
|
||||
@@ -5931,47 +5587,6 @@
|
||||
"@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": {
|
||||
"version": "3.0.4",
|
||||
"license": "MIT",
|
||||
@@ -6958,16 +6573,6 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"license": "MIT"
|
||||
@@ -7094,13 +6699,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": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
||||
@@ -7163,30 +6761,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
@@ -8045,19 +7619,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"dev": true,
|
||||
@@ -8136,22 +7697,6 @@
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
@@ -8184,20 +7729,6 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"dev": true,
|
||||
@@ -8258,19 +7789,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": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
@@ -8346,21 +7864,6 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"dev": true,
|
||||
@@ -9018,41 +8521,6 @@
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest",
|
||||
"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-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
@@ -139,6 +139,7 @@ import {
|
||||
startApiService,
|
||||
stopApiService,
|
||||
updateAutomation,
|
||||
updateComputerUseSettings,
|
||||
updateImageGenerationSettings,
|
||||
updateMcpServerTools,
|
||||
updateModelCallOrder,
|
||||
@@ -179,6 +180,7 @@ import type {
|
||||
AutomationUpdatePayload,
|
||||
CliAppInfo,
|
||||
CliAppsPayload,
|
||||
ComputerUseSettingsUpdate,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetInfo,
|
||||
McpPresetsPayload,
|
||||
@@ -762,6 +764,7 @@ export function SettingsView({
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
||||
const [computerUseSaving, setComputerUseSaving] = useState<"browser" | "computer" | null>(null);
|
||||
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
|
||||
const [apiServiceLoading, setApiServiceLoading] = useState(false);
|
||||
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
|
||||
@@ -1002,7 +1005,7 @@ export function SettingsView({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!pageVisible
|
||||
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|
||||
|| !["channels", "models", "browser", "runtime", "advanced"].includes(activeSection)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -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 (
|
||||
action: "start" | "stop",
|
||||
values?: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
@@ -2238,6 +2269,11 @@ export function SettingsView({
|
||||
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
|
||||
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
|
||||
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":
|
||||
@@ -2364,6 +2400,13 @@ export function SettingsView({
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
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:
|
||||
@@ -5255,6 +5298,11 @@ function WebSettings({
|
||||
olostepFeature,
|
||||
olostepInstalling,
|
||||
capabilityError,
|
||||
browserAutomationEnabled,
|
||||
computerUseFeature,
|
||||
computerUseSaving,
|
||||
computerUseInstalling,
|
||||
onToggleBrowserAutomation,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: WebSearchSettingsUpdate;
|
||||
@@ -5274,6 +5322,11 @@ function WebSettings({
|
||||
olostepFeature?: NanobotFeatureInfo;
|
||||
olostepInstalling: boolean;
|
||||
capabilityError: string | null;
|
||||
browserAutomationEnabled: boolean;
|
||||
computerUseFeature?: NanobotFeatureInfo;
|
||||
computerUseSaving: boolean;
|
||||
computerUseInstalling: boolean;
|
||||
onToggleBrowserAutomation: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
@@ -5302,9 +5355,46 @@ function WebSettings({
|
||||
: selectedProvider?.credential === "base_url"
|
||||
? !baseUrl
|
||||
: 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 (
|
||||
<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>
|
||||
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
||||
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
|
||||
@@ -7383,11 +7473,7 @@ function AppsCatalogSettings({
|
||||
]
|
||||
.filter((item) => {
|
||||
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
|
||||
if (filter === "ready") return appsReady(item);
|
||||
if (filter === "cli") {
|
||||
return item.kind === "cli" || item.preset.source === "agent-plugin";
|
||||
}
|
||||
return item.kind === "mcp" && item.preset.source !== "agent-plugin";
|
||||
return filter === "ready" ? appsReady(item) : item.kind === filter;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
|
||||
@@ -7627,7 +7713,6 @@ function McpAppsCatalogRow({
|
||||
const testBusy = actionKey === `test:${preset.name}`;
|
||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
||||
const agentPlugin = preset.source === "agent-plugin";
|
||||
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
||||
const hasFields = preset.required_fields.length > 0;
|
||||
const needsSetupInput = missingFields.length > 0;
|
||||
@@ -7639,13 +7724,8 @@ function McpAppsCatalogRow({
|
||||
const enabledTools = preset.enabled_tools ?? ["*"];
|
||||
const allowAllTools = enabledTools.includes("*");
|
||||
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
|
||||
const description = preset.description || preset.note || preset.name;
|
||||
const detail = agentPlugin && preset.requires
|
||||
? `${description} · ${preset.requires}`
|
||||
: description || preset.requires;
|
||||
const statusLabel = agentPlugin
|
||||
? tx("settings.apps.pluginEnabled", "Plugin enabled")
|
||||
: mcpPresetStatusLabel(preset.status, tx);
|
||||
const description = preset.description || preset.note || preset.requires || preset.name;
|
||||
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
|
||||
|
||||
useEffect(() => {
|
||||
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
||||
@@ -7678,13 +7758,9 @@ function McpAppsCatalogRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
||||
<AppsTypeBadge>
|
||||
{agentPlugin
|
||||
? tx("settings.apps.pluginLabel", "Plugin")
|
||||
: tx("settings.apps.mcpLabel", "Integration")}
|
||||
</AppsTypeBadge>
|
||||
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "Integration")}</AppsTypeBadge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
|
||||
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{readyInstalled ? (
|
||||
@@ -7701,41 +7777,35 @@ function McpAppsCatalogRow({
|
||||
</AppsActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!agentPlugin ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!agentPlugin && toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
{toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||
<SlidersHorizontal aria-hidden />
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
tone={agentPlugin ? undefined : "destructive"}
|
||||
tone="destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||
{agentPlugin
|
||||
? tx("settings.apps.pluginDisable", "Disable")
|
||||
: tx("settings.mcp.remove", "Remove")}
|
||||
<Trash2 aria-hidden />
|
||||
{tx("settings.mcp.remove", "Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!agentPlugin ? (
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||
busy={removeBusy}
|
||||
disabled={busy && !removeBusy}
|
||||
tone="danger"
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
) : null}
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||
busy={removeBusy}
|
||||
disabled={busy && !removeBusy}
|
||||
tone="danger"
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
</>
|
||||
) : preset.installed && !preset.configured ? (
|
||||
<AppsActionButton
|
||||
@@ -8785,6 +8855,13 @@ function AdvancedSettings({
|
||||
onSave,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
computerControlEnabled,
|
||||
computerUseBackend,
|
||||
computerUseFeature,
|
||||
computerUseSaving,
|
||||
computerUseInstalling,
|
||||
capabilityError,
|
||||
onToggleComputerControl,
|
||||
}: {
|
||||
form: NetworkSafetySettingsUpdate;
|
||||
dirty: boolean;
|
||||
@@ -8795,11 +8872,60 @@ function AdvancedSettings({
|
||||
onSave: () => void;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
computerControlEnabled: boolean;
|
||||
computerUseBackend: "desktop" | "browser";
|
||||
computerUseFeature?: NanobotFeatureInfo;
|
||||
computerUseSaving: boolean;
|
||||
computerUseInstalling: boolean;
|
||||
capabilityError: string | null;
|
||||
onToggleComputerControl: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<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>
|
||||
<SettingsSectionTitle>
|
||||
{isNativeHostSurface
|
||||
|
||||
@@ -2383,7 +2383,7 @@ export function ThreadComposer({
|
||||
<div
|
||||
role="alert"
|
||||
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",
|
||||
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,6 @@ const VOICE_MIME_CANDIDATES = [
|
||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||
export type VoiceRecorderErrorKey =
|
||||
| "failed"
|
||||
| "insecureContext"
|
||||
| "noDevice"
|
||||
| "notConfigured"
|
||||
| "permission"
|
||||
@@ -164,10 +163,6 @@ export function useVoiceRecorder({
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||
onClearError();
|
||||
if (window.isSecureContext === false) {
|
||||
onError("insecureContext");
|
||||
return;
|
||||
}
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Defaults",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"browserAutomation": "Browser automation",
|
||||
"computerControl": "Computer control",
|
||||
"cliApps": "CLI apps",
|
||||
"mcp": "MCP services",
|
||||
"regional": "Regional",
|
||||
@@ -199,6 +201,8 @@
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
"browserAutomation": "Browser automation",
|
||||
"computerControl": "Computer control",
|
||||
"imageGeneration": "Image generation",
|
||||
"imageProvider": "Image provider",
|
||||
"imageProviderStatus": "Provider status",
|
||||
@@ -244,6 +248,11 @@
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"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.",
|
||||
"imageProvider": "Choose the registry provider used by generate_image.",
|
||||
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
|
||||
@@ -584,9 +593,6 @@
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin enabled",
|
||||
"pluginDisable": "Disable",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "Ready",
|
||||
@@ -1150,7 +1156,6 @@
|
||||
"recordingStatus": "Recording {{time}}"
|
||||
},
|
||||
"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.",
|
||||
"permission": "Allow microphone access in the address bar, then retry.",
|
||||
"notConfigured": "Configure a transcription provider first.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Valores predeterminados",
|
||||
"webSearch": "Búsqueda web",
|
||||
"webBehavior": "Comportamiento",
|
||||
"browserAutomation": "Automatización del navegador",
|
||||
"computerControl": "Control del ordenador",
|
||||
"regional": "Configuración regional",
|
||||
"webuiSafety": "Seguridad de WebUI",
|
||||
"capabilities": "Capacidades",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "Resultados máximos",
|
||||
"timeout": "Tiempo de espera",
|
||||
"jinaReader": "Lector Jina",
|
||||
"browserAutomation": "Automatización del navegador",
|
||||
"computerControl": "Control del ordenador",
|
||||
"imageGeneration": "Generación de imágenes",
|
||||
"imageProvider": "Proveedor de imágenes",
|
||||
"imageProviderStatus": "Estado del proveedor",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
|
||||
"browserAutomation": "Permite que nanobot navegue y actúe en páginas web mediante elementos estructurados. Requiere Chromium de Playwright.",
|
||||
"computerControl": "Permite que nanobot vea y controle el ordenador que ejecuta su motor. macOS requiere permisos de grabación de pantalla y accesibilidad.",
|
||||
"computerControlBrowser": "El control por píxeles apunta actualmente a un navegador aislado, según config.json.",
|
||||
"computerUseInstall": "El soporte de Python necesario se instalará al activarlo.",
|
||||
"computerUseInstalling": "Instalando componentes de control...",
|
||||
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
|
||||
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
|
||||
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de los proveedores.",
|
||||
@@ -571,9 +580,6 @@
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"cliLabel": "Aplicación",
|
||||
"mcpLabel": "Integración",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activado",
|
||||
"pluginDisable": "Desactivar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Listo",
|
||||
@@ -1137,7 +1143,6 @@
|
||||
"recordingStatus": "Grabando {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Por seguridad, Chrome y otros navegadores bloquean el acceso al micrófono en páginas HTTP remotas. Abre esta WebUI mediante HTTPS para usar la entrada de voz.",
|
||||
"unsupported": "Este navegador no admite entrada de voz.",
|
||||
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
||||
"notConfigured": "Configura primero un proveedor de transcripción.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Valeurs par défaut",
|
||||
"webSearch": "Recherche web",
|
||||
"webBehavior": "Comportement",
|
||||
"browserAutomation": "Automatisation du navigateur",
|
||||
"computerControl": "Contrôle de l’ordinateur",
|
||||
"regional": "Paramètres régionaux",
|
||||
"webuiSafety": "Sécurité WebUI",
|
||||
"capabilities": "Capacités",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "Résultats max.",
|
||||
"timeout": "Délai d’attente",
|
||||
"jinaReader": "Lecteur Jina",
|
||||
"browserAutomation": "Automatisation du navigateur",
|
||||
"computerControl": "Contrôle de l’ordinateur",
|
||||
"imageGeneration": "Génération d’images",
|
||||
"imageProvider": "Fournisseur d’images",
|
||||
"imageProviderStatus": "État du fournisseur",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
||||
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
||||
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
|
||||
"browserAutomation": "Permet à nanobot de parcourir et manipuler les pages web à partir de leurs éléments structurés. Nécessite Chromium de Playwright.",
|
||||
"computerControl": "Permet à nanobot de voir et contrôler l’ordinateur qui exécute son moteur. macOS exige les autorisations Enregistrement de l’écran et Accessibilité.",
|
||||
"computerControlBrowser": "Le contrôle par pixels cible actuellement un navigateur isolé, conformément à config.json.",
|
||||
"computerUseInstall": "Les composants Python requis seront installés lors de l’activation.",
|
||||
"computerUseInstalling": "Installation des composants de contrôle...",
|
||||
"imageGeneration": "Expose generate_image dans les chats lorsqu’un fournisseur d’image configuré est disponible.",
|
||||
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
|
||||
"imageProviderStatus": "La génération d’images réutilise les identifiants des fournisseurs.",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"cliLabel": "Application",
|
||||
"mcpLabel": "Intégration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activé",
|
||||
"pluginDisable": "Désactiver",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Prêts",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "Enregistrement {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Pour des raisons de sécurité, Chrome et d’autres navigateurs bloquent l’accès au microphone sur les pages HTTP distantes. Ouvrez cette WebUI en HTTPS pour utiliser la saisie vocale.",
|
||||
"unsupported": "La saisie vocale n'est pas prise en charge par ce navigateur.",
|
||||
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
|
||||
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Bawaan",
|
||||
"webSearch": "Pencarian web",
|
||||
"webBehavior": "Perilaku",
|
||||
"browserAutomation": "Otomatisasi browser",
|
||||
"computerControl": "Kontrol komputer",
|
||||
"regional": "Regional",
|
||||
"webuiSafety": "Keamanan WebUI",
|
||||
"capabilities": "Kemampuan",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "Hasil maksimum",
|
||||
"timeout": "Batas waktu",
|
||||
"jinaReader": "Pembaca Jina",
|
||||
"browserAutomation": "Otomatisasi browser",
|
||||
"computerControl": "Kontrol komputer",
|
||||
"imageGeneration": "Pembuatan gambar",
|
||||
"imageProvider": "Penyedia gambar",
|
||||
"imageProviderStatus": "Status penyedia",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
|
||||
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
|
||||
"jinaReader": "Gunakan Jina Reader untuk web_fetch jika tersedia.",
|
||||
"browserAutomation": "Izinkan nanobot menjelajah dan bertindak pada halaman web melalui elemen terstruktur. Memerlukan Chromium dari Playwright.",
|
||||
"computerControl": "Izinkan nanobot melihat dan mengontrol komputer yang menjalankan mesinnya. macOS memerlukan izin Perekaman Layar dan Aksesibilitas.",
|
||||
"computerControlBrowser": "Kontrol berbasis piksel saat ini menargetkan browser terisolasi sesuai config.json.",
|
||||
"computerUseInstall": "Dukungan Python yang diperlukan akan dipasang saat diaktifkan.",
|
||||
"computerUseInstalling": "Memasang komponen kontrol komputer...",
|
||||
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
|
||||
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
|
||||
"imageProviderStatus": "Pembuatan gambar menggunakan kembali kredensial penyedia dari bagian Penyedia.",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "Integrasi",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin aktif",
|
||||
"pluginDisable": "Nonaktifkan",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Siap",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "Merekam {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Demi keamanan, Chrome dan browser lain memblokir akses mikrofon pada halaman HTTP jarak jauh. Buka WebUI ini melalui HTTPS untuk menggunakan input suara.",
|
||||
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "既定値",
|
||||
"webSearch": "ウェブ検索",
|
||||
"webBehavior": "動作",
|
||||
"browserAutomation": "ブラウザ自動操作",
|
||||
"computerControl": "コンピュータ操作",
|
||||
"regional": "地域",
|
||||
"webuiSafety": "WebUI の安全性",
|
||||
"capabilities": "機能",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "最大結果数",
|
||||
"timeout": "タイムアウト",
|
||||
"jinaReader": "Jina リーダー",
|
||||
"browserAutomation": "ブラウザ自動操作",
|
||||
"computerControl": "コンピュータ操作",
|
||||
"imageGeneration": "画像生成",
|
||||
"imageProvider": "画像プロバイダー",
|
||||
"imageProviderStatus": "プロバイダー状態",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
||||
"jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
|
||||
"browserAutomation": "構造化されたページ要素を使って、nanobot がウェブページを閲覧・操作できるようにします。Playwright Chromium が必要です。",
|
||||
"computerControl": "nanobot がエンジンを実行しているコンピュータを表示・操作できるようにします。macOS では画面収録とアクセシビリティの許可が必要です。",
|
||||
"computerControlBrowser": "ピクセル操作は現在、config.json の設定に従って分離ブラウザを対象としています。",
|
||||
"computerUseInstall": "有効にすると必要な Python コンポーネントがインストールされます。",
|
||||
"computerUseInstalling": "コンピュータ操作コンポーネントをインストール中...",
|
||||
"imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
|
||||
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
|
||||
"imageProviderStatus": "画像生成はプロバイダー設定の認証情報を再利用します。",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "連携",
|
||||
"pluginLabel": "プラグイン",
|
||||
"pluginEnabled": "プラグインは有効です",
|
||||
"pluginDisable": "無効にする",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "使用可能",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "録音中 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
|
||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "기본값",
|
||||
"webSearch": "웹 검색",
|
||||
"webBehavior": "동작",
|
||||
"browserAutomation": "브라우저 자동화",
|
||||
"computerControl": "컴퓨터 제어",
|
||||
"regional": "지역",
|
||||
"webuiSafety": "WebUI 보안",
|
||||
"capabilities": "기능",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "최대 결과 수",
|
||||
"timeout": "타임아웃",
|
||||
"jinaReader": "Jina 리더",
|
||||
"browserAutomation": "브라우저 자동화",
|
||||
"computerControl": "컴퓨터 제어",
|
||||
"imageGeneration": "이미지 생성",
|
||||
"imageProvider": "이미지 제공자",
|
||||
"imageProviderStatus": "제공자 상태",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||
"jinaReader": "가능할 때 web_fetch에 Jina Reader를 사용합니다.",
|
||||
"browserAutomation": "nanobot이 구조화된 페이지 요소를 사용해 웹페이지를 탐색하고 조작할 수 있게 합니다. Playwright Chromium이 필요합니다.",
|
||||
"computerControl": "nanobot이 엔진을 실행하는 컴퓨터를 보고 제어할 수 있게 합니다. macOS에서는 화면 기록 및 손쉬운 사용 권한이 필요합니다.",
|
||||
"computerControlBrowser": "픽셀 기반 제어는 현재 config.json 설정에 따라 격리된 브라우저를 대상으로 합니다.",
|
||||
"computerUseInstall": "켜면 필요한 Python 구성 요소가 설치됩니다.",
|
||||
"computerUseInstalling": "컴퓨터 제어 구성 요소 설치 중...",
|
||||
"imageGeneration": "구성된 이미지 제공자가 있을 때 채팅에서 generate_image를 노출합니다.",
|
||||
"imageProvider": "generate_image에 사용할 등록 제공자를 선택합니다.",
|
||||
"imageProviderStatus": "이미지 생성은 제공자 자격 증명을 재사용합니다.",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "연동",
|
||||
"pluginLabel": "플러그인",
|
||||
"pluginEnabled": "플러그인 활성화됨",
|
||||
"pluginDisable": "비활성화",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "사용 가능",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "녹음 중 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
|
||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Padrões",
|
||||
"webSearch": "Busca na web",
|
||||
"webBehavior": "Comportamento",
|
||||
"browserAutomation": "Automação do navegador",
|
||||
"computerControl": "Controle do computador",
|
||||
"cliApps": "Aplicativos CLI",
|
||||
"mcp": "Servidores MCP",
|
||||
"regional": "Regional",
|
||||
@@ -199,6 +201,8 @@
|
||||
"maxResults": "Máx. de resultados",
|
||||
"timeout": "Tempo limite",
|
||||
"jinaReader": "Leitor Jina",
|
||||
"browserAutomation": "Automação do navegador",
|
||||
"computerControl": "Controle do computador",
|
||||
"imageGeneration": "Geração de imagens",
|
||||
"imageProvider": "Provedor de imagem",
|
||||
"imageProviderStatus": "Status do provedor",
|
||||
@@ -244,6 +248,11 @@
|
||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||
"browserAutomation": "Permite que o nanobot navegue e interaja com páginas usando elementos estruturados. Requer o Chromium do Playwright.",
|
||||
"computerControl": "Permite que o nanobot veja e controle o computador que executa o mecanismo. No macOS, requer acesso à Gravação de Tela e Acessibilidade.",
|
||||
"computerControlBrowser": "O controle por pixels está direcionado a um navegador isolado, conforme o config.json.",
|
||||
"computerUseInstall": "O suporte Python necessário será instalado ao ativar.",
|
||||
"computerUseInstalling": "Instalando componentes de controle...",
|
||||
"imageGeneration": "Expõe generate_image nas conversas quando há um provedor de imagem configurado.",
|
||||
"imageProvider": "Escolha o provedor do registro usado por generate_image.",
|
||||
"imageProviderStatus": "A geração de imagens reaproveita as credenciais dos provedores.",
|
||||
@@ -584,9 +593,6 @@
|
||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||
"cliLabel": "Aplicativo",
|
||||
"mcpLabel": "Integração",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin ativado",
|
||||
"pluginDisable": "Desativar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Recurso",
|
||||
"filterAll": "Prontos",
|
||||
@@ -1150,7 +1156,6 @@
|
||||
"recordingStatus": "Gravando {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Por segurança, o Chrome e outros navegadores bloqueiam o acesso ao microfone em páginas HTTP remotas. Abra esta WebUI por HTTPS para usar a entrada de voz.",
|
||||
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "Mặc định",
|
||||
"webSearch": "Tìm kiếm web",
|
||||
"webBehavior": "Hành vi",
|
||||
"browserAutomation": "Tự động hóa trình duyệt",
|
||||
"computerControl": "Điều khiển máy tính",
|
||||
"regional": "Khu vực",
|
||||
"webuiSafety": "An toàn WebUI",
|
||||
"capabilities": "Khả năng",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "Kết quả tối đa",
|
||||
"timeout": "Thời gian chờ",
|
||||
"jinaReader": "Trình đọc Jina",
|
||||
"browserAutomation": "Tự động hóa trình duyệt",
|
||||
"computerControl": "Điều khiển máy tính",
|
||||
"imageGeneration": "Tạo hình ảnh",
|
||||
"imageProvider": "Nhà cung cấp hình ảnh",
|
||||
"imageProviderStatus": "Trạng thái nhà cung cấp",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
|
||||
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
|
||||
"jinaReader": "Dùng Jina Reader cho web_fetch khi có thể.",
|
||||
"browserAutomation": "Cho phép nanobot duyệt và thao tác trên trang web bằng các phần tử có cấu trúc. Cần Playwright Chromium.",
|
||||
"computerControl": "Cho phép nanobot xem và điều khiển máy tính đang chạy engine. macOS yêu cầu quyền Ghi màn hình và Trợ năng.",
|
||||
"computerControlBrowser": "Điều khiển theo điểm ảnh hiện nhắm tới trình duyệt cách ly theo config.json.",
|
||||
"computerUseInstall": "Hỗ trợ Python cần thiết sẽ được cài đặt khi bật.",
|
||||
"computerUseInstalling": "Đang cài đặt thành phần điều khiển...",
|
||||
"imageGeneration": "Hiển thị generate_image trong chat khi đã cấu hình nhà cung cấp hình ảnh.",
|
||||
"imageProvider": "Chọn nhà cung cấp registry được generate_image sử dụng.",
|
||||
"imageProviderStatus": "Tạo ảnh dùng lại thông tin xác thực từ mục Nhà cung cấp.",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "Tích hợp",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin đã bật",
|
||||
"pluginDisable": "Tắt",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Sẵn sàng",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "Đang ghi {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Vì lý do bảo mật, Chrome và các trình duyệt khác chặn quyền truy cập micrô trên các trang HTTP từ xa. Mở WebUI này qua HTTPS để dùng tính năng nhập bằng giọng nói.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ nhập bằng giọng nói.",
|
||||
"permission": "Cho phép micrô trong thanh địa chỉ rồi thử lại.",
|
||||
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "默认值",
|
||||
"webSearch": "网络搜索",
|
||||
"webBehavior": "网络行为",
|
||||
"browserAutomation": "浏览器自动化",
|
||||
"computerControl": "电脑控制",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP 服务",
|
||||
"regional": "区域",
|
||||
@@ -199,6 +201,8 @@
|
||||
"maxResults": "最大结果数",
|
||||
"timeout": "超时",
|
||||
"jinaReader": "Jina 阅读器",
|
||||
"browserAutomation": "浏览器自动化",
|
||||
"computerControl": "电脑控制",
|
||||
"imageGeneration": "图片生成",
|
||||
"imageProvider": "图片提供商",
|
||||
"imageProviderStatus": "提供商状态",
|
||||
@@ -244,6 +248,11 @@
|
||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||
"browserAutomation": "允许 nanobot 通过结构化页面元素浏览网页并执行操作,需要 Playwright Chromium。",
|
||||
"computerControl": "允许 nanobot 查看并控制运行引擎的电脑。macOS 需要授予“屏幕录制”和“辅助功能”权限。",
|
||||
"computerControlBrowser": "像素控制当前按 config.json 配置作用于隔离浏览器。",
|
||||
"computerUseInstall": "开启时会自动安装所需 Python 组件。",
|
||||
"computerUseInstalling": "正在安装电脑操作组件...",
|
||||
"imageGeneration": "配置图片提供商后,即可在聊天中使用 generate_image。",
|
||||
"imageProvider": "选择 generate_image 使用的注册提供商。",
|
||||
"imageProviderStatus": "图片生成会复用「提供商」里的凭据。",
|
||||
@@ -584,9 +593,6 @@
|
||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "集成",
|
||||
"pluginLabel": "插件",
|
||||
"pluginEnabled": "插件已启用",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "可用",
|
||||
@@ -1149,7 +1155,6 @@
|
||||
"recordingStatus": "正在录音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
|
||||
"unsupported": "当前浏览器不支持语音输入。",
|
||||
"permission": "请在地址栏允许麦克风后重试。",
|
||||
"notConfigured": "请先配置转写提供商。",
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"imageDefaults": "預設值",
|
||||
"webSearch": "網路搜尋",
|
||||
"webBehavior": "網路行為",
|
||||
"browserAutomation": "瀏覽器自動化",
|
||||
"computerControl": "電腦控制",
|
||||
"regional": "區域",
|
||||
"webuiSafety": "WebUI 安全",
|
||||
"capabilities": "能力",
|
||||
@@ -145,6 +147,8 @@
|
||||
"maxResults": "最大結果數",
|
||||
"timeout": "逾時",
|
||||
"jinaReader": "Jina 閱讀器",
|
||||
"browserAutomation": "瀏覽器自動化",
|
||||
"computerControl": "電腦控制",
|
||||
"imageGeneration": "圖片生成",
|
||||
"imageProvider": "圖片供應商",
|
||||
"imageProviderStatus": "供應商狀態",
|
||||
@@ -188,6 +192,11 @@
|
||||
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
||||
"timeout": "搜尋供應商請求逾時前的秒數。",
|
||||
"jinaReader": "若可用,則讓 web_fetch 使用 Jina Reader。",
|
||||
"browserAutomation": "允許 nanobot 透過結構化頁面元素瀏覽網頁並執行操作,需要 Playwright Chromium。",
|
||||
"computerControl": "允許 nanobot 查看並控制執行引擎的電腦。macOS 需要授予螢幕錄製與輔助使用權限。",
|
||||
"computerControlBrowser": "像素控制目前依 config.json 設定作用於隔離瀏覽器。",
|
||||
"computerUseInstall": "開啟時會自動安裝所需 Python 元件。",
|
||||
"computerUseInstalling": "正在安裝電腦操作元件...",
|
||||
"imageGeneration": "設定圖片供應商後,即可在聊天中使用 generate_image。",
|
||||
"imageProvider": "選擇 generate_image 使用的註冊供應商。",
|
||||
"imageProviderStatus": "圖片生成功能會沿用 [供應商] 中的憑證。",
|
||||
@@ -570,9 +579,6 @@
|
||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||
"cliLabel": "應用程式",
|
||||
"mcpLabel": "整合",
|
||||
"pluginLabel": "外掛",
|
||||
"pluginEnabled": "外掛已啟用",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "通訊管道",
|
||||
"featureLabel": "功能",
|
||||
"filterAll": "就緒",
|
||||
@@ -1136,7 +1142,6 @@
|
||||
"recordingStatus": "正在錄音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
|
||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||
"permission": "請在網址列允許麥克風後重試。",
|
||||
"notConfigured": "請先設定轉寫供應商。",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ChannelValidationPayload,
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
ComputerUseSettingsUpdate,
|
||||
FilePreviewPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
@@ -1077,6 +1078,24 @@ export async function updateNetworkSafetySettings(
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateComputerUseSettings(
|
||||
token: string,
|
||||
update: ComputerUseSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (update.browserEnabled !== undefined) {
|
||||
query.set("browser_enabled", String(update.browserEnabled));
|
||||
}
|
||||
if (update.computerEnabled !== undefined) {
|
||||
query.set("enabled", String(update.computerEnabled));
|
||||
}
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/computer-use/update?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateImageGenerationSettings(
|
||||
token: string,
|
||||
update: ImageGenerationSettingsUpdate,
|
||||
|
||||
@@ -579,6 +579,11 @@ export interface SettingsPayload {
|
||||
use_jina_reader: boolean;
|
||||
};
|
||||
};
|
||||
computer_use?: {
|
||||
browser_enabled: boolean;
|
||||
enabled: boolean;
|
||||
backend: "desktop" | "browser";
|
||||
};
|
||||
api?: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -1102,6 +1107,11 @@ export interface NetworkSafetySettingsUpdate {
|
||||
webuiDefaultAccessMode: WebuiDefaultAccessMode;
|
||||
}
|
||||
|
||||
export interface ComputerUseSettingsUpdate {
|
||||
browserEnabled?: boolean;
|
||||
computerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageGenerationSettingsUpdate {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
pollChannelConnect,
|
||||
startChannelConnect,
|
||||
updateAutomation,
|
||||
updateComputerUseSettings,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
updateModelCallOrder,
|
||||
@@ -836,6 +837,20 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("updates computer-use capability switches", async () => {
|
||||
await updateComputerUseSettings("tok", { browserEnabled: true });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/computer-use/update?browser_enabled=true",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await updateComputerUseSettings("tok", { computerEnabled: false });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/computer-use/update?enabled=false",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("manages the API service capability", async () => {
|
||||
await fetchApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -304,7 +304,6 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -2253,7 +2252,7 @@ describe("App layout", () => {
|
||||
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await user.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
|
||||
@@ -69,6 +69,8 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.sections.localPreferences",
|
||||
"settings.sections.webSearch",
|
||||
"settings.sections.webBehavior",
|
||||
"settings.sections.browserAutomation",
|
||||
"settings.sections.computerControl",
|
||||
"settings.sections.webuiSafety",
|
||||
"settings.sections.capabilities",
|
||||
"settings.sections.apps",
|
||||
@@ -149,6 +151,8 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.rows.fileEditDisplay",
|
||||
"settings.rows.codeWrap",
|
||||
"settings.rows.brandLogos",
|
||||
"settings.rows.browserAutomation",
|
||||
"settings.rows.computerControl",
|
||||
"settings.rows.currentModel",
|
||||
"settings.rows.localServiceAccess",
|
||||
"settings.rows.webuiDefaultAccess",
|
||||
@@ -160,6 +164,11 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.help.fileEditDisplay",
|
||||
"settings.help.codeWrap",
|
||||
"settings.help.brandLogos",
|
||||
"settings.help.browserAutomation",
|
||||
"settings.help.computerControl",
|
||||
"settings.help.computerControlBrowser",
|
||||
"settings.help.computerUseInstall",
|
||||
"settings.help.computerUseInstalling",
|
||||
"settings.help.currentModel",
|
||||
"settings.help.localServiceAccess",
|
||||
"settings.help.webuiDefaultAccess",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -64,6 +64,11 @@ function settingsPayload(): SettingsPayload {
|
||||
search: { max_results: 5, timeout: 30 },
|
||||
fetch: { use_jina_reader: true },
|
||||
},
|
||||
computer_use: {
|
||||
browser_enabled: false,
|
||||
enabled: false,
|
||||
backend: "desktop",
|
||||
},
|
||||
api: {
|
||||
host: "127.0.0.1",
|
||||
port: 8900,
|
||||
@@ -399,14 +404,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => new Promise<Response>(() => {})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -579,66 +579,6 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets up and enables an installed Agent Plugin explicitly", async () => {
|
||||
const plugin = {
|
||||
name: "plugin-computer-use",
|
||||
display_name: "Computer Use",
|
||||
description: "Control the desktop with a live preview.",
|
||||
category: "Productivity",
|
||||
docs_url: "https://github.com/nanobot-dev/computer-use",
|
||||
transport: "stdio",
|
||||
requires: "screen-recording, accessibility",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: false,
|
||||
available: false,
|
||||
status: "not_installed",
|
||||
logo_url: null,
|
||||
brand_color: "#ff7a1a",
|
||||
required_fields: [],
|
||||
connection_summary: "computer-use",
|
||||
source: "agent-plugin",
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [plugin], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets/enable?name=plugin-computer-use") {
|
||||
return jsonResponse({
|
||||
presets: [{ ...plugin, configured: true, available: true, status: "configured" }],
|
||||
installed_count: 1,
|
||||
last_action: { ok: true, message: "Computer Use enabled." },
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugin")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/enable?name=plugin-computer-use",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
@@ -4267,6 +4207,98 @@ describe("SettingsView Apps catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables browser automation from Web settings", async () => {
|
||||
const payload = settingsPayload();
|
||||
const updatedPayload: SettingsPayload = {
|
||||
...payload,
|
||||
computer_use: { ...payload.computer_use!, browser_enabled: true },
|
||||
requires_restart: true,
|
||||
restart_required_sections: ["runtime"],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/nanobot-features") {
|
||||
return jsonResponse({
|
||||
features: [{
|
||||
name: "computer-use",
|
||||
display_name: "Computer Use",
|
||||
type: "feature",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
}],
|
||||
enabled_count: 1,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/computer-use/update?browser_enabled=true") {
|
||||
return jsonResponse(updatedPayload);
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "browser" });
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "Browser automation" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/computer-use/update?browser_enabled=true",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables computer control from Security settings", async () => {
|
||||
const payload = settingsPayload();
|
||||
const updatedPayload: SettingsPayload = {
|
||||
...payload,
|
||||
computer_use: { ...payload.computer_use!, enabled: true },
|
||||
requires_restart: true,
|
||||
restart_required_sections: ["runtime"],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/nanobot-features") {
|
||||
return jsonResponse({
|
||||
features: [{
|
||||
name: "computer-use",
|
||||
display_name: "Computer Use",
|
||||
type: "feature",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
}],
|
||||
enabled_count: 1,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/computer-use/update?enabled=true") {
|
||||
return jsonResponse(updatedPayload);
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "advanced" });
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "Computer control" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/computer-use/update?enabled=true",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves network safety without exposing technical SSRF copy", async () => {
|
||||
const payload = settingsPayload();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
|
||||
@@ -651,49 +651,6 @@ describe("ThreadComposer", () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains the HTTPS requirement for voice input on an insecure origin", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", false);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Chrome and other browsers block microphone access on remote HTTP pages for security. "
|
||||
+ "Open this WebUI over HTTPS to use voice input.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveClass("max-h-24");
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unsupported-browser error for secure origins without recording support", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal("MediaRecorder", undefined);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Voice input is not supported in this browser."),
|
||||
).toBeInTheDocument();
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
|
||||
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
|
||||
const { decodeAudioData } = mockVoiceAudioInput(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -56,9 +56,7 @@ describe("ThreadMessages", () => {
|
||||
});
|
||||
|
||||
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
await preloadMarkdownText();
|
||||
const turnId = "turn-1";
|
||||
const streaming: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -161,18 +161,6 @@ export default defineConfig(({ mode }) => {
|
||||
environment: "happy-dom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/tests/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/tests/**", "src/**/*.d.ts"],
|
||||
reporter: ["text", "json-summary"],
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user